Compare commits

...

39 Commits

Author SHA1 Message Date
ragilap fc9197d00a feat/BE/US-278/TASK-288,289-adjust schema database,Create trigger in expense module, add filter in warehouse linked to project flock, and implement fifo system 2025-12-08 12:12:21 +07:00
ragilap f8e0614d50 Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-278/Purchase-Expedition-bop 2025-12-08 11:55:26 +07:00
Hafizh A. Y. 9f239b1840 Merge branch 'dev/teguh' into 'feat/BE/Sprint-6'
[FEAT/BE][US#277, 280/TASK#290,291,294,295] : Adjust Schema Database table expense and adjust expense API, and finishing module project budgets

See merge request mbugroup/lti-api!80
2025-12-08 02:47:50 +00:00
Hafizh A. Y. 167fd6d6cb Merge branch 'dev/gio' into 'feat/BE/Sprint-6'
fix query changes field stock logs

See merge request mbugroup/lti-api!83
2025-12-08 02:46:50 +00:00
aguhh18 ec2aca936c Merge branch sprint 6 into dev/teguh 2025-12-08 09:20:54 +07:00
aguhh18 f701b30cb3 Merge branch 'feat/BE/Sprint-6' into 'dev/teguh' - merge all closing methods 2025-12-08 08:39:17 +07:00
ragilap 0a18753dde feat/BE/US-278/TASK-288,289-adjust schema database,Create trigger in expense module, add filter in warehouse linked to project flock 2025-12-08 01:23:21 +07:00
ragilap 4638fba318 Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-278/Purchase-Expedition-bop 2025-12-08 01:10:37 +07:00
giovanni-ce 296e8e4c18 fix query changes field stock logs 2025-12-06 22:29:50 +07:00
ragilap a586fe3781 update purchase triger to expense 2025-12-06 21:09:23 +07:00
ragilap 2d3f7f7ef9 update purchase triger to expense 2025-12-06 21:06:53 +07:00
Hafizh A. Y. 67f7ec3a40 Merge branch 'dev/gio' into 'feat/BE/Sprint-6'
feat[BE-298]: add api get one closing general information

See merge request mbugroup/lti-api!82
2025-12-06 04:34:31 +00:00
aguhh18 008709c19c Feat[BE-300]: add preload for kandang for get penjualan 2025-12-05 19:08:58 +07:00
giovanni-ce 4c63bd14c3 feat[BE-298]: add api get one closing general information 2025-12-05 17:15:05 +07:00
ragilap ee2db748ea implement bop for expedition must recheck and qty in staff purchase need info 2025-12-05 14:08:54 +07:00
aguhh18 5afee298b0 FIX[BE]: uncomment middleware usage for delivery and sales orders routes 2025-12-05 13:44:57 +07:00
aguhh18 2bc67a8433 FIX[BE] : fixing deleted create at and create by on product warehouse 2025-12-05 13:35:12 +07:00
aguhh18 b4ccd33ea0 FIX{BE]: fixing product warehouse delete created user on preload 2025-12-05 13:30:36 +07:00
aguhh18 c279303b99 Feat[BE-300]: creating API Get closing penjualan 2025-12-05 12:31:52 +07:00
Hafizh A. Y. b8a769dc72 Merge branch 'dev/gio' into 'feat/BE/Sprint-6'
adjust for changes erd product_warehouse

See merge request mbugroup/lti-api!81
2025-12-05 03:38:55 +00:00
aguhh18 8c883669d3 Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-12-04 20:11:17 +07:00
giovanni-ce 1e9a637202 adjust for changes erd product_warehouse 2025-12-04 20:00:46 +07:00
Hafizh A. Y. c064fb1765 Merge branch 'dev/gio' into 'feat/BE/Sprint-6'
adjust response inventory stock-product

See merge request mbugroup/lti-api!79
2025-12-04 09:39:25 +00:00
giovanni-ce 4af631a1d3 adjust response inventory stock-product 2025-12-04 16:08:30 +07:00
Hafizh A. Y. 91e4762945 Merge branch 'dev/hafizh' into 'feat/BE/Sprint-6'
feat(BE-308,309): utility document and implementation s3 bucket

See merge request mbugroup/lti-api!78
2025-12-04 08:46:50 +00:00
Hafizh A. Y b8403f1c7e feat(BE-308,309): utility document and implementation s3 bucket 2025-12-04 15:46:06 +07:00
Hafizh A. Y. 753d8575c4 Merge branch 'dev/gio' into 'feat/BE/Sprint-6'
[FEAT/BE][US#286/TASK#296,297] : Adjust Schema Database table product_warehouse, product and stock_logs; create sub module inventory product_stock and api list and get one

See merge request mbugroup/lti-api!77
2025-12-04 06:37:45 +00:00
giovanni-ce 4c5266da23 resolve conflict to sprint 6 2025-12-04 12:20:47 +07:00
Hafizh A. Y. d79b1868fc Merge branch 'dev/ragil' into 'feat/BE/Sprint-6'
[FIX/BE][US#74] : Project flock dto

See merge request mbugroup/lti-api!72
2025-12-04 04:41:36 +00:00
giovanni-ce 33a9d7806e adjust response location to object 2025-12-04 11:27:02 +07:00
giovanni-ce 730fb22cc2 Feat[BE-297]: Create sub module inventory product stock; create api list product stock and api get one product stock 2025-12-03 19:47:12 +07:00
giovanni-ce 94fc9219af Feat[BE-296]: adjust schema db and entity products, product_warehouse, stock_logs 2025-12-03 18:43:03 +07:00
aguhh18 beee88322a FIX[BE] : fixing wrong project flock validation 2025-12-03 14:23:32 +07:00
aguhh18 1b464884c5 Feat[BE-290]: enhance expense update functionality and validation 2025-12-03 12:02:58 +07:00
aguhh18 31699f4162 FIX[BE]: fixing nonstock sometimes isn't appeared on get one 2025-12-03 11:14:26 +07:00
aguhh18 e667d88218 Feat[BE]: create resubmit projectflock API 2025-12-02 12:39:58 +07:00
aguhh18 002981e63b Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-12-02 09:35:02 +07:00
aguhh18 1d0ef8fb93 Feat[BE#280]:add project budgets to body create API and get one API 2025-12-02 09:32:42 +07:00
aguhh18 29f0fd6edb Merge branch 'dev/ragil' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-11-28 15:25:52 +07:00
82 changed files with 3626 additions and 544 deletions
+28 -1
View File
@@ -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
)
+57
View File
@@ -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=
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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)
}
+18
View File
@@ -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")
@@ -0,0 +1,2 @@
ALTER TABLE products
DROP COLUMN IF EXISTS is_visible;
@@ -0,0 +1,2 @@
ALTER TABLE products
ADD COLUMN IF NOT EXISTS is_visible BOOLEAN NOT NULL DEFAULT TRUE;
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS documents_documentable_polymorphic;
DROP TABLE IF EXISTS documents;
@@ -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);
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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);
+1 -1
View File
@@ -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
+18
View File
@@ -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"`
}
+7 -5
View File
@@ -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"`
}
+9 -17
View File
@@ -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"`
}
+8 -6
View File
@@ -5,11 +5,13 @@ import (
)
type ProjectBudget struct {
Id uint `gorm:"primaryKey"`
Qty float64 `gorm:"type:numeric(15,3);not null"`
Price float64 `gorm:"type:numeric(15,3);not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
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:Id;references:Id"`
ProjectFlock *ProjectFlock `gorm:"foreignKey:Id;references:Id"`
Nonstock *Nonstock `gorm:"foreignKey:NonstockId;references:Id"`
ProjectFlock *ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"`
}
+1
View File
@@ -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:"-"`
}
-2
View File
@@ -10,9 +10,7 @@ type Purchase struct {
PoNumber *string
PoDate *time.Time
SupplierId uint `gorm:"not null"`
CreditTerm *int
DueDate *time.Time
GrandTotal float64 `gorm:"type:numeric(15,3);default:0"`
Notes *string
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
+16 -14
View File
@@ -5,20 +5,22 @@ import (
)
type PurchaseItem struct {
Id uint `gorm:"primaryKey;autoIncrement"`
PurchaseId uint `gorm:"not null"`
ProductId uint `gorm:"not null"`
WarehouseId uint `gorm:"not null"`
ProductWarehouseId *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"`
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"`
+13 -18
View File
@@ -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"`
@@ -57,11 +57,11 @@ func (u *ClosingController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
if err != nil || id <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid id")
}
result, err := u.ClosingService.GetOne(c, uint(id))
result, err := u.ClosingService.GetProjectFlockByID(c, uint(id))
if err != nil {
return err
}
@@ -70,7 +70,56 @@ func (u *ClosingController) GetOne(c *fiber.Ctx) error {
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get closing successfully",
Data: dto.ToClosingListDTO(*result),
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),
})
}
@@ -1,6 +1,7 @@
package dto
import (
"fmt"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -26,6 +27,67 @@ type ClosingDetailDTO struct {
ClosingListDTO
}
type ClosingSummaryDTO struct {
LocationID uint `json:"location_id"`
Periode int `json:"periode"`
JenisProduk string `json:"jenis_produk"`
LabelPopulasi string `json:"label_populasi"`
JumlahPopulasi int `json:"jumlah_populasi"`
JumlahPopulasiFormatted string `json:"jumlah_populasi_formatted"`
JenisProject string `json:"jenis_project"`
KandangAktif int `json:"kandang_aktif"`
KandangAktifFormatted string `json:"kandang_aktif_formatted"`
StatusPembayaranPenjualan string `json:"status_pembayaran_penjualan"`
StatusPembayaranMitra string `json:"status_pembayaran_mitra"`
StatusProject string `json:"status_project"`
StatusClosing string `json:"status_closing"`
}
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{
LocationID: project.LocationId,
Periode: period,
JenisProduk: project.Category,
LabelPopulasi: "",
JumlahPopulasi: populationInt,
JumlahPopulasiFormatted: fmt.Sprintf("%d Ekor", populationInt),
JenisProject: "",
KandangAktif: kandangCount,
KandangAktifFormatted: fmt.Sprintf("%d Kandang", kandangCount),
StatusPembayaranPenjualan: "Tempo",
StatusPembayaranMitra: "",
StatusProject: statusProject,
StatusClosing: statusClosing,
}
}
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 {
@@ -0,0 +1,109 @@
package dto
import (
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
deliveryOrdersDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/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
}
+10 -2
View File
@@ -5,8 +5,12 @@ import (
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
rClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
sClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services"
rMarketings "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/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"
@@ -17,10 +21,14 @@ 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)
marketingRepo := rMarketings.NewMarketingRepository(db)
marketingDeliveryProductRepo := rMarketings.NewMarketingDeliveryProductRepository(db)
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo)
closingService := sClosing.NewClosingService(closingRepo, validate)
closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, validate)
userService := sUser.NewUserService(userRepo, validate)
ClosingRoutes(router, userService, closingService)
}
+3 -2
View File
@@ -12,7 +12,7 @@ import (
func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService) {
ctrl := controller.NewClosingController(s)
route := v1.Group("/closings")
route := v1.Group("/closing")
// route.Get("/", m.Auth(u), ctrl.GetAll)
// route.Post("/", m.Auth(u), ctrl.CreateOne)
@@ -21,5 +21,6 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService
// route.Delete("/:id", m.Auth(u), ctrl.DeleteOne)
route.Get("/", ctrl.GetAll)
route.Get("/:id", ctrl.GetOne)
route.Get("/:project_flock_id/penjualan", ctrl.GetPenjualan)
route.Get("/:projectFlockId", ctrl.GetClosingSummary)
}
@@ -1,12 +1,19 @@
package service
import (
"context"
"errors"
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"
marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories"
marketingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/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"
@@ -16,20 +23,30 @@ import (
type ClosingService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, 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)
}
type closingService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.ClosingRepository
Log *logrus.Logger
Validate *validator.Validate
Repository repository.ClosingRepository
ProjectFlockRepo projectflockRepository.ProjectflockRepository
MarketingRepo marketingRepository.MarketingRepository
MarketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository
ApprovalSvc commonSvc.ApprovalService
}
func NewClosingService(repo repository.ClosingRepository, validate *validator.Validate) ClosingService {
func NewClosingService(repo repository.ClosingRepository, projectFlockRepo projectflockRepository.ProjectflockRepository, marketingRepo marketingRepository.MarketingRepository, marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate) ClosingService {
return &closingService{
Log: utils.Log,
Validate: validate,
Repository: repo,
Log: utils.Log,
Validate: validate,
Repository: repo,
ProjectFlockRepo: projectFlockRepo,
MarketingRepo: marketingRepo,
MarketingDeliveryProductRepo: marketingDeliveryProductRepo,
ApprovalSvc: approvalSvc,
}
}
@@ -37,6 +54,12 @@ 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("KandangHistory").
Preload("KandangHistory.Chickins")
}
func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
@@ -59,14 +82,109 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity
return closings, total, nil
}
func (s closingService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) {
closing, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
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, "Closing not found")
return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock not found")
}
if err != nil {
s.Log.Errorf("Failed get closing by id: %+v", err)
return nil, err
}
return closing, nil
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) 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
}
@@ -151,12 +151,16 @@ 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
}
categoryVal := c.FormValue("category")
req.Category = &categoryVal
if categoryVal != "" {
req.Category = &categoryVal
}
supplierIDVal := c.FormValue("supplier_id")
if supplierIDVal != "" {
@@ -312,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)
+2
View File
@@ -14,6 +14,8 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService
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)
@@ -183,7 +183,7 @@ 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")
}
@@ -732,10 +732,10 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va
expenseRepoTx := repository.NewExpenseRepository(tx)
// Check if only updating documents
updateDataOnly := len(req.Realizations) == 0 && len(req.Documents) > 0
updateDataOnly := req.Realizations == nil && len(req.Documents) > 0
if len(req.Realizations) > 0 {
for _, realizationItem := range req.Realizations {
if req.Realizations != nil {
for _, realizationItem := range *req.Realizations {
expenseNonstockID := realizationItem.ExpenseNonstockID
@@ -770,6 +770,12 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va
}
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 {
if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil {
return err
@@ -1050,17 +1056,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)
@@ -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
}
@@ -5,11 +5,11 @@ 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"`
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"`
}
@@ -26,11 +26,11 @@ type CostItem struct {
}
type Update struct {
TransactionDate *string `form:"transaction_date" json:"transaction_date" validate:"omitempty,datetime=2006-01-02"`
Category *string `form:"category" json:"category" validate:"omitempty,oneof=BOP NON-BOP"`
SupplierID *uint64 `form:"supplier_id" json:"supplier_id" validate:"omitempty,gt=0"`
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"`
Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"`
}
type Query struct {
@@ -46,9 +46,9 @@ 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 {
@@ -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,
}
}
@@ -66,7 +66,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")
}
@@ -110,7 +110,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
ProductId: uint(req.ProductID),
WarehouseId: uint(req.WarehouseID),
Quantity: 0,
CreatedBy: actorID,
// CreatedBy: 1, // TODO: should Get from auth middleware
}
if err := s.ProductWarehouseRepo.CreateOne(ctx, newPW, nil); err != nil {
@@ -128,25 +128,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: actorID,
newLog.Decrease = afterQuantity
}
if err := s.StockLogsRepository.WithTx(tx).CreateOne(ctx, newLog, nil); err != nil {
@@ -200,7 +198,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))
@@ -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
}
@@ -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),
})
}
@@ -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
}
@@ -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)
}
@@ -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),
// }
// }
@@ -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)
}
@@ -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
}
@@ -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"`
}
@@ -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
}
@@ -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
}
@@ -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 {
@@ -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 {
+2
View File
@@ -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
}
@@ -271,15 +271,18 @@ 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: actorID,
}
@@ -302,7 +305,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
ProductId: uint(product.ProductID),
WarehouseId: uint(req.DestinationWarehouseID),
Quantity: 0,
CreatedBy: actorID,
// 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)
@@ -319,15 +322,16 @@ 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: actorID,
}
@@ -319,15 +319,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
@@ -9,7 +9,6 @@ import (
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"
@@ -22,7 +21,7 @@ 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)
marketingDeliveryProductRepo := rMarketing.NewMarketingDeliveryProductRepository(db)
userRepo := rUser.NewUserRepository(db)
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalSvc := commonSvc.NewApprovalService(approvalRepo)
@@ -1,7 +1,7 @@
package delivery_orderss
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/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"
@@ -17,6 +17,7 @@ func DeliveryOrdersRoutes(v1 fiber.Router, u user.UserService, s deliveryOrders.
// Sisanya di group /delivery-orders
route := v1.Group("/delivery-orders")
route.Use(m.Auth(u))
// route.Get("/", m.Auth(u), ctrl.GetAll)
// route.Post("/", m.Auth(u), ctrl.CreateOne)
@@ -8,9 +8,8 @@ import (
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
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"
@@ -35,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 {
@@ -200,7 +199,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create)
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)
@@ -259,7 +258,6 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create)
}
approvalAction := entity.ApprovalActionApproved
if _, err := approvalSvcTx.CreateApproval(
c.Context(),
@@ -301,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) {
@@ -1,6 +1,8 @@
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"
@@ -8,6 +10,9 @@ import (
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 {
@@ -19,3 +24,53 @@ func NewMarketingDeliveryProductRepository(db *gorm.DB) MarketingDeliveryProduct
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
}
@@ -1,7 +1,7 @@
package sales_orders
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/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"
@@ -14,6 +14,7 @@ func SalesOrdersRoutes(v1 fiber.Router, u user.UserService, s salesOrders.SalesO
v1.Delete("/:id", ctrl.DeleteOne)
route := v1.Group("/sales-orders")
route.Use(m.Auth(u))
// route.Post("/", m.Auth(u), ctrl.CreateOne)
// route.Patch("/:id", m.Auth(u), ctrl.UpdateOne)
@@ -10,7 +10,6 @@ import (
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"
rInvMarketingDeliveryProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/marketing-delivery-products/repositories"
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"
@@ -125,7 +124,7 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e
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{
@@ -220,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 {
@@ -527,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,
@@ -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 {
@@ -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")
})
@@ -17,8 +17,9 @@ type Update struct {
}
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"`
}
@@ -557,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 {
@@ -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),
})
}
@@ -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
}
@@ -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)
@@ -54,7 +54,10 @@ func (r *ProjectflockRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm
Preload("Location").
Preload("Kandangs").
Preload("KandangHistory").
Preload("KandangHistory.Kandang")
Preload("KandangHistory.Kandang").
Preload("Budgets").
Preload("Budgets.Nonstock").
Preload("Budgets.Nonstock.Uom")
}
}
@@ -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)
}
@@ -15,7 +15,9 @@ import (
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"
@@ -39,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 {
@@ -47,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
@@ -67,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,
@@ -76,6 +84,7 @@ func NewProjectflockService(
Repository: repo,
FlockRepo: flockRepo,
KandangRepo: kandangRepo,
NonstockRepo: nonstockRepo,
WarehouseRepo: warehouseRepo,
ProductWarehouseRepo: productWarehouseRepo,
PivotRepo: pivotRepo,
@@ -289,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
@@ -300,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
@@ -309,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(
@@ -1044,3 +1055,139 @@ func (s projectflockService) kandangRepoWithTx(tx *gorm.DB) kandangRepository.Ka
}
return kandangRepository.NewKandangRepository(s.Repository.DB())
}
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
}
@@ -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"`
}
@@ -39,7 +39,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "created_at",
CreatedAt: "id",
},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
@@ -778,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 {
+1 -10
View File
@@ -21,13 +21,10 @@ type PurchaseRelationDTO struct {
Notes *string `json:"notes"`
}
type PurchaseListDTO struct {
PurchaseRelationDTO
Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"`
CreditTerm *int `json:"credit_term"`
DueDate *time.Time `json:"due_date"`
GrandTotal float64 `json:"grand_total"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -37,9 +34,7 @@ type PurchaseListDTO struct {
type PurchaseDetailDTO struct {
PurchaseRelationDTO
Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"`
CreditTerm *int `json:"credit_term"`
DueDate *time.Time `json:"due_date"`
GrandTotal float64 `json:"grand_total"`
Items []PurchaseItemDTO `json:"items"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
@@ -145,9 +140,7 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO {
return PurchaseListDTO{
PurchaseRelationDTO: ToPurchaseRelationDTO(&p),
Supplier: supplier,
CreditTerm: p.CreditTerm,
DueDate: p.DueDate,
GrandTotal: p.GrandTotal,
CreatedUser: createdUser,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
@@ -188,13 +181,11 @@ func ToPurchaseDetailDTO(p entity.Purchase) PurchaseDetailDTO {
return PurchaseDetailDTO{
PurchaseRelationDTO: ToPurchaseRelationDTO(&p),
Supplier: supplier,
CreditTerm: p.CreditTerm,
DueDate: p.DueDate,
GrandTotal: p.GrandTotal,
Items: ToPurchaseItemDTOs(p.Items),
CreatedUser: createdUser,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
LatestApproval: latestApproval,
}
}
}
+44 -1
View File
@@ -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,8 +84,10 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
warehouseRepo,
supplierRepo,
productWarehouseRepo,
projectFlockKandangRepository,
approvalService,
expenseBridge,
fifoService,
)
userRepo := rUser.NewUserRepository(db)
@@ -19,12 +19,12 @@ type PurchaseRepository interface {
repository.BaseRepository[entity.Purchase]
CreateWithItems(ctx context.Context, purchase *entity.Purchase, items []*entity.PurchaseItem) error
CreateItems(ctx context.Context, purchaseID uint, items []*entity.PurchaseItem) error
UpdatePricing(ctx context.Context, purchaseID uint, updates []PurchasePricingUpdate, grandTotal float64) 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
UpdateGrandTotal(ctx context.Context, purchaseID uint, grandTotal float64) 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 {
@@ -59,6 +59,34 @@ func (r *PurchaseRepositoryImpl) CreateWithItems(ctx context.Context, purchase *
return nil
}
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
@@ -99,7 +127,6 @@ func (r *PurchaseRepositoryImpl) UpdatePricing(
ctx context.Context,
purchaseID uint,
updates []PurchasePricingUpdate,
grandTotal float64,
) error {
if len(updates) == 0 {
return errors.New("pricing updates cannot be empty")
@@ -133,14 +160,6 @@ func (r *PurchaseRepositoryImpl) UpdatePricing(
}
}
if err := db.Model(&entity.Purchase{}).
Where("id = ?", purchaseID).
Updates(map[string]interface{}{
"grand_total": grandTotal,
}).Error; err != nil {
return err
}
return nil
}
@@ -201,20 +220,6 @@ func (r *PurchaseRepositoryImpl) UpdateReceivingDetails(
return nil
}
func (r *PurchaseRepositoryImpl) UpdateGrandTotal(
ctx context.Context,
purchaseID uint,
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 uint, itemIDs []uint) error {
if len(itemIDs) == 0 {
return errors.New("itemIDs cannot be empty")
@@ -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 uint, items []entity.PurchaseItem) error
OnItemsDeleted(ctx context.Context, purchaseID uint, itemIDs []uint) error
OnItemsReceived(ctx context.Context, purchaseID uint, 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 uint
ProductID uint
WarehouseID uint
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, _ uint, _ []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, _ uint, _ []uint) 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, _ uint, _ []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
}
@@ -16,10 +16,12 @@ import (
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"
@@ -39,26 +41,28 @@ type PurchaseService interface {
}
const (
priceTolerance = 0.0001
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
ApprovalSvc commonSvc.ApprovalService
ExpenseBridge PurchaseExpenseBridge
approvalWorkflow approvalutils.ApprovalWorkflowKey
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(
@@ -68,23 +72,24 @@ func NewPurchaseService(
warehouseRepo rWarehouse.WarehouseRepository,
supplierRepo rSupplier.SupplierRepository,
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
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,
ApprovalSvc: approvalSvc,
ExpenseBridge: expenseBridge,
approvalWorkflow: utils.ApprovalWorkflowPurchase,
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 {
@@ -114,9 +119,9 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
offset := (params.Page - 1) * params.Limit
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())
}
purchases, total, err := s.PurchaseRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
@@ -225,6 +230,7 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase
productId uint
warehouseId uint
subQty float64
pfkID *uint
}
if len(req.Items) == 0 {
@@ -233,31 +239,47 @@ 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")
return db.Preload("Area").Preload("Location")
})
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
}
@@ -286,35 +308,42 @@ 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: uint(req.SupplierID),
CreditTerm: creditTerm,
DueDate: dueDate,
GrandTotal: 0,
Notes: req.Notes,
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,
})
}
@@ -331,6 +360,10 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase
return err
}
if err := purchaseRepoTx.BackfillProjectFlockKandang(c.Context(), purchase.Id); err != nil {
return err
}
actorID := uint(purchase.CreatedBy)
if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepPengajuan, entity.ApprovalActionCreated, actorID, nil, false); err != nil {
return err
@@ -361,6 +394,8 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
return nil, err
}
ctx := c.Context()
action, err := parseApprovalActionInput(req.Action)
if err != nil {
return nil, err
@@ -371,7 +406,7 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
return nil, err
}
purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations)
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")
@@ -379,7 +414,7 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase")
}
if err := s.attachLatestApproval(c.Context(), purchase); err != nil {
if err := s.attachLatestApproval(ctx, purchase); err != nil {
s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err)
}
@@ -418,12 +453,10 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
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(c.Context(), 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 {
@@ -432,12 +465,6 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
}
}
if !grandTotalUpdated {
if err := purchaseRepoTx.UpdateGrandTotal(c.Context(), purchase.Id, payload.GrandTotal); err != nil {
return err
}
}
if isInitialApproval {
if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepStaffPurchase, action, actorID, req.Notes, false); err != nil {
return err
@@ -481,17 +508,6 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
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(c.Context(), purchase.Id, newItems)
}
return updated, nil
}
@@ -611,6 +627,8 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
return nil, err
}
ctx := c.Context()
action, err := parseApprovalActionInput(req.Action)
if err != nil {
return nil, err
@@ -621,7 +639,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
return nil, err
}
purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations)
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")
@@ -647,14 +665,14 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
}
if action == entity.ApprovalActionRejected {
if err := s.createPurchaseApproval(c.Context(), nil, purchase.Id, utils.PurchaseStepReceiving, action, actorID, req.Notes, true); err != nil {
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(c.Context(), purchase.Id, s.withRelations)
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(c.Context(), updated); err != nil {
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
@@ -670,6 +688,8 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
payload validation.ReceivePurchaseItemRequest
receivedDate time.Time
warehouseID uint
supplierID uint
transportPerItem *float64
overrideWarehouse bool
receivedQty float64
}
@@ -682,7 +702,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
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))
}
@@ -697,6 +717,9 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
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 {
@@ -716,11 +739,27 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
}
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,
})
@@ -737,7 +776,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
approvalSvc := commonSvc.NewApprovalService(
commonRepo.NewApprovalRepository(s.PurchaseRepo.DB()),
)
if approvalSvc != nil {
filterStep := func(step approvalutils.ApprovalStep) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
@@ -767,6 +806,11 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
deltas := make(map[uint]float64)
affected := make(map[uint]struct{})
updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared))
fifoAdds := make([]struct {
itemID uint
pwID uint
qty float64
}, 0, len(prepared))
for _, prep := range prepared {
item := prep.item
@@ -780,21 +824,29 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
var newPWID *uint
clearPW := false
// Always ensure PW when qty > 0 so stockable has target.
if prep.receivedQty > 0 {
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
@@ -830,12 +882,21 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
return err
}
if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepReceiving, receivingAction, actorID, req.Notes, true); err != nil {
return err
}
if err := s.createPurchaseApproval(c.Context(), 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
@@ -860,15 +921,31 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
for _, prep := range prepared {
date := prep.receivedDate
payload := ExpenseReceivingPayload{
PurchaseItemID: prep.item.Id,
ProductID: prep.item.ProductId,
WarehouseID: uint(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(c.Context(), 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
}
@@ -918,6 +995,17 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del
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")
}
@@ -929,10 +1017,6 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del
return err
}
if err := repoTx.UpdateGrandTotal(ctx, purchase.Id, remainingTotal); err != nil {
return err
}
return nil
})
if transactionErr != nil {
@@ -942,8 +1026,14 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del
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.GetByID(ctx, purchase.Id, s.withRelations)
@@ -971,9 +1061,9 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase")
}
itemIDs := make([]uint, 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 {
@@ -995,38 +1085,87 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase")
}
if len(itemIDs) > 0 {
s.notifyExpenseItemsDeleted(ctx, uint(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
}
func (s *purchaseService) notifyExpenseItemsCreated(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) {
if s.ExpenseBridge == nil || purchaseID == 0 || len(items) == 0 {
return
func (s *purchaseService) createPurchaseApproval(
ctx context.Context,
db *gorm.DB,
purchaseID uint,
step approvalutils.ApprovalStep,
action entity.ApprovalAction,
actorID uint,
notes *string,
allowDuplicate bool,
) error {
if purchaseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Purchase is invalid for approval")
}
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)
if actorID == 0 {
actorID = 1
}
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))
}
latest, err := svc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), modifier)
if err != nil {
return err
}
if !allowDuplicate && latest != nil &&
latest.Action != nil &&
*latest.Action == action {
return nil
}
actionCopy := action
_, err = svc.CreateApproval(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), step, &actionCopy, actorID, notes)
return err
}
func (s *purchaseService) notifyExpenseItemsReceived(ctx context.Context, purchaseID uint, payloads []ExpenseReceivingPayload) {
func (s *purchaseService) approvalServiceForDB(db *gorm.DB) commonSvc.ApprovalService {
if db != nil {
return commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db))
}
if s.ApprovalSvc != nil {
return s.ApprovalSvc
}
if s.PurchaseRepo != nil && s.PurchaseRepo.DB() != nil {
return commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.PurchaseRepo.DB()))
}
return nil
}
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 uint, itemIDs []uint) {
if s.ExpenseBridge == nil || purchaseID == 0 || len(itemIDs) == 0 {
return
}
if err := s.ExpenseBridge.OnItemsDeleted(ctx, purchaseID, itemIDs); err != nil {
s.Log.Warnf("Failed to notify expense bridge for deleted purchase %d: %+v", purchaseID, err)
func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) error {
if s.ExpenseBridge == nil || purchaseID == 0 || len(items) == 0 {
return nil
}
return s.ExpenseBridge.OnItemsDeleted(ctx, purchaseID, items)
}
func (s *purchaseService) buildStaffAdjustmentPayload(
@@ -1054,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 {
@@ -1120,7 +1258,6 @@ func (s *purchaseService) buildStaffAdjustmentPayload(
}
updates = append(updates, update)
grandTotal += totalPrice
delete(requestItems, item.Id)
}
if len(requestItems) > 0 {
@@ -1129,6 +1266,7 @@ func (s *purchaseService) buildStaffAdjustmentPayload(
productSupplierCache := make(map[uint]bool)
newItems := make([]*entity.PurchaseItem, 0, len(newPayloads))
emptyVehicle := ""
for _, payload := range newPayloads {
if payload.ProductID == 0 || payload.WarehouseID == 0 {
@@ -1175,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 {
@@ -1196,7 +1334,6 @@ func (s *purchaseService) buildStaffAdjustmentPayload(
return &staffAdjustmentPayload{
PricingUpdates: updates,
NewItems: newItems,
GrandTotal: grandTotal,
}, nil
}
@@ -1239,36 +1376,6 @@ 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
const queryDateLayout = "2006-01-02"
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 parseApprovalActionInput(raw string) (entity.ApprovalAction, error) {
value := strings.ToUpper(strings.TrimSpace(raw))
switch value {
@@ -1302,53 +1409,3 @@ func (s *purchaseService) rejectAndReload(
}
return updated, nil
}
func (s *purchaseService) createPurchaseApproval(
ctx context.Context,
db *gorm.DB,
purchaseID uint,
step approvalutils.ApprovalStep,
action entity.ApprovalAction,
actorID uint,
notes *string,
allowDuplicate bool,
) error {
if purchaseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Purchase is invalid for approval")
}
if actorID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "ActorId is invalid for approval")
}
var svc commonSvc.ApprovalService
switch {
case db != nil:
svc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db))
case s.ApprovalSvc != nil:
svc = s.ApprovalSvc
case s.PurchaseRepo != nil && s.PurchaseRepo.DB() != nil:
svc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.PurchaseRepo.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))
}
latest, err := svc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), modifier)
if err != nil {
return err
}
if !allowDuplicate && latest != nil &&
latest.Action != nil &&
*latest.Action == action {
return nil
}
actionCopy := action
_, err = svc.CreateApproval(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), step, &actionCopy, actorID, notes)
return err
}
@@ -8,7 +8,7 @@ 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"`
}
@@ -38,6 +38,8 @@ type ReceivePurchaseItemRequest struct {
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"`
@@ -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").
+2
View File
@@ -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",
}
// -------------------------------------------------------------------
+34 -1
View File
@@ -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
}
@@ -263,7 +263,6 @@ func createProductWarehouseRow(t *testing.T, db *gorm.DB, qty float64) entity.Pr
ProductId: 1,
WarehouseId: 1,
Quantity: qty,
CreatedBy: 1,
}
if err := db.Create(&pw).Error; err != nil {
t.Fatalf("create product warehouse: %v", err)