mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
Compare commits
1038 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34c690956a | |||
| cb9c202b29 | |||
| ec7ec2219c | |||
| b8164e2e08 | |||
| 3c43426fbc | |||
| 131aa4fcb5 | |||
| c1acee7980 | |||
| 810aab1448 | |||
| b93150e8df | |||
| fc532e1202 | |||
| 71c7207bdb | |||
| 96bef248cb | |||
| 1dc1feed03 | |||
| 5ebdc9dfe4 | |||
| 65962f8e2b | |||
| 17ed266131 | |||
| 26e782b212 | |||
| 1fc6cb051d | |||
| e6159e4f90 | |||
| e80874bad7 | |||
| c5a27ef3a6 | |||
| 45cc057dd4 | |||
| 566567e328 | |||
| 3cb2e15629 | |||
| 1c5b013b9f | |||
| 83650d4486 | |||
| 970332f0be | |||
| b8fa79a2ab | |||
| b1c829bbf8 | |||
| f8ca404bbb | |||
| fca96df3d9 | |||
| 073b098843 | |||
| 650f8e0fdb | |||
| 7c6a401ac2 | |||
| 345fe32433 | |||
| 7f8013c5ed | |||
| 73dfeb64b0 | |||
| ee216327bb | |||
| 7fc52174e0 | |||
| 735c8e00d0 | |||
| 1a1ea14824 | |||
| ed1fb1b776 | |||
| 9e77d88c83 | |||
| 70db3cfd34 | |||
| 562d8a90c8 | |||
| 68f77a97a5 | |||
| 54e4878406 | |||
| 7677057d7c | |||
| 77ac46a029 | |||
| cdd22cf198 | |||
| 8a006f377e | |||
| 1b6041073e | |||
| 1724a5f846 | |||
| f082c5c122 | |||
| d334f46829 | |||
| 80135466df | |||
| 9d5f733172 | |||
| 4bb750fc98 | |||
| 55451a5ea8 | |||
| aafbba199d | |||
| 0206d9dc68 | |||
| e9d1cca294 | |||
| d335597bed | |||
| f6e25be76b | |||
| d5a1751868 | |||
| dd61b66af0 | |||
| 944604adad | |||
| 3bc0685b46 | |||
| f6c88b773d | |||
| 915302c445 | |||
| e7e065c320 | |||
| a2de21e351 | |||
| a8903b3598 | |||
| 4d009978ae | |||
| daca97f113 | |||
| 88f1381f4b | |||
| 625642c709 | |||
| f6f4cc5a10 | |||
| 5fb7a78a5a | |||
| bc1dac2a15 | |||
| a3334c6bb0 | |||
| b73f13ee76 | |||
| 9d6a69dc4d | |||
| 0b35012413 | |||
| 0ac174fdc6 | |||
| 4bf9b12680 | |||
| c3930ab555 | |||
| 2028cee274 | |||
| 1c22c0f01c | |||
| 5e28721651 | |||
| 95547ad7c7 | |||
| 3fd96834f9 | |||
| 3da05eea02 | |||
| 1e788e46f5 | |||
| e0d42fe6d3 | |||
| 28f57525f4 | |||
| ba6c9f61d2 | |||
| 62496f78a8 | |||
| 36ba4f34bb | |||
| 691573fbe5 | |||
| 7f623c0c1f | |||
| ad93cbba7a | |||
| 756fc431b3 | |||
| 71ce855feb | |||
| 5705e39f53 | |||
| 27c9c8cda7 | |||
| ad46f8aca0 | |||
| cad9328e5d | |||
| ac875a328e | |||
| 8ad923a90a | |||
| b43979bbba | |||
| 18db58a87b | |||
| 1a56b37e4e | |||
| 4340828fec | |||
| f74b6476de | |||
| 3011735458 | |||
| d40aac8960 | |||
| 18672f541e | |||
| 58aed76bbb | |||
| 77ec805931 | |||
| 505db703d8 | |||
| cc19b626e1 | |||
| fc157dfd79 | |||
| f407ef6a0c | |||
| 248ca1d522 | |||
| d41f1b9495 | |||
| 6efab80686 | |||
| 6253ca46bc | |||
| aa1fd1c35b | |||
| 1f10e96288 | |||
| ae69b138bf | |||
| b2dd9a6e13 | |||
| 9bc66798d4 | |||
| 1d95976360 | |||
| 114f1a7c24 | |||
| 14cc7ef2ae | |||
| 357b5709f5 | |||
| 474c42770b | |||
| 90de167fcd | |||
| 7183df6938 | |||
| b862fc4113 | |||
| f59cdd821a | |||
| 22038533d7 | |||
| 5641cadeec | |||
| 4eacdd543a | |||
| f75225b81b | |||
| a0221bb79c | |||
| 82f0db107a | |||
| d2ce0918b5 | |||
| 8f4fce1219 | |||
| b7ecf1dfcf | |||
| 9a328ae1e4 | |||
| 58ae03a090 | |||
| e406b20ca7 | |||
| 1c1f2f03aa | |||
| ce108da847 | |||
| e968b8ed9c | |||
| 96627e964f | |||
| 6e0ff557a8 | |||
| f4790e56ea | |||
| 760b37449e | |||
| fb2a7a6676 | |||
| a89c2edb99 | |||
| 5b80081d05 | |||
| 9bf33d2bae | |||
| 2cc89b31be | |||
| 3d92c4eb54 | |||
| 75b822eb19 | |||
| 3b661919c0 | |||
| 74146e90c6 | |||
| 1a9936eaa1 | |||
| c257a2cb28 | |||
| 8a403e803c | |||
| 0707876a81 | |||
| c191776e6c | |||
| 4bdcadb898 | |||
| 4f9cd5131f | |||
| 703c1548c9 | |||
| 616250d38b | |||
| a312b32aa2 | |||
| 9b6637066e | |||
| 4496d211a1 | |||
| 2c93558d05 | |||
| d5b3120ea3 | |||
| 4a85643aef | |||
| 22bd0e5891 | |||
| d1e3234f73 | |||
| 3d1d9c418b | |||
| 3669f20f4a | |||
| a21b554fc7 | |||
| 86caa6c4d4 | |||
| ef286949f8 | |||
| 16157b135f | |||
| a2012aa145 | |||
| 478487870b | |||
| 392b2f51ad | |||
| 20a45bb71e | |||
| 9c9cd1dd1b | |||
| bb8e4198f4 | |||
| 6b58eb0c65 | |||
| 97a807f494 | |||
| bee9feafaf | |||
| d0dd12776e | |||
| ac3623fa97 | |||
| 705939bbf5 | |||
| 4d6f731f80 | |||
| a26ccb7811 | |||
| 24289796b9 | |||
| 9dd4a93476 | |||
| b468ba3df9 | |||
| 2127e9d1f4 | |||
| 1dae84101f | |||
| 595ad6ed17 | |||
| 8c71e7f2a3 | |||
| 43f9b660ce | |||
| 641b7ebd38 | |||
| 396c1b5e45 | |||
| 2c169e7f83 | |||
| 150f8e8606 | |||
| 1a0261ed36 | |||
| c3fe6b0463 | |||
| 207e8ee3c4 | |||
| f2195ae208 | |||
| 8086d2fb9e | |||
| 3d39d6d31e | |||
| 3d70701d03 | |||
| 8410573ee6 | |||
| 095080320c | |||
| e503a84660 | |||
| 6cac4f0243 | |||
| 958dd4f241 | |||
| 37f0324e2a | |||
| 77043005dd | |||
| 8302345b30 | |||
| 461877f75c | |||
| f3ddd79974 | |||
| 8848a50e3b | |||
| ae7e53ac1f | |||
| ba20394a10 | |||
| b8de42b6fa | |||
| aa94c7cc02 | |||
| 5621c63e9a | |||
| 0920b91271 | |||
| 00cdfb692b | |||
| eef3c0f759 | |||
| 3c77aff413 | |||
| f8d42dbdb3 | |||
| e881c2b952 | |||
| 52ebcc5c2d | |||
| 3e0291c2ba | |||
| 7a704c4ec4 | |||
| bd0f89c521 | |||
| b83ebc0ff9 | |||
| 1572dfd0b8 | |||
| 258fd1d7e0 | |||
| f44ddef79b | |||
| 9339e1e9f0 | |||
| 798dd7f9a3 | |||
| 7a8f813e1f | |||
| d656eedfbe | |||
| 571f6b4bf3 | |||
| 76d980878d | |||
| fdd8e3ec31 | |||
| 0f6cd3a054 | |||
| c6d087eeab | |||
| 437cd3beda | |||
| 8fbce5a01e | |||
| f4b2408698 | |||
| 8c84981812 | |||
| 458c8e0a91 | |||
| 4646bf5577 | |||
| e60c08e09e | |||
| 33d3b18468 | |||
| 8b1831fc73 | |||
| 919dc5c2e8 | |||
| 129d253683 | |||
| f69321d9cd | |||
| 74158138c0 | |||
| 699a6e9289 | |||
| 507d6c4293 | |||
| 43286cead1 | |||
| 1216e65419 | |||
| 42f030a780 | |||
| cf475d678e | |||
| a42c201ac6 | |||
| d4699fba5b | |||
| e1ab5a90cb | |||
| f060da1cd3 | |||
| f82ac01e7c | |||
| fd2f773806 | |||
| 7db3afe985 | |||
| 8dc88b97a4 | |||
| f1787d3375 | |||
| 6b4eb758e4 | |||
| d54911f8b4 | |||
| 1edd071a8a | |||
| fb565ef728 | |||
| 9928b4c970 | |||
| 8c58cc4103 | |||
| 0d585a99a6 | |||
| 302147787e | |||
| 19a268adfe | |||
| b1b50c3c01 | |||
| c085888ca9 | |||
| 41d3c21fef | |||
| ad5e832d41 | |||
| 774c9b1d58 | |||
| 12ed9cd753 | |||
| 4ab1553340 | |||
| 3fa50b6344 | |||
| 80424dee17 | |||
| a9b33eaf28 | |||
| 551534d02a | |||
| 202a8ffc66 | |||
| 6bc5e7d293 | |||
| 2e0827dec5 | |||
| 87973a6c9f | |||
| ebac5f5a98 | |||
| 1ca6c6a104 | |||
| a1832a6144 | |||
| 78a45b11e7 | |||
| 58b29501c0 | |||
| bac0361df5 | |||
| c8912e503e | |||
| 645a97b460 | |||
| eb2479cc41 | |||
| 04ec8560a7 | |||
| 12240a9e2d | |||
| f2a46843c8 | |||
| 06e92d1c77 | |||
| d7ed768d14 | |||
| f04cbd24bd | |||
| ec4b849778 | |||
| 132e043597 | |||
| e99af36796 | |||
| f6bdb17699 | |||
| 8f7fc622f6 | |||
| 30f5ed417c | |||
| 1d726afa6f | |||
| 96ba947952 | |||
| ad0504f49e | |||
| 0b708cd57b | |||
| 32153f02b8 | |||
| cf37822a07 | |||
| 7fb6c3c7bf | |||
| fd689919b0 | |||
| 2ad0c17fbe | |||
| e8c7b3f2a8 | |||
| ce09c2473c | |||
| 3493d1d7b2 | |||
| 9fff954857 | |||
| c7c1e4b335 | |||
| 5dbe9bb989 | |||
| e555cfa950 | |||
| 3a457ceee5 | |||
| 32a8557a3b | |||
| baa49b7cf1 | |||
| 1818f5a295 | |||
| a73b44808f | |||
| 69d9137e78 | |||
| e32b231c6c | |||
| ca6d0b160b | |||
| e8a89f0f17 | |||
| d96a12776a | |||
| adabb4e035 | |||
| 16a0b848bc | |||
| c2d2701d72 | |||
| 894efa7aa5 | |||
| d0625e7d21 | |||
| 67ecdbc1dd | |||
| d50ab7cc97 | |||
| ad3bb0e29a | |||
| dd4dcc1c39 | |||
| aa4da68680 | |||
| 95965cb26a | |||
| e4e17f16f9 | |||
| edd77c5265 | |||
| dab692a0c1 | |||
| e6244dea8a | |||
| d1e4bf060e | |||
| fc06b3e4db | |||
| cf42e8c130 | |||
| 8ff97cb647 | |||
| 1b7ce3c62c | |||
| 21de17b18a | |||
| 2aaaab91f7 | |||
| 4227152979 | |||
| ec020ac17c | |||
| adc30ad5cd | |||
| 9fb5395469 | |||
| bc771660be | |||
| b615570036 | |||
| c793c3cf9a | |||
| b3e0410f5a | |||
| a882d5a687 | |||
| c0848b6d2d | |||
| b240478ed5 | |||
| 3052497fc0 | |||
| 71c62c5e02 | |||
| 768961d7d6 | |||
| 8cd9627a51 | |||
| 378d633ea4 | |||
| fb193fc61f | |||
| af7aabdec8 | |||
| bac36b4f00 | |||
| 687d02313b | |||
| 7d3602d829 | |||
| 533e9aca6f | |||
| dcfb5e10b4 | |||
| fbeccf4cdc | |||
| f2ae2cc731 | |||
| eda50930e7 | |||
| 0bc5480a1d | |||
| ef482dd1b9 | |||
| 302f0ed877 | |||
| 31c48ee1da | |||
| 8ad11af9c9 | |||
| 688d3fa757 | |||
| 409652c15e | |||
| 08be60c229 | |||
| 50b19dc1c3 | |||
| e770526c1a | |||
| 77af262662 | |||
| 9d611b7492 | |||
| 21ff1c8ab7 | |||
| 2ca84ecffe | |||
| f13e4f907c | |||
| 4d334e8d5c | |||
| 62522a751f | |||
| 62ccc2e5d6 | |||
| 13fc246f21 | |||
| 89293a843e | |||
| a5af469865 | |||
| 8efe9b668b | |||
| 89480deeb0 | |||
| 4146342120 | |||
| b375fb964e | |||
| fe002c9602 | |||
| f1032b44d1 | |||
| a3e9017e29 | |||
| 7f2401311b | |||
| 3f4d6c630a | |||
| 8792161c02 | |||
| 37c26d5877 | |||
| c316a6d7a9 | |||
| 3a89e18b16 | |||
| c6dc94a4e1 | |||
| aeb5433346 | |||
| 549e283757 | |||
| cad15bcd78 | |||
| e004354420 | |||
| e0ff6e6d79 | |||
| 804ff45dbd | |||
| 9f1c153841 | |||
| 2a884a8d09 | |||
| dbf72c7248 | |||
| 7daa509cd0 | |||
| 894fa0b22a | |||
| d680196919 | |||
| 9fc2d0556e | |||
| c2a89910fb | |||
| 32772a63c8 | |||
| 26bf7f165e | |||
| dff9e73ab1 | |||
| 1847e5590a | |||
| 57094f664c | |||
| f8b6e12d16 | |||
| e12c34db13 | |||
| 3012d260ec | |||
| f6e872c0aa | |||
| 8a639f127c | |||
| 2acaa10b60 | |||
| bd9d41e161 | |||
| 06d8d0b795 | |||
| 1d5e7b6e1a | |||
| da190f1b05 | |||
| c8905eb715 | |||
| 7f1d796b65 | |||
| 6c7ff3f415 | |||
| 03fbf7f4b7 | |||
| 7545e9b37d | |||
| 69f38bf16a | |||
| 5730053e04 | |||
| b087a703ef | |||
| 00edcb6add | |||
| de6580d11c | |||
| dcd6008946 | |||
| 711f58abae | |||
| ffd3c905fe | |||
| 6e4a8617da | |||
| 8a57d5d675 | |||
| 5de81f6315 | |||
| e50dd096a4 | |||
| 7551d11888 | |||
| 7444cfac31 | |||
| 1b5b5bc847 | |||
| 5d7b613ffc | |||
| 33e89d65ab | |||
| 0f4cc6e379 | |||
| 590df26a1f | |||
| ce7ce778fd | |||
| eaa208f733 | |||
| b088eebac5 | |||
| 3c10866208 | |||
| cfbe431222 | |||
| f7a392be52 | |||
| 4c434899aa | |||
| 7fd90f3268 | |||
| d26c2dba3f | |||
| f8415ea15d | |||
| 64fe845128 | |||
| 4bd8319e3b | |||
| bba2dec8c6 | |||
| f7b70d4b14 | |||
| 9f28294dc3 | |||
| 6a166ceb86 | |||
| f0b4fe916c | |||
| f37bf4d22d | |||
| ac5edb36e7 | |||
| 8fab5d7d91 | |||
| 5ddfb2c745 | |||
| 5cfa4d4a59 | |||
| 80b2cafd2f | |||
| b47f26d448 | |||
| 2f22182605 | |||
| e2d352721c | |||
| 068fe4329e | |||
| 15be8dcbea | |||
| 041e8763ac | |||
| 644e9911e4 | |||
| bb04cb53d9 | |||
| 048e607290 | |||
| 18441eb19f | |||
| 526e14f26e | |||
| 539081ce99 | |||
| d568b87e01 | |||
| 9515848d8f | |||
| c15ff8a211 | |||
| d1d94357cf | |||
| 67f5165bfb | |||
| 1217f34dcd | |||
| ae41422776 | |||
| 3978951d8f | |||
| 3422fceec7 | |||
| 09f1b29359 | |||
| 167d18fe87 | |||
| 473f4504ea | |||
| dc7dc0ba47 | |||
| a54129866e | |||
| d40243be4b | |||
| 525ff650f2 | |||
| c1e9b5a975 | |||
| 272367d8ef | |||
| d3c7d65bf5 | |||
| 944fd860a3 | |||
| af79db8726 | |||
| b42ca5e6fb | |||
| 3b2c6f16c3 | |||
| 359e982e76 | |||
| bc0bf7fe16 | |||
| 70a7b1b888 | |||
| 17d55bd2c0 | |||
| 9cc86df1ed | |||
| b7914e8294 | |||
| d33119661a | |||
| 8a57d439dc | |||
| 3d76854273 | |||
| b7a3882f20 | |||
| 29933a5df9 | |||
| f8aee4be7b | |||
| 4ee5bf3628 | |||
| 0f06dff761 | |||
| 0629c5ccf6 | |||
| 43eb1df118 | |||
| 338312edd1 | |||
| f7522636e2 | |||
| b11f03dfda | |||
| 76e65704d7 | |||
| 857a3c284b | |||
| 5606b9c4a3 | |||
| 7af78d04dd | |||
| 2650e919e7 | |||
| c729067ab5 | |||
| 7b2d3ae025 | |||
| f079bee92a | |||
| 64e8de2344 | |||
| 2be9ae36c1 | |||
| 6c08fe23ca | |||
| f1f7edb9ab | |||
| 8a64300ddd | |||
| 9164550263 | |||
| a4840fc98a | |||
| a2d2c4269a | |||
| 90f363bfdb | |||
| c3f8ae5887 | |||
| a7a784970d | |||
| 18b0663dc6 | |||
| 375e057e7c | |||
| 9336289573 | |||
| 76d5b6b69a | |||
| e545047165 | |||
| 42aa6829c5 | |||
| 0a84e427c1 | |||
| dded9e807b | |||
| cad91957b3 | |||
| fca2d63c6e | |||
| f5a016b74b | |||
| 82a7bada05 | |||
| c6626cb6f5 | |||
| ebfa88e721 | |||
| 705138795c | |||
| 538372a43a | |||
| 3bd0602525 | |||
| 7a26ca5fe5 | |||
| a08466a28e | |||
| 1bdaf63763 | |||
| d8fb427734 | |||
| c9ebd88e9d | |||
| 0c6d42070a | |||
| b1996be24c | |||
| 4a08be1f55 | |||
| 9f840f2650 | |||
| 80109b77db | |||
| df504e3ff0 | |||
| c1a162b4d4 | |||
| 1101879039 | |||
| 8de33a0f24 | |||
| 1348483b1c | |||
| 8725d79f8f | |||
| 2f8f84cb0d | |||
| cc5a58b6d1 | |||
| 39909d1c2e | |||
| fe51f33ab4 | |||
| e0dd2799fc | |||
| 556540e97f | |||
| e421307965 | |||
| 394eb0f363 | |||
| 47d497d6b0 | |||
| 1b5437bc01 | |||
| 7d6573fabd | |||
| ce083bccdc | |||
| dc4729c3b9 | |||
| bec6a93152 | |||
| 3a1a2b436d | |||
| 9d285869f5 | |||
| 42853aaac0 | |||
| 610555c3cf | |||
| c60c40af03 | |||
| 4e2724a702 | |||
| 953756c15c | |||
| 2749e44439 | |||
| b8c0b0c37d | |||
| acbf52a5e1 | |||
| 0fc560b91c | |||
| 2d098cb6b1 | |||
| d35d0bbe6b | |||
| d9afd2913e | |||
| dbaee73134 | |||
| 709e304f7f | |||
| d994cfdce7 | |||
| d3bb00a06a | |||
| 5302713811 | |||
| f698ca070c | |||
| 6c42119f4d | |||
| bc03c469f2 | |||
| fd5f83ca58 | |||
| 299c8c7177 | |||
| 78359db880 | |||
| 91fd8a253b | |||
| d91ff7a4c2 | |||
| 3ecea6741f | |||
| b988f45a0b | |||
| 10799cc1ed | |||
| c9c581ef30 | |||
| 6ee795cf2a | |||
| 471fd1dbbf | |||
| 4e5caa8cba | |||
| 0285852c42 | |||
| 0396aa0255 | |||
| 756ba223ed | |||
| 0c776e8332 | |||
| 90125ffe1a | |||
| c36719cc1a | |||
| e4acd9a21e | |||
| 9a094b8bfe | |||
| 16ef73fce3 | |||
| ddda696454 | |||
| 635049163e | |||
| 49af2d6448 | |||
| 68703d8752 | |||
| f19a3cb76e | |||
| d1ba13de76 | |||
| 6523290aaf | |||
| a2066979c1 | |||
| 8e7e976946 | |||
| e30ef5ef10 | |||
| bb76d27f25 | |||
| dbb13da7c4 | |||
| ac8536a4a1 | |||
| 96c2917834 | |||
| c3302397cc | |||
| c7ae836cf0 | |||
| 20f8a45823 | |||
| 67ddd8e667 | |||
| ebf0f8c5ab | |||
| 7dc5c9e9a5 | |||
| 306cf11fee | |||
| 9ee3b7582c | |||
| 8dfb224614 | |||
| 411d6fe6a9 | |||
| db4e8232b9 | |||
| 644896edfa | |||
| d945fcd19c | |||
| 812db3f79e | |||
| 10f42ed9c4 | |||
| a0d2c1c7dd | |||
| 56811f7c5b | |||
| 647bfbb667 | |||
| ec6da57510 | |||
| cdfa77566c | |||
| 1c875a916b | |||
| 85dc0ecd13 | |||
| c9633d1308 | |||
| b156e06cee | |||
| cd14de4dd2 | |||
| 54487b0fcf | |||
| a9037991ef | |||
| 12e5706318 | |||
| 3e575d96a7 | |||
| 98a34a1640 | |||
| c643e66282 | |||
| 9c3d0a44a6 | |||
| e935843cba | |||
| e33b23a2aa | |||
| c55fdb75a7 | |||
| 3a27917afc | |||
| c0132e5880 | |||
| 3d13cd966a | |||
| b41bb79125 | |||
| a2b8ebe665 | |||
| 2d8f20b70e | |||
| 824eb5905f | |||
| 817b6f82d0 | |||
| cbd3047a17 | |||
| ff4b4afcca | |||
| 240cd72204 | |||
| eae69a08fc | |||
| 17be6abc49 | |||
| ef117e66d1 | |||
| 4dfb988994 | |||
| dc726c49cf | |||
| a82df468d2 | |||
| 1af8f0a726 | |||
| 63068b8c3e | |||
| 5461c8b0ce | |||
| 5dc5f4c589 | |||
| ab9c7c216a | |||
| faa0861451 | |||
| 2eade07f0a | |||
| dbb9db960f | |||
| fa6d82b79a | |||
| 207382b3b0 | |||
| e551995c66 | |||
| cb076d92ac | |||
| f5c80fa560 | |||
| 14a4d9e944 | |||
| 84da0c27e0 | |||
| 047162699e | |||
| c95f90f0b9 | |||
| 9e0b4be4dd | |||
| f2df7f4847 | |||
| 30231fabe9 | |||
| e738a97e4c | |||
| 81f4a5e33e | |||
| 1e9fdd2b0d | |||
| d675b1e826 | |||
| e52a02b1c0 | |||
| 096a446450 | |||
| 1b23861656 | |||
| a7069a2e50 | |||
| 3bfc401206 | |||
| 21d22c20a3 | |||
| d9a1372077 | |||
| 40f192660d | |||
| afe4b2ffe3 | |||
| eef254021c | |||
| cd739f41b9 | |||
| 8f77031e02 | |||
| 062a7937e2 | |||
| 4094d38d7b | |||
| cf7b3418a5 | |||
| d5bc6838c8 | |||
| efaeb89ca1 | |||
| b6a60d5009 | |||
| a0a143b8ac | |||
| cbb3368141 | |||
| fc49cef781 | |||
| c79e35c217 | |||
| f60564d673 | |||
| b8425c0f58 | |||
| 0de2021308 | |||
| 3ada837b8b | |||
| c062d838e0 | |||
| 4ce7611c26 | |||
| 2dd3e3e271 | |||
| e98d0a9fa1 | |||
| 08c8c4a747 | |||
| de6304332b | |||
| f073bcc2c1 | |||
| 4853891191 | |||
| 086184bbaa | |||
| 4161dcfbdd | |||
| d0309f25dd | |||
| 59ebe29ec8 | |||
| 2b6ba3a41d | |||
| bb1e6833f0 | |||
| a536094481 | |||
| c33cc05f72 | |||
| 3f9865d267 | |||
| 822ca0268e | |||
| 16d1358b3a | |||
| e00f168a15 | |||
| 79d488c979 | |||
| 2effa08648 | |||
| 576f8083a3 | |||
| d7c543bc9d | |||
| 4a2a80916f | |||
| 511e5501bb | |||
| 0fbf04fc1d | |||
| 536e76d481 | |||
| 29aa737422 | |||
| 26f2f3ccbf | |||
| 4b147a3be7 | |||
| 7094d90034 | |||
| e6094528b5 | |||
| 347f21b45c | |||
| 89b23b0653 | |||
| e2a6c2a733 | |||
| e0e2d91db5 | |||
| 6e176688fa | |||
| cbb7f45c5f | |||
| fc9197d00a | |||
| f8e0614d50 | |||
| a8434a5246 | |||
| 9f239b1840 | |||
| 167fd6d6cb | |||
| ec2aca936c | |||
| f701b30cb3 | |||
| 0a18753dde | |||
| 4638fba318 | |||
| 296e8e4c18 | |||
| a586fe3781 | |||
| 2d3f7f7ef9 | |||
| 67f7ec3a40 | |||
| 2fbf66f9f7 | |||
| 70b2a5a2d1 | |||
| 008709c19c | |||
| 6572176cca | |||
| 4c63bd14c3 | |||
| c593df661c | |||
| ee2db748ea | |||
| 5afee298b0 | |||
| 2bc67a8433 | |||
| b4ccd33ea0 | |||
| c279303b99 | |||
| b8a769dc72 | |||
| 8c883669d3 | |||
| 1e9a637202 | |||
| fc14f9a98f | |||
| 17269d701c | |||
| c3305d3089 | |||
| b43e2b44ec | |||
| 6e3a8f3551 | |||
| c064fb1765 | |||
| 4af631a1d3 | |||
| 91e4762945 | |||
| b8403f1c7e | |||
| 415d5c0e67 | |||
| 1bca29cd31 | |||
| 753d8575c4 | |||
| 4c5266da23 | |||
| d79b1868fc | |||
| 33a9d7806e | |||
| ea294c6a18 | |||
| d572d04e3b | |||
| 730fb22cc2 | |||
| 94fc9219af | |||
| 5650253307 | |||
| 79bbe61dab | |||
| fa5609c183 | |||
| beee88322a | |||
| 1b464884c5 | |||
| 31699f4162 | |||
| 966d616022 | |||
| e667d88218 | |||
| 002981e63b | |||
| 1d0ef8fb93 | |||
| 53c321c3e3 | |||
| d76db26a4d | |||
| 91ad7ad5e0 | |||
| 29f0fd6edb | |||
| 79c754312e | |||
| f3b14cb8f2 | |||
| 886446b55f | |||
| dbeb0b62cb | |||
| 240496584f | |||
| c02f72c5e5 | |||
| 99688c8e11 | |||
| 1ceda3623e | |||
| 2e2aed67b8 | |||
| 1fc750efd3 | |||
| a801081a99 | |||
| b0dfa717d5 | |||
| 16d562e024 | |||
| 8881be2a22 | |||
| 3fc330d8f7 | |||
| af147f4f2b | |||
| 6768092e3b | |||
| 53b226f243 | |||
| cd752f19f4 | |||
| 5a73ad0164 | |||
| b8d1268dfa | |||
| da10861fd2 | |||
| 228aedc215 | |||
| b4b860b9d4 | |||
| 3080a6f8ef | |||
| b502751b4e | |||
| 4c7e5b0731 | |||
| 105b20c333 | |||
| f5b7fd60ad | |||
| ced27e23a0 | |||
| 242ccc9230 | |||
| 1e52c51987 | |||
| bf8519df3f | |||
| a57ef82ebb | |||
| c2b60c1aff | |||
| 320f5e65c6 | |||
| 28c81aac25 | |||
| 1dac74e25b | |||
| 9ca9dfc2be | |||
| 02cc082d67 | |||
| 5c25c84f7f | |||
| aaf129622b | |||
| 69469edb62 | |||
| 09d503f5be | |||
| d528096d56 | |||
| 0708628b78 | |||
| cb1df12b7e | |||
| 1156b376fc | |||
| 11f2389ec5 | |||
| 60757237c0 | |||
| 7905bdb0d7 | |||
| 26f9196876 | |||
| 17d3042586 | |||
| 903b114315 | |||
| 2f5fab9f80 | |||
| 74ec25db5b | |||
| 0a0c3f869b | |||
| 762dfa9fb9 | |||
| 6b5d27ae8e | |||
| fd0943dfaf | |||
| 80c84210b8 | |||
| 05ec64b456 | |||
| 9e97b3951c | |||
| b2ed58c734 | |||
| 3785d52925 | |||
| 4c279baad7 | |||
| 6e69e97d26 | |||
| ba12320d12 | |||
| d21aaead7b | |||
| 954cccd564 | |||
| 663d5129bb | |||
| e54b2157c7 | |||
| 95dad52cea | |||
| 28dcae5865 | |||
| 4129c36f9e | |||
| d587a793fe | |||
| a587584156 | |||
| 4b69afe4fa | |||
| 5cfa97dd03 | |||
| 028d5f6f91 | |||
| 60fe553f63 | |||
| 1c99093ff8 | |||
| 54cb1cf3da | |||
| a0569302c8 | |||
| 8f74391f1e | |||
| 5a2f99196f | |||
| 91fbbf5dd9 | |||
| ca168928c7 | |||
| 4d2a9bd7b4 | |||
| 4c4be2ef41 | |||
| a22c615ac1 | |||
| 4aed480662 | |||
| e5b91161a9 | |||
| f97d404121 | |||
| 3ecf39814e | |||
| 770adbd3ff | |||
| 50119ac538 | |||
| b459245c5c | |||
| d4a0d5c68b | |||
| c4448594e2 | |||
| fb831208f4 | |||
| 054ad2ad20 | |||
| 171191c97d | |||
| 587cfabb4a | |||
| 3ede6461cf | |||
| 1dfd1f747e | |||
| 40665b0d8f | |||
| 94f4929749 | |||
| ad815b3412 | |||
| d41e16cab9 | |||
| 22e4728738 | |||
| 501b6f8440 | |||
| 3ea5bf6787 | |||
| 0a4e06614b | |||
| 45f41f87ff | |||
| 02defcb86a | |||
| 0c791898ff | |||
| ddcda59239 | |||
| 85dfc33191 | |||
| bb60e987e5 | |||
| d5e8487f44 | |||
| ab8c5d2ec4 | |||
| 452403d71e | |||
| 5d676d5993 | |||
| e239246d02 | |||
| 6c387b420c | |||
| ae84c9d8cc | |||
| 6bddbbf9d9 | |||
| d04b9278d2 | |||
| 1684d69fae | |||
| 3376f538fe | |||
| d6c9747c54 | |||
| e4646faf30 | |||
| fac5f382ec | |||
| 1053b779e4 | |||
| 9444ad56dc | |||
| 4d26e6b7b4 |
@@ -3,7 +3,7 @@ root = "."
|
|||||||
tmp_dir = "tmp"
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
cmd = "go build -o ./tmp/main ./cmd/api"
|
cmd = "go build -buildvcs=false -o ./tmp/main ./cmd/api"
|
||||||
bin = "tmp/main"
|
bin = "tmp/main"
|
||||||
full_bin = "APP_ENV=dev ./tmp/main"
|
full_bin = "APP_ENV=dev ./tmp/main"
|
||||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
# server configuration
|
|
||||||
# Env value : prod || dev
|
|
||||||
VERSION=0.0.1
|
|
||||||
APP_ENV=dev
|
|
||||||
APP_HOST=0.0.0.0
|
|
||||||
APP_PORT=8081
|
|
||||||
APP_URL=http://localhost:8081
|
|
||||||
|
|
||||||
# database configuration
|
|
||||||
DB_HOST=postgresdb
|
|
||||||
DB_USER=postgres
|
|
||||||
DB_PASSWORD=changeme
|
|
||||||
DB_NAME=db_lti_erp
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_PORT_HOST=5542
|
|
||||||
|
|
||||||
# JWT
|
|
||||||
JWT_SECRET=changeme
|
|
||||||
JWT_ACCESS_EXP_MINUTES=30
|
|
||||||
JWT_REFRESH_EXP_DAYS=30
|
|
||||||
JWT_RESET_PASSWORD_EXP_MINUTES=10
|
|
||||||
JWT_VERIFY_EMAIL_EXP_MINUTES=10
|
|
||||||
|
|
||||||
# CORS
|
|
||||||
CORS_ALLOW_ORIGINS=changeme
|
|
||||||
CORS_ALLOW_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS
|
|
||||||
CORS_ALLOW_HEADERS=Authorization,Content-Type,X-Requested-With
|
|
||||||
CORS_EXPOSE_HEADERS=Link,Location
|
|
||||||
CORS_ALLOW_CREDENTIALS=true
|
|
||||||
CORS_MAX_AGE=600
|
|
||||||
|
|
||||||
# Redis
|
|
||||||
REDIS_URL=redis://redis:6379/0
|
|
||||||
REDIS_PORT_HOST=6381
|
|
||||||
+9
-1
@@ -9,9 +9,16 @@ main
|
|||||||
bin/
|
bin/
|
||||||
*.exe
|
*.exe
|
||||||
*.out
|
*.out
|
||||||
|
.air.toml
|
||||||
|
Makefile
|
||||||
|
docker-compose.local.yml
|
||||||
|
docker-compose.yaml
|
||||||
|
Dockerfile
|
||||||
|
Dockerfile.local
|
||||||
|
.gitlab-ci.yml
|
||||||
# Go build cache
|
# Go build cache
|
||||||
.gocache/
|
.gocache/
|
||||||
|
vendor
|
||||||
|
|
||||||
# Logs & reports
|
# Logs & reports
|
||||||
*.log
|
*.log
|
||||||
@@ -22,3 +29,4 @@ coverage/
|
|||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
|||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- gitops
|
||||||
|
|
||||||
|
variables:
|
||||||
|
AWS_REGION: ap-southeast-3
|
||||||
|
ECR_REGISTRY: 886436954922.dkr.ecr.ap-southeast-3.amazonaws.com
|
||||||
|
ECR_REPO_NAME: mbugroup/lti-api
|
||||||
|
ECR_REPOSITORY: ${ECR_REGISTRY}/${ECR_REPO_NAME}
|
||||||
|
TARGET_PLATFORM: linux/amd64
|
||||||
|
|
||||||
|
DOCKER_HOST: unix:///var/run/docker.sock
|
||||||
|
DOCKER_TLS_CERTDIR: ""
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
|
|
||||||
|
workflow:
|
||||||
|
rules:
|
||||||
|
# run untuk branch utama & MR
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "development"'
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
|
||||||
|
- when: never
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Helper: login ECR
|
||||||
|
# =========================
|
||||||
|
.ecr_login: &ecr_login |
|
||||||
|
AWS_CLI_ENV_ARGS=""
|
||||||
|
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_REGION=$AWS_REGION"
|
||||||
|
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}"
|
||||||
|
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}"
|
||||||
|
if [ -n "${AWS_SESSION_TOKEN:-}" ]; then
|
||||||
|
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN"
|
||||||
|
fi
|
||||||
|
|
||||||
|
PASS="$(docker run --rm $AWS_CLI_ENV_ARGS public.ecr.aws/aws-cli/aws-cli:latest \
|
||||||
|
ecr get-login-password --region "$AWS_REGION" || true)"
|
||||||
|
if [ -z "$PASS" ]; then
|
||||||
|
echo "ERROR: Failed to get ECR login password."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "$PASS" | docker login --username AWS --password-stdin "$ECR_REGISTRY"
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# MR
|
||||||
|
# =========================
|
||||||
|
build_mr:
|
||||||
|
stage: build
|
||||||
|
image: public.ecr.aws/docker/library/docker:27
|
||||||
|
tags: [self-hosted-dev]
|
||||||
|
rules:
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
|
||||||
|
variables:
|
||||||
|
IMAGE_TAG: "prod-${CI_COMMIT_SHORT_SHA}"
|
||||||
|
before_script:
|
||||||
|
- set -eu
|
||||||
|
- docker version
|
||||||
|
- docker info
|
||||||
|
- *ecr_login
|
||||||
|
script: |
|
||||||
|
set -eu
|
||||||
|
# force base image pulls via AWS ECR Public to avoid Docker Hub TLS timeout
|
||||||
|
sed -i 's|^FROM golang:1.23-alpine AS builder$|FROM public.ecr.aws/docker/library/golang:1.23-alpine AS builder|' Dockerfile
|
||||||
|
sed -i 's|^FROM alpine:3.20$|FROM public.ecr.aws/docker/library/alpine:3.20|' Dockerfile
|
||||||
|
echo "Build (MR) : $ECR_REPOSITORY:$IMAGE_TAG"
|
||||||
|
docker build --platform "$TARGET_PLATFORM" -f Dockerfile -t "$ECR_REPOSITORY:$IMAGE_TAG" .
|
||||||
|
echo "Pushing image for MR..."
|
||||||
|
docker push "$ECR_REPOSITORY:$IMAGE_TAG"
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# DEVELOPMENT (push branch development)
|
||||||
|
# =========================
|
||||||
|
build_push_dev:
|
||||||
|
stage: build
|
||||||
|
image: public.ecr.aws/docker/library/docker:27
|
||||||
|
tags: [self-hosted-dev]
|
||||||
|
rules:
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "development"'
|
||||||
|
variables:
|
||||||
|
IMAGE_TAG: "dev-${CI_COMMIT_SHORT_SHA}"
|
||||||
|
before_script:
|
||||||
|
- set -eu
|
||||||
|
- docker version
|
||||||
|
- docker info
|
||||||
|
- *ecr_login
|
||||||
|
script: |
|
||||||
|
set -eu
|
||||||
|
# force base image pulls via AWS ECR Public to avoid Docker Hub TLS timeout
|
||||||
|
sed -i 's|^FROM golang:1.23-alpine AS builder$|FROM public.ecr.aws/docker/library/golang:1.23-alpine AS builder|' Dockerfile
|
||||||
|
sed -i 's|^FROM alpine:3.20$|FROM public.ecr.aws/docker/library/alpine:3.20|' Dockerfile
|
||||||
|
echo "Build & push (dev): $ECR_REPOSITORY:$IMAGE_TAG"
|
||||||
|
docker build --platform "$TARGET_PLATFORM" -f Dockerfile -t "$ECR_REPOSITORY:$IMAGE_TAG" .
|
||||||
|
docker push "$ECR_REPOSITORY:$IMAGE_TAG"
|
||||||
|
|
||||||
|
update_gitops_dev_lti:
|
||||||
|
stage: gitops
|
||||||
|
image: public.ecr.aws/docker/library/alpine:3.20
|
||||||
|
tags: [self-hosted-dev]
|
||||||
|
rules:
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "development"'
|
||||||
|
needs: ["build_push_dev"]
|
||||||
|
variables:
|
||||||
|
IMAGE_TAG: "dev-${CI_COMMIT_SHORT_SHA}"
|
||||||
|
GITOPS_BRANCH: main
|
||||||
|
VALUES_FILE: environments/lti/dev/lti-values-dev.yaml
|
||||||
|
GITOPS_REPO_URL: https://oauth2:${GITOPS_TOKEN}@gitlab.com/cristian.anggita.parjaman/gitops.git
|
||||||
|
before_script:
|
||||||
|
- set -eu
|
||||||
|
- apk add --no-cache git yq
|
||||||
|
- git config --global user.email "ci@gitlab"
|
||||||
|
- git config --global user.name "gitlab-ci"
|
||||||
|
script: |
|
||||||
|
set -eu
|
||||||
|
rm -rf gitops
|
||||||
|
git clone --depth 1 --branch "$GITOPS_BRANCH" "$GITOPS_REPO_URL" gitops
|
||||||
|
cd gitops
|
||||||
|
|
||||||
|
echo "Updating dev image.tag to $IMAGE_TAG"
|
||||||
|
yq -i '.image.tag = strenv(IMAGE_TAG)' "$VALUES_FILE"
|
||||||
|
|
||||||
|
git add "$VALUES_FILE"
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "No changes to commit"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "lti dev deploy ${IMAGE_TAG}"
|
||||||
|
git push origin "$GITOPS_BRANCH"
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# PRODUCTION (push branch production)
|
||||||
|
# =========================
|
||||||
|
build_push_prod:
|
||||||
|
stage: build
|
||||||
|
image: public.ecr.aws/docker/library/docker:27
|
||||||
|
tags: [self-hosted-dev]
|
||||||
|
rules:
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
|
||||||
|
variables:
|
||||||
|
IMAGE_TAG: "prod-${CI_COMMIT_SHORT_SHA}"
|
||||||
|
before_script:
|
||||||
|
- set -eu
|
||||||
|
- docker version
|
||||||
|
- docker info
|
||||||
|
- *ecr_login
|
||||||
|
script: |
|
||||||
|
set -eu
|
||||||
|
# force base image pulls via AWS ECR Public to avoid Docker Hub TLS timeout
|
||||||
|
sed -i 's|^FROM golang:1.23-alpine AS builder$|FROM public.ecr.aws/docker/library/golang:1.23-alpine AS builder|' Dockerfile
|
||||||
|
sed -i 's|^FROM alpine:3.20$|FROM public.ecr.aws/docker/library/alpine:3.20|' Dockerfile
|
||||||
|
echo "Build & push (prod): $ECR_REPOSITORY:$IMAGE_TAG"
|
||||||
|
docker build --platform "$TARGET_PLATFORM" -f Dockerfile -t "$ECR_REPOSITORY:$IMAGE_TAG" .
|
||||||
|
docker push "$ECR_REPOSITORY:$IMAGE_TAG"
|
||||||
|
|
||||||
|
update_gitops_prod_lti:
|
||||||
|
stage: gitops
|
||||||
|
image: public.ecr.aws/docker/library/alpine:3.20
|
||||||
|
tags: [self-hosted-dev]
|
||||||
|
rules:
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
|
||||||
|
needs: ["build_push_prod"]
|
||||||
|
variables:
|
||||||
|
IMAGE_TAG: "prod-${CI_COMMIT_SHORT_SHA}"
|
||||||
|
GITOPS_BRANCH: main
|
||||||
|
VALUES_FILE: environments/lti/prod/lti-values-prod.yaml
|
||||||
|
GITOPS_REPO_URL: https://oauth2:${GITOPS_TOKEN}@gitlab.com/cristian.anggita.parjaman/gitops.git
|
||||||
|
before_script:
|
||||||
|
- set -eu
|
||||||
|
- apk add --no-cache git yq
|
||||||
|
- git config --global user.email "ci@gitlab"
|
||||||
|
- git config --global user.name "gitlab-ci"
|
||||||
|
script: |
|
||||||
|
set -eu
|
||||||
|
rm -rf gitops
|
||||||
|
git clone --depth 1 --branch "$GITOPS_BRANCH" "$GITOPS_REPO_URL" gitops
|
||||||
|
cd gitops
|
||||||
|
|
||||||
|
echo "Updating prod image.tag to $IMAGE_TAG"
|
||||||
|
yq -i '.image.tag = strenv(IMAGE_TAG)' "$VALUES_FILE"
|
||||||
|
|
||||||
|
git add "$VALUES_FILE"
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "No changes to commit"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "lti prod deploy ${IMAGE_TAG}"
|
||||||
|
git push origin "$GITOPS_BRANCH"
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# =========================
|
||||||
|
# Builder stage
|
||||||
|
# =========================
|
||||||
|
FROM public.ecr.aws/docker/library/golang:1.23-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git ca-certificates tzdata
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build API binary
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||||
|
go build -trimpath -ldflags="-s -w" -o lti-api ./cmd/api
|
||||||
|
|
||||||
|
# Build SEED binary
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||||
|
go build -trimpath -ldflags="-s -w" -o lti-seed ./cmd/seed
|
||||||
|
|
||||||
|
# Build migrate CLI with postgres + file drivers
|
||||||
|
RUN GOBIN=/usr/local/bin go install -tags "postgres file" -ldflags="-s -w" github.com/golang-migrate/migrate/v4/cmd/migrate@v4.18.3
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Runtime stage
|
||||||
|
# =========================
|
||||||
|
FROM public.ecr.aws/docker/library/alpine:3.20
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata curl bash postgresql-client \
|
||||||
|
&& adduser -D -H -u 10001 appuser
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/lti-api /app/lti-api
|
||||||
|
COPY --from=builder /app/lti-seed /app/lti-seed
|
||||||
|
COPY --from=builder /usr/local/bin/migrate /app/migrate
|
||||||
|
COPY --from=builder /app/internal/database/migrations /app/migrations
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
EXPOSE 8081
|
||||||
|
|
||||||
|
CMD ["/app/lti-api"]
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
FROM golang:1.23-alpine
|
|
||||||
|
|
||||||
# Install dependensi dasar
|
|
||||||
RUN apk add --no-cache git curl bash build-base
|
|
||||||
|
|
||||||
# Install Air (pakai repo baru air-verse)
|
|
||||||
RUN go install github.com/air-verse/air@v1.52.3
|
|
||||||
|
|
||||||
WORKDIR /lti-api
|
|
||||||
|
|
||||||
# Cache dependencies
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
EXPOSE 8081
|
|
||||||
|
|
||||||
CMD ["air", "-c", ".air.toml"]
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
# --- Load .env kalau ada, dan export ke shell child ---
|
|
||||||
ifneq (,$(wildcard .env))
|
|
||||||
include .env
|
|
||||||
export
|
|
||||||
endif
|
|
||||||
|
|
||||||
# --- Konfigurasi umum ---
|
|
||||||
COMPOSE ?= docker compose -f docker-compose.local.yml
|
|
||||||
NETWORK ?= lti-api_go-network
|
|
||||||
MIGRATE_IMAGE ?= migrate/migrate
|
|
||||||
MIGRATIONS_DIR := $(PWD)/internal/database/migrations
|
|
||||||
|
|
||||||
# Fallback agar tetap jalan meski .env kosong
|
|
||||||
DB_HOST ?= postgresdb
|
|
||||||
DB_PORT ?= 5432
|
|
||||||
DB_USER ?= postgres
|
|
||||||
DB_PASSWORD ?= postgres
|
|
||||||
DB_NAME ?= db_lti_erp
|
|
||||||
|
|
||||||
DB_URL := postgres://$(DB_USER):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable
|
|
||||||
|
|
||||||
# Tunggu DB ready memakai pg_isready dari image postgres
|
|
||||||
WAIT_DB := docker run --rm --network $(NETWORK) postgres:alpine \
|
|
||||||
sh -c 'until pg_isready -h $(DB_HOST) -p $(DB_PORT) -U $(DB_USER) -d $(DB_NAME); do echo "waiting for postgres..."; sleep 1; done'
|
|
||||||
|
|
||||||
# Default target
|
|
||||||
.DEFAULT_GOAL := start
|
|
||||||
|
|
||||||
# --- Daftar phony targets ---
|
|
||||||
.PHONY: start build test lint gen \
|
|
||||||
db-up wait-db \
|
|
||||||
migration-% migrate-up migrate-down migrate-fresh \
|
|
||||||
seed \
|
|
||||||
docker-local docker-down docker-nuke docker-cache psql
|
|
||||||
|
|
||||||
# --- Go workflow ---
|
|
||||||
start:
|
|
||||||
@go run cmd/api/main.go
|
|
||||||
|
|
||||||
build:
|
|
||||||
@go build -o tmp/app ./cmd/api
|
|
||||||
|
|
||||||
test:
|
|
||||||
@go test ./test/...
|
|
||||||
|
|
||||||
lint:
|
|
||||||
@golangci-lint run
|
|
||||||
|
|
||||||
# --- Compose / DB helpers ---
|
|
||||||
db-up:
|
|
||||||
@$(COMPOSE) up -d postgresdb
|
|
||||||
|
|
||||||
wait-db:
|
|
||||||
@$(WAIT_DB)
|
|
||||||
|
|
||||||
# --- Migration (pembuatan file) ---
|
|
||||||
# Contoh: make migration-create_users_table
|
|
||||||
# ":" akan diubah ke "_" (biar aman untuk nama file)
|
|
||||||
migration-%:
|
|
||||||
@migrate create -ext sql -dir $(MIGRATIONS_DIR) $(subst :,_,$*)
|
|
||||||
|
|
||||||
# --- Migration (apply via docker image 'migrate') ---
|
|
||||||
migrate-up: db-up wait-db
|
|
||||||
@docker run --rm -v $(MIGRATIONS_DIR):/migrations --network $(NETWORK) \
|
|
||||||
$(MIGRATE_IMAGE) -path=/migrations/ -database "$(DB_URL)" up
|
|
||||||
|
|
||||||
# Contoh:
|
|
||||||
# make migrate-down step=2 → rollback 2 step
|
|
||||||
# make migrate-down → rollback semua
|
|
||||||
|
|
||||||
migrate-down: db-up wait-db
|
|
||||||
@if [ -n "$(step)" ]; then \
|
|
||||||
echo "⬇️ Migrating down $(step) step(s)..."; \
|
|
||||||
docker run --rm -v $(MIGRATIONS_DIR):/migrations --network $(NETWORK) \
|
|
||||||
$(MIGRATE_IMAGE) -path=/migrations/ -database "$(DB_URL)" down $(step); \
|
|
||||||
else \
|
|
||||||
echo "⬇️ Migrating down ALL steps..."; \
|
|
||||||
docker run --rm -v $(MIGRATIONS_DIR):/migrations --network $(NETWORK) \
|
|
||||||
$(MIGRATE_IMAGE) -path=/migrations/ -database "$(DB_URL)" down -all; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
migrate-fresh: migrate-down migrate-up
|
|
||||||
@true
|
|
||||||
|
|
||||||
# Pakai: make migrate-force v=20250917120000
|
|
||||||
migrate-force:
|
|
||||||
@docker run --rm -v $(MIGRATIONS_DIR):/migrations --network $(NETWORK) \
|
|
||||||
$(MIGRATE_IMAGE) -path=/migrations/ -database "$(DB_URL)" force $(v)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Seeder ---
|
|
||||||
seed: db-up wait-db
|
|
||||||
@$(COMPOSE) run --rm app go run cmd/seed/main.go
|
|
||||||
|
|
||||||
# --- Docker orchestration convenience ---
|
|
||||||
docker-local:
|
|
||||||
@$(COMPOSE) up --build -d
|
|
||||||
|
|
||||||
docker-down:
|
|
||||||
@$(COMPOSE) down --remove-orphans
|
|
||||||
|
|
||||||
# ⚠️ Akan menghapus container, images dan volumes.
|
|
||||||
docker-nuke:
|
|
||||||
@$(COMPOSE) down --rmi all --volumes --remove-orphans
|
|
||||||
|
|
||||||
docker-cache:
|
|
||||||
@docker builder prune -f
|
|
||||||
|
|
||||||
# --- PSQL shell ke DB di container ---
|
|
||||||
psql: db-up
|
|
||||||
@$(COMPOSE) exec -it postgresdb psql -U $(DB_USER) -d $(DB_NAME)
|
|
||||||
|
|
||||||
# Single feature
|
|
||||||
# example: make gen feat=product-category
|
|
||||||
|
|
||||||
# Sub feature
|
|
||||||
# make gen feat=master/area
|
|
||||||
gen:
|
|
||||||
@go run tools/gen.go $(feat)
|
|
||||||
# @goimports -w internal
|
|
||||||
@@ -110,4 +110,5 @@ IT Development PT Mitra Berlian Unggas Group
|
|||||||
|
|
||||||
## 📃 License
|
## 📃 License
|
||||||
|
|
||||||
This project is private. All rights reserved.
|
> This project is private. All rights reserved.
|
||||||
|
# mr test Sat 7 Feb 2026 00:14:58 WIB
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- gitops
|
||||||
|
|
||||||
|
variables:
|
||||||
|
AWS_REGION: ap-southeast-3
|
||||||
|
ECR_REGISTRY: 886436954922.dkr.ecr.ap-southeast-3.amazonaws.com
|
||||||
|
ECR_REPO_NAME: mbugroup/lti-api
|
||||||
|
ECR_REPOSITORY: ${ECR_REGISTRY}/${ECR_REPO_NAME}
|
||||||
|
|
||||||
|
DOCKER_HOST: unix:///var/run/docker.sock
|
||||||
|
DOCKER_TLS_CERTDIR: ""
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
|
|
||||||
|
workflow:
|
||||||
|
rules:
|
||||||
|
- if: '$CI_COMMIT_BRANCH'
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Helper: login ECR
|
||||||
|
# =========================
|
||||||
|
.ecr_login: &ecr_login |
|
||||||
|
AWS_CLI_ENV_ARGS=""
|
||||||
|
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_REGION=$AWS_REGION"
|
||||||
|
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}"
|
||||||
|
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}"
|
||||||
|
if [ -n "${AWS_SESSION_TOKEN:-}" ]; then
|
||||||
|
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN"
|
||||||
|
fi
|
||||||
|
|
||||||
|
PASS="$(docker run --rm $AWS_CLI_ENV_ARGS public.ecr.aws/aws-cli/aws-cli:latest \
|
||||||
|
ecr get-login-password --region "$AWS_REGION" || true)"
|
||||||
|
if [ -z "$PASS" ]; then
|
||||||
|
echo "ERROR: Failed to get ECR login password."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "$PASS" | docker login --username AWS --password-stdin "$ECR_REGISTRY"
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# DEV
|
||||||
|
# =========================
|
||||||
|
build_push_dev_lti:
|
||||||
|
stage: build
|
||||||
|
image: public.ecr.aws/docker/library/docker:27
|
||||||
|
tags: [self-hosted-dev]
|
||||||
|
rules:
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "development"'
|
||||||
|
variables:
|
||||||
|
IMAGE_TAG: "dev-${CI_COMMIT_SHORT_SHA}"
|
||||||
|
before_script:
|
||||||
|
- set -eu
|
||||||
|
- docker version
|
||||||
|
- docker info
|
||||||
|
- *ecr_login
|
||||||
|
script: |
|
||||||
|
set -eu
|
||||||
|
echo "Build & push: $ECR_REPOSITORY:$IMAGE_TAG"
|
||||||
|
|
||||||
|
docker build \
|
||||||
|
-t "$ECR_REPOSITORY:$IMAGE_TAG" \
|
||||||
|
.
|
||||||
|
|
||||||
|
docker push "$ECR_REPOSITORY:$IMAGE_TAG"
|
||||||
|
|
||||||
|
update_gitops_dev_lti:
|
||||||
|
stage: gitops
|
||||||
|
image: public.ecr.aws/docker/library/alpine:3.20
|
||||||
|
tags: [self-hosted-dev]
|
||||||
|
rules:
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "development"'
|
||||||
|
needs: ["build_push_dev_lti"]
|
||||||
|
variables:
|
||||||
|
IMAGE_TAG: "dev-${CI_COMMIT_SHORT_SHA}"
|
||||||
|
GITOPS_BRANCH: main
|
||||||
|
VALUES_FILE: environments/lti/dev/lti-values-dev.yaml
|
||||||
|
GITOPS_REPO_URL: https://oauth2:${GITOPS_TOKEN}@gitlab.com/cristian.anggita.parjaman/gitops.git
|
||||||
|
before_script:
|
||||||
|
- set -eu
|
||||||
|
- apk add --no-cache git yq
|
||||||
|
- git config --global user.email "ci@gitlab"
|
||||||
|
- git config --global user.name "gitlab-ci"
|
||||||
|
script: |
|
||||||
|
set -eu
|
||||||
|
rm -rf gitops
|
||||||
|
git clone --depth 1 --branch "$GITOPS_BRANCH" "$GITOPS_REPO_URL" gitops
|
||||||
|
cd gitops
|
||||||
|
|
||||||
|
echo "Updating DEV image.tag to $IMAGE_TAG in $VALUES_FILE"
|
||||||
|
yq -i '.image.repository = strenv(ECR_REPOSITORY)' "$VALUES_FILE"
|
||||||
|
yq -i '.image.tag = strenv(IMAGE_TAG)' "$VALUES_FILE"
|
||||||
|
|
||||||
|
git add "$VALUES_FILE"
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "No changes to commit"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "lti dev deploy ${IMAGE_TAG}"
|
||||||
|
git push origin "$GITOPS_BRANCH"
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# PROD
|
||||||
|
# =========================
|
||||||
|
# build_push_prod_lti:
|
||||||
|
# stage: build
|
||||||
|
# image: public.ecr.aws/docker/library/docker:27
|
||||||
|
# tags: [self-hosted-dev]
|
||||||
|
# rules:
|
||||||
|
# - if: '$CI_COMMIT_BRANCH == "production"'
|
||||||
|
# variables:
|
||||||
|
# IMAGE_TAG: "prod-${CI_COMMIT_SHORT_SHA}"
|
||||||
|
# before_script:
|
||||||
|
# - set -eu
|
||||||
|
# - docker version
|
||||||
|
# - docker info
|
||||||
|
# - *ecr_login
|
||||||
|
# script: |
|
||||||
|
# set -eu
|
||||||
|
# echo "Build & push: $ECR_REPOSITORY:$IMAGE_TAG"
|
||||||
|
|
||||||
|
# docker build \
|
||||||
|
# -t "$ECR_REPOSITORY:$IMAGE_TAG" \
|
||||||
|
# .
|
||||||
|
|
||||||
|
# docker push "$ECR_REPOSITORY:$IMAGE_TAG"
|
||||||
|
|
||||||
|
# update_gitops_prod_lti:
|
||||||
|
# stage: gitops
|
||||||
|
# image: public.ecr.aws/docker/library/alpine:3.20
|
||||||
|
# tags: [self-hosted-dev]
|
||||||
|
# rules:
|
||||||
|
# - if: '$CI_COMMIT_BRANCH == "production"'
|
||||||
|
# needs: ["build_push_prod_lti"]
|
||||||
|
# variables:
|
||||||
|
# IMAGE_TAG: "prod-${CI_COMMIT_SHORT_SHA}"
|
||||||
|
# GITOPS_BRANCH: main
|
||||||
|
# VALUES_FILE: environments/lti/prod/lti-values-prod.yaml
|
||||||
|
# GITOPS_REPO_URL: https://oauth2:${GITOPS_TOKEN}@gitlab.com/cristian.anggita.parjaman/gitops.git
|
||||||
|
# before_script:
|
||||||
|
# - set -eu
|
||||||
|
# - apk add --no-cache git yq
|
||||||
|
# - git config --global user.email "ci@gitlab"
|
||||||
|
# - git config --global user.name "gitlab-ci"
|
||||||
|
# script: |
|
||||||
|
# set -eu
|
||||||
|
# rm -rf gitops
|
||||||
|
# git clone --depth 1 --branch "$GITOPS_BRANCH" "$GITOPS_REPO_URL" gitops
|
||||||
|
# cd gitops
|
||||||
|
|
||||||
|
# echo "Updating PROD image.tag to $IMAGE_TAG in $VALUES_FILE"
|
||||||
|
# yq -i '.image.repository = strenv(ECR_REPOSITORY)' "$VALUES_FILE"
|
||||||
|
# yq -i '.image.tag = strenv(IMAGE_TAG)' "$VALUES_FILE"
|
||||||
|
|
||||||
|
# git add "$VALUES_FILE"
|
||||||
|
# if git diff --cached --quiet; then
|
||||||
|
# echo "No changes to commit"
|
||||||
|
# exit 0
|
||||||
|
# fi
|
||||||
|
# git commit -m "lti prod deploy ${IMAGE_TAG}"
|
||||||
|
# git push origin "$GITOPS_BRANCH"
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
stages:
|
||||||
|
- notify
|
||||||
|
|
||||||
|
notify_discord_on_mr_request_main_dev:
|
||||||
|
stage: notify
|
||||||
|
image: alpine:3.20
|
||||||
|
rules:
|
||||||
|
# hanya MR yang target ke main atau development
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "development")'
|
||||||
|
when: on_success
|
||||||
|
- when: never
|
||||||
|
|
||||||
|
script:
|
||||||
|
- apk add --no-cache curl jq coreutils
|
||||||
|
- |
|
||||||
|
TIME_HUMAN="$(date '+%d/%m/%y, %H.%M')"
|
||||||
|
TIME_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
|
||||||
|
TITLE="${CI_MERGE_REQUEST_TITLE}"
|
||||||
|
IID="!${CI_MERGE_REQUEST_IID}"
|
||||||
|
USER_LINE="${GITLAB_USER_NAME} (${GITLAB_USER_LOGIN})"
|
||||||
|
PROJECT_PATH="${CI_PROJECT_PATH}"
|
||||||
|
USERNAME="${GITLAB_USER_LOGIN}"
|
||||||
|
MR_URL="${CI_PROJECT_URL}/-/merge_requests/${CI_MERGE_REQUEST_IID}"
|
||||||
|
|
||||||
|
DESC="$(printf "**%s**\n\n%s opened merge request %s %s\n%s" \
|
||||||
|
"$USERNAME" "$USER_LINE" "$IID" "$TITLE" "$TIME_HUMAN")"
|
||||||
|
|
||||||
|
payload=$(jq -n \
|
||||||
|
--arg desc "$DESC" \
|
||||||
|
--arg project "$PROJECT_PATH" \
|
||||||
|
--arg timeiso "$TIME_ISO" \
|
||||||
|
--arg mrurl "$MR_URL" \
|
||||||
|
'{
|
||||||
|
"username": "Mock-api - Merge Requests",
|
||||||
|
"embeds": [
|
||||||
|
{
|
||||||
|
"description": ($desc + "\n" + $mrurl),
|
||||||
|
"color": 15105570,
|
||||||
|
"footer": { "text": $project },
|
||||||
|
"timestamp": $timeiso
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}')
|
||||||
|
|
||||||
|
curl -sS -H "Content-Type: application/json" \
|
||||||
|
-d "$payload" \
|
||||||
|
"$DISCORD_WEBHOOK_URL"
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- migrate
|
||||||
|
- deploy
|
||||||
|
- seed
|
||||||
|
|
||||||
|
default:
|
||||||
|
tags:
|
||||||
|
- self-hosted-prod
|
||||||
|
|
||||||
|
variables:
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
|
|
||||||
|
IMAGE_TAG: "production_${CI_COMMIT_SHORT_SHA}"
|
||||||
|
IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}"
|
||||||
|
IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:production_latest"
|
||||||
|
|
||||||
|
DEPLOY_DIR: "/opt/deploy/lti"
|
||||||
|
COMPOSE_FILE: "docker-compose.yaml"
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# BUILD (AUTO)
|
||||||
|
# =========================
|
||||||
|
build_production:
|
||||||
|
stage: build
|
||||||
|
rules:
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "production"'
|
||||||
|
when: on_success
|
||||||
|
- when: never
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
docker info
|
||||||
|
|
||||||
|
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
|
||||||
|
|
||||||
|
echo "✅ Build image: $IMAGE_NAME"
|
||||||
|
docker build -t "$IMAGE_NAME" -f Dockerfile .
|
||||||
|
|
||||||
|
echo "✅ Push image: $IMAGE_NAME"
|
||||||
|
docker push "$IMAGE_NAME"
|
||||||
|
|
||||||
|
echo "✅ Tag latest: $IMAGE_LATEST"
|
||||||
|
docker tag "$IMAGE_NAME" "$IMAGE_LATEST"
|
||||||
|
docker push "$IMAGE_LATEST"
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# MIGRATE (PRODUCTION)
|
||||||
|
# =========================
|
||||||
|
migrate_production:
|
||||||
|
stage: migrate
|
||||||
|
rules:
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "production"'
|
||||||
|
when: on_success
|
||||||
|
- when: never
|
||||||
|
needs:
|
||||||
|
- job: build_production
|
||||||
|
artifacts: false
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
echo "✅ Running migrations (production) ..."
|
||||||
|
|
||||||
|
cd "$DEPLOY_DIR"
|
||||||
|
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
|
||||||
|
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
|
||||||
|
|
||||||
|
set -a
|
||||||
|
. ./.env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1)
|
||||||
|
test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1)
|
||||||
|
test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1)
|
||||||
|
test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1)
|
||||||
|
test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1)
|
||||||
|
|
||||||
|
export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
|
||||||
|
echo "✅ DATABASE_URL=$DATABASE_URL"
|
||||||
|
|
||||||
|
# NOTE: pastikan nama servicenya benar untuk production (ini sebelumnya masih stg-*)
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true
|
||||||
|
|
||||||
|
COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')"
|
||||||
|
NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)"
|
||||||
|
test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1)
|
||||||
|
|
||||||
|
echo "✅ Checking migrations from repo..."
|
||||||
|
ls -lah "$CI_PROJECT_DIR/internal/database/migrations"
|
||||||
|
|
||||||
|
echo "✅ Running migrations via migrate/migrate container"
|
||||||
|
set +e
|
||||||
|
out=$(docker run --rm \
|
||||||
|
--network "$NETWORK_NAME" \
|
||||||
|
-v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \
|
||||||
|
migrate/migrate:v4.15.2 \
|
||||||
|
-path=/migrations -database "$DATABASE_URL" up 2>&1)
|
||||||
|
code=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "$out"
|
||||||
|
|
||||||
|
if echo "$out" | grep -qi "no change"; then
|
||||||
|
echo "✅ No change (already up to date)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $code -ne 0 ]; then
|
||||||
|
echo "❌ Migration failed with exit code $code"
|
||||||
|
exit $code
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Migration applied successfully"
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# DEPLOY (AUTO)
|
||||||
|
# =========================
|
||||||
|
deploy_production:
|
||||||
|
stage: deploy
|
||||||
|
rules:
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "production"'
|
||||||
|
when: on_success
|
||||||
|
- when: never
|
||||||
|
needs:
|
||||||
|
- job: build_production
|
||||||
|
artifacts: false
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
docker info
|
||||||
|
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
|
||||||
|
|
||||||
|
cd "$DEPLOY_DIR"
|
||||||
|
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
|
||||||
|
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
|
||||||
|
|
||||||
|
docker compose -f "$COMPOSE_FILE" pull
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
|
||||||
|
docker image prune -f
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# SEED (MANUAL)
|
||||||
|
# =========================
|
||||||
|
seed_production:
|
||||||
|
stage: seed
|
||||||
|
rules:
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "production"'
|
||||||
|
when: manual
|
||||||
|
- when: never
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
cd "$DEPLOY_DIR"
|
||||||
|
test -f .env || (echo "❌ .env not found" && exit 1)
|
||||||
|
|
||||||
|
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
|
||||||
|
|
||||||
|
docker compose --env-file .env pull seed
|
||||||
|
docker compose --env-file .env run --rm seed
|
||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- migrate
|
||||||
|
- deploy
|
||||||
|
- seed
|
||||||
|
|
||||||
|
default:
|
||||||
|
tags:
|
||||||
|
- self-hosted-stg
|
||||||
|
|
||||||
|
variables:
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
|
|
||||||
|
IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}"
|
||||||
|
IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}"
|
||||||
|
IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest"
|
||||||
|
|
||||||
|
DEPLOY_DIR: "/opt/deploy/stg-lti-api"
|
||||||
|
COMPOSE_FILE: "docker-compose.yaml"
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# BUILD (AUTO)
|
||||||
|
# =========================
|
||||||
|
build_staging:
|
||||||
|
stage: build
|
||||||
|
rules:
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "staging"'
|
||||||
|
when: on_success
|
||||||
|
- when: never
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
docker info
|
||||||
|
|
||||||
|
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
|
||||||
|
|
||||||
|
echo "✅ Build image: $IMAGE_NAME"
|
||||||
|
docker build -t "$IMAGE_NAME" -f Dockerfile .
|
||||||
|
|
||||||
|
echo "✅ Push image: $IMAGE_NAME"
|
||||||
|
docker push "$IMAGE_NAME"
|
||||||
|
|
||||||
|
echo "✅ Tag latest: $IMAGE_LATEST"
|
||||||
|
docker tag "$IMAGE_NAME" "$IMAGE_LATEST"
|
||||||
|
docker push "$IMAGE_LATEST"
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# MIGRATE (AUTO)
|
||||||
|
# =========================
|
||||||
|
migrate_staging:
|
||||||
|
stage: migrate
|
||||||
|
rules:
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "staging"'
|
||||||
|
when: on_success
|
||||||
|
- when: never
|
||||||
|
needs:
|
||||||
|
- job: build_staging
|
||||||
|
artifacts: false
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
echo "✅ Running migrations (staging) ..."
|
||||||
|
|
||||||
|
cd "$DEPLOY_DIR"
|
||||||
|
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
|
||||||
|
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
|
||||||
|
|
||||||
|
set -a
|
||||||
|
. ./.env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1)
|
||||||
|
test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1)
|
||||||
|
test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1)
|
||||||
|
test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1)
|
||||||
|
test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1)
|
||||||
|
|
||||||
|
export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
|
||||||
|
echo "✅ DATABASE_URL=$DATABASE_URL"
|
||||||
|
|
||||||
|
echo "✅ Ensuring postgres & redis running ..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true
|
||||||
|
|
||||||
|
COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')"
|
||||||
|
echo "✅ Compose network key: $COMPOSE_NETWORK_KEY"
|
||||||
|
|
||||||
|
NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)"
|
||||||
|
test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1)
|
||||||
|
|
||||||
|
echo "✅ Docker network detected: $NETWORK_NAME"
|
||||||
|
|
||||||
|
echo "✅ Checking migrations from repo..."
|
||||||
|
ls -lah "$CI_PROJECT_DIR/internal/database/migrations"
|
||||||
|
|
||||||
|
echo "✅ Running migrations via migrate/migrate container"
|
||||||
|
set +e
|
||||||
|
out=$(docker run --rm \
|
||||||
|
--network "$NETWORK_NAME" \
|
||||||
|
-v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \
|
||||||
|
migrate/migrate:v4.15.2 \
|
||||||
|
-path=/migrations -database "$DATABASE_URL" up 2>&1)
|
||||||
|
code=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "$out"
|
||||||
|
|
||||||
|
if echo "$out" | grep -qi "no change"; then
|
||||||
|
echo "✅ No change (already up to date)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $code -ne 0 ]; then
|
||||||
|
echo "❌ Migration failed with exit code $code"
|
||||||
|
exit $code
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Migration applied successfully"
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# DEPLOY (AUTO)
|
||||||
|
# =========================
|
||||||
|
deploy_staging:
|
||||||
|
stage: deploy
|
||||||
|
rules:
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "staging"'
|
||||||
|
when: on_success
|
||||||
|
- when: never
|
||||||
|
needs:
|
||||||
|
- job: migrate_staging
|
||||||
|
artifacts: false
|
||||||
|
- job: build_staging
|
||||||
|
artifacts: false
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
docker info
|
||||||
|
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
|
||||||
|
|
||||||
|
cd "$DEPLOY_DIR"
|
||||||
|
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
|
||||||
|
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
|
||||||
|
|
||||||
|
docker compose -f "$COMPOSE_FILE" pull
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
|
||||||
|
docker image prune -f
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# SEED (MANUAL)
|
||||||
|
# =========================
|
||||||
|
seed_staging:
|
||||||
|
stage: seed
|
||||||
|
rules:
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "staging"'
|
||||||
|
when: manual
|
||||||
|
- when: never
|
||||||
|
needs:
|
||||||
|
- job: deploy_staging
|
||||||
|
artifacts: false
|
||||||
|
allow_failure: false
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
cd "$DEPLOY_DIR"
|
||||||
|
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found" && exit 1)
|
||||||
|
test -f .env || (echo "❌ .env not found" && exit 1)
|
||||||
|
|
||||||
|
docker compose -f "$COMPOSE_FILE" pull seed || true
|
||||||
|
docker compose -f "$COMPOSE_FILE" run --rm seed
|
||||||
@@ -9,9 +9,12 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/cache"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/middleware"
|
"gitlab.com/mbugroup/lti-api.git/internal/middleware"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
|
||||||
|
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/route"
|
"gitlab.com/mbugroup/lti-api.git/internal/route"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||||
|
|
||||||
@@ -33,6 +36,7 @@ func main() {
|
|||||||
defer closeDatabase(db)
|
defer closeDatabase(db)
|
||||||
rdb := setupRedis()
|
rdb := setupRedis()
|
||||||
defer rdb.Close()
|
defer rdb.Close()
|
||||||
|
setupSSO(ctx, rdb)
|
||||||
setupRoutes(app, db, rdb)
|
setupRoutes(app, db, rdb)
|
||||||
|
|
||||||
address := fmt.Sprintf("%s:%d", config.AppHost, config.AppPort)
|
address := fmt.Sprintf("%s:%d", config.AppHost, config.AppPort)
|
||||||
@@ -52,10 +56,47 @@ func setupRedis() *redis.Client {
|
|||||||
if err := rdb.Ping(context.Background()).Err(); err != nil {
|
if err := rdb.Ping(context.Background()).Err(); err != nil {
|
||||||
utils.Log.Fatalf("Redis ping failed: %v", err)
|
utils.Log.Fatalf("Redis ping failed: %v", err)
|
||||||
}
|
}
|
||||||
|
cache.SetRedis(rdb)
|
||||||
utils.Log.Infof("Redis connected: %s", config.RedisURL)
|
utils.Log.Infof("Redis connected: %s", config.RedisURL)
|
||||||
return rdb
|
return rdb
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setupSSO(ctx context.Context, rdb *redis.Client) {
|
||||||
|
const (
|
||||||
|
maxAttempts = 12
|
||||||
|
retryDelay = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||||
|
if err := sso.Init(ctx, config.SSOJWKSURL, config.SSOIssuer, config.SSOAllowedAudiences); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
utils.Log.WithError(err).Warnf("SSO initialization attempt %d/%d failed", attempt, maxAttempts)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
utils.Log.Fatalf("SSO initialization aborted: %v", ctx.Err())
|
||||||
|
case <-time.After(retryDelay):
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lastErr = nil
|
||||||
|
if attempt > 1 {
|
||||||
|
utils.Log.Infof("SSO initialization succeeded after %d attempts", attempt)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr != nil {
|
||||||
|
utils.Log.Fatalf("SSO initialization failed: %v", lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rdb != nil {
|
||||||
|
session.SetRevocationStore(session.NewRevocationStore(rdb, config.SSOTokenBlacklistPrefix))
|
||||||
|
} else {
|
||||||
|
session.SetRevocationStore(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func setupFiberApp() *fiber.App {
|
func setupFiberApp() *fiber.App {
|
||||||
app := fiber.New(config.FiberConfig())
|
app := fiber.New(config.FiberConfig())
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||||
|
"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"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type adjustmentRow struct {
|
||||||
|
ID uint `gorm:"column:id"`
|
||||||
|
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
||||||
|
ProductID uint `gorm:"column:product_id"`
|
||||||
|
FunctionCode string `gorm:"column:function_code"`
|
||||||
|
TotalQty float64 `gorm:"column:total_qty"`
|
||||||
|
UsageQty float64 `gorm:"column:usage_qty"`
|
||||||
|
PendingQty float64 `gorm:"column:pending_qty"`
|
||||||
|
StockLogIncrease float64 `gorm:"column:stock_log_increase"`
|
||||||
|
StockLogDecrease float64 `gorm:"column:stock_log_decrease"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type routeResolution struct {
|
||||||
|
FlagGroupCode string `gorm:"column:flag_group_code"`
|
||||||
|
Lane string `gorm:"column:lane"`
|
||||||
|
FunctionCode string `gorm:"column:function_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
idsRaw string
|
||||||
|
apply bool
|
||||||
|
)
|
||||||
|
|
||||||
|
flag.StringVar(&idsRaw, "ids", "", "Comma-separated adjustment IDs (required), example: 1,2")
|
||||||
|
flag.BoolVar(&apply, "apply", false, "Apply delete. If false, run as dry-run")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
ids, err := parseIDs(idsRaw)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid --ids: %v", err)
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
log.Fatal("--ids is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
db := database.Connect(config.DBHost, config.DBName)
|
||||||
|
|
||||||
|
fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil)
|
||||||
|
|
||||||
|
adjustments, err := loadAdjustments(ctx, db, ids)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to load adjustments: %v", err)
|
||||||
|
}
|
||||||
|
if len(adjustments) == 0 {
|
||||||
|
log.Fatal("no adjustments found for provided IDs")
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(adjustments, func(i, j int) bool {
|
||||||
|
return adjustments[i].ID < adjustments[j].ID
|
||||||
|
})
|
||||||
|
|
||||||
|
fmt.Printf("Mode: %s\n", modeLabel(apply))
|
||||||
|
fmt.Printf("Adjustments loaded: %d\n\n", len(adjustments))
|
||||||
|
|
||||||
|
success := 0
|
||||||
|
failed := 0
|
||||||
|
skipped := 0
|
||||||
|
|
||||||
|
for _, adj := range adjustments {
|
||||||
|
if strings.TrimSpace(adj.FunctionCode) == "" {
|
||||||
|
fmt.Printf("SKIP adj=%d reason=function_code empty\n", adj.ID)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
route, err := resolveRouteByFunctionCode(ctx, db, adj.ProductID, strings.ToUpper(strings.TrimSpace(adj.FunctionCode)))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("FAIL adj=%d error=resolve route: %v\n", adj.ID, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch route.Lane {
|
||||||
|
case "USABLE":
|
||||||
|
desiredQty := adj.UsageQty + adj.PendingQty
|
||||||
|
if desiredQty <= 0 && adj.StockLogDecrease > 0 {
|
||||||
|
desiredQty = adj.StockLogDecrease
|
||||||
|
}
|
||||||
|
|
||||||
|
activeAlloc, err := countActiveUsableAllocations(ctx, db, fifo.UsableKeyAdjustmentOut.String(), adj.ID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("FAIL adj=%d error=count usable allocations: %v\n", adj.ID, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(
|
||||||
|
"PLAN adj=%d lane=USABLE function=%s usage=%.3f pending=%.3f active_alloc=%d action=reflow_to_zero+delete\n",
|
||||||
|
adj.ID,
|
||||||
|
route.FunctionCode,
|
||||||
|
adj.UsageQty,
|
||||||
|
adj.PendingQty,
|
||||||
|
activeAlloc,
|
||||||
|
)
|
||||||
|
|
||||||
|
if !apply {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
reflowReq := commonSvc.FifoStockV2ReflowRequest{
|
||||||
|
FlagGroupCode: route.FlagGroupCode,
|
||||||
|
ProductWarehouseID: adj.ProductWarehouseID,
|
||||||
|
AsOf: &adj.CreatedAt,
|
||||||
|
IdempotencyKey: fmt.Sprintf("delete-adjustment-usable-%d-%d", adj.ID, time.Now().UnixNano()),
|
||||||
|
Tx: tx,
|
||||||
|
}
|
||||||
|
if _, err := fifoStockV2Svc.Reflow(ctx, reflowReq); err != nil {
|
||||||
|
return fmt.Errorf("reflow usable to zero: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := hardDeleteUsableAllocations(ctx, tx, fifo.UsableKeyAdjustmentOut.String(), adj.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := hardDeleteAdjustmentStockLogs(ctx, tx, adj.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := hardDeleteAdjustment(ctx, tx, adj.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("DONE adj=%d deleted\n", adj.ID)
|
||||||
|
success++
|
||||||
|
|
||||||
|
case "STOCKABLE":
|
||||||
|
removeQty := adj.TotalQty
|
||||||
|
if removeQty <= 0 && adj.StockLogIncrease > 0 {
|
||||||
|
removeQty = adj.StockLogIncrease
|
||||||
|
}
|
||||||
|
|
||||||
|
activeAlloc, err := countActiveStockableAllocations(ctx, db, fifo.StockableKeyAdjustmentIn.String(), adj.ID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("FAIL adj=%d error=count stockable allocations: %v\n", adj.ID, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if activeAlloc > 0 {
|
||||||
|
fmt.Printf(
|
||||||
|
"FAIL adj=%d reason=stockable still allocated active_alloc=%d action=delete blocked\n",
|
||||||
|
adj.ID,
|
||||||
|
activeAlloc,
|
||||||
|
)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(
|
||||||
|
"PLAN adj=%d lane=STOCKABLE function=%s total=%.3f remove_qty=%.3f action=reflow_to_zero+delete\n",
|
||||||
|
adj.ID,
|
||||||
|
route.FunctionCode,
|
||||||
|
adj.TotalQty,
|
||||||
|
removeQty,
|
||||||
|
)
|
||||||
|
|
||||||
|
if !apply {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.WithContext(ctx).
|
||||||
|
Table("adjustment_stocks").
|
||||||
|
Where("id = ?", adj.ID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"total_qty": 0,
|
||||||
|
"total_used": 0,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return fmt.Errorf("set stockable qty to zero: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reflowReq := commonSvc.FifoStockV2ReflowRequest{
|
||||||
|
FlagGroupCode: route.FlagGroupCode,
|
||||||
|
ProductWarehouseID: adj.ProductWarehouseID,
|
||||||
|
AsOf: &adj.CreatedAt,
|
||||||
|
IdempotencyKey: fmt.Sprintf("delete-adjustment-stockable-%d-%d", adj.ID, time.Now().UnixNano()),
|
||||||
|
Tx: tx,
|
||||||
|
}
|
||||||
|
if _, err := fifoStockV2Svc.Reflow(ctx, reflowReq); err != nil {
|
||||||
|
return fmt.Errorf("reflow stockable to zero: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := hardDeleteStockableAllocations(ctx, tx, fifo.StockableKeyAdjustmentIn.String(), adj.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := hardDeleteAdjustmentStockLogs(ctx, tx, adj.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := hardDeleteAdjustment(ctx, tx, adj.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("DONE adj=%d deleted\n", adj.ID)
|
||||||
|
success++
|
||||||
|
|
||||||
|
default:
|
||||||
|
fmt.Printf("SKIP adj=%d reason=unsupported lane=%s\n", adj.ID, route.Lane)
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("Summary: success=%d failed=%d skipped=%d\n", success, failed, skipped)
|
||||||
|
if failed > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modeLabel(apply bool) string {
|
||||||
|
if apply {
|
||||||
|
return "APPLY"
|
||||||
|
}
|
||||||
|
return "DRY-RUN"
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIDs(raw string) ([]uint, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
out := make([]uint, 0, len(parts))
|
||||||
|
seen := map[uint]struct{}{}
|
||||||
|
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseUint(part, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid id %q", part)
|
||||||
|
}
|
||||||
|
if v == 0 {
|
||||||
|
return nil, fmt.Errorf("id must be > 0: %q", part)
|
||||||
|
}
|
||||||
|
id := uint(v)
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAdjustments(ctx context.Context, db *gorm.DB, ids []uint) ([]adjustmentRow, error) {
|
||||||
|
var rows []adjustmentRow
|
||||||
|
err := db.WithContext(ctx).
|
||||||
|
Table("adjustment_stocks a").
|
||||||
|
Select(`
|
||||||
|
a.id,
|
||||||
|
a.product_warehouse_id,
|
||||||
|
pw.product_id,
|
||||||
|
a.function_code,
|
||||||
|
COALESCE(a.total_qty, 0) AS total_qty,
|
||||||
|
COALESCE(a.usage_qty, 0) AS usage_qty,
|
||||||
|
COALESCE(a.pending_qty, 0) AS pending_qty,
|
||||||
|
COALESCE((
|
||||||
|
SELECT sl.increase
|
||||||
|
FROM stock_logs sl
|
||||||
|
WHERE sl.loggable_type = 'ADJUSTMENT'
|
||||||
|
AND sl.loggable_id = a.id
|
||||||
|
ORDER BY sl.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
), 0) AS stock_log_increase,
|
||||||
|
COALESCE((
|
||||||
|
SELECT sl.decrease
|
||||||
|
FROM stock_logs sl
|
||||||
|
WHERE sl.loggable_type = 'ADJUSTMENT'
|
||||||
|
AND sl.loggable_id = a.id
|
||||||
|
ORDER BY sl.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
), 0) AS stock_log_decrease,
|
||||||
|
a.created_at
|
||||||
|
`).
|
||||||
|
Joins("JOIN product_warehouses pw ON pw.id = a.product_warehouse_id").
|
||||||
|
Where("a.id IN ?", ids).
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveRouteByFunctionCode(ctx context.Context, db *gorm.DB, productID uint, functionCode string) (*routeResolution, error) {
|
||||||
|
var rows []routeResolution
|
||||||
|
err := db.WithContext(ctx).
|
||||||
|
Table("fifo_stock_v2_route_rules rr").
|
||||||
|
Select("rr.flag_group_code, rr.lane, rr.function_code").
|
||||||
|
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
|
||||||
|
Where("rr.is_active = TRUE").
|
||||||
|
Where("rr.function_code = ?", functionCode).
|
||||||
|
Where(`
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM flags f
|
||||||
|
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
|
||||||
|
WHERE f.flagable_type = ?
|
||||||
|
AND f.flagable_id = ?
|
||||||
|
AND fm.flag_group_code = rr.flag_group_code
|
||||||
|
)
|
||||||
|
`, entity.FlagableTypeProduct, productID).
|
||||||
|
Order("CASE WHEN rr.source_table = 'adjustment_stocks' THEN 0 ELSE 1 END ASC").
|
||||||
|
Order("rr.id ASC").
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, fmt.Errorf("no route found for product_id=%d function_code=%s", productID, functionCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := rows[0]
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Lane != selected.Lane {
|
||||||
|
return nil, fmt.Errorf("ambiguous lane for product_id=%d function_code=%s", productID, functionCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selected.FunctionCode = functionCode
|
||||||
|
return &selected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func countActiveUsableAllocations(ctx context.Context, db *gorm.DB, usableType string, usableID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := db.WithContext(ctx).
|
||||||
|
Table("stock_allocations").
|
||||||
|
Where("usable_type = ? AND usable_id = ?", usableType, usableID).
|
||||||
|
Where("status = ?", entity.StockAllocationStatusActive).
|
||||||
|
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||||
|
Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func countActiveStockableAllocations(ctx context.Context, db *gorm.DB, stockableType string, stockableID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := db.WithContext(ctx).
|
||||||
|
Table("stock_allocations").
|
||||||
|
Where("stockable_type = ? AND stockable_id = ?", stockableType, stockableID).
|
||||||
|
Where("status = ?", entity.StockAllocationStatusActive).
|
||||||
|
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||||
|
Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func hardDeleteUsableAllocations(ctx context.Context, tx *gorm.DB, usableType string, usableID uint) error {
|
||||||
|
return tx.WithContext(ctx).
|
||||||
|
Exec("DELETE FROM stock_allocations WHERE usable_type = ? AND usable_id = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationPurposeConsume).
|
||||||
|
Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func hardDeleteStockableAllocations(ctx context.Context, tx *gorm.DB, stockableType string, stockableID uint) error {
|
||||||
|
return tx.WithContext(ctx).
|
||||||
|
Exec("DELETE FROM stock_allocations WHERE stockable_type = ? AND stockable_id = ? AND allocation_purpose = ?", stockableType, stockableID, entity.StockAllocationPurposeConsume).
|
||||||
|
Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func hardDeleteAdjustmentStockLogs(ctx context.Context, tx *gorm.DB, adjustmentID uint) error {
|
||||||
|
return tx.WithContext(ctx).
|
||||||
|
Exec("DELETE FROM stock_logs WHERE loggable_type = ? AND loggable_id = ?", "ADJUSTMENT", adjustmentID).
|
||||||
|
Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func hardDeleteAdjustment(ctx context.Context, tx *gorm.DB, adjustmentID uint) error {
|
||||||
|
return tx.WithContext(ctx).
|
||||||
|
Exec("DELETE FROM adjustment_stocks WHERE id = ?", adjustmentID).
|
||||||
|
Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||||
|
"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"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type adjustmentRow struct {
|
||||||
|
ID uint `gorm:"column:id"`
|
||||||
|
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
||||||
|
ProductID uint `gorm:"column:product_id"`
|
||||||
|
FunctionCode string `gorm:"column:function_code"`
|
||||||
|
UsageQty float64 `gorm:"column:usage_qty"`
|
||||||
|
PendingQty float64 `gorm:"column:pending_qty"`
|
||||||
|
StockLogIncrease float64 `gorm:"column:stock_log_increase"`
|
||||||
|
StockLogDecrease float64 `gorm:"column:stock_log_decrease"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type routeResolution struct {
|
||||||
|
FlagGroupCode string `gorm:"column:flag_group_code"`
|
||||||
|
Lane string `gorm:"column:lane"`
|
||||||
|
FunctionCode string `gorm:"column:function_code"`
|
||||||
|
SourceTable string `gorm:"column:source_table"`
|
||||||
|
LegacyTypeKey string `gorm:"column:legacy_type_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
idsRaw string
|
||||||
|
apply bool
|
||||||
|
asOfCreatedAt bool
|
||||||
|
compensateMissingAlloc bool
|
||||||
|
)
|
||||||
|
|
||||||
|
flag.StringVar(&idsRaw, "ids", "", "Comma-separated adjustment IDs (required), example: 1,2")
|
||||||
|
flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run")
|
||||||
|
flag.BoolVar(&asOfCreatedAt, "as-of-created-at", true, "Use adjustment created_at as reflow AsOf boundary")
|
||||||
|
flag.BoolVar(&compensateMissingAlloc, "compensate-missing-alloc", true, "When active allocations are missing and usage_qty > 0, temporarily add back usage_qty before reflow")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
ids, err := parseIDs(idsRaw)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid --ids: %v", err)
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
log.Fatal("--ids is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
db := database.Connect(config.DBHost, config.DBName)
|
||||||
|
fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil)
|
||||||
|
|
||||||
|
adjustments, err := loadAdjustments(ctx, db, ids)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to load adjustments: %v", err)
|
||||||
|
}
|
||||||
|
if len(adjustments) == 0 {
|
||||||
|
log.Fatal("no adjustments found for provided IDs")
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(adjustments, func(i, j int) bool {
|
||||||
|
return adjustments[i].ID < adjustments[j].ID
|
||||||
|
})
|
||||||
|
|
||||||
|
fmt.Printf("Mode: %s\n", modeLabel(apply))
|
||||||
|
fmt.Printf("Adjustments loaded: %d\n\n", len(adjustments))
|
||||||
|
|
||||||
|
success := 0
|
||||||
|
failed := 0
|
||||||
|
skipped := 0
|
||||||
|
|
||||||
|
for _, adj := range adjustments {
|
||||||
|
if strings.TrimSpace(adj.FunctionCode) == "" {
|
||||||
|
fmt.Printf("SKIP adj=%d reason=function_code empty\n", adj.ID)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
route, err := resolveRouteByFunctionCode(ctx, db, adj.ProductID, strings.ToUpper(strings.TrimSpace(adj.FunctionCode)))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("FAIL adj=%d error=resolve route: %v\n", adj.ID, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if route.Lane != "USABLE" {
|
||||||
|
fmt.Printf("SKIP adj=%d reason=lane=%s (not USABLE)\n", adj.ID, route.Lane)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
desiredQty := adj.UsageQty + adj.PendingQty
|
||||||
|
desiredQtySource := "usage+pending"
|
||||||
|
if desiredQty <= 0 && adj.StockLogDecrease > 0 {
|
||||||
|
desiredQty = adj.StockLogDecrease
|
||||||
|
desiredQtySource = "stock_log.decrease"
|
||||||
|
}
|
||||||
|
if desiredQty <= 0 {
|
||||||
|
fmt.Printf(
|
||||||
|
"SKIP adj=%d reason=no usable qty (usage=%.3f pending=%.3f stock_log.decrease=%.3f)\n",
|
||||||
|
adj.ID,
|
||||||
|
adj.UsageQty,
|
||||||
|
adj.PendingQty,
|
||||||
|
adj.StockLogDecrease,
|
||||||
|
)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
activeAllocationCount, err := countActiveAllocations(ctx, db, fifo.UsableKeyAdjustmentOut.String(), adj.ID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("FAIL adj=%d error=count allocations: %v\n", adj.ID, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
compensateQty := adj.UsageQty
|
||||||
|
if compensateQty <= 0 && desiredQtySource == "stock_log.decrease" {
|
||||||
|
compensateQty = adj.StockLogDecrease
|
||||||
|
}
|
||||||
|
shouldCompensate := compensateMissingAlloc && activeAllocationCount == 0 && compensateQty > 0
|
||||||
|
|
||||||
|
reflowReq := commonSvc.FifoStockV2ReflowRequest{
|
||||||
|
FlagGroupCode: route.FlagGroupCode,
|
||||||
|
ProductWarehouseID: adj.ProductWarehouseID,
|
||||||
|
IdempotencyKey: fmt.Sprintf("manual-adjustment-reflow-%d-%d", adj.ID, time.Now().UnixNano()),
|
||||||
|
}
|
||||||
|
if asOfCreatedAt {
|
||||||
|
asOf := adj.CreatedAt
|
||||||
|
reflowReq.AsOf = &asOf
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(
|
||||||
|
"PLAN adj=%d pw=%d product=%d function=%s group=%s desired=%.3f source=%s active_alloc=%d compensate=%t\n",
|
||||||
|
adj.ID,
|
||||||
|
adj.ProductWarehouseID,
|
||||||
|
adj.ProductID,
|
||||||
|
route.FunctionCode,
|
||||||
|
route.FlagGroupCode,
|
||||||
|
desiredQty,
|
||||||
|
desiredQtySource,
|
||||||
|
activeAllocationCount,
|
||||||
|
shouldCompensate,
|
||||||
|
)
|
||||||
|
|
||||||
|
if !apply {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if shouldCompensate {
|
||||||
|
if err := tx.Table("product_warehouses").
|
||||||
|
Where("id = ?", adj.ProductWarehouseID).
|
||||||
|
Update("qty", gorm.Expr("COALESCE(qty,0) + ?", compensateQty)).Error; err != nil {
|
||||||
|
return fmt.Errorf("compensate product_warehouse qty: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reflowReq.Tx = tx
|
||||||
|
res, err := fifoStockV2Svc.Reflow(ctx, reflowReq)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(
|
||||||
|
"DONE adj=%d rollback=%.3f allocate=%.3f pending=%.3f\n",
|
||||||
|
adj.ID,
|
||||||
|
res.Rollback.ReleasedQty,
|
||||||
|
res.Allocate.AllocatedQty,
|
||||||
|
res.Allocate.PendingQty,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
success++
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("Summary: success=%d failed=%d skipped=%d\n", success, failed, skipped)
|
||||||
|
if failed > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modeLabel(apply bool) string {
|
||||||
|
if apply {
|
||||||
|
return "APPLY"
|
||||||
|
}
|
||||||
|
return "DRY-RUN"
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIDs(raw string) ([]uint, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
ids := make([]uint, 0, len(parts))
|
||||||
|
seen := map[uint]struct{}{}
|
||||||
|
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseUint(part, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid id %q", part)
|
||||||
|
}
|
||||||
|
if value == 0 {
|
||||||
|
return nil, fmt.Errorf("id must be > 0: %q", part)
|
||||||
|
}
|
||||||
|
id := uint(value)
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAdjustments(ctx context.Context, db *gorm.DB, ids []uint) ([]adjustmentRow, error) {
|
||||||
|
var rows []adjustmentRow
|
||||||
|
err := db.WithContext(ctx).
|
||||||
|
Table("adjustment_stocks a").
|
||||||
|
Select(`
|
||||||
|
a.id,
|
||||||
|
a.product_warehouse_id,
|
||||||
|
pw.product_id,
|
||||||
|
a.function_code,
|
||||||
|
COALESCE(a.usage_qty, 0) AS usage_qty,
|
||||||
|
COALESCE(a.pending_qty, 0) AS pending_qty,
|
||||||
|
COALESCE((
|
||||||
|
SELECT sl.increase
|
||||||
|
FROM stock_logs sl
|
||||||
|
WHERE sl.loggable_type = 'ADJUSTMENT'
|
||||||
|
AND sl.loggable_id = a.id
|
||||||
|
ORDER BY sl.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
), 0) AS stock_log_increase,
|
||||||
|
COALESCE((
|
||||||
|
SELECT sl.decrease
|
||||||
|
FROM stock_logs sl
|
||||||
|
WHERE sl.loggable_type = 'ADJUSTMENT'
|
||||||
|
AND sl.loggable_id = a.id
|
||||||
|
ORDER BY sl.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
), 0) AS stock_log_decrease,
|
||||||
|
a.created_at
|
||||||
|
`).
|
||||||
|
Joins("JOIN product_warehouses pw ON pw.id = a.product_warehouse_id").
|
||||||
|
Where("a.id IN ?", ids).
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveRouteByFunctionCode(ctx context.Context, db *gorm.DB, productID uint, functionCode string) (*routeResolution, error) {
|
||||||
|
var rows []routeResolution
|
||||||
|
err := db.WithContext(ctx).
|
||||||
|
Table("fifo_stock_v2_route_rules rr").
|
||||||
|
Select("rr.flag_group_code, rr.lane, rr.function_code, rr.source_table, rr.legacy_type_key").
|
||||||
|
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
|
||||||
|
Where("rr.is_active = TRUE").
|
||||||
|
Where("rr.function_code = ?", functionCode).
|
||||||
|
Where(`
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM flags f
|
||||||
|
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
|
||||||
|
WHERE f.flagable_type = ?
|
||||||
|
AND f.flagable_id = ?
|
||||||
|
AND fm.flag_group_code = rr.flag_group_code
|
||||||
|
)
|
||||||
|
`, entity.FlagableTypeProduct, productID).
|
||||||
|
Order("CASE WHEN rr.source_table = 'adjustment_stocks' THEN 0 ELSE 1 END ASC").
|
||||||
|
Order("rr.id ASC").
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, fmt.Errorf("no route found for product_id=%d function_code=%s", productID, functionCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := rows[0]
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Lane != selected.Lane {
|
||||||
|
return nil, fmt.Errorf("ambiguous lane for product_id=%d function_code=%s", productID, functionCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selected.FunctionCode = functionCode
|
||||||
|
return &selected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func countActiveAllocations(ctx context.Context, db *gorm.DB, usableType string, usableID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := db.WithContext(ctx).
|
||||||
|
Table("stock_allocations").
|
||||||
|
Where("usable_type = ? AND usable_id = ?", usableType, usableID).
|
||||||
|
Where("status = ?", entity.StockAllocationStatusActive).
|
||||||
|
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||||
|
Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,648 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||||
|
"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"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type productWarehouseScopeRow struct {
|
||||||
|
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
||||||
|
ProductID uint `gorm:"column:product_id"`
|
||||||
|
WarehouseID uint `gorm:"column:warehouse_id"`
|
||||||
|
ProjectFlockKandangID *uint `gorm:"column:project_flock_kandang_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type reflowTarget struct {
|
||||||
|
ProductWarehouseID uint
|
||||||
|
ProductID uint
|
||||||
|
WarehouseID uint
|
||||||
|
ProjectFlockKandangID *uint
|
||||||
|
FlagGroupCode string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
projectFlockKandangID uint
|
||||||
|
apply bool
|
||||||
|
asOfRaw string
|
||||||
|
includeShared bool
|
||||||
|
)
|
||||||
|
|
||||||
|
flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Project flock kandang ID (required)")
|
||||||
|
flag.BoolVar(&apply, "apply", false, "Apply reflow. If false, run as dry-run")
|
||||||
|
flag.StringVar(&asOfRaw, "as-of", "", "Optional AsOf boundary. Format: RFC3339 or YYYY-MM-DD")
|
||||||
|
flag.BoolVar(&includeShared, "include-shared", true, "Include product warehouses referenced by transactions in this PFK scope (including shared/non-bound product warehouses)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if projectFlockKandangID == 0 {
|
||||||
|
log.Fatal("--project-flock-kandang-id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
asOf, err := parseAsOf(asOfRaw)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid --as-of: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
db := database.Connect(config.DBHost, config.DBName)
|
||||||
|
fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil)
|
||||||
|
|
||||||
|
exists, err := projectFlockKandangExists(ctx, db, projectFlockKandangID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to check project flock kandang: %v", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
log.Fatalf("project_flock_kandang_id %d not found", projectFlockKandangID)
|
||||||
|
}
|
||||||
|
|
||||||
|
scopedPWs, err := loadScopedProductWarehouses(ctx, db, projectFlockKandangID, includeShared)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to load scoped product warehouses: %v", err)
|
||||||
|
}
|
||||||
|
if len(scopedPWs) == 0 {
|
||||||
|
fmt.Printf("Mode: %s\n", modeLabel(apply))
|
||||||
|
fmt.Printf("Scope: project_flock_kandang_id=%d\n", projectFlockKandangID)
|
||||||
|
fmt.Println("No product warehouse found in scope")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targets := make([]reflowTarget, 0, len(scopedPWs))
|
||||||
|
skippedPW := 0
|
||||||
|
failedResolve := 0
|
||||||
|
|
||||||
|
for _, pw := range scopedPWs {
|
||||||
|
flagGroups, err := resolveFlagGroupsByProductWarehouse(ctx, db, pw.ProductWarehouseID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("FAIL pw=%d error=resolve flag groups: %v\n", pw.ProductWarehouseID, err)
|
||||||
|
failedResolve++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(flagGroups) == 0 {
|
||||||
|
fmt.Printf("SKIP pw=%d reason=no active fifo v2 route by product flag\n", pw.ProductWarehouseID)
|
||||||
|
skippedPW++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, group := range flagGroups {
|
||||||
|
targets = append(targets, reflowTarget{
|
||||||
|
ProductWarehouseID: pw.ProductWarehouseID,
|
||||||
|
ProductID: pw.ProductID,
|
||||||
|
WarehouseID: pw.WarehouseID,
|
||||||
|
ProjectFlockKandangID: pw.ProjectFlockKandangID,
|
||||||
|
FlagGroupCode: group,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(targets, func(i, j int) bool {
|
||||||
|
if targets[i].ProductWarehouseID == targets[j].ProductWarehouseID {
|
||||||
|
return targets[i].FlagGroupCode < targets[j].FlagGroupCode
|
||||||
|
}
|
||||||
|
return targets[i].ProductWarehouseID < targets[j].ProductWarehouseID
|
||||||
|
})
|
||||||
|
|
||||||
|
fmt.Printf("Mode: %s\n", modeLabel(apply))
|
||||||
|
fmt.Printf("Scope: project_flock_kandang_id=%d include_shared=%t\n", projectFlockKandangID, includeShared)
|
||||||
|
if asOf != nil {
|
||||||
|
fmt.Printf("AsOf: %s\n", asOf.UTC().Format(time.RFC3339))
|
||||||
|
} else {
|
||||||
|
fmt.Println("AsOf: <nil> (full timeline)")
|
||||||
|
}
|
||||||
|
fmt.Printf("Product warehouses in scope: %d\n", len(scopedPWs))
|
||||||
|
fmt.Printf("Planned reflow targets: %d\n\n", len(targets))
|
||||||
|
|
||||||
|
for _, target := range targets {
|
||||||
|
fmt.Printf(
|
||||||
|
"PLAN pw=%d product=%d warehouse=%d pw_pfk=%s flag_group=%s\n",
|
||||||
|
target.ProductWarehouseID,
|
||||||
|
target.ProductID,
|
||||||
|
target.WarehouseID,
|
||||||
|
displayOptionalUint(target.ProjectFlockKandangID),
|
||||||
|
target.FlagGroupCode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !apply {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("Summary: planned=%d skipped_pw=%d failed_resolve=%d applied=0 failed_apply=0\n", len(targets), skippedPW, failedResolve)
|
||||||
|
if failedResolve > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
successApply := 0
|
||||||
|
failedApply := 0
|
||||||
|
for idx, target := range targets {
|
||||||
|
req := commonSvc.FifoStockV2ReflowRequest{
|
||||||
|
FlagGroupCode: target.FlagGroupCode,
|
||||||
|
ProductWarehouseID: target.ProductWarehouseID,
|
||||||
|
AsOf: asOf,
|
||||||
|
IdempotencyKey: fmt.Sprintf(
|
||||||
|
"manual-pfk-reflow-%d-%d-%s-%d-%d",
|
||||||
|
projectFlockKandangID,
|
||||||
|
target.ProductWarehouseID,
|
||||||
|
strings.ToUpper(strings.TrimSpace(target.FlagGroupCode)),
|
||||||
|
time.Now().UnixNano(),
|
||||||
|
idx,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := fifoStockV2Svc.Reflow(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("FAIL pw=%d flag_group=%s error=%v\n", target.ProductWarehouseID, target.FlagGroupCode, err)
|
||||||
|
failedApply++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(
|
||||||
|
"DONE pw=%d flag_group=%s rollback=%.3f allocate=%.3f pending=%.3f processed_usable=%d\n",
|
||||||
|
target.ProductWarehouseID,
|
||||||
|
target.FlagGroupCode,
|
||||||
|
res.Rollback.ReleasedQty,
|
||||||
|
res.Allocate.AllocatedQty,
|
||||||
|
res.Allocate.PendingQty,
|
||||||
|
res.ProcessedUsables,
|
||||||
|
)
|
||||||
|
successApply++
|
||||||
|
}
|
||||||
|
|
||||||
|
orphanPopulationRows := int64(0)
|
||||||
|
syncedPopulationQtyRows := int64(0)
|
||||||
|
syncedPopulationUsedRows := int64(0)
|
||||||
|
traceReleasedRows := int64(0)
|
||||||
|
traceInsertedRows := int64(0)
|
||||||
|
if rowsOrphan, rowsQty, rowsUsed, err := resyncProjectFlockPopulation(ctx, db, projectFlockKandangID); err != nil {
|
||||||
|
fmt.Printf("FAIL population_resync project_flock_kandang_id=%d error=%v\n", projectFlockKandangID, err)
|
||||||
|
failedApply++
|
||||||
|
} else {
|
||||||
|
orphanPopulationRows = rowsOrphan
|
||||||
|
syncedPopulationQtyRows = rowsQty
|
||||||
|
syncedPopulationUsedRows = rowsUsed
|
||||||
|
fmt.Printf(
|
||||||
|
"SYNC project_flock_populations orphan_marked=%d qty_synced=%d used_synced=%d\n",
|
||||||
|
orphanPopulationRows,
|
||||||
|
syncedPopulationQtyRows,
|
||||||
|
syncedPopulationUsedRows,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if released, inserted, err := resyncChickinTraceByProjectFlockKandang(ctx, db, fifoStockV2Svc, projectFlockKandangID); err != nil {
|
||||||
|
fmt.Printf("FAIL chickin_trace_resync project_flock_kandang_id=%d error=%v\n", projectFlockKandangID, err)
|
||||||
|
failedApply++
|
||||||
|
} else {
|
||||||
|
traceReleasedRows = released
|
||||||
|
traceInsertedRows = inserted
|
||||||
|
fmt.Printf(
|
||||||
|
"SYNC chickin_trace released=%d inserted=%d\n",
|
||||||
|
traceReleasedRows,
|
||||||
|
traceInsertedRows,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf(
|
||||||
|
"Summary: planned=%d skipped_pw=%d failed_resolve=%d applied=%d failed_apply=%d population_orphan=%d population_qty_synced=%d population_used_synced=%d trace_released=%d trace_inserted=%d\n",
|
||||||
|
len(targets),
|
||||||
|
skippedPW,
|
||||||
|
failedResolve,
|
||||||
|
successApply,
|
||||||
|
failedApply,
|
||||||
|
orphanPopulationRows,
|
||||||
|
syncedPopulationQtyRows,
|
||||||
|
syncedPopulationUsedRows,
|
||||||
|
traceReleasedRows,
|
||||||
|
traceInsertedRows,
|
||||||
|
)
|
||||||
|
if failedResolve > 0 || failedApply > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAsOf(raw string) (*time.Time, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
layouts := []string{
|
||||||
|
time.RFC3339Nano,
|
||||||
|
time.RFC3339,
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
"2006-01-02",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, layout := range layouts {
|
||||||
|
parsed, err := time.Parse(layout, raw)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if layout == "2006-01-02" {
|
||||||
|
endOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC)
|
||||||
|
return &endOfDay, nil
|
||||||
|
}
|
||||||
|
asOf := parsed.UTC()
|
||||||
|
return &asOf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("unsupported format %q", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func modeLabel(apply bool) string {
|
||||||
|
if apply {
|
||||||
|
return "APPLY"
|
||||||
|
}
|
||||||
|
return "DRY-RUN"
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayOptionalUint(v *uint) string {
|
||||||
|
if v == nil {
|
||||||
|
return "NULL"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", *v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectFlockKandangExists(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := db.WithContext(ctx).
|
||||||
|
Table("project_flock_kandangs").
|
||||||
|
Where("id = ?", projectFlockKandangID).
|
||||||
|
Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadScopedProductWarehouses(ctx context.Context, db *gorm.DB, projectFlockKandangID uint, includeShared bool) ([]productWarehouseScopeRow, error) {
|
||||||
|
if !includeShared {
|
||||||
|
var rows []productWarehouseScopeRow
|
||||||
|
err := db.WithContext(ctx).
|
||||||
|
Table("product_warehouses").
|
||||||
|
Select("id AS product_warehouse_id, product_id, warehouse_id, project_flock_kandang_id").
|
||||||
|
Where("project_flock_kandang_id = ?", projectFlockKandangID).
|
||||||
|
Order("id ASC").
|
||||||
|
Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
WITH scoped_pw AS (
|
||||||
|
SELECT pw.id AS product_warehouse_id
|
||||||
|
FROM product_warehouses pw
|
||||||
|
WHERE pw.project_flock_kandang_id = ?
|
||||||
|
|
||||||
|
UNION
|
||||||
|
SELECT pc.product_warehouse_id
|
||||||
|
FROM project_chickins pc
|
||||||
|
WHERE pc.project_flock_kandang_id = ?
|
||||||
|
AND pc.deleted_at IS NULL
|
||||||
|
|
||||||
|
UNION
|
||||||
|
SELECT rs.product_warehouse_id
|
||||||
|
FROM recordings r
|
||||||
|
JOIN recording_stocks rs ON rs.recording_id = r.id
|
||||||
|
WHERE r.project_flock_kandangs_id = ?
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
|
|
||||||
|
UNION
|
||||||
|
SELECT rd.product_warehouse_id
|
||||||
|
FROM recordings r
|
||||||
|
JOIN recording_depletions rd ON rd.recording_id = r.id
|
||||||
|
WHERE r.project_flock_kandangs_id = ?
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
|
|
||||||
|
UNION
|
||||||
|
SELECT rd.source_product_warehouse_id
|
||||||
|
FROM recordings r
|
||||||
|
JOIN recording_depletions rd ON rd.recording_id = r.id
|
||||||
|
WHERE r.project_flock_kandangs_id = ?
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
|
AND rd.source_product_warehouse_id IS NOT NULL
|
||||||
|
|
||||||
|
UNION
|
||||||
|
SELECT re.product_warehouse_id
|
||||||
|
FROM recordings r
|
||||||
|
JOIN recording_eggs re ON re.recording_id = r.id
|
||||||
|
WHERE r.project_flock_kandangs_id = ?
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
|
|
||||||
|
UNION
|
||||||
|
SELECT lts.product_warehouse_id
|
||||||
|
FROM laying_transfer_sources lts
|
||||||
|
WHERE lts.source_project_flock_kandang_id = ?
|
||||||
|
AND lts.deleted_at IS NULL
|
||||||
|
AND lts.product_warehouse_id IS NOT NULL
|
||||||
|
|
||||||
|
UNION
|
||||||
|
SELECT ltt.product_warehouse_id
|
||||||
|
FROM laying_transfer_targets ltt
|
||||||
|
WHERE ltt.target_project_flock_kandang_id = ?
|
||||||
|
AND ltt.deleted_at IS NULL
|
||||||
|
AND ltt.product_warehouse_id IS NOT NULL
|
||||||
|
|
||||||
|
UNION
|
||||||
|
SELECT pi.product_warehouse_id
|
||||||
|
FROM purchase_items pi
|
||||||
|
WHERE pi.project_flock_kandang_id = ?
|
||||||
|
AND pi.product_warehouse_id IS NOT NULL
|
||||||
|
)
|
||||||
|
SELECT DISTINCT
|
||||||
|
pw.id AS product_warehouse_id,
|
||||||
|
pw.product_id,
|
||||||
|
pw.warehouse_id,
|
||||||
|
pw.project_flock_kandang_id
|
||||||
|
FROM scoped_pw s
|
||||||
|
JOIN product_warehouses pw ON pw.id = s.product_warehouse_id
|
||||||
|
ORDER BY pw.id ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
var rows []productWarehouseScopeRow
|
||||||
|
err := db.WithContext(ctx).
|
||||||
|
Raw(
|
||||||
|
query,
|
||||||
|
projectFlockKandangID,
|
||||||
|
projectFlockKandangID,
|
||||||
|
projectFlockKandangID,
|
||||||
|
projectFlockKandangID,
|
||||||
|
projectFlockKandangID,
|
||||||
|
projectFlockKandangID,
|
||||||
|
projectFlockKandangID,
|
||||||
|
projectFlockKandangID,
|
||||||
|
projectFlockKandangID,
|
||||||
|
).
|
||||||
|
Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveFlagGroupsByProductWarehouse(ctx context.Context, db *gorm.DB, productWarehouseID uint) ([]string, error) {
|
||||||
|
var groups []string
|
||||||
|
err := db.WithContext(ctx).
|
||||||
|
Table("fifo_stock_v2_route_rules rr").
|
||||||
|
Select("DISTINCT rr.flag_group_code").
|
||||||
|
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
|
||||||
|
Where("rr.is_active = TRUE").
|
||||||
|
Where(`
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM product_warehouses pw
|
||||||
|
JOIN flags f ON f.flagable_id = pw.product_id
|
||||||
|
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
|
||||||
|
WHERE pw.id = ?
|
||||||
|
AND f.flagable_type = ?
|
||||||
|
AND fm.flag_group_code = rr.flag_group_code
|
||||||
|
)
|
||||||
|
`, productWarehouseID, entity.FlagableTypeProduct).
|
||||||
|
Order("rr.flag_group_code ASC").
|
||||||
|
Scan(&groups).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return groups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resyncProjectFlockPopulation(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (int64, int64, int64, error) {
|
||||||
|
if projectFlockKandangID == 0 {
|
||||||
|
return 0, 0, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
orphanResult := db.WithContext(ctx).Exec(`
|
||||||
|
UPDATE project_flock_populations pfp
|
||||||
|
SET deleted_at = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM project_chickins pc
|
||||||
|
WHERE pfp.project_chickin_id = pc.id
|
||||||
|
AND pc.project_flock_kandang_id = ?
|
||||||
|
AND pc.deleted_at IS NOT NULL
|
||||||
|
AND pfp.deleted_at IS NULL
|
||||||
|
`, projectFlockKandangID)
|
||||||
|
if orphanResult.Error != nil {
|
||||||
|
return 0, 0, 0, orphanResult.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
qtyResult := db.WithContext(ctx).Exec(`
|
||||||
|
UPDATE project_flock_populations p
|
||||||
|
SET total_qty = GREATEST(COALESCE(pc.usage_qty, 0), 0),
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM project_chickins pc
|
||||||
|
WHERE p.project_chickin_id = pc.id
|
||||||
|
AND pc.project_flock_kandang_id = ?
|
||||||
|
AND pc.deleted_at IS NULL
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
`, projectFlockKandangID)
|
||||||
|
if qtyResult.Error != nil {
|
||||||
|
return 0, 0, 0, qtyResult.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
usedResult := db.WithContext(ctx).Exec(`
|
||||||
|
WITH scoped AS (
|
||||||
|
SELECT pfp.id, pfp.total_qty
|
||||||
|
FROM project_flock_populations pfp
|
||||||
|
JOIN project_chickins pc ON pc.id = pfp.project_chickin_id
|
||||||
|
WHERE pc.project_flock_kandang_id = ?
|
||||||
|
AND pc.deleted_at IS NULL
|
||||||
|
AND pfp.deleted_at IS NULL
|
||||||
|
),
|
||||||
|
alloc AS (
|
||||||
|
SELECT sa.stockable_id, SUM(sa.qty) AS used_qty
|
||||||
|
FROM stock_allocations sa
|
||||||
|
WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION'
|
||||||
|
AND sa.status = 'ACTIVE'
|
||||||
|
AND sa.allocation_purpose = 'CONSUME'
|
||||||
|
GROUP BY sa.stockable_id
|
||||||
|
)
|
||||||
|
UPDATE project_flock_populations p
|
||||||
|
SET total_used_qty = LEAST(COALESCE(a.used_qty, 0), GREATEST(s.total_qty, 0)),
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM scoped s
|
||||||
|
LEFT JOIN alloc a ON a.stockable_id = s.id
|
||||||
|
WHERE p.id = s.id
|
||||||
|
`, projectFlockKandangID)
|
||||||
|
if usedResult.Error != nil {
|
||||||
|
return 0, 0, 0, usedResult.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return orphanResult.RowsAffected, qtyResult.RowsAffected, usedResult.RowsAffected, nil
|
||||||
|
}
|
||||||
|
func resyncChickinTraceByProjectFlockKandang(
|
||||||
|
ctx context.Context,
|
||||||
|
db *gorm.DB,
|
||||||
|
fifoStockV2Svc commonSvc.FifoStockV2Service,
|
||||||
|
projectFlockKandangID uint,
|
||||||
|
) (int64, int64, error) {
|
||||||
|
if projectFlockKandangID == 0 {
|
||||||
|
return 0, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var productWarehouseIDs []uint
|
||||||
|
if err := db.WithContext(ctx).
|
||||||
|
Table("project_chickins").
|
||||||
|
Distinct("product_warehouse_id").
|
||||||
|
Where("project_flock_kandang_id = ?", projectFlockKandangID).
|
||||||
|
Where("deleted_at IS NULL").
|
||||||
|
Order("product_warehouse_id ASC").
|
||||||
|
Pluck("product_warehouse_id", &productWarehouseIDs).Error; err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
if len(productWarehouseIDs) == 0 {
|
||||||
|
return 0, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
totalReleased := int64(0)
|
||||||
|
totalInserted := int64(0)
|
||||||
|
|
||||||
|
for _, productWarehouseID := range productWarehouseIDs {
|
||||||
|
var releasedRows int64
|
||||||
|
var insertedRows int64
|
||||||
|
|
||||||
|
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
flagGroups, err := resolveFlagGroupsByProductWarehouse(ctx, tx, productWarehouseID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(flagGroups) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
flagGroupCode := strings.TrimSpace(flagGroups[0])
|
||||||
|
if flagGroupCode == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
released := tx.WithContext(ctx).
|
||||||
|
Table("stock_allocations").
|
||||||
|
Where("product_warehouse_id = ?", productWarehouseID).
|
||||||
|
Where("usable_type = ?", fifo.UsableKeyProjectChickin.String()).
|
||||||
|
Where("allocation_purpose = ?", entity.StockAllocationPurposeTraceChickin).
|
||||||
|
Where("status = ?", entity.StockAllocationStatusActive).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": entity.StockAllocationStatusReleased,
|
||||||
|
"released_at": time.Now(),
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
"note": "chickin_trace_reflow_reset",
|
||||||
|
})
|
||||||
|
if released.Error != nil {
|
||||||
|
return released.Error
|
||||||
|
}
|
||||||
|
releasedRows = released.RowsAffected
|
||||||
|
|
||||||
|
type chickinRow struct {
|
||||||
|
ID uint `gorm:"column:id"`
|
||||||
|
UsageQty float64 `gorm:"column:usage_qty"`
|
||||||
|
ChickIn time.Time `gorm:"column:chick_in_date"`
|
||||||
|
}
|
||||||
|
chickins := make([]chickinRow, 0)
|
||||||
|
if err := tx.WithContext(ctx).
|
||||||
|
Table("project_chickins").
|
||||||
|
Select("id, usage_qty, chick_in_date").
|
||||||
|
Where("product_warehouse_id = ?", productWarehouseID).
|
||||||
|
Where("deleted_at IS NULL").
|
||||||
|
Where("usage_qty > 0").
|
||||||
|
Order("chick_in_date ASC, id ASC").
|
||||||
|
Scan(&chickins).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(chickins) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
gatherRows, err := fifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{
|
||||||
|
FlagGroupCode: flagGroupCode,
|
||||||
|
Lane: "STOCKABLE",
|
||||||
|
AllocationPurpose: entity.StockAllocationPurposeTraceChickin,
|
||||||
|
IgnoreSourceUsed: true,
|
||||||
|
ProductWarehouseID: productWarehouseID,
|
||||||
|
Limit: 50000,
|
||||||
|
Tx: tx,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(gatherRows) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type lotKey struct {
|
||||||
|
StockableType string
|
||||||
|
StockableID uint
|
||||||
|
}
|
||||||
|
remainingByLot := make(map[lotKey]float64, len(gatherRows))
|
||||||
|
for _, row := range gatherRows {
|
||||||
|
key := lotKey{StockableType: row.Ref.LegacyTypeKey, StockableID: row.Ref.ID}
|
||||||
|
remainingByLot[key] = row.AvailableQuantity
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
lotIndex := 0
|
||||||
|
for _, chickinRow := range chickins {
|
||||||
|
remaining := chickinRow.UsageQty
|
||||||
|
for remaining > 1e-6 && lotIndex < len(gatherRows) {
|
||||||
|
lot := gatherRows[lotIndex]
|
||||||
|
key := lotKey{StockableType: lot.Ref.LegacyTypeKey, StockableID: lot.Ref.ID}
|
||||||
|
available := remainingByLot[key]
|
||||||
|
if available <= 1e-6 {
|
||||||
|
lotIndex++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
portion := math.Min(remaining, available)
|
||||||
|
if portion <= 1e-6 {
|
||||||
|
lotIndex++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
insert := map[string]any{
|
||||||
|
"product_warehouse_id": productWarehouseID,
|
||||||
|
"stockable_type": lot.Ref.LegacyTypeKey,
|
||||||
|
"stockable_id": lot.Ref.ID,
|
||||||
|
"usable_type": fifo.UsableKeyProjectChickin.String(),
|
||||||
|
"usable_id": chickinRow.ID,
|
||||||
|
"qty": portion,
|
||||||
|
"status": entity.StockAllocationStatusActive,
|
||||||
|
"allocation_purpose": entity.StockAllocationPurposeTraceChickin,
|
||||||
|
"engine_version": "v2",
|
||||||
|
"flag_group_code": flagGroupCode,
|
||||||
|
"function_code": "CHICKIN_TRACE",
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
if err := tx.WithContext(ctx).Table("stock_allocations").Create(insert).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
insertedRows++
|
||||||
|
remaining -= portion
|
||||||
|
remainingByLot[key] = available - portion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return totalReleased, totalInserted, err
|
||||||
|
}
|
||||||
|
|
||||||
|
totalReleased += releasedRows
|
||||||
|
totalInserted += insertedRows
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalReleased, totalInserted, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mismatchRow struct {
|
||||||
|
ChickinID uint `gorm:"column:chickin_id"`
|
||||||
|
ProjectFlockKandang uint `gorm:"column:project_flock_kandang_id"`
|
||||||
|
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
||||||
|
UsageQty float64 `gorm:"column:usage_qty"`
|
||||||
|
TraceQty float64 `gorm:"column:trace_qty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var projectFlockKandangID uint
|
||||||
|
flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Optional project flock kandang scope")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
db := database.Connect(config.DBHost, config.DBName)
|
||||||
|
|
||||||
|
rows, err := loadTraceMismatches(ctx, db, projectFlockKandangID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to load trace mismatches: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeConsumeRows, err := countActiveConsumeProjectChickin(ctx, db, projectFlockKandangID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to count active consume rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Scope project_flock_kandang_id=%d\n", projectFlockKandangID)
|
||||||
|
fmt.Printf("Mismatched chickin trace rows: %d\n", len(rows))
|
||||||
|
fmt.Printf("Active PROJECT_CHICKIN consume rows: %d\n", activeConsumeRows)
|
||||||
|
|
||||||
|
if len(rows) > 0 {
|
||||||
|
for _, row := range rows {
|
||||||
|
fmt.Printf(
|
||||||
|
"MISMATCH chickin_id=%d pfk=%d pw=%d usage=%.3f trace=%.3f diff=%.3f\n",
|
||||||
|
row.ChickinID,
|
||||||
|
row.ProjectFlockKandang,
|
||||||
|
row.ProductWarehouseID,
|
||||||
|
row.UsageQty,
|
||||||
|
row.TraceQty,
|
||||||
|
row.TraceQty-row.UsageQty,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) > 0 || activeConsumeRows > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTraceMismatches(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) ([]mismatchRow, error) {
|
||||||
|
query := db.WithContext(ctx).
|
||||||
|
Table("project_chickins pc").
|
||||||
|
Select(`
|
||||||
|
pc.id AS chickin_id,
|
||||||
|
pc.project_flock_kandang_id,
|
||||||
|
pc.product_warehouse_id,
|
||||||
|
COALESCE(pc.usage_qty, 0) AS usage_qty,
|
||||||
|
COALESCE(SUM(sa.qty), 0) AS trace_qty
|
||||||
|
`).
|
||||||
|
Joins(`
|
||||||
|
LEFT JOIN stock_allocations sa
|
||||||
|
ON sa.usable_type = ?
|
||||||
|
AND sa.usable_id = pc.id
|
||||||
|
AND sa.status = 'ACTIVE'
|
||||||
|
AND sa.allocation_purpose = 'TRACE_CHICKIN'
|
||||||
|
`, fifo.UsableKeyProjectChickin.String()).
|
||||||
|
Where("pc.deleted_at IS NULL").
|
||||||
|
Where("COALESCE(pc.usage_qty,0) > 0").
|
||||||
|
Group("pc.id, pc.project_flock_kandang_id, pc.product_warehouse_id, pc.usage_qty")
|
||||||
|
|
||||||
|
if projectFlockKandangID > 0 {
|
||||||
|
query = query.Where("pc.project_flock_kandang_id = ?", projectFlockKandangID)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]mismatchRow, 0)
|
||||||
|
if err := query.Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]mismatchRow, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if math.Abs(row.TraceQty-row.UsageQty) > 1e-3 {
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func countActiveConsumeProjectChickin(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (int64, error) {
|
||||||
|
q := db.WithContext(ctx).
|
||||||
|
Table("stock_allocations sa").
|
||||||
|
Joins("JOIN project_chickins pc ON pc.id = sa.usable_id").
|
||||||
|
Where("sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()).
|
||||||
|
Where("sa.status = 'ACTIVE'").
|
||||||
|
Where("sa.allocation_purpose = 'CONSUME'")
|
||||||
|
|
||||||
|
if projectFlockKandangID > 0 {
|
||||||
|
q = q.Where("pc.project_flock_kandang_id = ?", projectFlockKandangID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := q.Count(&count).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=Postgres@Secure2025!
|
||||||
|
POSTGRES_DB=db_lti_erp
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 🧩 INIT SCRIPT: CREATE LIMITED APP USER FOR LTI API
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Buat user aplikasi jika belum ada
|
||||||
|
DO
|
||||||
|
$$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'app_lti_user') THEN
|
||||||
|
CREATE ROLE app_lti_user WITH LOGIN PASSWORD 'AppLti@Secure2025!' NOINHERIT NOCREATEROLE NOCREATEDB NOSUPERUSER;
|
||||||
|
RAISE NOTICE '✅ Role app_lti_user created successfully.';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'ℹ️ Role app_lti_user already exists.';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Buat database jika belum ada
|
||||||
|
DO
|
||||||
|
$$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_database WHERE datname = 'db_lti_erp') THEN
|
||||||
|
CREATE DATABASE db_lti_erp OWNER app_lti_user;
|
||||||
|
RAISE NOTICE '✅ Database db_lti_erp created and owned by app_lti_user.';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'ℹ️ Database db_lti_erp already exists.';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
\connect db_lti_erp
|
||||||
|
|
||||||
|
-- Beri hak CRUD untuk app_lti_user
|
||||||
|
GRANT CONNECT ON DATABASE db_lti_erp TO app_lti_user;
|
||||||
|
GRANT USAGE ON SCHEMA public TO app_lti_user;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_lti_user;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_lti_user;
|
||||||
|
|
||||||
|
-- Set default privileges agar tabel baru juga bisa diakses
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_lti_user;
|
||||||
|
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCES TO app_lti_user;
|
||||||
|
|
||||||
|
-- Tampilkan hasil
|
||||||
|
\du app_lti_user
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
services:
|
|
||||||
postgresdb:
|
|
||||||
image: postgres:alpine
|
|
||||||
restart: always
|
|
||||||
ports:
|
|
||||||
- "${DB_PORT_HOST:-5542}:5432"
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: ${DB_USER:-postgres}
|
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
|
|
||||||
POSTGRES_DB: ${DB_NAME:-db_lti_erp}
|
|
||||||
volumes:
|
|
||||||
- dbdata:/var/lib/postgresql/data
|
|
||||||
- ./internal/database/init:/docker-entrypoint-initdb.d
|
|
||||||
networks: [go-network]
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
[
|
|
||||||
"CMD-SHELL",
|
|
||||||
"pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-db_lti_erp}",
|
|
||||||
]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "${REDIS_PORT_HOST:-6381}:6379"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 10
|
|
||||||
networks: [go-network]
|
|
||||||
|
|
||||||
app:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.local
|
|
||||||
image: cosmtrek/air:v1.52.3
|
|
||||||
working_dir: /lti-api
|
|
||||||
volumes:
|
|
||||||
- .:/lti-api
|
|
||||||
- ./internal/config/jwtRS256.key:/run/keys/jwtRS256.key
|
|
||||||
- ./internal/config/jwtRS256.key.pub:/run/keys/jwtRS256.key.pub
|
|
||||||
command: air -c .air.toml
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
DB_HOST: postgresdb
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_USER: ${DB_USER:-postgres}
|
|
||||||
DB_PASSWORD: ${DB_PASSWORD:-postgres}
|
|
||||||
DB_NAME: ${DB_NAME:-db_lti_erp}
|
|
||||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
|
|
||||||
ports:
|
|
||||||
- "${APP_PORT:-8081}:8081"
|
|
||||||
depends_on:
|
|
||||||
postgresdb:
|
|
||||||
condition: service_healthy
|
|
||||||
networks: [go-network]
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/healthz || exit 1"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 10
|
|
||||||
start_period: 10s
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
dbdata:
|
|
||||||
go-mod-cache:
|
|
||||||
go-build-cache:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
go-network:
|
|
||||||
name: lti-api_go-network
|
|
||||||
driver: bridge
|
|
||||||
@@ -3,23 +3,46 @@ module gitlab.com/mbugroup/lti-api.git
|
|||||||
go 1.23
|
go 1.23
|
||||||
|
|
||||||
require (
|
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/bytedance/sonic v1.12.1
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/go-playground/validator/v10 v10.27.0
|
github.com/go-playground/validator/v10 v10.27.0
|
||||||
github.com/gofiber/contrib/jwt v1.0.10
|
github.com/gofiber/contrib/jwt v1.0.10
|
||||||
github.com/gofiber/fiber/v2 v2.52.5
|
github.com/gofiber/fiber/v2 v2.52.5
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
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/jackc/pgx/v5 v5.5.5
|
||||||
github.com/redis/go-redis/v9 v9.14.0
|
github.com/redis/go-redis/v9 v9.14.0
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
github.com/spf13/viper v1.19.0
|
github.com/spf13/viper v1.19.0
|
||||||
|
github.com/xuri/excelize/v2 v2.9.0
|
||||||
golang.org/x/crypto v0.33.0
|
golang.org/x/crypto v0.33.0
|
||||||
gorm.io/driver/postgres v1.5.9
|
gorm.io/driver/postgres v1.5.9
|
||||||
gorm.io/gorm v1.25.11
|
gorm.io/gorm v1.25.11
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/MicahParks/keyfunc/v2 v2.1.0 // indirect
|
|
||||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
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/bytedance/sonic/loader v0.2.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
@@ -32,11 +55,12 @@ require (
|
|||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/google/go-cmp v0.6.0 // 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/hashicorp/hcl v1.0.0 // indirect
|
||||||
|
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||||
|
github.com/jackc/pgio v1.0.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgproto3/v2 v2.3.2 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
|
||||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
@@ -48,9 +72,12 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
github.com/philhofer/fwd v1.1.2 // indirect
|
github.com/philhofer/fwd v1.1.2 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||||
|
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||||
@@ -59,12 +86,15 @@ require (
|
|||||||
github.com/spf13/afero v1.11.0 // indirect
|
github.com/spf13/afero v1.11.0 // indirect
|
||||||
github.com/spf13/cast v1.6.0 // indirect
|
github.com/spf13/cast v1.6.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
github.com/stretchr/testify v1.11.1 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/tinylib/msgp v1.1.8 // indirect
|
github.com/tinylib/msgp v1.1.8 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasthttp v1.55.0 // indirect
|
github.com/valyala/fasthttp v1.55.0 // indirect
|
||||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||||
|
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d // indirect
|
||||||
|
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 // indirect
|
||||||
go.uber.org/atomic v1.9.0 // indirect
|
go.uber.org/atomic v1.9.0 // indirect
|
||||||
go.uber.org/multierr v1.9.0 // indirect
|
go.uber.org/multierr v1.9.0 // indirect
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
|
||||||
|
|||||||
@@ -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/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 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
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 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
@@ -17,6 +55,10 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/
|
|||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
|
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||||
|
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||||
|
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||||
|
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
@@ -43,6 +85,7 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
|||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
|
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||||
github.com/gofiber/contrib/jwt v1.0.10 h1:/ilGepl6i0Bntl0Zcd+lAzagY8BiS1+fEiAj32HMApk=
|
github.com/gofiber/contrib/jwt v1.0.10 h1:/ilGepl6i0Bntl0Zcd+lAzagY8BiS1+fEiAj32HMApk=
|
||||||
github.com/gofiber/contrib/jwt v1.0.10/go.mod h1:1qBENE6sZ6PPT4xIpBzx1VxeyROQO7sj48OlM1I9qdU=
|
github.com/gofiber/contrib/jwt v1.0.10/go.mod h1:1qBENE6sZ6PPT4xIpBzx1VxeyROQO7sj48OlM1I9qdU=
|
||||||
github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo=
|
github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo=
|
||||||
@@ -57,12 +100,47 @@ 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/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||||
|
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
|
||||||
|
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||||
|
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
|
||||||
|
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||||
|
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
|
||||||
|
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
|
||||||
|
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
|
||||||
|
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
|
||||||
|
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
|
||||||
|
github.com/jackc/pgconn v1.14.1 h1:smbxIaZA08n6YuxEX1sDyjV/qkbtUtkH20qLkR9MUR4=
|
||||||
|
github.com/jackc/pgconn v1.14.1/go.mod h1:9mBNlny0UvkgJdCDvdVHYSjI+8tD2rnKK69Wz8ti++E=
|
||||||
|
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
|
||||||
|
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
|
||||||
|
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
|
||||||
|
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
|
||||||
|
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=
|
||||||
|
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.3.2 h1:7eY55bdBeCz1F2fTzSz69QC+pG46jYq9/jtSPiJ5nn0=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.3.2/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
|
||||||
|
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
|
||||||
|
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
|
||||||
|
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
|
||||||
|
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
|
||||||
|
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
|
||||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||||
|
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||||
|
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
@@ -75,16 +153,28 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
|
|||||||
github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
|
github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
|
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
|
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
|
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
|
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||||
|
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
@@ -92,10 +182,13 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
|
|||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||||
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
|
github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
|
||||||
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
|
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
@@ -104,15 +197,27 @@ github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6
|
|||||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
||||||
|
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
||||||
|
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
|
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
|
||||||
|
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||||
|
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||||
|
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||||
|
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||||
|
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||||
|
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||||
|
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||||
|
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||||
@@ -126,17 +231,23 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
|
|||||||
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
||||||
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=
|
github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=
|
||||||
@@ -149,25 +260,50 @@ github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8
|
|||||||
github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
|
github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
|
||||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||||
|
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d h1:llb0neMWDQe87IzJLS4Ci7psK/lVsjIS2otl+1WyRyY=
|
||||||
|
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||||
|
github.com/xuri/excelize/v2 v2.9.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE=
|
||||||
|
github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE=
|
||||||
|
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 h1:hPVCafDV85blFTabnqKgNhDCkJX25eik94Si9cTER4A=
|
||||||
|
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||||
|
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||||
|
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
|
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||||
|
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||||
|
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
|
||||||
|
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||||
|
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||||
|
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||||
|
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -175,7 +311,14 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
|||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -184,28 +327,43 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
|
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
|
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
||||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
Vendored
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
redisClient *redis.Client
|
||||||
|
mu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetRedis assigns the global redis client used across the application.
|
||||||
|
func SetRedis(client *redis.Client) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
redisClient = client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis returns the configured redis client. It may be nil if not yet initialised.
|
||||||
|
func Redis() *redis.Client {
|
||||||
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
return redisClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustRedis returns the redis client or panics if it has not been set.
|
||||||
|
func MustRedis() *redis.Client {
|
||||||
|
mu.RLock()
|
||||||
|
client := redisClient
|
||||||
|
mu.RUnlock()
|
||||||
|
if client == nil {
|
||||||
|
panic(errors.New("redis client not initialised"))
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ type ApprovalRepository interface {
|
|||||||
FindByTarget(ctx context.Context, workflow string, approvableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Approval, error)
|
FindByTarget(ctx context.Context, workflow string, approvableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Approval, error)
|
||||||
LatestByTarget(ctx context.Context, workflow string, approvableID uint, modifier func(*gorm.DB) *gorm.DB) (*entity.Approval, error)
|
LatestByTarget(ctx context.Context, workflow string, approvableID uint, modifier func(*gorm.DB) *gorm.DB) (*entity.Approval, error)
|
||||||
LatestByTargets(ctx context.Context, workflow string, approvableIDs []uint, modifier func(*gorm.DB) *gorm.DB) (map[uint]entity.Approval, error)
|
LatestByTargets(ctx context.Context, workflow string, approvableIDs []uint, modifier func(*gorm.DB) *gorm.DB) (map[uint]entity.Approval, error)
|
||||||
|
DeleteByTarget(ctx context.Context, workflow string, approvableID uint) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type approvalRepositoryImpl struct {
|
type approvalRepositoryImpl struct {
|
||||||
@@ -83,8 +84,9 @@ func (r *approvalRepositoryImpl) LatestByTargets(
|
|||||||
result := make(map[uint]entity.Approval, len(approvableIDs))
|
result := make(map[uint]entity.Approval, len(approvableIDs))
|
||||||
|
|
||||||
q := r.DB().WithContext(ctx).
|
q := r.DB().WithContext(ctx).
|
||||||
|
Select("DISTINCT ON (approvable_id) *").
|
||||||
Where("approvable_type = ? AND approvable_id IN ?", workflow, approvableIDs).
|
Where("approvable_type = ? AND approvable_id IN ?", workflow, approvableIDs).
|
||||||
Order("action_at DESC")
|
Order("approvable_id, action_at DESC")
|
||||||
|
|
||||||
if modifier != nil {
|
if modifier != nil {
|
||||||
q = modifier(q)
|
q = modifier(q)
|
||||||
@@ -104,3 +106,13 @@ func (r *approvalRepositoryImpl) LatestByTargets(
|
|||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *approvalRepositoryImpl) DeleteByTarget(
|
||||||
|
ctx context.Context,
|
||||||
|
workflow string,
|
||||||
|
approvableID uint,
|
||||||
|
) error {
|
||||||
|
return r.DB().WithContext(ctx).
|
||||||
|
Where("approvable_type = ? AND approvable_id = ?", workflow, approvableID).
|
||||||
|
Delete(&entity.Approval{}).Error
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type BaseRepository[T any] interface {
|
|||||||
GetAll(ctx context.Context, offset, limit int, modifier func(*gorm.DB) *gorm.DB) ([]T, int64, error)
|
GetAll(ctx context.Context, offset, limit int, modifier func(*gorm.DB) *gorm.DB) ([]T, int64, error)
|
||||||
GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*T, error)
|
GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*T, error)
|
||||||
GetByIDs(ctx context.Context, ids []uint, modifier func(*gorm.DB) *gorm.DB) ([]T, error)
|
GetByIDs(ctx context.Context, ids []uint, modifier func(*gorm.DB) *gorm.DB) ([]T, error)
|
||||||
|
First(ctx context.Context, modifier func(*gorm.DB) *gorm.DB) (*T, error)
|
||||||
|
|
||||||
CreateOne(ctx context.Context, entity *T, modifier func(*gorm.DB) *gorm.DB) error
|
CreateOne(ctx context.Context, entity *T, modifier func(*gorm.DB) *gorm.DB) error
|
||||||
CreateMany(ctx context.Context, entities []*T, modifier func(*gorm.DB) *gorm.DB) error
|
CreateMany(ctx context.Context, entities []*T, modifier func(*gorm.DB) *gorm.DB) error
|
||||||
@@ -96,6 +97,21 @@ func (r *BaseRepositoryImpl[T]) GetByIDs(
|
|||||||
return entities, nil
|
return entities, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *BaseRepositoryImpl[T]) First(
|
||||||
|
ctx context.Context,
|
||||||
|
modifier func(*gorm.DB) *gorm.DB,
|
||||||
|
) (*T, error) {
|
||||||
|
entity := new(T)
|
||||||
|
q := r.db.WithContext(ctx)
|
||||||
|
if modifier != nil {
|
||||||
|
q = modifier(q)
|
||||||
|
}
|
||||||
|
if err := q.First(entity).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return entity, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---- CREATE ----
|
// ---- CREATE ----
|
||||||
func (r *BaseRepositoryImpl[T]) CreateOne(
|
func (r *BaseRepositoryImpl[T]) CreateOne(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -171,10 +187,11 @@ func (r *BaseRepositoryImpl[T]) PatchOne(
|
|||||||
updates map[string]any,
|
updates map[string]any,
|
||||||
modifier func(*gorm.DB) *gorm.DB,
|
modifier func(*gorm.DB) *gorm.DB,
|
||||||
) error {
|
) error {
|
||||||
q := r.db.WithContext(ctx).Model(new(T)).Where("id = ?", id)
|
q := r.db.WithContext(ctx)
|
||||||
if modifier != nil {
|
if modifier != nil {
|
||||||
q = modifier(q)
|
q = modifier(q)
|
||||||
}
|
}
|
||||||
|
q = q.Model(new(T)).Where("id = ?", id)
|
||||||
|
|
||||||
result := q.Updates(updates)
|
result := q.Updates(updates)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -9,45 +10,59 @@ import (
|
|||||||
|
|
||||||
// Exists reports whether a record with the given ID exists for type T.
|
// Exists reports whether a record with the given ID exists for type T.
|
||||||
func Exists[T any](ctx context.Context, db *gorm.DB, id uint) (bool, error) {
|
func Exists[T any](ctx context.Context, db *gorm.DB, id uint) (bool, error) {
|
||||||
var count int64
|
var marker int
|
||||||
if err := db.WithContext(ctx).
|
err := db.WithContext(ctx).
|
||||||
Model(new(T)).
|
Model(new(T)).
|
||||||
|
Select("1").
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Count(&count).Error; err != nil {
|
Limit(1).
|
||||||
|
Take(&marker).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return count > 0, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExistsByName[T any](ctx context.Context, db *gorm.DB, name string, excludeID *uint) (bool, error) {
|
func ExistsByName[T any](ctx context.Context, db *gorm.DB, name string, excludeID *uint) (bool, error) {
|
||||||
var count int64
|
|
||||||
q := db.WithContext(ctx).
|
q := db.WithContext(ctx).
|
||||||
Model(new(T)).
|
Model(new(T)).
|
||||||
|
Select("1").
|
||||||
Where("name = ?", name).
|
Where("name = ?", name).
|
||||||
Where("deleted_at IS NULL")
|
Where("deleted_at IS NULL")
|
||||||
if excludeID != nil {
|
if excludeID != nil {
|
||||||
q = q.Where("id <> ?", *excludeID)
|
q = q.Where("id <> ?", *excludeID)
|
||||||
}
|
}
|
||||||
if err := q.Count(&count).Error; err != nil {
|
var marker int
|
||||||
|
if err := q.Limit(1).Take(&marker).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return count > 0, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExistsByField[T any](ctx context.Context, db *gorm.DB, field string, value any, excludeID *uint) (bool, error) {
|
func ExistsByField[T any](ctx context.Context, db *gorm.DB, field string, value any, excludeID *uint) (bool, error) {
|
||||||
if field == "" {
|
if field == "" {
|
||||||
return false, fmt.Errorf("field is required")
|
return false, fmt.Errorf("field is required")
|
||||||
}
|
}
|
||||||
var count int64
|
|
||||||
q := db.WithContext(ctx).
|
q := db.WithContext(ctx).
|
||||||
Model(new(T)).
|
Model(new(T)).
|
||||||
|
Select("1").
|
||||||
Where(fmt.Sprintf("%s = ?", field), value).
|
Where(fmt.Sprintf("%s = ?", field), value).
|
||||||
Where("deleted_at IS NULL")
|
Where("deleted_at IS NULL")
|
||||||
if excludeID != nil {
|
if excludeID != nil {
|
||||||
q = q.Where("id <> ?", *excludeID)
|
q = q.Where("id <> ?", *excludeID)
|
||||||
}
|
}
|
||||||
if err := q.Count(&count).Error; err != nil {
|
var marker int
|
||||||
|
if err := q.Limit(1).Take(&marker).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return count > 0, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HppCostRepository interface {
|
||||||
|
GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error)
|
||||||
|
GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
|
||||||
|
GetBudgetCostByProjectFlockId(ctx context.Context, projectFlockId uint) (float64, error)
|
||||||
|
GetExpedisionCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
|
||||||
|
GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error)
|
||||||
|
GetOvkUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error)
|
||||||
|
GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
|
||||||
|
GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error)
|
||||||
|
GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error)
|
||||||
|
GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error)
|
||||||
|
GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error)
|
||||||
|
GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type HppRepositoryImpl struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHppCostRepository(db *gorm.DB) HppCostRepository {
|
||||||
|
return &HppRepositoryImpl{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HppRepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) {
|
||||||
|
var ids []uint
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("project_flock_kandangs").
|
||||||
|
Select("id").
|
||||||
|
Where("project_flock_id = ?", projectFlockId).
|
||||||
|
Scan(&ids).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
|
||||||
|
var total float64
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("project_chickins AS pc").
|
||||||
|
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
|
||||||
|
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin).
|
||||||
|
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
|
||||||
|
Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
|
||||||
|
Scan(&total).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HppRepositoryImpl) GetBudgetCostByProjectFlockId(ctx context.Context, projectFlockId uint) (float64, error) {
|
||||||
|
var total float64
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("project_budgets AS pb").
|
||||||
|
Select("COALESCE(SUM(pb.qty * pb.price), 0)").
|
||||||
|
Where("pb.project_flock_id = ?", projectFlockId).
|
||||||
|
Scan(&total).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HppRepositoryImpl) GetExpedisionCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
|
||||||
|
var total float64
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("expense_nonstocks AS en").
|
||||||
|
Select("COALESCE(SUM(er.qty * er.price), 0)").
|
||||||
|
Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id").
|
||||||
|
Joins("JOIN flags AS f ON f.flagable_id = en.nonstock_id AND f.flagable_type = ?", entity.FlagableTypeNonstock).
|
||||||
|
Where("en.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
|
||||||
|
Where("f.name = ?", utils.FlagEkspedisi).
|
||||||
|
Scan(&total).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) {
|
||||||
|
if date == nil {
|
||||||
|
now := time.Now()
|
||||||
|
date = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
var total float64
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("recordings AS r").
|
||||||
|
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
|
||||||
|
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
|
||||||
|
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
|
||||||
|
Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
|
||||||
|
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
|
||||||
|
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
|
||||||
|
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
||||||
|
Where("r.record_datetime <= ?", *date).
|
||||||
|
Where("f.name = ?", utils.FlagPakan).
|
||||||
|
Scan(&total).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) {
|
||||||
|
if date == nil {
|
||||||
|
now := time.Now()
|
||||||
|
date = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
flags := []utils.FlagType{
|
||||||
|
utils.FlagOVK,
|
||||||
|
utils.FlagObat,
|
||||||
|
utils.FlagVitamin,
|
||||||
|
utils.FlagKimia,
|
||||||
|
}
|
||||||
|
|
||||||
|
var total float64
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("recordings AS r").
|
||||||
|
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
|
||||||
|
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
|
||||||
|
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
|
||||||
|
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
|
||||||
|
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
|
||||||
|
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
||||||
|
Where("r.record_datetime <= ?", *date).
|
||||||
|
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags).
|
||||||
|
Scan(&total).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HppRepositoryImpl) GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
|
||||||
|
var total float64
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("project_chickins AS pc").
|
||||||
|
Select("COALESCE(SUM(pc.usage_qty), 0)").
|
||||||
|
Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
|
||||||
|
Scan(&total).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) {
|
||||||
|
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
|
||||||
|
stockableTransferIn := fifo.StockableKeyStockTransferIn.String()
|
||||||
|
usableProjectChickin := fifo.UsableKeyProjectChickin.String()
|
||||||
|
|
||||||
|
var total float64
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("project_chickins AS pc").
|
||||||
|
Select(`
|
||||||
|
COALESCE(SUM(sa.qty * CASE
|
||||||
|
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
|
||||||
|
WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0)
|
||||||
|
ELSE 0
|
||||||
|
END), 0)`,
|
||||||
|
stockablePurchase, stockableTransferIn).
|
||||||
|
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.status = ? AND sa.allocation_purpose = ?", usableProjectChickin, entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin).
|
||||||
|
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
|
||||||
|
Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ? AND tsa.status = ? AND tsa.allocation_purpose = ?", stockableTransferIn, stockableTransferIn, stockablePurchase, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
|
||||||
|
Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa.stockable_id").
|
||||||
|
Where("pc.project_flock_kandang_id = ?", projectFlockKandangId).
|
||||||
|
Scan(&total).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) {
|
||||||
|
if date == nil {
|
||||||
|
now := time.Now()
|
||||||
|
date = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
var totals struct {
|
||||||
|
TotalPieces float64
|
||||||
|
TotalWeightKg float64
|
||||||
|
}
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("recordings AS r").
|
||||||
|
Select("COALESCE(SUM(re.qty), 0) AS total_pieces, COALESCE(SUM(re.weight), 0)AS total_weight_kg").
|
||||||
|
Joins("JOIN recording_eggs AS re ON re.recording_id = r.id").
|
||||||
|
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
||||||
|
Where("r.record_datetime <= ?", *date).
|
||||||
|
Scan(&totals).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return totals.TotalPieces, totals.TotalWeightKg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(
|
||||||
|
ctx context.Context,
|
||||||
|
projectFlockKandangIDs []uint,
|
||||||
|
startDate *time.Time,
|
||||||
|
endDate *time.Time,
|
||||||
|
) (float64, float64, error) {
|
||||||
|
|
||||||
|
if endDate == nil {
|
||||||
|
now := time.Now()
|
||||||
|
endDate = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
type subResult struct {
|
||||||
|
UsableID uint
|
||||||
|
MdpUsageQty float64
|
||||||
|
MdpWeight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
subQuery := r.db.WithContext(ctx).
|
||||||
|
Table("recordings AS r").
|
||||||
|
Select(`
|
||||||
|
DISTINCT sa.usable_id,
|
||||||
|
mdp.usage_qty AS mdp_usage_qty,
|
||||||
|
mdp.total_weight AS mdp_weight
|
||||||
|
`).
|
||||||
|
Joins("JOIN recording_eggs re ON re.recording_id = r.id").
|
||||||
|
Joins(
|
||||||
|
"JOIN stock_allocations sa ON sa.stockable_type = ? AND sa.stockable_id = re.id AND sa.usable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?",
|
||||||
|
fifo.StockableKeyRecordingEgg.String(),
|
||||||
|
fifo.UsableKeyMarketingDelivery.String(),
|
||||||
|
entity.StockAllocationStatusActive,
|
||||||
|
entity.StockAllocationPurposeConsume,
|
||||||
|
).
|
||||||
|
Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id").
|
||||||
|
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
||||||
|
Where("r.record_datetime <= ?", *endDate).
|
||||||
|
Where("mdp.delivery_date <= ?", *startDate)
|
||||||
|
|
||||||
|
var totals struct {
|
||||||
|
TotalPieces float64
|
||||||
|
TotalWeight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("(?) AS x", subQuery).
|
||||||
|
Select(`
|
||||||
|
COALESCE(SUM(x.mdp_usage_qty), 0) AS total_pieces,
|
||||||
|
COALESCE(SUM(x.mdp_weight), 0) AS total_weight
|
||||||
|
`).
|
||||||
|
Scan(&totals).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return totals.TotalPieces, totals.TotalWeight, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HppRepositoryImpl) GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error) {
|
||||||
|
var projectFlockID uint
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("project_flock_kandangs").
|
||||||
|
Select("project_flock_id").
|
||||||
|
Where("id = ?", projectFlockKandangId).
|
||||||
|
Scan(&projectFlockID).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectFlockID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) {
|
||||||
|
var summary struct {
|
||||||
|
ProjectFlockID uint
|
||||||
|
TotalQty float64
|
||||||
|
}
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("laying_transfer_targets AS ltt").
|
||||||
|
Select("lt.from_project_flock_id AS project_flock_id, COALESCE(SUM(ltt.total_qty), 0) AS total_qty").
|
||||||
|
Joins("JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id").
|
||||||
|
Where("lt.deleted_at IS NULL").
|
||||||
|
Where("ltt.deleted_at IS NULL").
|
||||||
|
Where("lt.executed_at IS NOT NULL").
|
||||||
|
Where("ltt.target_project_flock_kandang_id = ?", projectFlockKandangId).
|
||||||
|
Group("lt.from_project_flock_id").
|
||||||
|
Scan(&summary).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary.ProjectFlockID, summary.TotalQty, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StockAllocationRepository interface {
|
||||||
|
BaseRepository[entity.StockAllocation]
|
||||||
|
FindActiveByUsable(ctx context.Context, usableType string, usableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.StockAllocation, error)
|
||||||
|
ReleaseByUsable(ctx context.Context, usableType string, usableID uint, note *string, modifier func(*gorm.DB) *gorm.DB) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type StockAllocationRepositoryImpl struct {
|
||||||
|
*BaseRepositoryImpl[entity.StockAllocation]
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStockAllocationRepository(db *gorm.DB) StockAllocationRepository {
|
||||||
|
return &StockAllocationRepositoryImpl{
|
||||||
|
BaseRepositoryImpl: NewBaseRepository[entity.StockAllocation](db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *StockAllocationRepositoryImpl) FindActiveByUsable(
|
||||||
|
ctx context.Context,
|
||||||
|
usableType string,
|
||||||
|
usableID uint,
|
||||||
|
modifier func(*gorm.DB) *gorm.DB,
|
||||||
|
) ([]entity.StockAllocation, error) {
|
||||||
|
var allocations []entity.StockAllocation
|
||||||
|
|
||||||
|
q := r.DB().WithContext(ctx).
|
||||||
|
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume)
|
||||||
|
|
||||||
|
if modifier != nil {
|
||||||
|
q = modifier(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := q.Order("created_at ASC").Find(&allocations).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return allocations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *StockAllocationRepositoryImpl) ReleaseByUsable(
|
||||||
|
ctx context.Context,
|
||||||
|
usableType string,
|
||||||
|
usableID uint,
|
||||||
|
note *string,
|
||||||
|
modifier func(*gorm.DB) *gorm.DB,
|
||||||
|
) error {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
updates := map[string]any{
|
||||||
|
"status": entity.StockAllocationStatusReleased,
|
||||||
|
"released_at": now,
|
||||||
|
}
|
||||||
|
if note != nil {
|
||||||
|
updates["note"] = *note
|
||||||
|
}
|
||||||
|
|
||||||
|
baseDB := r.DB()
|
||||||
|
if modifier != nil {
|
||||||
|
baseDB = modifier(baseDB)
|
||||||
|
}
|
||||||
|
|
||||||
|
q := baseDB.WithContext(ctx).
|
||||||
|
Model(&entity.StockAllocation{}).
|
||||||
|
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume)
|
||||||
|
|
||||||
|
return q.Updates(updates).Error
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ type ApprovalService interface {
|
|||||||
WorkflowSteps(workflow approvalutils.ApprovalWorkflowKey) map[approvalutils.ApprovalStep]string
|
WorkflowSteps(workflow approvalutils.ApprovalWorkflowKey) map[approvalutils.ApprovalStep]string
|
||||||
WorkflowStepName(workflow approvalutils.ApprovalWorkflowKey, step approvalutils.ApprovalStep) (string, bool)
|
WorkflowStepName(workflow approvalutils.ApprovalWorkflowKey, step approvalutils.ApprovalStep) (string, bool)
|
||||||
CreateApproval(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, step approvalutils.ApprovalStep, action *entity.ApprovalAction, actorID uint, note *string) (*entity.Approval, error)
|
CreateApproval(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, step approvalutils.ApprovalStep, action *entity.ApprovalAction, actorID uint, note *string) (*entity.Approval, error)
|
||||||
List(ctx context.Context, module string, approvableID *uint, page, limit int, search string) ([]entity.Approval, int64, error)
|
List(ctx context.Context, module string, approvableID *uint, page, limit int, search string, orderByDate string) ([]entity.Approval, int64, error)
|
||||||
ListByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Approval, error)
|
ListByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Approval, error)
|
||||||
LatestByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) (*entity.Approval, error)
|
LatestByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) (*entity.Approval, error)
|
||||||
LatestByTargets(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableIDs []uint, modifier func(*gorm.DB) *gorm.DB) (map[uint]*entity.Approval, error)
|
LatestByTargets(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableIDs []uint, modifier func(*gorm.DB) *gorm.DB) (map[uint]*entity.Approval, error)
|
||||||
@@ -70,9 +70,14 @@ func (s *approvalService) List(
|
|||||||
approvableID *uint,
|
approvableID *uint,
|
||||||
page, limit int,
|
page, limit int,
|
||||||
search string,
|
search string,
|
||||||
|
orderByDate string,
|
||||||
) ([]entity.Approval, int64, error) {
|
) ([]entity.Approval, int64, error) {
|
||||||
module = strings.TrimSpace(strings.ToUpper(module))
|
module = strings.TrimSpace(strings.ToUpper(module))
|
||||||
search = strings.TrimSpace(search)
|
search = strings.TrimSpace(search)
|
||||||
|
orderByDate = strings.TrimSpace(strings.ToUpper(orderByDate))
|
||||||
|
if orderByDate != "ASC" && orderByDate != "DESC" {
|
||||||
|
orderByDate = "DESC"
|
||||||
|
}
|
||||||
|
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 10
|
limit = 10
|
||||||
@@ -90,7 +95,7 @@ func (s *approvalService) List(
|
|||||||
func(db *gorm.DB) *gorm.DB {
|
func(db *gorm.DB) *gorm.DB {
|
||||||
query := db.
|
query := db.
|
||||||
Where("approvable_type = ?", module).
|
Where("approvable_type = ?", module).
|
||||||
Order("action_at DESC").
|
Order("action_at " + orderByDate).
|
||||||
Preload("ActionUser")
|
Preload("ActionUser")
|
||||||
|
|
||||||
if approvableID != nil {
|
if approvableID != nil {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
|
||||||
|
warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
|
||||||
|
projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dipakai untuk semua module yang butuh cek:
|
||||||
|
// "PW ini → warehouse → kandang → project_flock_kandang sudah closing atau belum"
|
||||||
|
func EnsureProjectFlockNotClosedForProductWarehouses(
|
||||||
|
ctx context.Context,
|
||||||
|
db *gorm.DB,
|
||||||
|
productWarehouseIDs []uint,
|
||||||
|
) error {
|
||||||
|
if len(productWarehouseIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pwRepo := productWarehouseRepo.NewProductWarehouseRepository(db)
|
||||||
|
wRepo := warehouseRepo.NewWarehouseRepository(db)
|
||||||
|
pfkRepo := projectFlockKandangRepo.NewProjectFlockKandangRepository(db)
|
||||||
|
|
||||||
|
seenPW := make(map[uint]struct{})
|
||||||
|
seenKandang := make(map[uint]struct{})
|
||||||
|
|
||||||
|
for _, pwID := range productWarehouseIDs {
|
||||||
|
if pwID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seenPW[pwID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenPW[pwID] = struct{}{}
|
||||||
|
|
||||||
|
pw, err := pwRepo.GetByID(ctx, pwID, nil)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest,
|
||||||
|
fmt.Sprintf("Product warehouse %d tidak ditemukan", pwID))
|
||||||
|
}
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product warehouse")
|
||||||
|
}
|
||||||
|
|
||||||
|
wh, err := wRepo.GetByID(ctx, uint(pw.WarehouseId), nil)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest,
|
||||||
|
fmt.Sprintf("Warehouse %d tidak ditemukan", pw.WarehouseId))
|
||||||
|
}
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warehouse tanpa kandang → bukan kandang produksi → skip
|
||||||
|
if wh.KandangId == nil || *wh.KandangId == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
kandangID := uint(*wh.KandangId)
|
||||||
|
if _, ok := seenKandang[kandangID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenKandang[kandangID] = struct{}{}
|
||||||
|
|
||||||
|
pfk, err := pfkRepo.GetActiveByKandangID(ctx, kandangID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
// nggak ada project aktif untuk kandang ini → aman
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock")
|
||||||
|
}
|
||||||
|
// INTI RULE: kalau aktif tapi sudah punya ClosedAt → anggap "project sudah closing"
|
||||||
|
if pfk != nil && pfk.ClosedAt != nil {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "Project sudah closing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnsureProjectFlockNotClosedByProjectFlockKandangID(
|
||||||
|
ctx context.Context,
|
||||||
|
db *gorm.DB,
|
||||||
|
pfkIDs []uint,
|
||||||
|
) error {
|
||||||
|
pfkRepo := projectFlockKandangRepo.NewProjectFlockKandangRepository(db)
|
||||||
|
|
||||||
|
seen := make(map[uint]struct{})
|
||||||
|
for _, id := range pfkIDs {
|
||||||
|
if id == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
|
||||||
|
pfk, err := pfkRepo.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest,
|
||||||
|
fmt.Sprintf("Project flock kandang %d tidak ditemukan", id))
|
||||||
|
}
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock")
|
||||||
|
}
|
||||||
|
|
||||||
|
if pfk.ClosedAt != nil {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "Project sudah closing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||||
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultDocumentPathLimit = 255
|
||||||
|
defaultDocumentKeyPrefix = "docs"
|
||||||
|
maxDocumentNameLength = 50
|
||||||
|
)
|
||||||
|
|
||||||
|
type DocumentService interface {
|
||||||
|
UploadDocuments(ctx context.Context, req DocumentUploadRequest) ([]DocumentUploadResult, error)
|
||||||
|
ListByTarget(ctx context.Context, documentableType string, documentableID uint64) ([]entity.Document, error)
|
||||||
|
DeleteDocuments(ctx context.Context, ids []uint, removeFromStorage bool) error
|
||||||
|
DeleteByTarget(ctx context.Context, documentableType string, documentableID uint64, removeFromStorage bool) error
|
||||||
|
PublicURL(document entity.Document) string
|
||||||
|
PresignURL(ctx context.Context, document entity.Document, expires time.Duration) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocumentUploadRequest struct {
|
||||||
|
DocumentableType string
|
||||||
|
DocumentableID uint64
|
||||||
|
CreatedBy *uint
|
||||||
|
Files []DocumentFile
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocumentFile struct {
|
||||||
|
File *multipart.FileHeader
|
||||||
|
Type string
|
||||||
|
Index *int
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocumentUploadResult struct {
|
||||||
|
Document entity.Document
|
||||||
|
URL string
|
||||||
|
Index *int
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocumentServiceOption func(*documentService)
|
||||||
|
|
||||||
|
type documentService struct {
|
||||||
|
repo commonRepo.DocumentRepository
|
||||||
|
storage DocumentStorage
|
||||||
|
keyPrefix string
|
||||||
|
maxPathLength int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDocumentService(repo commonRepo.DocumentRepository, storage DocumentStorage, opts ...DocumentServiceOption) DocumentService {
|
||||||
|
svc := &documentService{
|
||||||
|
repo: repo,
|
||||||
|
storage: storage,
|
||||||
|
keyPrefix: defaultDocumentKeyPrefix,
|
||||||
|
maxPathLength: defaultDocumentPathLimit,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(svc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDocumentServiceFromConfig(ctx context.Context, repo commonRepo.DocumentRepository) (DocumentService, error) {
|
||||||
|
if repo == nil {
|
||||||
|
return nil, errors.New("document repository is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.S3Bucket) == "" {
|
||||||
|
return nil, errors.New("S3_BUCKET is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
storage, err := NewS3DocumentStorage(ctx, S3DocumentStorageConfig{
|
||||||
|
Region: config.S3Region,
|
||||||
|
Bucket: config.S3Bucket,
|
||||||
|
AccessKey: config.S3AccessKey,
|
||||||
|
SecretKey: config.S3SecretKey,
|
||||||
|
Endpoint: config.S3Endpoint,
|
||||||
|
BaseURL: config.S3PublicBaseURL,
|
||||||
|
ForcePathStyle: config.S3ForcePathStyle,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := config.S3DocumentKeyPrefix
|
||||||
|
if prefix == "" {
|
||||||
|
prefix = defaultDocumentKeyPrefix
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewDocumentService(
|
||||||
|
repo,
|
||||||
|
storage,
|
||||||
|
WithDocumentKeyPrefix(prefix),
|
||||||
|
WithDocumentPathLimit(defaultDocumentPathLimit),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithDocumentKeyPrefix(prefix string) DocumentServiceOption {
|
||||||
|
return func(svc *documentService) {
|
||||||
|
prefix = strings.Trim(prefix, "/")
|
||||||
|
if prefix == "" {
|
||||||
|
prefix = defaultDocumentKeyPrefix
|
||||||
|
}
|
||||||
|
svc.keyPrefix = prefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithDocumentPathLimit(limit int) DocumentServiceOption {
|
||||||
|
return func(svc *documentService) {
|
||||||
|
if limit > 0 {
|
||||||
|
svc.maxPathLength = limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *documentService) UploadDocuments(ctx context.Context, req DocumentUploadRequest) ([]DocumentUploadResult, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, errors.New("document repository not configured")
|
||||||
|
}
|
||||||
|
if s.storage == nil {
|
||||||
|
return nil, errors.New("document storage not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
documentableType := strings.ToUpper(strings.TrimSpace(req.DocumentableType))
|
||||||
|
if documentableType == "" {
|
||||||
|
return nil, errors.New("documentable type is required")
|
||||||
|
}
|
||||||
|
if req.DocumentableID == 0 {
|
||||||
|
return nil, errors.New("documentable id is required")
|
||||||
|
}
|
||||||
|
if len(req.Files) == 0 {
|
||||||
|
return nil, errors.New("no files to upload")
|
||||||
|
}
|
||||||
|
|
||||||
|
var createdBy *uint
|
||||||
|
if req.CreatedBy != nil && *req.CreatedBy != 0 {
|
||||||
|
idCopy := *req.CreatedBy
|
||||||
|
createdBy = &idCopy
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]DocumentUploadResult, 0, len(req.Files))
|
||||||
|
createdDocs := make([]entity.Document, 0, len(req.Files))
|
||||||
|
|
||||||
|
for _, file := range req.Files {
|
||||||
|
if file.File == nil {
|
||||||
|
return nil, errors.New("file header is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
originalName := sanitizeDocumentName(file.File.Filename)
|
||||||
|
contentType := detectContentType(file.File, originalName)
|
||||||
|
ext := detectExtension(file.File.Filename, contentType)
|
||||||
|
key, err := s.generateObjectKey(ext)
|
||||||
|
if err != nil {
|
||||||
|
s.rollbackDocuments(ctx, createdDocs)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := file.File.Open()
|
||||||
|
if err != nil {
|
||||||
|
s.rollbackDocuments(ctx, createdDocs)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
uploadRes, err := s.storage.Upload(ctx, key, reader, file.File.Size, contentType)
|
||||||
|
_ = reader.Close()
|
||||||
|
if err != nil {
|
||||||
|
s.rollbackDocuments(ctx, createdDocs)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
docType := resolveDocumentType(file.Type, documentableType)
|
||||||
|
doc := entity.Document{
|
||||||
|
DocumentableType: documentableType,
|
||||||
|
DocumentableId: req.DocumentableID,
|
||||||
|
Type: docType,
|
||||||
|
Path: uploadRes.Key,
|
||||||
|
Name: originalName,
|
||||||
|
Ext: strings.TrimPrefix(ext, "."),
|
||||||
|
Size: float64(file.File.Size),
|
||||||
|
CreatedBy: createdBy,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.CreateOne(ctx, &doc, nil); err != nil {
|
||||||
|
_ = s.storage.Delete(ctx, uploadRes.Key)
|
||||||
|
s.rollbackDocuments(ctx, createdDocs)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
createdDocs = append(createdDocs, doc)
|
||||||
|
results = append(results, DocumentUploadResult{
|
||||||
|
Document: doc,
|
||||||
|
URL: uploadRes.URL,
|
||||||
|
Index: cloneIndex(file.Index),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *documentService) ListByTarget(ctx context.Context, documentableType string, documentableID uint64) ([]entity.Document, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, errors.New("document repository not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
documentableType = strings.ToUpper(strings.TrimSpace(documentableType))
|
||||||
|
if documentableType == "" {
|
||||||
|
return nil, errors.New("documentable type is required")
|
||||||
|
}
|
||||||
|
if documentableID == 0 {
|
||||||
|
return nil, errors.New("documentable id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.ListByTarget(ctx, documentableType, documentableID, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *documentService) DeleteDocuments(ctx context.Context, ids []uint, removeFromStorage bool) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return errors.New("document repository not configured")
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
docs, err := s.repo.GetByIDs(ctx, ids, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, doc := range docs {
|
||||||
|
if err := s.repo.DeleteOne(ctx, doc.Id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if removeFromStorage && s.storage != nil {
|
||||||
|
if err := s.storage.Delete(ctx, doc.Path); err != nil {
|
||||||
|
utils.Log.WithError(err).Warnf("failed to delete document object %s", doc.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *documentService) DeleteByTarget(ctx context.Context, documentableType string, documentableID uint64, removeFromStorage bool) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return errors.New("document repository not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
documentableType = strings.ToUpper(strings.TrimSpace(documentableType))
|
||||||
|
if documentableType == "" || documentableID == 0 {
|
||||||
|
return errors.New("documentable type and id are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var docs []entity.Document
|
||||||
|
if removeFromStorage && s.storage != nil {
|
||||||
|
var err error
|
||||||
|
docs, err = s.repo.ListByTarget(ctx, documentableType, documentableID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.DeleteByTarget(ctx, documentableType, documentableID, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if removeFromStorage && len(docs) > 0 {
|
||||||
|
for _, doc := range docs {
|
||||||
|
if err := s.storage.Delete(ctx, doc.Path); err != nil {
|
||||||
|
utils.Log.WithError(err).Warnf("failed to delete document object %s", doc.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *documentService) PublicURL(document entity.Document) string {
|
||||||
|
if s.storage == nil || strings.TrimSpace(document.Path) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.storage.URL(document.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *documentService) PresignURL(ctx context.Context, document entity.Document, expires time.Duration) (string, error) {
|
||||||
|
if s.storage == nil {
|
||||||
|
return "", errors.New("document storage not configured")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(document.Path) == "" {
|
||||||
|
return "", errors.New("document path is required")
|
||||||
|
}
|
||||||
|
return s.storage.PresignURL(ctx, document.Path, expires)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveDocumentURL normalizes a stored path or URL into a presigned URL.
|
||||||
|
func ResolveDocumentURL(
|
||||||
|
ctx context.Context,
|
||||||
|
svc DocumentService,
|
||||||
|
rawPath string,
|
||||||
|
expires time.Duration,
|
||||||
|
) (string, error) {
|
||||||
|
if svc == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rawPath = strings.TrimSpace(rawPath)
|
||||||
|
if rawPath == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
key := rawPath
|
||||||
|
lower := strings.ToLower(rawPath)
|
||||||
|
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
|
||||||
|
key = extractS3KeyFromURL(rawPath)
|
||||||
|
if key == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return svc.PresignURL(ctx, entity.Document{Path: key}, expires)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractS3KeyFromURL(raw string) string {
|
||||||
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
path := strings.TrimPrefix(parsed.Path, "/")
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
host := strings.ToLower(strings.TrimSpace(parsed.Host))
|
||||||
|
if strings.HasPrefix(host, "s3.") || strings.HasPrefix(host, "s3-") {
|
||||||
|
parts := strings.SplitN(path, "/", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return parts[1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *documentService) generateObjectKey(ext string) (string, error) {
|
||||||
|
normalizedExt := strings.TrimSpace(ext)
|
||||||
|
if normalizedExt != "" && !strings.HasPrefix(normalizedExt, ".") {
|
||||||
|
normalizedExt = "." + normalizedExt
|
||||||
|
}
|
||||||
|
|
||||||
|
u := uuid.New().String()
|
||||||
|
keyPrefix := strings.Trim(s.keyPrefix, "/")
|
||||||
|
key := fmt.Sprintf("%s%s", u, normalizedExt)
|
||||||
|
if keyPrefix != "" {
|
||||||
|
key = fmt.Sprintf("%s/%s%s", keyPrefix, u, normalizedExt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(key) > s.maxPathLength {
|
||||||
|
compact := strings.ReplaceAll(u, "-", "")
|
||||||
|
if keyPrefix != "" {
|
||||||
|
key = fmt.Sprintf("%s/%s%s", keyPrefix, compact, normalizedExt)
|
||||||
|
} else {
|
||||||
|
key = fmt.Sprintf("%s%s", compact, 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,185 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DocumentStorage interface {
|
||||||
|
Upload(ctx context.Context, key string, body io.Reader, size int64, contentType string) (DocumentStorageUploadResult, error)
|
||||||
|
Delete(ctx context.Context, key string) error
|
||||||
|
URL(key string) string
|
||||||
|
PresignURL(ctx context.Context, key string, expires time.Duration) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocumentStorageUploadResult struct {
|
||||||
|
Key string
|
||||||
|
URL string
|
||||||
|
ETag string
|
||||||
|
}
|
||||||
|
|
||||||
|
type S3DocumentStorageConfig struct {
|
||||||
|
Region string
|
||||||
|
Bucket string
|
||||||
|
AccessKey string
|
||||||
|
SecretKey string
|
||||||
|
Endpoint string
|
||||||
|
BaseURL string
|
||||||
|
ForcePathStyle bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type s3DocumentStorage struct {
|
||||||
|
client *s3.Client
|
||||||
|
presignClient *s3.PresignClient
|
||||||
|
bucket string
|
||||||
|
base string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewS3DocumentStorage(ctx context.Context, cfg S3DocumentStorageConfig) (DocumentStorage, error) {
|
||||||
|
bucket := strings.TrimSpace(cfg.Bucket)
|
||||||
|
if bucket == "" {
|
||||||
|
return nil, errors.New("s3 bucket is required")
|
||||||
|
}
|
||||||
|
region := strings.TrimSpace(cfg.Region)
|
||||||
|
if region == "" {
|
||||||
|
region = "us-east-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
options := []func(*awsconfig.LoadOptions) error{
|
||||||
|
awsconfig.WithRegion(region),
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := strings.TrimSpace(cfg.Endpoint)
|
||||||
|
if endpoint != "" {
|
||||||
|
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, _ ...interface{}) (aws.Endpoint, error) {
|
||||||
|
if service == s3.ServiceID {
|
||||||
|
return aws.Endpoint{
|
||||||
|
URL: endpoint,
|
||||||
|
SigningRegion: region,
|
||||||
|
HostnameImmutable: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return aws.Endpoint{}, &aws.EndpointNotFoundError{}
|
||||||
|
})
|
||||||
|
options = append(options, awsconfig.WithEndpointResolverWithOptions(resolver))
|
||||||
|
}
|
||||||
|
|
||||||
|
accessKey := strings.TrimSpace(cfg.AccessKey)
|
||||||
|
secretKey := strings.TrimSpace(cfg.SecretKey)
|
||||||
|
if accessKey != "" && secretKey != "" {
|
||||||
|
options = append(options, awsconfig.WithCredentialsProvider(
|
||||||
|
credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
awsCfg, err := awsconfig.LoadDefaultConfig(ctx, options...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
|
||||||
|
o.UsePathStyle = cfg.ForcePathStyle
|
||||||
|
})
|
||||||
|
presignClient := s3.NewPresignClient(client)
|
||||||
|
|
||||||
|
baseURL := strings.TrimSuffix(strings.TrimSpace(cfg.BaseURL), "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
if endpoint != "" {
|
||||||
|
baseURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(endpoint, "/"), bucket)
|
||||||
|
} else {
|
||||||
|
baseURL = fmt.Sprintf("https://%s.s3.%s.amazonaws.com", bucket, region)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &s3DocumentStorage{
|
||||||
|
client: client,
|
||||||
|
presignClient: presignClient,
|
||||||
|
bucket: bucket,
|
||||||
|
base: baseURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3DocumentStorage) Upload(ctx context.Context, key string, body io.Reader, size int64, contentType string) (DocumentStorageUploadResult, error) {
|
||||||
|
if strings.TrimSpace(key) == "" {
|
||||||
|
return DocumentStorageUploadResult{}, errors.New("storage key is required")
|
||||||
|
}
|
||||||
|
if size < 0 {
|
||||||
|
size = 0
|
||||||
|
}
|
||||||
|
input := &s3.PutObjectInput{
|
||||||
|
Bucket: aws.String(s.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
Body: body,
|
||||||
|
}
|
||||||
|
input.ContentLength = aws.Int64(size)
|
||||||
|
if ct := strings.TrimSpace(contentType); ct != "" {
|
||||||
|
input.ContentType = aws.String(ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := s.client.PutObject(ctx, input)
|
||||||
|
if err != nil {
|
||||||
|
return DocumentStorageUploadResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var etag string
|
||||||
|
if out.ETag != nil {
|
||||||
|
etag = strings.Trim(*out.ETag, "\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
return DocumentStorageUploadResult{
|
||||||
|
Key: key,
|
||||||
|
URL: s.URL(key),
|
||||||
|
ETag: etag,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3DocumentStorage) Delete(ctx context.Context, key string) error {
|
||||||
|
if strings.TrimSpace(key) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||||
|
Bucket: aws.String(s.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3DocumentStorage) URL(key string) string {
|
||||||
|
key = strings.TrimPrefix(strings.TrimSpace(key), "/")
|
||||||
|
if key == "" {
|
||||||
|
return s.base
|
||||||
|
}
|
||||||
|
if s.base == "" {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s/%s", s.base, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3DocumentStorage) PresignURL(ctx context.Context, key string, expires time.Duration) (string, error) {
|
||||||
|
key = strings.TrimPrefix(strings.TrimSpace(key), "/")
|
||||||
|
if key == "" {
|
||||||
|
return "", errors.New("storage key is required")
|
||||||
|
}
|
||||||
|
if expires <= 0 {
|
||||||
|
expires = 15 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := s.presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(s.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
}, s3.WithPresignExpires(expires))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.URL, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,968 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FifoService interface {
|
||||||
|
RegisterStockable(cfg fifo.StockableConfig) error
|
||||||
|
RegisterUsable(cfg fifo.UsableConfig) error
|
||||||
|
|
||||||
|
Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error)
|
||||||
|
Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error)
|
||||||
|
ReleaseUsage(ctx context.Context, req StockReleaseRequest) error
|
||||||
|
AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error
|
||||||
|
ResolvePending(ctx context.Context, req PendingResolveRequest) ([]PendingResolution, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type fifoService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
logger *logrus.Logger
|
||||||
|
allocations commonRepo.StockAllocationRepository
|
||||||
|
productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository
|
||||||
|
defaultOrderBy []string
|
||||||
|
pendingBatchPerUsable int
|
||||||
|
maxLotsPerStockable int
|
||||||
|
defaultAllocationNotes string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFifoService(
|
||||||
|
db *gorm.DB,
|
||||||
|
allocations commonRepo.StockAllocationRepository,
|
||||||
|
productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository,
|
||||||
|
logger *logrus.Logger,
|
||||||
|
) FifoService {
|
||||||
|
if logger == nil {
|
||||||
|
logger = logrus.StandardLogger()
|
||||||
|
}
|
||||||
|
return &fifoService{
|
||||||
|
db: db,
|
||||||
|
logger: logger,
|
||||||
|
allocations: allocations,
|
||||||
|
productWarehouseRepo: productWarehouseRepo,
|
||||||
|
defaultOrderBy: []string{"created_at ASC", "id ASC"},
|
||||||
|
pendingBatchPerUsable: 25,
|
||||||
|
maxLotsPerStockable: 50,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoService) withTransaction(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *gorm.DB,
|
||||||
|
fn func(*gorm.DB) error,
|
||||||
|
) error {
|
||||||
|
if tx != nil {
|
||||||
|
return fn(tx.WithContext(ctx))
|
||||||
|
}
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(inner *gorm.DB) error {
|
||||||
|
return fn(inner)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoService) txOrDB(tx, db *gorm.DB) *gorm.DB {
|
||||||
|
if tx != nil {
|
||||||
|
return tx
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoService) RegisterStockable(cfg fifo.StockableConfig) error {
|
||||||
|
return fifo.RegisterStockable(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoService) RegisterUsable(cfg fifo.UsableConfig) error {
|
||||||
|
return fifo.RegisterUsable(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
type StockReplenishRequest struct {
|
||||||
|
StockableKey fifo.StockableKey
|
||||||
|
StockableID uint
|
||||||
|
ProductWarehouseID uint
|
||||||
|
Quantity float64
|
||||||
|
Note *string
|
||||||
|
Tx *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type StockAdjustRequest struct {
|
||||||
|
StockableKey fifo.StockableKey
|
||||||
|
StockableID uint
|
||||||
|
ProductWarehouseID uint
|
||||||
|
Quantity float64
|
||||||
|
Note *string
|
||||||
|
Tx *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingResolution struct {
|
||||||
|
UsableKey fifo.UsableKey
|
||||||
|
UsableID uint
|
||||||
|
Quantity float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingResolveRequest struct {
|
||||||
|
ProductWarehouseID uint
|
||||||
|
Tx *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type StockReplenishResult struct {
|
||||||
|
AddedQuantity float64
|
||||||
|
PendingResolved []PendingResolution
|
||||||
|
RemainingPending float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type StockConsumeRequest struct {
|
||||||
|
UsableKey fifo.UsableKey
|
||||||
|
UsableID uint
|
||||||
|
ProductWarehouseID uint
|
||||||
|
Quantity float64
|
||||||
|
AllowPending bool
|
||||||
|
Note *string
|
||||||
|
Tx *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type AllocationDetail struct {
|
||||||
|
StockableKey fifo.StockableKey
|
||||||
|
StockableID uint
|
||||||
|
Quantity float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type StockConsumeResult struct {
|
||||||
|
RequestedQuantity float64
|
||||||
|
UsageQuantity float64
|
||||||
|
PendingQuantity float64
|
||||||
|
AddedAllocations []AllocationDetail
|
||||||
|
ReleasedQuantity float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type StockReleaseRequest struct {
|
||||||
|
UsableKey fifo.UsableKey
|
||||||
|
UsableID uint
|
||||||
|
Reason *string
|
||||||
|
Tx *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoService) AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error {
|
||||||
|
if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" {
|
||||||
|
return errors.New("stockable key and id are required")
|
||||||
|
}
|
||||||
|
if req.ProductWarehouseID == 0 {
|
||||||
|
return errors.New("product warehouse id is required")
|
||||||
|
}
|
||||||
|
if req.Quantity == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if req.Quantity > 0 {
|
||||||
|
return errors.New("quantity must be negative")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, ok := fifo.Stockable(req.StockableKey)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("stockable %q is not registered", req.StockableKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
||||||
|
if err := s.incrementStockableQty(ctx, tx, cfg, req.StockableID, req.Quantity); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{
|
||||||
|
req.ProductWarehouseID: req.Quantity,
|
||||||
|
}, func(db *gorm.DB) *gorm.DB {
|
||||||
|
return s.txOrDB(tx, db)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) {
|
||||||
|
if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" {
|
||||||
|
return nil, errors.New("stockable key and id are required")
|
||||||
|
}
|
||||||
|
if req.ProductWarehouseID == 0 {
|
||||||
|
return nil, errors.New("product warehouse id is required")
|
||||||
|
}
|
||||||
|
if req.Quantity <= 0 {
|
||||||
|
return nil, errors.New("quantity must be greater than zero")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, ok := fifo.Stockable(req.StockableKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("stockable %q is not registered", req.StockableKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &StockReplenishResult{
|
||||||
|
AddedQuantity: req.Quantity,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
||||||
|
if err := s.incrementStockableQty(ctx, tx, cfg, req.StockableID, req.Quantity); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{
|
||||||
|
req.ProductWarehouseID: req.Quantity,
|
||||||
|
}, func(db *gorm.DB) *gorm.DB {
|
||||||
|
return s.txOrDB(tx, db)
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := s.resolvePendingForWarehouse(ctx, tx, req.ProductWarehouseID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.PendingResolved = resolved
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoService) ResolvePending(ctx context.Context, req PendingResolveRequest) ([]PendingResolution, error) {
|
||||||
|
if req.ProductWarehouseID == 0 {
|
||||||
|
return nil, errors.New("product warehouse id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolved []PendingResolution
|
||||||
|
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
||||||
|
var err error
|
||||||
|
resolved, err = s.resolvePendingForWarehouse(ctx, tx, req.ProductWarehouseID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) {
|
||||||
|
if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" {
|
||||||
|
return nil, errors.New("usable key and id are required")
|
||||||
|
}
|
||||||
|
if req.Quantity < 0 {
|
||||||
|
return nil, errors.New("quantity must be zero or greater")
|
||||||
|
}
|
||||||
|
cfg, ok := fifo.Usable(req.UsableKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("usable %q is not registered", req.UsableKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &StockConsumeResult{
|
||||||
|
RequestedQuantity: req.Quantity,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
||||||
|
ctxRow, err := s.loadUsableContext(ctx, tx, cfg, req.UsableID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
productWarehouseID := ctxRow.ProductWarehouseID
|
||||||
|
if productWarehouseID == 0 {
|
||||||
|
return fmt.Errorf("usable %q (id: %d) has no product warehouse reference", req.UsableKey, req.UsableID)
|
||||||
|
}
|
||||||
|
if req.ProductWarehouseID != 0 && req.ProductWarehouseID != productWarehouseID {
|
||||||
|
return fmt.Errorf("usable %q (id: %d) references product warehouse %d but %d was provided", req.UsableKey, req.UsableID, productWarehouseID, req.ProductWarehouseID)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUsage := ctxRow.UsageQty
|
||||||
|
currentPending := ctxRow.PendingQty
|
||||||
|
currentTotal := currentUsage + currentPending
|
||||||
|
delta := req.Quantity - currentTotal
|
||||||
|
var (
|
||||||
|
usageDelta float64
|
||||||
|
pendingDelta float64
|
||||||
|
addedAlloc []AllocationDetail
|
||||||
|
releasedAmount float64
|
||||||
|
)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case delta > 0:
|
||||||
|
|
||||||
|
var excludedStockables []fifo.StockableKey
|
||||||
|
if cfg.ExcludedStockables != nil {
|
||||||
|
excludedStockables = cfg.ExcludedStockables
|
||||||
|
}
|
||||||
|
|
||||||
|
allocationRes, err := s.allocateFromStock(ctx, tx, productWarehouseID, req.UsableKey, req.UsableID, delta, excludedStockables)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if allocationRes.pending > 0 && !req.AllowPending {
|
||||||
|
return fmt.Errorf("insufficient stock: requested %.3f, allocated %.3f", req.Quantity, currentUsage+allocationRes.allocated)
|
||||||
|
}
|
||||||
|
|
||||||
|
usageDelta += allocationRes.allocated
|
||||||
|
pendingDelta += allocationRes.pending
|
||||||
|
addedAlloc = allocationRes.allocations
|
||||||
|
|
||||||
|
if allocationRes.allocated > 0 {
|
||||||
|
if err := s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{
|
||||||
|
productWarehouseID: -allocationRes.allocated,
|
||||||
|
}, func(db *gorm.DB) *gorm.DB {
|
||||||
|
return s.txOrDB(tx, db)
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case delta < 0:
|
||||||
|
reductionTarget := -delta
|
||||||
|
|
||||||
|
if currentPending > 0 {
|
||||||
|
pendingReduction := math.Min(currentPending, reductionTarget)
|
||||||
|
if pendingReduction > 0 {
|
||||||
|
pendingDelta -= pendingReduction
|
||||||
|
reductionTarget -= pendingReduction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if reductionTarget > 0 {
|
||||||
|
released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget, productWarehouseID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if released+1e-6 < reductionTarget {
|
||||||
|
return fmt.Errorf("unable to release %.3f from usable %d, only %.3f available", reductionTarget, req.UsableID, released)
|
||||||
|
}
|
||||||
|
usageDelta -= released
|
||||||
|
releasedAmount = released
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// no change
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.applyUsableDeltas(ctx, tx, cfg, req.UsableID, usageDelta, pendingDelta); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
result.AddedAllocations = addedAlloc
|
||||||
|
result.ReleasedQuantity = releasedAmount
|
||||||
|
result.UsageQuantity = currentUsage + usageDelta
|
||||||
|
result.PendingQuantity = currentPending + pendingDelta
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) error {
|
||||||
|
if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" {
|
||||||
|
return errors.New("usable key and id are required")
|
||||||
|
}
|
||||||
|
return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
||||||
|
cfg, ok := fifo.Usable(req.UsableKey)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("usable %q is not registered", req.UsableKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctxRow, err := s.loadUsableContext(ctx, tx, cfg, req.UsableID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var usageDelta, pendingDelta float64
|
||||||
|
if ctxRow.UsageQty > 0 {
|
||||||
|
if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty, ctxRow.ProductWarehouseID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
usageDelta -= ctxRow.UsageQty
|
||||||
|
}
|
||||||
|
if ctxRow.PendingQty > 0 {
|
||||||
|
pendingDelta -= ctxRow.PendingQty
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.applyUsableDeltas(ctx, tx, cfg, req.UsableID, usageDelta, pendingDelta); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.allocations.ReleaseByUsable(ctx, req.UsableKey.String(), req.UsableID, req.Reason, func(db *gorm.DB) *gorm.DB {
|
||||||
|
return s.txOrDB(tx, db)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
type usableContextRow struct {
|
||||||
|
ProductWarehouseID uint
|
||||||
|
UsageQty float64
|
||||||
|
PendingQty float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoService) loadUsableContext(ctx context.Context, tx *gorm.DB, cfg fifo.UsableConfig, id uint) (*usableContextRow, error) {
|
||||||
|
var row usableContextRow
|
||||||
|
|
||||||
|
query := tx.Table(cfg.Table).
|
||||||
|
Select(fmt.Sprintf("%s AS product_warehouse_id, COALESCE(%s,0) AS usage_qty, COALESCE(%s,0) AS pending_qty", cfg.Columns.ProductWarehouseID, cfg.Columns.UsageQuantity, cfg.Columns.PendingQuantity)).
|
||||||
|
Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id).
|
||||||
|
Clauses(clause.Locking{Strength: "UPDATE"})
|
||||||
|
|
||||||
|
if cfg.Scope != nil {
|
||||||
|
query = cfg.Scope(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.Take(&row).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, fmt.Errorf("usable record %d not found", id)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoService) incrementStockableQty(ctx context.Context, tx *gorm.DB, cfg fifo.StockableConfig, id uint, qty float64) error {
|
||||||
|
column := cfg.Columns.TotalQuantity
|
||||||
|
|
||||||
|
query := tx.Table(cfg.Table).
|
||||||
|
Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id)
|
||||||
|
if cfg.Scope != nil {
|
||||||
|
query = cfg.Scope(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]any{
|
||||||
|
column: gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", column), qty),
|
||||||
|
}
|
||||||
|
if cfg.Columns.TotalUsedQuantity != "" {
|
||||||
|
updates[cfg.Columns.TotalUsedQuantity] = gorm.Expr(fmt.Sprintf("COALESCE(%s,0)", cfg.Columns.TotalUsedQuantity))
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.Updates(updates).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoService) incrementStockableUsage(ctx context.Context, tx *gorm.DB, cfg fifo.StockableConfig, id uint, qty float64) error {
|
||||||
|
if qty == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
column := cfg.Columns.TotalUsedQuantity
|
||||||
|
query := tx.Table(cfg.Table).
|
||||||
|
Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id)
|
||||||
|
if cfg.Scope != nil {
|
||||||
|
query = cfg.Scope(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.Update(column, gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", column), qty)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
type allocationOutcome struct {
|
||||||
|
allocated float64
|
||||||
|
pending float64
|
||||||
|
allocations []AllocationDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
type stockLot struct {
|
||||||
|
StockableKey fifo.StockableKey
|
||||||
|
RecordID uint
|
||||||
|
AvailableQty float64
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoService) allocateFromStock(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *gorm.DB,
|
||||||
|
productWarehouseID uint,
|
||||||
|
usableKey fifo.UsableKey,
|
||||||
|
usableID uint,
|
||||||
|
requestQty float64,
|
||||||
|
excludedStockables []fifo.StockableKey,
|
||||||
|
) (*allocationOutcome, error) {
|
||||||
|
lots, err := s.fetchStockLots(ctx, tx, productWarehouseID, excludedStockables)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(lots) == 0 {
|
||||||
|
return &allocationOutcome{pending: requestQty}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
remaining = requestQty
|
||||||
|
applied float64
|
||||||
|
allocations []*entities.StockAllocation
|
||||||
|
allocationSummaries []AllocationDetail
|
||||||
|
usageAdjustments = make(map[fifo.StockableKey]map[uint]float64)
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, lot := range lots {
|
||||||
|
if remaining <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if lot.AvailableQty <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
portion := lot.AvailableQty
|
||||||
|
if portion > remaining {
|
||||||
|
portion = remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
applied += portion
|
||||||
|
remaining -= portion
|
||||||
|
|
||||||
|
allocationSummaries = append(allocationSummaries, AllocationDetail{
|
||||||
|
StockableKey: lot.StockableKey,
|
||||||
|
StockableID: lot.RecordID,
|
||||||
|
Quantity: portion,
|
||||||
|
})
|
||||||
|
|
||||||
|
allocations = append(allocations, &entities.StockAllocation{
|
||||||
|
ProductWarehouseId: productWarehouseID,
|
||||||
|
StockableType: lot.StockableKey.String(),
|
||||||
|
StockableId: lot.RecordID,
|
||||||
|
UsableType: usableKey.String(),
|
||||||
|
UsableId: usableID,
|
||||||
|
Qty: portion,
|
||||||
|
AllocationPurpose: entities.StockAllocationPurposeConsume,
|
||||||
|
Status: entities.StockAllocationStatusActive,
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, ok := usageAdjustments[lot.StockableKey]; !ok {
|
||||||
|
usageAdjustments[lot.StockableKey] = make(map[uint]float64)
|
||||||
|
}
|
||||||
|
usageAdjustments[lot.StockableKey][lot.RecordID] += portion
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allocations) > 0 {
|
||||||
|
if err := s.allocations.CreateMany(ctx, allocations, func(db *gorm.DB) *gorm.DB {
|
||||||
|
return s.txOrDB(tx, db)
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, deltas := range usageAdjustments {
|
||||||
|
cfg, ok := fifo.Stockable(key)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for id, qty := range deltas {
|
||||||
|
if err := s.incrementStockableUsage(ctx, tx, cfg, id, qty); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &allocationOutcome{
|
||||||
|
allocated: applied,
|
||||||
|
pending: remaining,
|
||||||
|
allocations: allocationSummaries,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoService) fetchStockLots(ctx context.Context, tx *gorm.DB, productWarehouseID uint, excludedStockables []fifo.StockableKey) ([]stockLot, error) {
|
||||||
|
configs := fifo.Stockables()
|
||||||
|
if len(configs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create exclusion set for faster lookup
|
||||||
|
excludedSet := make(map[fifo.StockableKey]bool)
|
||||||
|
for _, key := range excludedStockables {
|
||||||
|
excludedSet[key] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var lots []stockLot
|
||||||
|
for key, cfg := range configs {
|
||||||
|
// Skip excluded stockables
|
||||||
|
if excludedSet[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
usesNumericTime := cfg.Columns.CreatedAt == cfg.Columns.ID
|
||||||
|
|
||||||
|
var selectStmt string
|
||||||
|
if usesNumericTime {
|
||||||
|
|
||||||
|
selectStmt = fmt.Sprintf(
|
||||||
|
"%s AS id, %s AS available_qty, '1970-01-01 00:00:00 UTC'::timestamp AS created_at",
|
||||||
|
cfg.Columns.ID,
|
||||||
|
fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
selectStmt = fmt.Sprintf(
|
||||||
|
"%s AS id, %s AS available_qty, %s AS created_at",
|
||||||
|
cfg.Columns.ID,
|
||||||
|
fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity),
|
||||||
|
cfg.Columns.CreatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []struct {
|
||||||
|
ID uint
|
||||||
|
AvailableQty float64
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
query := tx.Table(cfg.Table).
|
||||||
|
Select(selectStmt).
|
||||||
|
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
|
||||||
|
Where(fmt.Sprintf("%s > %s", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity))
|
||||||
|
|
||||||
|
if cfg.Scope != nil {
|
||||||
|
query = cfg.Scope(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, order := range s.orderClauses(cfg.OrderBy) {
|
||||||
|
query = query.Order(order)
|
||||||
|
}
|
||||||
|
query = query.Limit(s.maxLotsPerStockable)
|
||||||
|
|
||||||
|
if err := query.Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.AvailableQty <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lots = append(lots, stockLot{
|
||||||
|
StockableKey: key,
|
||||||
|
RecordID: row.ID,
|
||||||
|
AvailableQty: row.AvailableQty,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lots) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(lots, func(i, j int) bool {
|
||||||
|
if lots[i].CreatedAt.Equal(lots[j].CreatedAt) {
|
||||||
|
return lots[i].RecordID < lots[j].RecordID
|
||||||
|
}
|
||||||
|
return lots[i].CreatedAt.Before(lots[j].CreatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
return lots, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoService) applyUsableDeltas(ctx context.Context, tx *gorm.DB, cfg fifo.UsableConfig, id uint, usageDelta, pendingDelta float64) error {
|
||||||
|
if usageDelta == 0 && pendingDelta == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]any{}
|
||||||
|
if usageDelta != 0 {
|
||||||
|
updates[cfg.Columns.UsageQuantity] = gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", cfg.Columns.UsageQuantity), usageDelta)
|
||||||
|
}
|
||||||
|
if pendingDelta != 0 {
|
||||||
|
updates[cfg.Columns.PendingQuantity] = gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", cfg.Columns.PendingQuantity), pendingDelta)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := tx.Table(cfg.Table).Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id)
|
||||||
|
if cfg.Scope != nil {
|
||||||
|
query = cfg.Scope(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.Updates(updates).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
type pendingCandidate struct {
|
||||||
|
UsableKey fifo.UsableKey
|
||||||
|
Config fifo.UsableConfig
|
||||||
|
UsableID uint
|
||||||
|
Pending float64
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoService) resolvePendingForWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]PendingResolution, error) {
|
||||||
|
candidates, err := s.fetchPendingCandidates(ctx, tx, productWarehouseID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolutions []PendingResolution
|
||||||
|
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if candidate.Pending <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get excluded stockables from candidate usable config
|
||||||
|
var excludedStockables []fifo.StockableKey
|
||||||
|
if candidate.Config.ExcludedStockables != nil {
|
||||||
|
excludedStockables = candidate.Config.ExcludedStockables
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome, err := s.allocateFromStock(ctx, tx, productWarehouseID, candidate.UsableKey, candidate.UsableID, candidate.Pending, excludedStockables)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if outcome.allocated <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.applyUsableDeltas(ctx, tx, candidate.Config, candidate.UsableID, outcome.allocated, -outcome.allocated); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{
|
||||||
|
productWarehouseID: -outcome.allocated,
|
||||||
|
}, func(db *gorm.DB) *gorm.DB {
|
||||||
|
return s.txOrDB(tx, db)
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resolutions = append(resolutions, PendingResolution{
|
||||||
|
UsableKey: candidate.UsableKey,
|
||||||
|
UsableID: candidate.UsableID,
|
||||||
|
Quantity: outcome.allocated,
|
||||||
|
})
|
||||||
|
|
||||||
|
if outcome.pending > 0 {
|
||||||
|
// No more stock available for this warehouse at the moment.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolutions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoService) releaseUsagePortion(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *gorm.DB,
|
||||||
|
usableKey fifo.UsableKey,
|
||||||
|
usableID uint,
|
||||||
|
target float64,
|
||||||
|
expectedWarehouseID uint,
|
||||||
|
) (float64, error) {
|
||||||
|
if target <= 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
allocations, err := s.allocations.FindActiveByUsable(ctx, usableKey.String(), usableID, func(db *gorm.DB) *gorm.DB {
|
||||||
|
target := s.txOrDB(tx, db)
|
||||||
|
return target.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if len(allocations) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
for i := range allocations {
|
||||||
|
alloc := &allocations[i]
|
||||||
|
if expectedWarehouseID == 0 || alloc.ProductWarehouseId == expectedWarehouseID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := tx.Model(&entities.StockAllocation{}).
|
||||||
|
Where("id = ?", alloc.Id).
|
||||||
|
Update("product_warehouse_id", expectedWarehouseID).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
alloc.ProductWarehouseId = expectedWarehouseID
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
remaining = target
|
||||||
|
totalReleased float64
|
||||||
|
warehouseAdjustments = make(map[uint]float64)
|
||||||
|
stockableAdjustments = make(map[fifo.StockableKey]map[uint]float64)
|
||||||
|
)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for i := len(allocations) - 1; i >= 0 && remaining > 0; i-- {
|
||||||
|
allocation := allocations[i]
|
||||||
|
releaseAmt := allocation.Qty
|
||||||
|
if releaseAmt > remaining {
|
||||||
|
releaseAmt = remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining -= releaseAmt
|
||||||
|
totalReleased += releaseAmt
|
||||||
|
warehouseAdjustments[allocation.ProductWarehouseId] += releaseAmt
|
||||||
|
|
||||||
|
key := fifo.StockableKey(allocation.StockableType)
|
||||||
|
if _, ok := stockableAdjustments[key]; !ok {
|
||||||
|
stockableAdjustments[key] = make(map[uint]float64)
|
||||||
|
}
|
||||||
|
stockableAdjustments[key][allocation.StockableId] += releaseAmt
|
||||||
|
|
||||||
|
if releaseAmt == allocation.Qty {
|
||||||
|
if err := s.allocations.PatchOne(ctx, allocation.Id, map[string]any{
|
||||||
|
"status": entities.StockAllocationStatusReleased,
|
||||||
|
"released_at": now,
|
||||||
|
}, func(db *gorm.DB) *gorm.DB {
|
||||||
|
return s.txOrDB(tx, db)
|
||||||
|
}); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := s.allocations.PatchOne(ctx, allocation.Id, map[string]any{
|
||||||
|
"qty": allocation.Qty - releaseAmt,
|
||||||
|
}, func(db *gorm.DB) *gorm.DB {
|
||||||
|
return s.txOrDB(tx, db)
|
||||||
|
}); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalReleased == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, deltas := range stockableAdjustments {
|
||||||
|
cfg, ok := fifo.Stockable(key)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for id, qty := range deltas {
|
||||||
|
if err := s.incrementStockableUsage(ctx, tx, cfg, id, -qty); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(warehouseAdjustments) > 0 {
|
||||||
|
if err := s.productWarehouseRepo.AdjustQuantities(ctx, warehouseAdjustments, func(db *gorm.DB) *gorm.DB {
|
||||||
|
return s.txOrDB(tx, db)
|
||||||
|
}); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for warehouseID := range warehouseAdjustments {
|
||||||
|
if _, err := s.resolvePendingForWarehouse(ctx, tx, warehouseID); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalReleased, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]pendingCandidate, error) {
|
||||||
|
configs := fifo.Usables()
|
||||||
|
if len(configs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates []pendingCandidate
|
||||||
|
|
||||||
|
for key, cfg := range configs {
|
||||||
|
selectStmt := fmt.Sprintf(
|
||||||
|
"%s AS id, %s AS pending_qty, %s AS created_at",
|
||||||
|
cfg.Columns.ID,
|
||||||
|
cfg.Columns.PendingQuantity,
|
||||||
|
cfg.Columns.CreatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if cfg.Columns.CreatedAt == cfg.Columns.ID {
|
||||||
|
var rows []struct {
|
||||||
|
ID uint
|
||||||
|
Pending float64 `gorm:"column:pending_qty"`
|
||||||
|
CreatedAt int64 `gorm:"column:created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
query := tx.Table(cfg.Table).
|
||||||
|
Select(selectStmt).
|
||||||
|
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
|
||||||
|
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
|
||||||
|
Limit(s.pendingBatchPerUsable)
|
||||||
|
|
||||||
|
if cfg.Scope != nil {
|
||||||
|
query = cfg.Scope(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, order := range s.orderClauses(cfg.OrderBy) {
|
||||||
|
query = query.Order(order)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Pending <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidates = append(candidates, pendingCandidate{
|
||||||
|
UsableKey: key,
|
||||||
|
Config: cfg,
|
||||||
|
UsableID: row.ID,
|
||||||
|
Pending: row.Pending,
|
||||||
|
CreatedAt: time.Unix(0, row.CreatedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var rows []struct {
|
||||||
|
ID uint
|
||||||
|
Pending float64 `gorm:"column:pending_qty"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
query := tx.Table(cfg.Table).
|
||||||
|
Select(selectStmt).
|
||||||
|
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
|
||||||
|
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
|
||||||
|
Limit(s.pendingBatchPerUsable)
|
||||||
|
|
||||||
|
if cfg.Scope != nil {
|
||||||
|
query = cfg.Scope(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, order := range s.orderClauses(cfg.OrderBy) {
|
||||||
|
query = query.Order(order)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Pending <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidates = append(candidates, pendingCandidate{
|
||||||
|
UsableKey: key,
|
||||||
|
Config: cfg,
|
||||||
|
UsableID: row.ID,
|
||||||
|
Pending: row.Pending,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(candidates, func(i, j int) bool {
|
||||||
|
if candidates[i].CreatedAt.Equal(candidates[j].CreatedAt) {
|
||||||
|
return candidates[i].UsableID < candidates[j].UsableID
|
||||||
|
}
|
||||||
|
return candidates[i].CreatedAt.Before(candidates[j].CreatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoService) orderClauses(custom []string) []string {
|
||||||
|
if len(custom) > 0 {
|
||||||
|
return custom
|
||||||
|
}
|
||||||
|
return s.defaultOrderBy
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
fifoStockV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FifoStockV2Service = fifoStockV2.Service
|
||||||
|
|
||||||
|
type FifoStockV2Lane = fifoStockV2.Lane
|
||||||
|
|
||||||
|
type FifoStockV2Ref = fifoStockV2.Ref
|
||||||
|
|
||||||
|
type FifoStockV2GatherRequest = fifoStockV2.GatherRequest
|
||||||
|
|
||||||
|
type FifoStockV2GatherRow = fifoStockV2.GatherRow
|
||||||
|
|
||||||
|
type FifoStockV2AllocateRequest = fifoStockV2.AllocateRequest
|
||||||
|
|
||||||
|
type FifoStockV2AllocateResult = fifoStockV2.AllocateResult
|
||||||
|
|
||||||
|
type FifoStockV2AllocationDetail = fifoStockV2.AllocationDetail
|
||||||
|
|
||||||
|
type FifoStockV2RollbackRequest = fifoStockV2.RollbackRequest
|
||||||
|
|
||||||
|
type FifoStockV2RollbackResult = fifoStockV2.RollbackResult
|
||||||
|
|
||||||
|
type FifoStockV2ReflowRequest = fifoStockV2.ReflowRequest
|
||||||
|
|
||||||
|
type FifoStockV2ReflowResult = fifoStockV2.ReflowResult
|
||||||
|
|
||||||
|
type FifoStockV2RecalculateRequest = fifoStockV2.RecalculateRequest
|
||||||
|
|
||||||
|
type FifoStockV2RecalculateResult = fifoStockV2.RecalculateResult
|
||||||
|
|
||||||
|
type FifoStockV2WarehouseDrift = fifoStockV2.WarehouseDrift
|
||||||
|
|
||||||
|
func NewFifoStockV2Service(db *gorm.DB, logger *logrus.Logger) FifoStockV2Service {
|
||||||
|
return fifoStockV2.NewService(db, logger)
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HppService interface {
|
||||||
|
CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error)
|
||||||
|
GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error)
|
||||||
|
GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error)
|
||||||
|
GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error)
|
||||||
|
GetDepresiasiTransfer(projectFlockKandangId uint, date *time.Time) (float64, error)
|
||||||
|
GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type HppCostResponse struct {
|
||||||
|
Estimation HppCostDetail `json:"estimation"`
|
||||||
|
Real HppCostDetail `json:"real"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HppCostDetail struct {
|
||||||
|
HargaKg float64 `json:"harga_kg"`
|
||||||
|
HargaButir float64 `json:"harga_butir"`
|
||||||
|
Total float64 `json:"total"`
|
||||||
|
Kg float64 `json:"kg"`
|
||||||
|
Butir float64 `json:"butir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type hppService struct {
|
||||||
|
hppRepo commonRepo.HppCostRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHppService(hppRepo commonRepo.HppCostRepository) HppService {
|
||||||
|
return &hppService{hppRepo: hppRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) {
|
||||||
|
if date == nil {
|
||||||
|
now := time.Now()
|
||||||
|
date = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
|
||||||
|
endOfDay := startOfDay.Add(24 * time.Hour)
|
||||||
|
|
||||||
|
depresiasiTransfer, err := s.GetDepresiasiTransfer(projectFlockKandangId, &endOfDay)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
totalProductionCost, err := s.GetTotalProductionCost(projectFlockKandangId, &endOfDay, depresiasiTransfer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error) {
|
||||||
|
if date == nil {
|
||||||
|
now := time.Now()
|
||||||
|
date = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.hppRepo == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
docCost, err := s.hppRepo.GetDocCost(context.Background(), kandangIDs)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
budgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), sourceProjectFlockID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
expedisionCost, err := s.hppRepo.GetExpedisionCost(context.Background(), kandangIDs)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
feedCost, err := s.hppRepo.GetFeedUsageCost(context.Background(), kandangIDs, date)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ovkCost, err := s.hppRepo.GetOvkUsageCost(context.Background(), kandangIDs, date)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return docCost + budgetCost + expedisionCost + feedCost + ovkCost, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error) {
|
||||||
|
// if date == nil {
|
||||||
|
// now := time.Now()
|
||||||
|
// date = &now
|
||||||
|
// }
|
||||||
|
|
||||||
|
costPullet, err := s.hppRepo.GetPulletCost(context.Background(), projectFlockKandangId)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
costFeed, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
costOvk, err := s.hppRepo.GetOvkUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
costExpedision, err := s.hppRepo.GetExpedisionCost(context.Background(), []uint{projectFlockKandangId})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
costBudget, err := s.GetBudgetKandangLaying(projectFlockKandangId, endDate)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return depresiasiTransfer + costPullet + costFeed + costOvk + costExpedision + costBudget, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
|
||||||
|
// if date == nil {
|
||||||
|
// now := time.Now()
|
||||||
|
// date = &now
|
||||||
|
// }
|
||||||
|
|
||||||
|
if s.hppRepo == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
projectFlockId, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
projectFlockKandangIds, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockId)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
eggProduksiPiecesFlock, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), projectFlockKandangIds, endDate)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
eggProduksiPiecesKandang, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
totalBudgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), projectFlockId)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if eggProduksiPiecesFlock == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return (totalBudgetCost * eggProduksiPiecesKandang) / eggProduksiPiecesFlock, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
|
||||||
|
// if endDate == nil {
|
||||||
|
// now := time.Now()
|
||||||
|
// endDate = &now
|
||||||
|
// }
|
||||||
|
|
||||||
|
if s.hppRepo == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceProjectFlockID, transferTotalQty, err := s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPopulationFlockGrowing, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDsGrowing)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if totalPopulationFlockGrowing == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
totalDepresiasiFlockGrowing, err := s.GetTotalDepresiasiFlockGrowing(sourceProjectFlockID, endDate)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) {
|
||||||
|
|
||||||
|
if s.hppRepo == nil {
|
||||||
|
return &HppCostResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
estimation := HppCostDetail{
|
||||||
|
Total: totalProductionCost,
|
||||||
|
Kg: estimWeightKg,
|
||||||
|
Butir: estimPieces,
|
||||||
|
}
|
||||||
|
if estimWeightKg > 0 {
|
||||||
|
estimation.HargaKg = roundToTwoDecimals(totalProductionCost / estimWeightKg)
|
||||||
|
}
|
||||||
|
if estimPieces > 0 {
|
||||||
|
estimation.HargaButir = roundToTwoDecimals(totalProductionCost / estimPieces)
|
||||||
|
}
|
||||||
|
|
||||||
|
real := HppCostDetail{
|
||||||
|
Total: totalProductionCost,
|
||||||
|
Kg: realWeightKg,
|
||||||
|
Butir: realPieces,
|
||||||
|
}
|
||||||
|
if realWeightKg > 0 {
|
||||||
|
real.HargaKg = roundToTwoDecimals(totalProductionCost / realWeightKg)
|
||||||
|
}
|
||||||
|
if realPieces > 0 {
|
||||||
|
real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &HppCostResponse{
|
||||||
|
Estimation: estimation,
|
||||||
|
Real: real,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundToTwoDecimals(value float64) float64 {
|
||||||
|
return math.Round(value*100) / 100
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# RFC Ringkas: FIFO Stock V2
|
||||||
|
|
||||||
|
## Tujuan
|
||||||
|
`fifo_stock_v2` adalah engine FIFO baru berbasis konfigurasi `Flag Group + Jalur` yang berjalan paralel dengan v1 tanpa memutus kompatibilitas `stock_allocations`, HPP, dan closing/reporting existing.
|
||||||
|
|
||||||
|
## Prinsip
|
||||||
|
- V1 tidak dihapus, V2 jalan paralel.
|
||||||
|
- Semua operasi transactional.
|
||||||
|
- FIFO sorting deterministic lintas tabel.
|
||||||
|
- Default over-consume `ALLOW` (pending), exception dapat `BLOCK`.
|
||||||
|
- Reflow idempotent.
|
||||||
|
- Recalculate bisa memperbaiki drift `product_warehouses.qty`.
|
||||||
|
|
||||||
|
## Komponen
|
||||||
|
- `fifo_stock_v2_flag_groups`: master grouping flag produk.
|
||||||
|
- `fifo_stock_v2_flag_members`: pemetaan flag -> group.
|
||||||
|
- `fifo_stock_v2_traits`: trait sort per `table:date_column` (+ optional join date source).
|
||||||
|
- `fifo_stock_v2_route_rules`: rule per `flag_group + lane + function + table`.
|
||||||
|
- `fifo_stock_v2_overconsume_rules`: policy pending/over-consume.
|
||||||
|
- `fifo_stock_v2_operation_log`: idempotency + audit operasi.
|
||||||
|
- `fifo_stock_v2_reflow_runs` + checkpoints + shadow allocations: bulk reflow resumable/observable.
|
||||||
|
|
||||||
|
## API Service
|
||||||
|
- `Gather`: union cross-table berdasarkan route rules + trait sorting.
|
||||||
|
- `Allocate`: alokasi lot FIFO ke usable.
|
||||||
|
- `Rollback`: batalkan alokasi aktif.
|
||||||
|
- `Reflow`: rollback penuh lalu allocate ulang (idempotent).
|
||||||
|
- `Recalculate`: rekonsiliasi qty warehouse dari ledger FIFO.
|
||||||
|
|
||||||
|
## Deterministic Sorting
|
||||||
|
Urutan gather:
|
||||||
|
1. `sort_at ASC` (dari trait `date_column`)
|
||||||
|
2. `sort_priority ASC`
|
||||||
|
3. `source_table ASC`
|
||||||
|
4. `source_id ASC`
|
||||||
|
|
||||||
|
Fallback waktu: `1970-01-01 00:00:00+00` bila tanggal null.
|
||||||
|
|
||||||
|
## Compat Strategy
|
||||||
|
- Tetap menulis ke `stock_allocations` dengan tambahan metadata:
|
||||||
|
- `engine_version` (`v1`/`v2`)
|
||||||
|
- `flag_group_code`
|
||||||
|
- `function_code`
|
||||||
|
- `idempotency_key`
|
||||||
|
- Query lama yang bergantung `stockable_type/usable_type` tetap berjalan.
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
1. Deploy schema + seed v2.
|
||||||
|
2. Aktifkan shadow-run comparator v1 vs v2.
|
||||||
|
3. Canary cutover per flag group.
|
||||||
|
4. Full cutover jika parity aman.
|
||||||
|
5. Jalankan bulk reflow existing data.
|
||||||
|
|
||||||
|
## Acceptance Criteria Singkat
|
||||||
|
- Parity mismatch terkendali pada aggregate + detail alokasi.
|
||||||
|
- Tidak ada regression closing/HPP.
|
||||||
|
- Drift qty warehouse turun signifikan pasca reflow.
|
||||||
|
- Rollback via feature flag memungkinkan kembali ke v1.
|
||||||
@@ -0,0 +1,765 @@
|
|||||||
|
package fifo_stock_v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type allocationRow struct {
|
||||||
|
ID uint `gorm:"column:id"`
|
||||||
|
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
||||||
|
StockableType string `gorm:"column:stockable_type"`
|
||||||
|
StockableID uint `gorm:"column:stockable_id"`
|
||||||
|
UsableType string `gorm:"column:usable_type"`
|
||||||
|
UsableID uint `gorm:"column:usable_id"`
|
||||||
|
Qty float64 `gorm:"column:qty"`
|
||||||
|
Status string `gorm:"column:status"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type usableQtySnapshot struct {
|
||||||
|
Usage float64 `gorm:"column:usage_qty"`
|
||||||
|
Pending float64 `gorm:"column:pending_qty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) Allocate(ctx context.Context, req AllocateRequest) (*AllocateResult, error) {
|
||||||
|
if err := s.validateAllocateRequest(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &AllocateResult{}
|
||||||
|
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
||||||
|
if err := s.ensureStockAllocationColumns(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.lockShard(tx, req.FlagGroupCode, req.ProductWarehouseID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := requestHash(map[string]any{
|
||||||
|
"flag_group_code": req.FlagGroupCode,
|
||||||
|
"product_warehouse_id": req.ProductWarehouseID,
|
||||||
|
"usable_type": req.Usable.LegacyTypeKey,
|
||||||
|
"usable_id": req.Usable.ID,
|
||||||
|
"need_qty": req.NeedQty,
|
||||||
|
"as_of": req.AsOf,
|
||||||
|
"allow_over_consume": req.AllowOverConsume,
|
||||||
|
})
|
||||||
|
logRow, reused, err := s.beginOperation(
|
||||||
|
tx,
|
||||||
|
OperationAllocate,
|
||||||
|
req.IdempotencyKey,
|
||||||
|
hash,
|
||||||
|
req.ProductWarehouseID,
|
||||||
|
req.FlagGroupCode,
|
||||||
|
req.Usable.LegacyTypeKey,
|
||||||
|
req.Usable.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if reused {
|
||||||
|
if len(logRow.ResultPayload) == 0 {
|
||||||
|
return fmt.Errorf("idempotent allocate has empty payload")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if logRow != nil {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
s.failOperation(tx, logRow, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
allocated, allocErr := s.allocateInternal(ctx, tx, req)
|
||||||
|
if allocErr != nil {
|
||||||
|
err = allocErr
|
||||||
|
return allocErr
|
||||||
|
}
|
||||||
|
*result = *allocated
|
||||||
|
|
||||||
|
if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil {
|
||||||
|
err = finishErr
|
||||||
|
return finishErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB, req AllocateRequest) (*AllocateResult, error) {
|
||||||
|
usableRule, err := s.loadRouteRuleByLegacyType(ctx, tx, LaneUsable, req.FlagGroupCode, req.Usable.LegacyTypeKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
allowOverConsume := usableRule.AllowPendingDefault
|
||||||
|
if req.AllowOverConsume != nil {
|
||||||
|
allowOverConsume = *req.AllowOverConsume
|
||||||
|
} else {
|
||||||
|
allowOverConsume, err = s.resolveOverConsume(tx, req.FlagGroupCode, req.Usable.FunctionCode, LaneUsable, allowOverConsume)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gatherRows, err := s.gatherRows(ctx, tx, GatherRequest{
|
||||||
|
FlagGroupCode: req.FlagGroupCode,
|
||||||
|
Lane: LaneStockable,
|
||||||
|
ProductWarehouseID: req.ProductWarehouseID,
|
||||||
|
AsOf: req.AsOf,
|
||||||
|
Limit: s.defaultGatherLimit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stockableRuleMap, err := s.loadStockableRuleMap(ctx, tx, req.FlagGroupCode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
remaining := req.NeedQty
|
||||||
|
result := &AllocateResult{Details: make([]AllocationDetail, 0)}
|
||||||
|
|
||||||
|
for _, lot := range gatherRows {
|
||||||
|
if remaining <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if shouldSkipStockableForUsable(req, lot.Ref.LegacyTypeKey) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if lot.AvailableQuantity <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
portion := math.Min(remaining, lot.AvailableQuantity)
|
||||||
|
if nearlyZero(portion) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
allocationInsert := map[string]any{
|
||||||
|
"product_warehouse_id": req.ProductWarehouseID,
|
||||||
|
"stockable_type": lot.Ref.LegacyTypeKey,
|
||||||
|
"stockable_id": lot.Ref.ID,
|
||||||
|
"usable_type": req.Usable.LegacyTypeKey,
|
||||||
|
"usable_id": req.Usable.ID,
|
||||||
|
"qty": portion,
|
||||||
|
"status": activeAllocationStatus(),
|
||||||
|
"allocation_purpose": defaultAllocationPurpose(),
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
"engine_version": "v2",
|
||||||
|
"flag_group_code": req.FlagGroupCode,
|
||||||
|
"function_code": req.Usable.FunctionCode,
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.IdempotencyKey) != "" {
|
||||||
|
allocationInsert["idempotency_key"] = req.IdempotencyKey
|
||||||
|
}
|
||||||
|
if err := tx.Table("stock_allocations").Create(allocationInsert).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rule, ok := stockableRuleMap[lot.Ref.LegacyTypeKey]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("missing stockable route rule for type %s", lot.Ref.LegacyTypeKey)
|
||||||
|
}
|
||||||
|
if err := s.adjustStockableUsedQuantity(tx, rule, lot.Ref.ID, portion); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Details = append(result.Details, AllocationDetail{
|
||||||
|
StockableType: lot.Ref.LegacyTypeKey,
|
||||||
|
StockableID: lot.Ref.ID,
|
||||||
|
Qty: portion,
|
||||||
|
SortAt: lot.SortAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
remaining -= portion
|
||||||
|
result.AllocatedQty += portion
|
||||||
|
}
|
||||||
|
|
||||||
|
if remaining > 0 {
|
||||||
|
if !allowOverConsume {
|
||||||
|
return nil, fmt.Errorf("%w: requested %.3f, allocated %.3f", ErrInsufficientStock, req.NeedQty, result.AllocatedQty)
|
||||||
|
}
|
||||||
|
result.PendingQty = remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.applyUsableDeltas(tx, *usableRule, req.Usable.ID, result.AllocatedQty, result.PendingQty); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, -result.AllocatedQty); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldSkipStockableForUsable(req AllocateRequest, stockableType string) bool {
|
||||||
|
usableType := strings.ToUpper(strings.TrimSpace(req.Usable.LegacyTypeKey))
|
||||||
|
functionCode := strings.ToUpper(strings.TrimSpace(req.Usable.FunctionCode))
|
||||||
|
stockable := strings.ToUpper(strings.TrimSpace(stockableType))
|
||||||
|
|
||||||
|
// CHICKIN_OUT must consume physical stock sources, not population lots,
|
||||||
|
// otherwise approved chickin can consume its own just-created population.
|
||||||
|
if (usableType == "PROJECT_CHICKIN" || functionCode == "CHICKIN_OUT") && stockable == "PROJECT_FLOCK_POPULATION" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) Rollback(ctx context.Context, req RollbackRequest) (*RollbackResult, error) {
|
||||||
|
if err := s.validateRollbackRequest(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &RollbackResult{}
|
||||||
|
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
||||||
|
if err := s.ensureStockAllocationColumns(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
flagGroupCode, err := s.resolveRollbackFlagGroup(ctx, tx, req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.lockShard(tx, flagGroupCode, req.ProductWarehouseID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := requestHash(map[string]any{
|
||||||
|
"product_warehouse_id": req.ProductWarehouseID,
|
||||||
|
"usable_type": req.Usable.LegacyTypeKey,
|
||||||
|
"usable_id": req.Usable.ID,
|
||||||
|
"release_qty": req.ReleaseQty,
|
||||||
|
"reason": req.Reason,
|
||||||
|
"flag_group_code": flagGroupCode,
|
||||||
|
})
|
||||||
|
logRow, reused, beginErr := s.beginOperation(
|
||||||
|
tx,
|
||||||
|
OperationRollback,
|
||||||
|
req.IdempotencyKey,
|
||||||
|
hash,
|
||||||
|
req.ProductWarehouseID,
|
||||||
|
flagGroupCode,
|
||||||
|
req.Usable.LegacyTypeKey,
|
||||||
|
req.Usable.ID,
|
||||||
|
)
|
||||||
|
if beginErr != nil {
|
||||||
|
return beginErr
|
||||||
|
}
|
||||||
|
if reused {
|
||||||
|
if len(logRow.ResultPayload) == 0 {
|
||||||
|
return fmt.Errorf("idempotent rollback has empty payload")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if logRow != nil {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
s.failOperation(tx, logRow, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
rolled, rollbackErr := s.rollbackInternal(ctx, tx, req, flagGroupCode)
|
||||||
|
if rollbackErr != nil {
|
||||||
|
err = rollbackErr
|
||||||
|
return rollbackErr
|
||||||
|
}
|
||||||
|
*result = *rolled
|
||||||
|
|
||||||
|
if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil {
|
||||||
|
err = finishErr
|
||||||
|
return finishErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) rollbackInternal(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *gorm.DB,
|
||||||
|
req RollbackRequest,
|
||||||
|
flagGroupCode string,
|
||||||
|
) (*RollbackResult, error) {
|
||||||
|
usableRule, err := s.loadRouteRuleByLegacyType(ctx, tx, LaneUsable, flagGroupCode, req.Usable.LegacyTypeKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
allocations, err := s.loadActiveAllocations(tx, req.Usable.LegacyTypeKey, req.Usable.ID, req.ProductWarehouseID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(allocations) == 0 {
|
||||||
|
if req.ReleaseQty == nil {
|
||||||
|
if err := s.resetUsableQuantities(tx, *usableRule, req.Usable.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &RollbackResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
stockableRuleMap, err := s.loadStockableRuleMap(ctx, tx, flagGroupCode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
target := 0.0
|
||||||
|
for _, alloc := range allocations {
|
||||||
|
target += alloc.Qty
|
||||||
|
}
|
||||||
|
if req.ReleaseQty != nil {
|
||||||
|
if *req.ReleaseQty < 0 {
|
||||||
|
return nil, fmt.Errorf("%w: release qty must be >= 0", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
target = *req.ReleaseQty
|
||||||
|
}
|
||||||
|
if nearlyZero(target) {
|
||||||
|
return &RollbackResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &RollbackResult{Details: make([]AllocationDetail, 0)}
|
||||||
|
now := time.Now()
|
||||||
|
remaining := target
|
||||||
|
|
||||||
|
for _, alloc := range allocations {
|
||||||
|
if remaining <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
portion := math.Min(remaining, alloc.Qty)
|
||||||
|
if nearlyZero(portion) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if nearlyZero(alloc.Qty - portion) {
|
||||||
|
updates := map[string]any{
|
||||||
|
"status": releasedAllocationStatus(),
|
||||||
|
"released_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Reason) != "" {
|
||||||
|
updates["note"] = req.Reason
|
||||||
|
}
|
||||||
|
if err := tx.Table("stock_allocations").Where("id = ?", alloc.ID).Updates(updates).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := tx.Table("stock_allocations").
|
||||||
|
Where("id = ?", alloc.ID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"qty": alloc.Qty - portion,
|
||||||
|
"updated_at": now,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stockableRule, ok := stockableRuleMap[alloc.StockableType]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("missing stockable route rule for type %s", alloc.StockableType)
|
||||||
|
}
|
||||||
|
if err := s.adjustStockableUsedQuantity(tx, stockableRule, alloc.StockableID, -portion); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result.ReleasedQty += portion
|
||||||
|
remaining -= portion
|
||||||
|
result.Details = append(result.Details, AllocationDetail{
|
||||||
|
StockableType: alloc.StockableType,
|
||||||
|
StockableID: alloc.StockableID,
|
||||||
|
Qty: portion,
|
||||||
|
SortAt: alloc.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ReleaseQty != nil && remaining > 1e-6 {
|
||||||
|
return nil, fmt.Errorf("unable to release %.3f; only %.3f allocation exists", target, result.ReleasedQty)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ReleaseQty == nil {
|
||||||
|
if err := s.resetUsableQuantities(tx, *usableRule, req.Usable.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := s.applyUsableDeltas(tx, *usableRule, req.Usable.ID, -result.ReleasedQty, 0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, result.ReleasedQty); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*ReflowResult, error) {
|
||||||
|
if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 {
|
||||||
|
return nil, fmt.Errorf("%w: invalid reflow request", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &ReflowResult{}
|
||||||
|
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
||||||
|
if err := s.ensureStockAllocationColumns(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.lockShard(tx, req.FlagGroupCode, req.ProductWarehouseID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := requestHash(map[string]any{
|
||||||
|
"flag_group_code": req.FlagGroupCode,
|
||||||
|
"product_warehouse_id": req.ProductWarehouseID,
|
||||||
|
"as_of": req.AsOf,
|
||||||
|
})
|
||||||
|
logRow, reused, err := s.beginOperation(
|
||||||
|
tx,
|
||||||
|
OperationReflow,
|
||||||
|
req.IdempotencyKey,
|
||||||
|
hash,
|
||||||
|
req.ProductWarehouseID,
|
||||||
|
req.FlagGroupCode,
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if reused {
|
||||||
|
if len(logRow.ResultPayload) == 0 {
|
||||||
|
return fmt.Errorf("idempotent reflow has empty payload")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if logRow != nil {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
s.failOperation(tx, logRow, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
usableRows, gatherErr := s.gatherAllRows(ctx, tx, GatherRequest{
|
||||||
|
FlagGroupCode: req.FlagGroupCode,
|
||||||
|
Lane: LaneUsable,
|
||||||
|
ProductWarehouseID: req.ProductWarehouseID,
|
||||||
|
Limit: s.defaultGatherLimit,
|
||||||
|
})
|
||||||
|
if gatherErr != nil {
|
||||||
|
err = gatherErr
|
||||||
|
return gatherErr
|
||||||
|
}
|
||||||
|
result.ProcessedUsables = len(usableRows)
|
||||||
|
|
||||||
|
for _, usableRow := range usableRows {
|
||||||
|
desiredQty := usableRow.Quantity + usableRow.PendingQuantity
|
||||||
|
|
||||||
|
rollbackRes, rollbackErr := s.rollbackInternal(ctx, tx, RollbackRequest{
|
||||||
|
ProductWarehouseID: req.ProductWarehouseID,
|
||||||
|
Usable: usableRow.Ref,
|
||||||
|
ReleaseQty: nil,
|
||||||
|
Reason: "reflow reset",
|
||||||
|
}, req.FlagGroupCode)
|
||||||
|
if rollbackErr != nil {
|
||||||
|
err = rollbackErr
|
||||||
|
return rollbackErr
|
||||||
|
}
|
||||||
|
result.Rollback.ReleasedQty += rollbackRes.ReleasedQty
|
||||||
|
if len(rollbackRes.Details) > 0 {
|
||||||
|
result.Rollback.Details = append(result.Rollback.Details, rollbackRes.Details...)
|
||||||
|
}
|
||||||
|
minDesired := rollbackRes.ReleasedQty + usableRow.PendingQuantity
|
||||||
|
if desiredQty < minDesired {
|
||||||
|
desiredQty = minDesired
|
||||||
|
}
|
||||||
|
|
||||||
|
if desiredQty <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allocateRes, allocateErr := s.allocateInternal(ctx, tx, AllocateRequest{
|
||||||
|
FlagGroupCode: req.FlagGroupCode,
|
||||||
|
ProductWarehouseID: req.ProductWarehouseID,
|
||||||
|
Usable: usableRow.Ref,
|
||||||
|
NeedQty: desiredQty,
|
||||||
|
AsOf: nil,
|
||||||
|
})
|
||||||
|
if allocateErr != nil {
|
||||||
|
err = allocateErr
|
||||||
|
return allocateErr
|
||||||
|
}
|
||||||
|
result.Allocate.AllocatedQty += allocateRes.AllocatedQty
|
||||||
|
result.Allocate.PendingQty += allocateRes.PendingQty
|
||||||
|
if len(allocateRes.Details) > 0 {
|
||||||
|
result.Allocate.Details = append(result.Allocate.Details, allocateRes.Details...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedQty, calcErr := s.calculateWarehouseAvailableForGroup(ctx, tx, req.ProductWarehouseID, req.FlagGroupCode, nil)
|
||||||
|
if calcErr != nil {
|
||||||
|
err = calcErr
|
||||||
|
return calcErr
|
||||||
|
}
|
||||||
|
actualQty, loadErr := s.loadWarehouseQty(ctx, tx, req.ProductWarehouseID)
|
||||||
|
if loadErr != nil {
|
||||||
|
err = loadErr
|
||||||
|
return loadErr
|
||||||
|
}
|
||||||
|
drift := expectedQty - actualQty
|
||||||
|
if math.Abs(drift) >= 1e-6 {
|
||||||
|
if adjustErr := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, drift); adjustErr != nil {
|
||||||
|
err = adjustErr
|
||||||
|
return adjustErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil {
|
||||||
|
err = finishErr
|
||||||
|
return finishErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) gatherAllRows(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *gorm.DB,
|
||||||
|
req GatherRequest,
|
||||||
|
) ([]GatherRow, error) {
|
||||||
|
limit := req.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = s.defaultGatherLimit
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Limit = limit
|
||||||
|
out := make([]GatherRow, 0, limit)
|
||||||
|
|
||||||
|
var cursorSortAt *time.Time
|
||||||
|
cursorSourceTable := ""
|
||||||
|
var cursorSourceID uint
|
||||||
|
|
||||||
|
for {
|
||||||
|
req.AfterSortAt = cursorSortAt
|
||||||
|
req.AfterSourceTable = cursorSourceTable
|
||||||
|
req.AfterSourceID = cursorSourceID
|
||||||
|
|
||||||
|
rows, err := s.gatherRows(ctx, tx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, rows...)
|
||||||
|
if len(rows) < limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
last := rows[len(rows)-1]
|
||||||
|
lastSortAt := last.SortAt
|
||||||
|
cursorSortAt = &lastSortAt
|
||||||
|
cursorSourceTable = last.SourceTable
|
||||||
|
cursorSourceID = last.SourceID
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) loadActiveAllocations(
|
||||||
|
tx *gorm.DB,
|
||||||
|
usableType string,
|
||||||
|
usableID uint,
|
||||||
|
productWarehouseID uint,
|
||||||
|
) ([]allocationRow, error) {
|
||||||
|
query := tx.Table("stock_allocations").
|
||||||
|
Select("id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, created_at").
|
||||||
|
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, activeAllocationStatus(), defaultAllocationPurpose())
|
||||||
|
if productWarehouseID > 0 {
|
||||||
|
query = query.Where("product_warehouse_id = ?", productWarehouseID)
|
||||||
|
}
|
||||||
|
query = query.Order("created_at DESC, id DESC")
|
||||||
|
|
||||||
|
var rows []allocationRow
|
||||||
|
if err := query.Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) loadStockableRuleMap(ctx context.Context, tx *gorm.DB, flagGroupCode string) (map[string]routeRule, error) {
|
||||||
|
rules, err := s.loadRouteRules(ctx, tx, flagGroupCode, LaneStockable)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m := make(map[string]routeRule, len(rules))
|
||||||
|
for _, rule := range rules {
|
||||||
|
m[rule.LegacyTypeKey] = rule
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) adjustStockableUsedQuantity(tx *gorm.DB, rule routeRule, sourceID uint, delta float64) error {
|
||||||
|
if nearlyZero(delta) || sourceID == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if rule.UsedQuantityCol == nil || strings.TrimSpace(*rule.UsedQuantityCol) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
usedCol, _ := mustSafeIdentifier(*rule.UsedQuantityCol)
|
||||||
|
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
|
||||||
|
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
|
||||||
|
|
||||||
|
expr := fmt.Sprintf("GREATEST(0, COALESCE(%s,0) + ?)", usedCol)
|
||||||
|
return tx.Table(sourceTable).
|
||||||
|
Where(fmt.Sprintf("%s = ?", sourceIDCol), sourceID).
|
||||||
|
Update(usedCol, gorm.Expr(expr, delta)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) applyUsableDeltas(tx *gorm.DB, rule routeRule, sourceID uint, usageDelta, pendingDelta float64) error {
|
||||||
|
if sourceID == 0 || (nearlyZero(usageDelta) && nearlyZero(pendingDelta)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
|
||||||
|
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
|
||||||
|
usageCol, _ := mustSafeIdentifier(rule.QuantityCol)
|
||||||
|
|
||||||
|
updates := map[string]any{}
|
||||||
|
if !nearlyZero(usageDelta) {
|
||||||
|
expr := fmt.Sprintf("GREATEST(0, COALESCE(%s,0) + ?)", usageCol)
|
||||||
|
updates[usageCol] = gorm.Expr(expr, usageDelta)
|
||||||
|
}
|
||||||
|
if rule.PendingQuantityCol != nil && strings.TrimSpace(*rule.PendingQuantityCol) != "" && !nearlyZero(pendingDelta) {
|
||||||
|
pendingCol, _ := mustSafeIdentifier(*rule.PendingQuantityCol)
|
||||||
|
expr := fmt.Sprintf("GREATEST(0, COALESCE(%s,0) + ?)", pendingCol)
|
||||||
|
updates[pendingCol] = gorm.Expr(expr, pendingDelta)
|
||||||
|
}
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Table(sourceTable).
|
||||||
|
Where(fmt.Sprintf("%s = ?", sourceIDCol), sourceID).
|
||||||
|
Updates(updates).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) resetUsableQuantities(tx *gorm.DB, rule routeRule, sourceID uint) error {
|
||||||
|
if sourceID == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
|
||||||
|
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
|
||||||
|
usageCol, _ := mustSafeIdentifier(rule.QuantityCol)
|
||||||
|
|
||||||
|
updates := map[string]any{usageCol: 0}
|
||||||
|
if rule.PendingQuantityCol != nil && strings.TrimSpace(*rule.PendingQuantityCol) != "" {
|
||||||
|
pendingCol, _ := mustSafeIdentifier(*rule.PendingQuantityCol)
|
||||||
|
updates[pendingCol] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Table(sourceTable).
|
||||||
|
Where(fmt.Sprintf("%s = ?", sourceIDCol), sourceID).
|
||||||
|
Updates(updates).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *gorm.DB, req RollbackRequest) (string, error) {
|
||||||
|
type row struct {
|
||||||
|
FlagGroupCode string `gorm:"column:flag_group_code"`
|
||||||
|
}
|
||||||
|
var latest row
|
||||||
|
err := tx.WithContext(ctx).
|
||||||
|
Table("stock_allocations").
|
||||||
|
Select("flag_group_code").
|
||||||
|
Where("usable_type = ? AND usable_id = ?", req.Usable.LegacyTypeKey, req.Usable.ID).
|
||||||
|
Where("engine_version = 'v2'").
|
||||||
|
Where("allocation_purpose = ?", defaultAllocationPurpose()).
|
||||||
|
Where("flag_group_code IS NOT NULL AND flag_group_code <> ''").
|
||||||
|
Order("id DESC").
|
||||||
|
Limit(1).
|
||||||
|
Take(&latest).Error
|
||||||
|
if err == nil && strings.TrimSpace(latest.FlagGroupCode) != "" {
|
||||||
|
return latest.FlagGroupCode, nil
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var rules []routeRule
|
||||||
|
err = tx.WithContext(ctx).
|
||||||
|
Table("fifo_stock_v2_route_rules").
|
||||||
|
Where("is_active = TRUE").
|
||||||
|
Where("lane = ?", string(LaneUsable)).
|
||||||
|
Where("legacy_type_key = ?", req.Usable.LegacyTypeKey).
|
||||||
|
Find(&rules).Error
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(rules) == 0 {
|
||||||
|
return "", fmt.Errorf("cannot resolve flag group for usable type %s", req.Usable.LegacyTypeKey)
|
||||||
|
}
|
||||||
|
if len(rules) > 1 {
|
||||||
|
return "", fmt.Errorf("ambiguous rollback flag group for usable type %s", req.Usable.LegacyTypeKey)
|
||||||
|
}
|
||||||
|
return rules[0].FlagGroupCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) validateAllocateRequest(req AllocateRequest) error {
|
||||||
|
if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 {
|
||||||
|
return fmt.Errorf("%w: missing flag group or product warehouse", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
if req.Usable.ID == 0 || strings.TrimSpace(req.Usable.LegacyTypeKey) == "" {
|
||||||
|
return fmt.Errorf("%w: usable id and type are required", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
if req.NeedQty < 0 {
|
||||||
|
return fmt.Errorf("%w: need qty must be >= 0", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) validateRollbackRequest(req RollbackRequest) error {
|
||||||
|
if req.ProductWarehouseID == 0 {
|
||||||
|
return fmt.Errorf("%w: product warehouse is required", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
if req.Usable.ID == 0 || strings.TrimSpace(req.Usable.LegacyTypeKey) == "" {
|
||||||
|
return fmt.Errorf("%w: usable id and type are required", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
if req.ReleaseQty != nil && *req.ReleaseQty < 0 {
|
||||||
|
return fmt.Errorf("%w: release qty must be >= 0", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package fifo_stock_v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type routeRule struct {
|
||||||
|
ID uint `gorm:"column:id"`
|
||||||
|
FlagGroupCode string `gorm:"column:flag_group_code"`
|
||||||
|
Lane string `gorm:"column:lane"`
|
||||||
|
FunctionCode string `gorm:"column:function_code"`
|
||||||
|
SourceTable string `gorm:"column:source_table"`
|
||||||
|
SourceIDColumn string `gorm:"column:source_id_column"`
|
||||||
|
ProductWarehouseCol string `gorm:"column:product_warehouse_col"`
|
||||||
|
QuantityCol string `gorm:"column:quantity_col"`
|
||||||
|
UsedQuantityCol *string `gorm:"column:used_quantity_col"`
|
||||||
|
PendingQuantityCol *string `gorm:"column:pending_quantity_col"`
|
||||||
|
ScopeSQL *string `gorm:"column:scope_sql"`
|
||||||
|
LegacyTypeKey string `gorm:"column:legacy_type_key"`
|
||||||
|
AllowPendingDefault bool `gorm:"column:allow_pending_default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type traitRule struct {
|
||||||
|
ID uint `gorm:"column:id"`
|
||||||
|
SourceTable string `gorm:"column:source_table"`
|
||||||
|
Lane string `gorm:"column:lane"`
|
||||||
|
DateTable *string `gorm:"column:date_table"`
|
||||||
|
DateJoinLeftCol *string `gorm:"column:date_join_left_col"`
|
||||||
|
DateJoinRightCol *string `gorm:"column:date_join_right_col"`
|
||||||
|
DateColumn string `gorm:"column:date_column"`
|
||||||
|
FallbackDateColumn *string `gorm:"column:fallback_date_column"`
|
||||||
|
SortPriority int `gorm:"column:sort_priority"`
|
||||||
|
IDColumn string `gorm:"column:id_column"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) loadRouteRules(ctx context.Context, tx *gorm.DB, flagGroupCode string, lane Lane) ([]routeRule, error) {
|
||||||
|
var rules []routeRule
|
||||||
|
err := tx.WithContext(ctx).
|
||||||
|
Table("fifo_stock_v2_route_rules").
|
||||||
|
Where("is_active = TRUE").
|
||||||
|
Where("flag_group_code = ?", flagGroupCode).
|
||||||
|
Where("lane = ?", string(lane)).
|
||||||
|
Order("id ASC").
|
||||||
|
Find(&rules).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, rule := range rules {
|
||||||
|
if err := validateRouteRule(rule); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) loadRouteRuleByLegacyType(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *gorm.DB,
|
||||||
|
lane Lane,
|
||||||
|
flagGroupCode string,
|
||||||
|
legacyTypeKey string,
|
||||||
|
) (*routeRule, error) {
|
||||||
|
var rule routeRule
|
||||||
|
err := tx.WithContext(ctx).
|
||||||
|
Table("fifo_stock_v2_route_rules").
|
||||||
|
Where("is_active = TRUE").
|
||||||
|
Where("lane = ?", string(lane)).
|
||||||
|
Where("flag_group_code = ?", flagGroupCode).
|
||||||
|
Where("legacy_type_key = ?", legacyTypeKey).
|
||||||
|
Order("id ASC").
|
||||||
|
Limit(1).
|
||||||
|
Take(&rule).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateRouteRule(rule); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) loadTraitMap(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *gorm.DB,
|
||||||
|
lane Lane,
|
||||||
|
sourceTables []string,
|
||||||
|
) (map[string]traitRule, error) {
|
||||||
|
if len(sourceTables) == 0 {
|
||||||
|
return map[string]traitRule{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var traits []traitRule
|
||||||
|
err := tx.WithContext(ctx).
|
||||||
|
Table("fifo_stock_v2_traits").
|
||||||
|
Where("is_active = TRUE").
|
||||||
|
Where("lane = ?", string(lane)).
|
||||||
|
Where("source_table IN ?", sourceTables).
|
||||||
|
Find(&traits).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make(map[string]traitRule, len(traits))
|
||||||
|
for _, tr := range traits {
|
||||||
|
if err := validateTraitRule(tr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[tr.SourceTable] = tr
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRouteRule(rule routeRule) error {
|
||||||
|
fields := []string{rule.SourceTable, rule.SourceIDColumn, rule.ProductWarehouseCol, rule.QuantityCol}
|
||||||
|
for _, value := range fields {
|
||||||
|
if _, err := mustSafeIdentifier(value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rule.UsedQuantityCol != nil {
|
||||||
|
if _, err := mustSafeIdentifier(*rule.UsedQuantityCol); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rule.PendingQuantityCol != nil {
|
||||||
|
if _, err := mustSafeIdentifier(*rule.PendingQuantityCol); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rule.LegacyTypeKey) == "" {
|
||||||
|
return fmt.Errorf("route rule has empty legacy type key")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTraitRule(rule traitRule) error {
|
||||||
|
if _, err := mustSafeIdentifier(rule.SourceTable); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := mustSafeIdentifier(rule.DateColumn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := mustSafeIdentifier(rule.IDColumn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rule.DateTable != nil {
|
||||||
|
if _, err := mustSafeIdentifier(*rule.DateTable); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rule.DateJoinLeftCol == nil || rule.DateJoinRightCol == nil {
|
||||||
|
return fmt.Errorf("trait %s requires date join columns", rule.SourceTable)
|
||||||
|
}
|
||||||
|
if _, err := mustSafeIdentifier(*rule.DateJoinLeftCol); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := mustSafeIdentifier(*rule.DateJoinRightCol); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rule.FallbackDateColumn != nil {
|
||||||
|
if _, err := mustSafeIdentifier(*rule.FallbackDateColumn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package fifo_stock_v2
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidRequest = errors.New("invalid fifo stock v2 request")
|
||||||
|
ErrInsufficientStock = errors.New("insufficient stock")
|
||||||
|
)
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
package fifo_stock_v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type gatherSQLRow struct {
|
||||||
|
SourceTable string `gorm:"column:source_table"`
|
||||||
|
LegacyTypeKey string `gorm:"column:legacy_type_key"`
|
||||||
|
FunctionCode string `gorm:"column:function_code"`
|
||||||
|
SourceID uint `gorm:"column:source_id"`
|
||||||
|
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
||||||
|
SortAt time.Time `gorm:"column:sort_at"`
|
||||||
|
SortPriority int `gorm:"column:sort_priority"`
|
||||||
|
Quantity float64 `gorm:"column:quantity"`
|
||||||
|
UsedQuantity float64 `gorm:"column:used_quantity"`
|
||||||
|
PendingQuantity float64 `gorm:"column:pending_quantity"`
|
||||||
|
AvailableQuantity float64 `gorm:"column:available_quantity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) Gather(ctx context.Context, req GatherRequest) ([]GatherRow, error) {
|
||||||
|
if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 {
|
||||||
|
return nil, fmt.Errorf("%w: flag group and product warehouse are required", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
if req.Lane != LaneStockable && req.Lane != LaneUsable {
|
||||||
|
return nil, fmt.Errorf("%w: unsupported lane %q", ErrInvalidRequest, req.Lane)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []GatherRow
|
||||||
|
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
||||||
|
rows, err := s.gatherRows(ctx, tx, req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = rows
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) gatherRows(ctx context.Context, tx *gorm.DB, req GatherRequest) ([]GatherRow, error) {
|
||||||
|
req.AllocationPurpose = normalizeAllocationPurpose(req.AllocationPurpose)
|
||||||
|
|
||||||
|
rules, err := s.loadRouteRules(ctx, tx, req.FlagGroupCode, req.Lane)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rules) == 0 {
|
||||||
|
return []GatherRow{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := make([]string, 0, len(rules))
|
||||||
|
for _, rule := range rules {
|
||||||
|
tables = append(tables, rule.SourceTable)
|
||||||
|
}
|
||||||
|
|
||||||
|
traits, err := s.loadTraitMap(ctx, tx, req.Lane, tables)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
subqueries := make([]string, 0, len(rules))
|
||||||
|
args := make([]any, 0, len(rules)*10)
|
||||||
|
|
||||||
|
for _, rule := range rules {
|
||||||
|
trait, ok := traits[rule.SourceTable]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("missing trait for table %s lane %s", rule.SourceTable, req.Lane)
|
||||||
|
}
|
||||||
|
subSQL, subArgs, err := s.buildGatherSubquery(rule, trait, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
subqueries = append(subqueries, subSQL)
|
||||||
|
args = append(args, subArgs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(subqueries) == 0 {
|
||||||
|
return []GatherRow{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := req.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = s.defaultGatherLimit
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "SELECT * FROM (" + strings.Join(subqueries, " UNION ALL ") + ") AS g"
|
||||||
|
if req.AfterSortAt != nil {
|
||||||
|
query += `
|
||||||
|
WHERE
|
||||||
|
(g.sort_at > ?)
|
||||||
|
OR (g.sort_at = ? AND g.source_table > ?)
|
||||||
|
OR (g.sort_at = ? AND g.source_table = ? AND g.source_id > ?)
|
||||||
|
`
|
||||||
|
args = append(args,
|
||||||
|
*req.AfterSortAt,
|
||||||
|
*req.AfterSortAt, req.AfterSourceTable,
|
||||||
|
*req.AfterSortAt, req.AfterSourceTable, req.AfterSourceID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
query += " ORDER BY g.sort_at ASC, g.sort_priority ASC, g.source_table ASC, g.source_id ASC LIMIT ?"
|
||||||
|
args = append(args, limit)
|
||||||
|
|
||||||
|
var rows []gatherSQLRow
|
||||||
|
if err := tx.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]GatherRow, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, GatherRow{
|
||||||
|
Ref: Ref{
|
||||||
|
Table: row.SourceTable,
|
||||||
|
ID: row.SourceID,
|
||||||
|
LegacyTypeKey: row.LegacyTypeKey,
|
||||||
|
FunctionCode: row.FunctionCode,
|
||||||
|
},
|
||||||
|
FlagGroupCode: req.FlagGroupCode,
|
||||||
|
ProductWarehouseID: row.ProductWarehouseID,
|
||||||
|
SortAt: row.SortAt,
|
||||||
|
SortPriority: row.SortPriority,
|
||||||
|
Quantity: row.Quantity,
|
||||||
|
UsedQuantity: row.UsedQuantity,
|
||||||
|
PendingQuantity: row.PendingQuantity,
|
||||||
|
AvailableQuantity: row.AvailableQuantity,
|
||||||
|
SourceTable: row.SourceTable,
|
||||||
|
SourceID: row.SourceID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule, req GatherRequest) (string, []any, error) {
|
||||||
|
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
|
||||||
|
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
|
||||||
|
productWarehouseCol, _ := mustSafeIdentifier(rule.ProductWarehouseCol)
|
||||||
|
quantityCol, _ := mustSafeIdentifier(rule.QuantityCol)
|
||||||
|
|
||||||
|
baseQtyExpr := fmt.Sprintf("COALESCE(src.%s,0)::numeric", quantityCol)
|
||||||
|
usedExpr := "0::numeric"
|
||||||
|
pendingExpr := "0::numeric"
|
||||||
|
availableExpr := baseQtyExpr
|
||||||
|
extraArgs := make([]any, 0, 2)
|
||||||
|
whereExtraArgs := make([]any, 0, 1)
|
||||||
|
|
||||||
|
if req.Lane == LaneStockable {
|
||||||
|
if !req.IgnoreSourceUsed && rule.UsedQuantityCol != nil && strings.TrimSpace(*rule.UsedQuantityCol) != "" {
|
||||||
|
usedCol, _ := mustSafeIdentifier(*rule.UsedQuantityCol)
|
||||||
|
usedExpr = fmt.Sprintf("COALESCE(src.%s,0)::numeric", usedCol)
|
||||||
|
} else {
|
||||||
|
// NOTE:
|
||||||
|
// usedExpr is referenced twice in the generated SELECT:
|
||||||
|
// 1) as used_quantity
|
||||||
|
// 2) inside available_quantity = base - usedExpr
|
||||||
|
// plus once in stockable WHERE clause via availableExpr > 0.
|
||||||
|
// We split the args because the WHERE placeholder order appears
|
||||||
|
// after product/flag filter placeholders in the final SQL.
|
||||||
|
usedExpr = fmt.Sprintf(
|
||||||
|
"(SELECT COALESCE(SUM(sa.qty),0)::numeric FROM stock_allocations sa WHERE sa.stockable_type = ? AND sa.stockable_id = src.%s AND sa.status = '%s' AND sa.allocation_purpose = ?)",
|
||||||
|
sourceIDCol,
|
||||||
|
activeAllocationStatus(),
|
||||||
|
)
|
||||||
|
extraArgs = append(extraArgs, rule.LegacyTypeKey, req.AllocationPurpose)
|
||||||
|
extraArgs = append(extraArgs, rule.LegacyTypeKey, req.AllocationPurpose)
|
||||||
|
whereExtraArgs = append(whereExtraArgs, rule.LegacyTypeKey, req.AllocationPurpose)
|
||||||
|
}
|
||||||
|
availableExpr = fmt.Sprintf("(%s - %s)", baseQtyExpr, usedExpr)
|
||||||
|
} else {
|
||||||
|
if rule.PendingQuantityCol != nil && strings.TrimSpace(*rule.PendingQuantityCol) != "" {
|
||||||
|
pendingCol, _ := mustSafeIdentifier(*rule.PendingQuantityCol)
|
||||||
|
pendingExpr = fmt.Sprintf("COALESCE(src.%s,0)::numeric", pendingCol)
|
||||||
|
}
|
||||||
|
availableExpr = baseQtyExpr
|
||||||
|
}
|
||||||
|
|
||||||
|
sortExpr, joinClause, err := buildSortExpr(trait)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
functionCodeExpr := "?::text"
|
||||||
|
functionCodeArgs := []any{rule.FunctionCode}
|
||||||
|
if rule.SourceTable == "adjustment_stocks" {
|
||||||
|
functionCodeExpr = "COALESCE(NULLIF(src.function_code,''), ?::text)"
|
||||||
|
}
|
||||||
|
|
||||||
|
whereParts := []string{
|
||||||
|
fmt.Sprintf("src.%s = ?", productWarehouseCol),
|
||||||
|
fmt.Sprintf(`EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM product_warehouses pw
|
||||||
|
JOIN flags f ON f.flagable_type = ? AND f.flagable_id = pw.product_id
|
||||||
|
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
|
||||||
|
WHERE pw.id = src.%s AND fm.flag_group_code = ?
|
||||||
|
)`, productWarehouseCol),
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Lane == LaneStockable {
|
||||||
|
whereParts = append(whereParts, fmt.Sprintf("%s > 0", availableExpr))
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.AsOf != nil {
|
||||||
|
whereParts = append(whereParts, fmt.Sprintf("%s <= ?", sortExpr))
|
||||||
|
}
|
||||||
|
if req.From != nil {
|
||||||
|
whereParts = append(whereParts, fmt.Sprintf("%s >= ?", sortExpr))
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.ScopeSQL != nil && strings.TrimSpace(*rule.ScopeSQL) != "" {
|
||||||
|
whereParts = append(whereParts, fmt.Sprintf("(%s)", normalizeScopeSQL(*rule.ScopeSQL)))
|
||||||
|
}
|
||||||
|
|
||||||
|
subquery := fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
?::text AS source_table,
|
||||||
|
?::text AS legacy_type_key,
|
||||||
|
%s AS function_code,
|
||||||
|
src.%s AS source_id,
|
||||||
|
src.%s AS product_warehouse_id,
|
||||||
|
%s AS sort_at,
|
||||||
|
?::int AS sort_priority,
|
||||||
|
%s AS quantity,
|
||||||
|
%s AS used_quantity,
|
||||||
|
%s AS pending_quantity,
|
||||||
|
%s AS available_quantity
|
||||||
|
FROM %s src
|
||||||
|
%s
|
||||||
|
WHERE %s
|
||||||
|
`, functionCodeExpr, sourceIDCol, productWarehouseCol, sortExpr, baseQtyExpr, usedExpr, pendingExpr, availableExpr, sourceTable, joinClause, strings.Join(whereParts, " AND "))
|
||||||
|
|
||||||
|
args := []any{
|
||||||
|
rule.SourceTable,
|
||||||
|
rule.LegacyTypeKey,
|
||||||
|
}
|
||||||
|
args = append(args, functionCodeArgs...)
|
||||||
|
args = append(args, trait.SortPriority)
|
||||||
|
args = append(args, extraArgs...)
|
||||||
|
args = append(args,
|
||||||
|
req.ProductWarehouseID,
|
||||||
|
entity.FlagableTypeProduct,
|
||||||
|
req.FlagGroupCode,
|
||||||
|
)
|
||||||
|
args = append(args, whereExtraArgs...)
|
||||||
|
|
||||||
|
if req.AsOf != nil {
|
||||||
|
args = append(args, *req.AsOf)
|
||||||
|
}
|
||||||
|
if req.From != nil {
|
||||||
|
args = append(args, *req.From)
|
||||||
|
}
|
||||||
|
|
||||||
|
return subquery, args, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSortExpr(trait traitRule) (string, string, error) {
|
||||||
|
dateCol, _ := mustSafeIdentifier(trait.DateColumn)
|
||||||
|
idCol, _ := mustSafeIdentifier(trait.IDColumn)
|
||||||
|
_ = idCol
|
||||||
|
|
||||||
|
joinClause := ""
|
||||||
|
sortBase := fmt.Sprintf("src.%s", dateCol)
|
||||||
|
if trait.DateTable != nil && strings.TrimSpace(*trait.DateTable) != "" {
|
||||||
|
dateTable, _ := mustSafeIdentifier(*trait.DateTable)
|
||||||
|
if trait.DateJoinLeftCol == nil || trait.DateJoinRightCol == nil {
|
||||||
|
return "", "", fmt.Errorf("trait %s requires date join columns", trait.SourceTable)
|
||||||
|
}
|
||||||
|
leftCol, _ := mustSafeIdentifier(*trait.DateJoinLeftCol)
|
||||||
|
rightCol, _ := mustSafeIdentifier(*trait.DateJoinRightCol)
|
||||||
|
joinClause = fmt.Sprintf("LEFT JOIN %s dt ON src.%s = dt.%s", dateTable, leftCol, rightCol)
|
||||||
|
sortBase = fmt.Sprintf("dt.%s", dateCol)
|
||||||
|
}
|
||||||
|
|
||||||
|
if trait.FallbackDateColumn != nil && strings.TrimSpace(*trait.FallbackDateColumn) != "" {
|
||||||
|
fallbackCol, _ := mustSafeIdentifier(*trait.FallbackDateColumn)
|
||||||
|
sortBase = fmt.Sprintf("COALESCE(%s, src.%s)", sortBase, fallbackCol)
|
||||||
|
}
|
||||||
|
|
||||||
|
sortExpr := fmt.Sprintf("COALESCE(%s, '1970-01-01 00:00:00+00'::timestamptz)", sortBase)
|
||||||
|
return sortExpr, joinClause, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package fifo_stock_v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||||
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReleasePopulationConsumptionByUsable(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *gorm.DB,
|
||||||
|
usableType string,
|
||||||
|
usableID uint,
|
||||||
|
) error {
|
||||||
|
if tx == nil {
|
||||||
|
return errors.New("transaction is required")
|
||||||
|
}
|
||||||
|
if usableType == "" || usableID == 0 {
|
||||||
|
return errors.New("usable type and id are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
|
||||||
|
allocations, err := stockAllocationRepo.FindActiveByUsable(ctx, usableType, usableID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, allocation := range allocations {
|
||||||
|
if allocation.StockableType != fifo.StockableKeyProjectFlockPopulation.String() || allocation.StockableId == 0 || allocation.Qty <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := tx.WithContext(ctx).
|
||||||
|
Model(&entity.ProjectFlockPopulation{}).
|
||||||
|
Where("id = ?", allocation.StockableId).
|
||||||
|
Update("total_used_qty", gorm.Expr("GREATEST(total_used_qty - ?, 0)", allocation.Qty)).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stockAllocationRepo.ReleaseByUsable(ctx, usableType, usableID, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AllocatePopulationConsumption(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *gorm.DB,
|
||||||
|
populations []entity.ProjectFlockPopulation,
|
||||||
|
productWarehouseID uint,
|
||||||
|
usableType string,
|
||||||
|
usableID uint,
|
||||||
|
consumeQty float64,
|
||||||
|
) error {
|
||||||
|
if consumeQty <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if tx == nil {
|
||||||
|
return errors.New("transaction is required")
|
||||||
|
}
|
||||||
|
if productWarehouseID == 0 {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "Product warehouse tidak valid")
|
||||||
|
}
|
||||||
|
if usableType == "" || usableID == 0 {
|
||||||
|
return errors.New("usable type and id are required")
|
||||||
|
}
|
||||||
|
if len(populations) == 0 {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ReleasePopulationConsumptionByUsable(ctx, tx, usableType, usableID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(populations, func(i, j int) bool {
|
||||||
|
if populations[i].CreatedAt.Equal(populations[j].CreatedAt) {
|
||||||
|
return populations[i].Id < populations[j].Id
|
||||||
|
}
|
||||||
|
return populations[i].CreatedAt.Before(populations[j].CreatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
|
||||||
|
remaining := consumeQty
|
||||||
|
for _, pop := range populations {
|
||||||
|
available := pop.TotalQty - pop.TotalUsedQty
|
||||||
|
if available <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
portion := math.Min(available, remaining)
|
||||||
|
if portion <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
allocation := &entity.StockAllocation{
|
||||||
|
ProductWarehouseId: productWarehouseID,
|
||||||
|
StockableType: fifo.StockableKeyProjectFlockPopulation.String(),
|
||||||
|
StockableId: pop.Id,
|
||||||
|
UsableType: usableType,
|
||||||
|
UsableId: usableID,
|
||||||
|
Qty: portion,
|
||||||
|
Status: entity.StockAllocationStatusActive,
|
||||||
|
AllocationPurpose: entity.StockAllocationPurposeConsume,
|
||||||
|
}
|
||||||
|
if err := stockAllocationRepo.CreateOne(ctx, allocation, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.WithContext(ctx).
|
||||||
|
Model(&entity.ProjectFlockPopulation{}).
|
||||||
|
Where("id = ?", pop.Id).
|
||||||
|
Update("total_used_qty", gorm.Expr("total_used_qty + ?", portion)).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining -= portion
|
||||||
|
if remaining <= 1e-6 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if remaining > 1e-6 {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak mencukupi")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package fifo_stock_v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) Recalculate(ctx context.Context, req RecalculateRequest) (*RecalculateResult, error) {
|
||||||
|
result := &RecalculateResult{Drifts: make([]WarehouseDrift, 0)}
|
||||||
|
|
||||||
|
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
||||||
|
hash := requestHash(map[string]any{
|
||||||
|
"product_warehouse_ids": req.ProductWarehouseIDs,
|
||||||
|
"flag_group_codes": req.FlagGroupCodes,
|
||||||
|
"as_of": req.AsOf,
|
||||||
|
"fix_drift": req.FixDrift,
|
||||||
|
})
|
||||||
|
logRow, reused, err := s.beginOperation(
|
||||||
|
tx,
|
||||||
|
OperationRecalculate,
|
||||||
|
req.IdempotencyKey,
|
||||||
|
hash,
|
||||||
|
0,
|
||||||
|
"RECALCULATE",
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if reused {
|
||||||
|
if len(logRow.ResultPayload) == 0 {
|
||||||
|
return fmt.Errorf("idempotent recalculate has empty payload")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if logRow != nil {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
s.failOperation(tx, logRow, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
warehouseIDs, err := s.resolveRecalculateWarehouseIDs(ctx, tx, req.ProductWarehouseIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
groupCodes, err := s.resolveRecalculateGroupCodes(ctx, tx, req.FlagGroupCodes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, warehouseID := range warehouseIDs {
|
||||||
|
expected := 0.0
|
||||||
|
for _, flagGroup := range groupCodes {
|
||||||
|
available, calcErr := s.calculateWarehouseAvailableForGroup(ctx, tx, warehouseID, flagGroup, req.AsOf)
|
||||||
|
if calcErr != nil {
|
||||||
|
return calcErr
|
||||||
|
}
|
||||||
|
expected += available
|
||||||
|
}
|
||||||
|
|
||||||
|
actual, actualErr := s.loadWarehouseQty(ctx, tx, warehouseID)
|
||||||
|
if actualErr != nil {
|
||||||
|
return actualErr
|
||||||
|
}
|
||||||
|
|
||||||
|
delta := expected - actual
|
||||||
|
result.Checked++
|
||||||
|
if math.Abs(delta) < 1e-6 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
drift := WarehouseDrift{
|
||||||
|
ProductWarehouseID: warehouseID,
|
||||||
|
ExpectedQty: expected,
|
||||||
|
ActualQty: actual,
|
||||||
|
Delta: delta,
|
||||||
|
}
|
||||||
|
result.Drifts = append(result.Drifts, drift)
|
||||||
|
|
||||||
|
if req.FixDrift {
|
||||||
|
if err := s.adjustProductWarehouseQty(tx, warehouseID, delta); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.Fixed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.finishOperation(tx, logRow, result); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) resolveRecalculateWarehouseIDs(ctx context.Context, tx *gorm.DB, provided []uint) ([]uint, error) {
|
||||||
|
if len(provided) > 0 {
|
||||||
|
return provided, nil
|
||||||
|
}
|
||||||
|
var ids []uint
|
||||||
|
err := tx.WithContext(ctx).Table("product_warehouses").Select("id").Order("id ASC").Scan(&ids).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) resolveRecalculateGroupCodes(ctx context.Context, tx *gorm.DB, provided []string) ([]string, error) {
|
||||||
|
if len(provided) > 0 {
|
||||||
|
return provided, nil
|
||||||
|
}
|
||||||
|
var groups []string
|
||||||
|
err := tx.WithContext(ctx).
|
||||||
|
Table("fifo_stock_v2_flag_groups").
|
||||||
|
Select("code").
|
||||||
|
Where("is_active = TRUE").
|
||||||
|
Order("priority ASC, code ASC").
|
||||||
|
Scan(&groups).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return groups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) calculateWarehouseAvailableForGroup(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *gorm.DB,
|
||||||
|
warehouseID uint,
|
||||||
|
flagGroupCode string,
|
||||||
|
asOf *time.Time,
|
||||||
|
) (float64, error) {
|
||||||
|
rows, err := s.gatherRows(ctx, tx, GatherRequest{
|
||||||
|
FlagGroupCode: flagGroupCode,
|
||||||
|
Lane: LaneStockable,
|
||||||
|
ProductWarehouseID: warehouseID,
|
||||||
|
AsOf: asOf,
|
||||||
|
Limit: 50000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
total := 0.0
|
||||||
|
for _, row := range rows {
|
||||||
|
total += row.AvailableQuantity
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) loadWarehouseQty(ctx context.Context, tx *gorm.DB, warehouseID uint) (float64, error) {
|
||||||
|
type row struct {
|
||||||
|
Qty float64 `gorm:"column:qty"`
|
||||||
|
}
|
||||||
|
var out row
|
||||||
|
err := tx.WithContext(ctx).
|
||||||
|
Table("product_warehouses").
|
||||||
|
Select("COALESCE(qty,0) AS qty").
|
||||||
|
Where("id = ?", warehouseID).
|
||||||
|
Take(&out).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return out.Qty, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package fifo_stock_v2
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
func normalizeScopeSQL(scopeSQL string) string {
|
||||||
|
scopeSQL = strings.TrimSpace(scopeSQL)
|
||||||
|
if scopeSQL == "" {
|
||||||
|
return scopeSQL
|
||||||
|
}
|
||||||
|
|
||||||
|
var out strings.Builder
|
||||||
|
out.Grow(len(scopeSQL) + 16)
|
||||||
|
|
||||||
|
inSingleQuote := false
|
||||||
|
inDoubleQuote := false
|
||||||
|
|
||||||
|
for i := 0; i < len(scopeSQL); {
|
||||||
|
ch := scopeSQL[i]
|
||||||
|
|
||||||
|
if inSingleQuote {
|
||||||
|
out.WriteByte(ch)
|
||||||
|
i++
|
||||||
|
if ch == '\'' {
|
||||||
|
if i < len(scopeSQL) && scopeSQL[i] == '\'' {
|
||||||
|
out.WriteByte(scopeSQL[i])
|
||||||
|
i++
|
||||||
|
} else {
|
||||||
|
inSingleQuote = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if inDoubleQuote {
|
||||||
|
out.WriteByte(ch)
|
||||||
|
i++
|
||||||
|
if ch == '"' {
|
||||||
|
inDoubleQuote = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == '\'' {
|
||||||
|
inSingleQuote = true
|
||||||
|
out.WriteByte(ch)
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == '"' {
|
||||||
|
inDoubleQuote = true
|
||||||
|
out.WriteByte(ch)
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if isIdentifierStart(ch) {
|
||||||
|
start := i
|
||||||
|
i++
|
||||||
|
for i < len(scopeSQL) && isIdentifierPart(scopeSQL[i]) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
token := scopeSQL[start:i]
|
||||||
|
if strings.EqualFold(token, "deleted_at") && !hasAliasQualifier(scopeSQL, start) {
|
||||||
|
out.WriteString("src.deleted_at")
|
||||||
|
} else {
|
||||||
|
out.WriteString(token)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out.WriteByte(ch)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAliasQualifier(scopeSQL string, tokenStart int) bool {
|
||||||
|
for i := tokenStart - 1; i >= 0; i-- {
|
||||||
|
switch scopeSQL[i] {
|
||||||
|
case ' ', '\t', '\n', '\r':
|
||||||
|
continue
|
||||||
|
case '.':
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isIdentifierStart(ch byte) bool {
|
||||||
|
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '_'
|
||||||
|
}
|
||||||
|
|
||||||
|
func isIdentifierPart(ch byte) bool {
|
||||||
|
return isIdentifierStart(ch) || (ch >= '0' && ch <= '9')
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package fifo_stock_v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var identifierPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
|
||||||
|
|
||||||
|
type fifoStockV2Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
logger *logrus.Logger
|
||||||
|
defaultGatherLimit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db *gorm.DB, logger *logrus.Logger) Service {
|
||||||
|
if logger == nil {
|
||||||
|
logger = logrus.StandardLogger()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &fifoStockV2Service{
|
||||||
|
db: db,
|
||||||
|
logger: logger,
|
||||||
|
defaultGatherLimit: 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) withTransaction(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *gorm.DB,
|
||||||
|
fn func(*gorm.DB) error,
|
||||||
|
) error {
|
||||||
|
if tx != nil {
|
||||||
|
return fn(tx.WithContext(ctx))
|
||||||
|
}
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(inner *gorm.DB) error {
|
||||||
|
return fn(inner)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSafeIdentifier(v string) bool {
|
||||||
|
return identifierPattern.MatchString(strings.TrimSpace(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustSafeIdentifier(v string) (string, error) {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if !isSafeIdentifier(v) {
|
||||||
|
return "", fmt.Errorf("unsafe identifier: %s", v)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestHash(v any) string {
|
||||||
|
payload, _ := json.Marshal(v)
|
||||||
|
sum := sha256.Sum256(payload)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func shardLockKey(flagGroupCode string, productWarehouseID uint) int64 {
|
||||||
|
h := fnv.New64a()
|
||||||
|
_, _ = h.Write([]byte(strings.TrimSpace(strings.ToUpper(flagGroupCode))))
|
||||||
|
_, _ = h.Write([]byte("|"))
|
||||||
|
_, _ = h.Write([]byte(fmt.Sprintf("%d", productWarehouseID)))
|
||||||
|
return int64(h.Sum64())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) lockShard(tx *gorm.DB, flagGroupCode string, productWarehouseID uint) error {
|
||||||
|
if strings.TrimSpace(flagGroupCode) == "" || productWarehouseID == 0 {
|
||||||
|
return fmt.Errorf("lock shard requires flag group and product warehouse")
|
||||||
|
}
|
||||||
|
return tx.Exec("SELECT pg_advisory_xact_lock(?)", shardLockKey(flagGroupCode, productWarehouseID)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
type operationLogRow struct {
|
||||||
|
ID uint `gorm:"column:id"`
|
||||||
|
Status string `gorm:"column:status"`
|
||||||
|
RequestHash string `gorm:"column:request_hash"`
|
||||||
|
ResultPayload json.RawMessage `gorm:"column:result_payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) beginOperation(
|
||||||
|
tx *gorm.DB,
|
||||||
|
op Operation,
|
||||||
|
idempotencyKey string,
|
||||||
|
requestHashValue string,
|
||||||
|
productWarehouseID uint,
|
||||||
|
flagGroupCode string,
|
||||||
|
usableType string,
|
||||||
|
usableID uint,
|
||||||
|
) (*operationLogRow, bool, error) {
|
||||||
|
if strings.TrimSpace(idempotencyKey) == "" {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
inserted := operationLogRow{}
|
||||||
|
insertSQL := `
|
||||||
|
INSERT INTO fifo_stock_v2_operation_log
|
||||||
|
(idempotency_key, operation, product_warehouse_id, flag_group_code, usable_type, usable_id, request_hash, status, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, 0), ?, 'RUNNING', NOW())
|
||||||
|
ON CONFLICT (idempotency_key, operation) DO NOTHING
|
||||||
|
RETURNING id, status, request_hash
|
||||||
|
`
|
||||||
|
if err := tx.Raw(insertSQL,
|
||||||
|
idempotencyKey,
|
||||||
|
string(op),
|
||||||
|
productWarehouseID,
|
||||||
|
flagGroupCode,
|
||||||
|
usableType,
|
||||||
|
usableID,
|
||||||
|
requestHashValue,
|
||||||
|
).Scan(&inserted).Error; err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if inserted.ID != 0 {
|
||||||
|
return &inserted, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
existing := operationLogRow{}
|
||||||
|
if err := tx.Table("fifo_stock_v2_operation_log").
|
||||||
|
Select("id, status, request_hash, result_payload").
|
||||||
|
Where("idempotency_key = ? AND operation = ?", idempotencyKey, string(op)).
|
||||||
|
Take(&existing).Error; err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing.RequestHash != requestHashValue {
|
||||||
|
return nil, false, fmt.Errorf("idempotency key %s reused with different payload", idempotencyKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToUpper(existing.Status) {
|
||||||
|
case "DONE":
|
||||||
|
return &existing, true, nil
|
||||||
|
case "RUNNING":
|
||||||
|
return nil, false, fmt.Errorf("operation %s with idempotency key %s is still running", op, idempotencyKey)
|
||||||
|
case "FAILED":
|
||||||
|
if err := tx.Table("fifo_stock_v2_operation_log").
|
||||||
|
Where("id = ?", existing.ID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": "RUNNING",
|
||||||
|
"error_text": nil,
|
||||||
|
"finished_at": nil,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
existing.Status = "RUNNING"
|
||||||
|
return &existing, false, nil
|
||||||
|
default:
|
||||||
|
return nil, false, fmt.Errorf("unknown operation status: %s", existing.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) finishOperation(tx *gorm.DB, logRow *operationLogRow, payload any) error {
|
||||||
|
if logRow == nil || logRow.ID == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Table("fifo_stock_v2_operation_log").
|
||||||
|
Where("id = ?", logRow.ID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": "DONE",
|
||||||
|
"result_payload": encoded,
|
||||||
|
"finished_at": gorm.Expr("NOW()"),
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) failOperation(tx *gorm.DB, logRow *operationLogRow, failure error) {
|
||||||
|
if logRow == nil || logRow.ID == 0 || failure == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = tx.Table("fifo_stock_v2_operation_log").
|
||||||
|
Where("id = ?", logRow.ID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": "FAILED",
|
||||||
|
"error_text": failure.Error(),
|
||||||
|
"finished_at": gorm.Expr("NOW()"),
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) resolveOverConsume(
|
||||||
|
tx *gorm.DB,
|
||||||
|
flagGroupCode string,
|
||||||
|
functionCode string,
|
||||||
|
lane Lane,
|
||||||
|
defaultValue bool,
|
||||||
|
) (bool, error) {
|
||||||
|
type row struct {
|
||||||
|
Allow bool `gorm:"column:allow_overconsume"`
|
||||||
|
}
|
||||||
|
selected := row{}
|
||||||
|
err := tx.Table("fifo_stock_v2_overconsume_rules").
|
||||||
|
Select("allow_overconsume").
|
||||||
|
Where("is_active = TRUE").
|
||||||
|
Where("lane = ?", string(lane)).
|
||||||
|
Where("(flag_group_code IS NULL OR flag_group_code = ?)", flagGroupCode).
|
||||||
|
Where("(function_code IS NULL OR function_code = ?)", functionCode).
|
||||||
|
Order("CASE WHEN flag_group_code IS NULL THEN 1 ELSE 0 END ASC").
|
||||||
|
Order("CASE WHEN function_code IS NULL THEN 1 ELSE 0 END ASC").
|
||||||
|
Order("priority ASC, id ASC").
|
||||||
|
Limit(1).
|
||||||
|
Take(&selected).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return defaultValue, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return selected.Allow, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) adjustProductWarehouseQty(tx *gorm.DB, productWarehouseID uint, delta float64) error {
|
||||||
|
if productWarehouseID == 0 || delta == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return tx.Table("product_warehouses").
|
||||||
|
Where("id = ?", productWarehouseID).
|
||||||
|
Update("qty", gorm.Expr("COALESCE(qty,0) + ?", delta)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func nearlyZero(v float64) bool {
|
||||||
|
return math.Abs(v) < 1e-6
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fifoStockV2Service) ensureStockAllocationColumns(tx *gorm.DB) error {
|
||||||
|
checkCols := []string{"engine_version", "flag_group_code", "function_code", "idempotency_key", "allocation_purpose"}
|
||||||
|
for _, col := range checkCols {
|
||||||
|
var count int64
|
||||||
|
err := tx.Raw(`
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'stock_allocations' AND column_name = ?
|
||||||
|
`, col).Scan(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return fmt.Errorf("stock_allocations.%s does not exist, run fifo_stock_v2 migration first", col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func activeAllocationStatus() string {
|
||||||
|
return entity.StockAllocationStatusActive
|
||||||
|
}
|
||||||
|
|
||||||
|
func releasedAllocationStatus() string {
|
||||||
|
return entity.StockAllocationStatusReleased
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultAllocationPurpose() string {
|
||||||
|
return entity.StockAllocationPurposeConsume
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAllocationPurpose(purpose string) string {
|
||||||
|
purpose = strings.TrimSpace(strings.ToUpper(purpose))
|
||||||
|
if purpose == "" {
|
||||||
|
return defaultAllocationPurpose()
|
||||||
|
}
|
||||||
|
return purpose
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package fifo_stock_v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Lane string
|
||||||
|
|
||||||
|
const (
|
||||||
|
LaneStockable Lane = "STOCKABLE"
|
||||||
|
LaneUsable Lane = "USABLE"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Operation string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OperationAllocate Operation = "ALLOCATE"
|
||||||
|
OperationRollback Operation = "ROLLBACK"
|
||||||
|
OperationReflow Operation = "REFLOW"
|
||||||
|
OperationRecalculate Operation = "RECALCULATE"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Ref struct {
|
||||||
|
Table string
|
||||||
|
ID uint
|
||||||
|
LegacyTypeKey string
|
||||||
|
FunctionCode string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GatherRequest struct {
|
||||||
|
FlagGroupCode string
|
||||||
|
Lane Lane
|
||||||
|
AllocationPurpose string
|
||||||
|
IgnoreSourceUsed bool
|
||||||
|
ProductWarehouseID uint
|
||||||
|
From *time.Time
|
||||||
|
AsOf *time.Time
|
||||||
|
Limit int
|
||||||
|
AfterSortAt *time.Time
|
||||||
|
AfterSourceTable string
|
||||||
|
AfterSourceID uint
|
||||||
|
ForUpdate bool
|
||||||
|
Tx *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type GatherRow struct {
|
||||||
|
Ref Ref
|
||||||
|
FlagGroupCode string
|
||||||
|
ProductWarehouseID uint
|
||||||
|
SortAt time.Time
|
||||||
|
SortPriority int
|
||||||
|
Quantity float64
|
||||||
|
UsedQuantity float64
|
||||||
|
PendingQuantity float64
|
||||||
|
AvailableQuantity float64
|
||||||
|
SourceTable string
|
||||||
|
SourceID uint
|
||||||
|
}
|
||||||
|
|
||||||
|
type AllocateRequest struct {
|
||||||
|
FlagGroupCode string
|
||||||
|
ProductWarehouseID uint
|
||||||
|
Usable Ref
|
||||||
|
NeedQty float64
|
||||||
|
AllowOverConsume *bool
|
||||||
|
IdempotencyKey string
|
||||||
|
AsOf *time.Time
|
||||||
|
Tx *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type AllocationDetail struct {
|
||||||
|
StockableType string
|
||||||
|
StockableID uint
|
||||||
|
Qty float64
|
||||||
|
SortAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AllocateResult struct {
|
||||||
|
AllocatedQty float64
|
||||||
|
PendingQty float64
|
||||||
|
Details []AllocationDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
type RollbackRequest struct {
|
||||||
|
ProductWarehouseID uint
|
||||||
|
Usable Ref
|
||||||
|
ReleaseQty *float64
|
||||||
|
Reason string
|
||||||
|
IdempotencyKey string
|
||||||
|
Tx *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type RollbackResult struct {
|
||||||
|
ReleasedQty float64
|
||||||
|
Details []AllocationDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReflowRequest struct {
|
||||||
|
FlagGroupCode string
|
||||||
|
ProductWarehouseID uint
|
||||||
|
AsOf *time.Time
|
||||||
|
IdempotencyKey string
|
||||||
|
Tx *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReflowResult struct {
|
||||||
|
ProcessedUsables int
|
||||||
|
Rollback RollbackResult
|
||||||
|
Allocate AllocateResult
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecalculateRequest struct {
|
||||||
|
ProductWarehouseIDs []uint
|
||||||
|
FlagGroupCodes []string
|
||||||
|
AsOf *time.Time
|
||||||
|
FixDrift bool
|
||||||
|
IdempotencyKey string
|
||||||
|
Tx *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type WarehouseDrift struct {
|
||||||
|
ProductWarehouseID uint
|
||||||
|
ExpectedQty float64
|
||||||
|
ActualQty float64
|
||||||
|
Delta float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecalculateResult struct {
|
||||||
|
Checked int
|
||||||
|
Fixed int
|
||||||
|
Drifts []WarehouseDrift
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service interface {
|
||||||
|
Gather(ctx context.Context, req GatherRequest) ([]GatherRow, error)
|
||||||
|
Allocate(ctx context.Context, req AllocateRequest) (*AllocateResult, error)
|
||||||
|
Rollback(ctx context.Context, req RollbackRequest) (*RollbackResult, error)
|
||||||
|
Reflow(ctx context.Context, req ReflowRequest) (*ReflowResult, error)
|
||||||
|
Recalculate(ctx context.Context, req RecalculateRequest) (*RecalculateResult, error)
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package validation
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
)
|
)
|
||||||
@@ -21,34 +23,41 @@ var customMessages = map[string]string{
|
|||||||
"alphanum": "Field %s must contain only alphanumeric characters",
|
"alphanum": "Field %s must contain only alphanumeric characters",
|
||||||
"oneof": "Invalid value for field %s",
|
"oneof": "Invalid value for field %s",
|
||||||
"password": "Field %s must be at least 8 characters, contain uppercase, lowercase, number, and special character",
|
"password": "Field %s must be at least 8 characters, contain uppercase, lowercase, number, and special character",
|
||||||
|
"gt": "Invalid %s, must be greater than %s",
|
||||||
}
|
}
|
||||||
|
|
||||||
func CustomErrorMessages(err error) map[string]string {
|
func CustomErrorMessages(err error) (string, map[string]string) {
|
||||||
var validationErrors validator.ValidationErrors
|
var validationErrors validator.ValidationErrors
|
||||||
if errors.As(err, &validationErrors) {
|
if errors.As(err, &validationErrors) {
|
||||||
return generateErrorMessages(validationErrors)
|
return generateErrorMessages(validationErrors)
|
||||||
}
|
}
|
||||||
return nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateErrorMessages(validationErrors validator.ValidationErrors) map[string]string {
|
func generateErrorMessages(validationErrors validator.ValidationErrors) (string, map[string]string) {
|
||||||
errorsMap := make(map[string]string)
|
errorsMap := make(map[string]string)
|
||||||
for _, err := range validationErrors {
|
var firstMessage string
|
||||||
|
for i, err := range validationErrors {
|
||||||
fieldName := err.StructNamespace()
|
fieldName := err.StructNamespace()
|
||||||
tag := err.Tag()
|
tag := err.Tag()
|
||||||
|
|
||||||
customMessage := customMessages[tag]
|
customMessage := customMessages[tag]
|
||||||
|
var msg string
|
||||||
if customMessage != "" {
|
if customMessage != "" {
|
||||||
errorsMap[fieldName] = formatErrorMessage(customMessage, err, tag)
|
msg = formatErrorMessage(customMessage, err, tag)
|
||||||
} else {
|
} else {
|
||||||
errorsMap[fieldName] = defaultErrorMessage(err)
|
msg = defaultErrorMessage(err)
|
||||||
|
}
|
||||||
|
errorsMap[fieldName] = msg
|
||||||
|
if i == 0 {
|
||||||
|
firstMessage = msg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return errorsMap
|
return firstMessage, errorsMap
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatErrorMessage(customMessage string, err validator.FieldError, tag string) string {
|
func formatErrorMessage(customMessage string, err validator.FieldError, tag string) string {
|
||||||
if tag == "min" || tag == "max" || tag == "len" {
|
if tag == "min" || tag == "max" || tag == "len" || tag == "gt" {
|
||||||
return fmt.Sprintf(customMessage, err.Field(), err.Param())
|
return fmt.Sprintf(customMessage, err.Field(), err.Param())
|
||||||
}
|
}
|
||||||
return fmt.Sprintf(customMessage, err.Field())
|
return fmt.Sprintf(customMessage, err.Field())
|
||||||
@@ -61,6 +70,16 @@ func defaultErrorMessage(err validator.FieldError) string {
|
|||||||
func Validator() *validator.Validate {
|
func Validator() *validator.Validate {
|
||||||
validate := validator.New()
|
validate := validator.New()
|
||||||
|
|
||||||
|
validate.RegisterTagNameFunc(func(fld reflect.StructField) string {
|
||||||
|
if jsonTag := getTagName(fld, "json"); jsonTag != "" {
|
||||||
|
return jsonTag
|
||||||
|
}
|
||||||
|
if queryTag := getTagName(fld, "query"); queryTag != "" {
|
||||||
|
return queryTag
|
||||||
|
}
|
||||||
|
return fld.Name
|
||||||
|
})
|
||||||
|
|
||||||
if err := validate.RegisterValidation("password", Password); err != nil {
|
if err := validate.RegisterValidation("password", Password); err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -72,3 +91,16 @@ func Validator() *validator.Validate {
|
|||||||
}
|
}
|
||||||
return validate
|
return validate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getTagName(fld reflect.StructField, tag string) string {
|
||||||
|
value, ok := fld.Tag.Lookup(tag)
|
||||||
|
if !ok || value == "-" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.Split(value, ",")[0]
|
||||||
|
if name == "" || name == "-" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
+215
-23
@@ -2,36 +2,82 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type SSOClientConfig struct {
|
||||||
|
PublicID string `json:"public_id"`
|
||||||
|
RedirectURI string `json:"redirect_uri"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
// Prompt string `json:"prompt"`
|
||||||
|
DefaultReturnURI string `json:"default_return_uri"`
|
||||||
|
AllowedReturnOrigins []string `json:"allowed_return_origins"`
|
||||||
|
SyncSecret string `json:"sync_secret"`
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
IsProd bool
|
IsProd bool
|
||||||
AppHost string
|
AppHost string
|
||||||
Version string
|
Version string
|
||||||
LogLevel string
|
LogLevel string
|
||||||
AppPort int
|
AppPort int
|
||||||
DBHost string
|
DBHost string
|
||||||
DBUser string
|
DBUser string
|
||||||
DBPassword string
|
DBPassword string
|
||||||
DBName string
|
DBName string
|
||||||
DBPort int
|
DBPort int
|
||||||
JWTSecret string
|
DBSSLMode string
|
||||||
JWTAccessExp int
|
DBSSLRootCert string
|
||||||
JWTRefreshExp int
|
DBSSLCert string
|
||||||
JWTResetPasswordExp int
|
DBSSLKey string
|
||||||
JWTVerifyEmailExp int
|
JWTSecret string
|
||||||
RedisURL string
|
JWTAccessExp int
|
||||||
CORSAllowOrigins []string
|
JWTRefreshExp int
|
||||||
CORSAllowMethods []string
|
JWTResetPasswordExp int
|
||||||
CORSAllowHeaders []string
|
JWTVerifyEmailExp int
|
||||||
CORSExposeHeaders []string
|
RedisURL string
|
||||||
CORSAllowCredentials bool
|
CORSAllowOrigins []string
|
||||||
CORSMaxAge int
|
CORSAllowMethods []string
|
||||||
|
CORSAllowHeaders []string
|
||||||
|
CORSExposeHeaders []string
|
||||||
|
CORSAllowCredentials bool
|
||||||
|
CORSMaxAge int
|
||||||
|
SSOIssuer string
|
||||||
|
SSOJWKSURL string
|
||||||
|
SSOAllowedAudiences []string
|
||||||
|
SSOAuthorizeURL string
|
||||||
|
SSOTokenURL string
|
||||||
|
SSOGetMeURL string
|
||||||
|
SSOPortalURL string
|
||||||
|
SSOClients map[string]SSOClientConfig
|
||||||
|
SSOAccessCookieName string
|
||||||
|
SSOAccessCookieFallback []string
|
||||||
|
SSORefreshCookieName string
|
||||||
|
SSOCookieDomain string
|
||||||
|
SSOCookieSecure bool
|
||||||
|
SSOCookieSameSite string
|
||||||
|
SSOAccessTokenMaxBytes int
|
||||||
|
SSOTokenBlacklistPrefix string
|
||||||
|
SSOPKCETTL time.Duration
|
||||||
|
SSOUserSyncDrift time.Duration
|
||||||
|
SSOUserSyncNonceTTL time.Duration
|
||||||
|
SSOUserSyncMaxBodyBytes int
|
||||||
|
S3Endpoint string
|
||||||
|
S3Region string
|
||||||
|
S3Bucket string
|
||||||
|
S3AccessKey string
|
||||||
|
S3SecretKey string
|
||||||
|
S3ForcePathStyle bool
|
||||||
|
S3PublicBaseURL string
|
||||||
|
S3EnvPrefix string
|
||||||
|
S3DocumentKeyPrefix string
|
||||||
|
TransferToLayingGrowingMaxWeek int
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -50,6 +96,10 @@ func init() {
|
|||||||
DBPassword = viper.GetString("DB_PASSWORD")
|
DBPassword = viper.GetString("DB_PASSWORD")
|
||||||
DBName = viper.GetString("DB_NAME")
|
DBName = viper.GetString("DB_NAME")
|
||||||
DBPort = viper.GetInt("DB_PORT")
|
DBPort = viper.GetInt("DB_PORT")
|
||||||
|
DBSSLMode = defaultString(viper.GetString("DB_SSLMODE"), "disable")
|
||||||
|
DBSSLRootCert = strings.TrimSpace(viper.GetString("DB_SSLROOTCERT"))
|
||||||
|
DBSSLCert = strings.TrimSpace(viper.GetString("DB_SSLCERT"))
|
||||||
|
DBSSLKey = strings.TrimSpace(viper.GetString("DB_SSLKEY"))
|
||||||
|
|
||||||
// jwt configuration
|
// jwt configuration
|
||||||
JWTSecret = viper.GetString("JWT_SECRET")
|
JWTSecret = viper.GetString("JWT_SECRET")
|
||||||
@@ -58,7 +108,7 @@ func init() {
|
|||||||
JWTResetPasswordExp = viper.GetInt("JWT_RESET_PASSWORD_EXP_MINUTES")
|
JWTResetPasswordExp = viper.GetInt("JWT_RESET_PASSWORD_EXP_MINUTES")
|
||||||
JWTVerifyEmailExp = viper.GetInt("JWT_VERIFY_EMAIL_EXP_MINUTES")
|
JWTVerifyEmailExp = viper.GetInt("JWT_VERIFY_EMAIL_EXP_MINUTES")
|
||||||
|
|
||||||
//Cors
|
// Cors
|
||||||
CORSAllowOrigins = parseList("CORS_ALLOW_ORIGINS")
|
CORSAllowOrigins = parseList("CORS_ALLOW_ORIGINS")
|
||||||
CORSAllowMethods = parseListWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
|
CORSAllowMethods = parseListWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
|
||||||
CORSAllowHeaders = parseListWithDefault("CORS_ALLOW_HEADERS", "Content-Type,Authorization,X-Requested-With")
|
CORSAllowHeaders = parseListWithDefault("CORS_ALLOW_HEADERS", "Content-Type,Authorization,X-Requested-With")
|
||||||
@@ -68,6 +118,70 @@ func init() {
|
|||||||
|
|
||||||
// Redis
|
// Redis
|
||||||
RedisURL = viper.GetString("REDIS_URL")
|
RedisURL = viper.GetString("REDIS_URL")
|
||||||
|
|
||||||
|
TransferToLayingGrowingMaxWeek = viper.GetInt("TRANSFER_TO_LAYING_GROWING_MAX_WEEK")
|
||||||
|
if TransferToLayingGrowingMaxWeek <= 0 {
|
||||||
|
TransferToLayingGrowingMaxWeek = 19
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")), "/")
|
||||||
|
S3EnvPrefix = defaultString(strings.Trim(strings.TrimSpace(viper.GetString("S3_ENV_PREFIX")), "/"), "local")
|
||||||
|
docPrefix := strings.Trim(strings.TrimSpace(viper.GetString("S3_DOCUMENT_PREFIX")), "/")
|
||||||
|
if docPrefix == "" {
|
||||||
|
docPrefix = "docs"
|
||||||
|
}
|
||||||
|
S3DocumentKeyPrefix = joinPath(S3EnvPrefix, docPrefix)
|
||||||
|
|
||||||
|
// SSO integration
|
||||||
|
SSOIssuer = viper.GetString("SSO_ISSUER")
|
||||||
|
SSOJWKSURL = viper.GetString("SSO_JWKS_URL")
|
||||||
|
SSOAllowedAudiences = parseList("SSO_ALLOWED_AUDIENCES")
|
||||||
|
SSOAuthorizeURL = viper.GetString("SSO_AUTHORIZE_URL")
|
||||||
|
SSOTokenURL = viper.GetString("SSO_TOKEN_URL")
|
||||||
|
SSOGetMeURL = viper.GetString("SSO_GETME_URL")
|
||||||
|
SSOPortalURL = strings.TrimSpace(viper.GetString("SSO_PORTAL_URL"))
|
||||||
|
SSOAccessCookieName = defaultString(viper.GetString("SSO_ACCESS_COOKIE_NAME"), "sso_access")
|
||||||
|
SSOAccessCookieFallback = parseList("SSO_ACCESS_COOKIE_FALLBACK")
|
||||||
|
SSORefreshCookieName = defaultString(viper.GetString("SSO_REFRESH_COOKIE_NAME"), "sso_refresh")
|
||||||
|
SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN")
|
||||||
|
SSOCookieSecure = viper.GetBool("SSO_COOKIE_SECURE")
|
||||||
|
SSOCookieSameSite = defaultString(viper.GetString("SSO_COOKIE_SAMESITE"), "Lax")
|
||||||
|
SSOAccessTokenMaxBytes = viper.GetInt("SSO_ACCESS_TOKEN_MAX_BYTES")
|
||||||
|
if SSOAccessTokenMaxBytes <= 0 {
|
||||||
|
SSOAccessTokenMaxBytes = 4096
|
||||||
|
}
|
||||||
|
SSOTokenBlacklistPrefix = defaultString(viper.GetString("SSO_TOKEN_BLACKLIST_PREFIX"), "sso:blacklist")
|
||||||
|
if ttl := viper.GetInt("SSO_PKCE_TTL_SECONDS"); ttl > 0 {
|
||||||
|
SSOPKCETTL = time.Duration(ttl) * time.Second
|
||||||
|
} else {
|
||||||
|
SSOPKCETTL = 5 * time.Minute
|
||||||
|
}
|
||||||
|
SSOClients = loadSSOClients("SSO_CLIENTS")
|
||||||
|
if drift := viper.GetInt("SSO_USER_SYNC_SIGNATURE_DRIFT_SECONDS"); drift > 0 {
|
||||||
|
SSOUserSyncDrift = time.Duration(drift) * time.Second
|
||||||
|
} else {
|
||||||
|
SSOUserSyncDrift = 2 * time.Minute
|
||||||
|
}
|
||||||
|
if ttl := viper.GetInt("SSO_USER_SYNC_NONCE_TTL_SECONDS"); ttl > 0 {
|
||||||
|
SSOUserSyncNonceTTL = time.Duration(ttl) * time.Second
|
||||||
|
} else {
|
||||||
|
SSOUserSyncNonceTTL = 10 * time.Minute
|
||||||
|
}
|
||||||
|
SSOUserSyncMaxBodyBytes = viper.GetInt("SSO_USER_SYNC_MAX_BODY_BYTES")
|
||||||
|
if SSOUserSyncMaxBodyBytes <= 0 {
|
||||||
|
SSOUserSyncMaxBodyBytes = 32 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
if IsProd {
|
||||||
|
ensureProdConfig()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() {
|
func loadConfig() {
|
||||||
@@ -117,3 +231,81 @@ func parseListWithDefault(key, def string) []string {
|
|||||||
}
|
}
|
||||||
return parts
|
return parts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadSSOClients(key string) map[string]SSOClientConfig {
|
||||||
|
clients := make(map[string]SSOClientConfig)
|
||||||
|
raw := strings.TrimSpace(viper.GetString(key))
|
||||||
|
if raw == "" {
|
||||||
|
return clients
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &clients); err != nil {
|
||||||
|
utils.Log.Errorf("Failed to parse %s: %v", key, err)
|
||||||
|
return make(map[string]SSOClientConfig)
|
||||||
|
}
|
||||||
|
result := make(map[string]SSOClientConfig, len(clients))
|
||||||
|
for alias, cfg := range clients {
|
||||||
|
alias = strings.ToLower(strings.TrimSpace(alias))
|
||||||
|
for i, origin := range cfg.AllowedReturnOrigins {
|
||||||
|
cfg.AllowedReturnOrigins[i] = strings.TrimSpace(origin)
|
||||||
|
}
|
||||||
|
cfg.SyncSecret = strings.TrimSpace(cfg.SyncSecret)
|
||||||
|
result[alias] = cfg
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultString(v, def string) string {
|
||||||
|
if strings.TrimSpace(v) == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinPath(parts ...string) string {
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.Trim(part, "/")
|
||||||
|
if part != "" {
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(out, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureProdConfig() {
|
||||||
|
if SSOAuthorizeURL == "" || !strings.HasPrefix(SSOAuthorizeURL, "https://") {
|
||||||
|
panic("SSO_AUTHORIZE_URL must be https in production")
|
||||||
|
}
|
||||||
|
if SSOTokenURL == "" || !strings.HasPrefix(SSOTokenURL, "https://") {
|
||||||
|
panic("SSO_TOKEN_URL must be https in production")
|
||||||
|
}
|
||||||
|
if SSOGetMeURL == "" || !strings.HasPrefix(SSOGetMeURL, "https://") {
|
||||||
|
panic("SSO_GETME_URL must be https in production")
|
||||||
|
}
|
||||||
|
if !SSOCookieSecure {
|
||||||
|
panic("SSO_COOKIE_SECURE must be true in production")
|
||||||
|
}
|
||||||
|
if SSOCookieDomain == "" {
|
||||||
|
panic("SSO_COOKIE_DOMAIN must be configured in production")
|
||||||
|
}
|
||||||
|
if len(SSOAllowedAudiences) == 0 {
|
||||||
|
panic("SSO_ALLOWED_AUDIENCES must contain at least one audience in production")
|
||||||
|
}
|
||||||
|
for alias, cfg := range SSOClients {
|
||||||
|
if strings.TrimSpace(cfg.SyncSecret) == "" {
|
||||||
|
panic(fmt.Sprintf("SSO_CLIENTS[%s].sync_secret must be configured in production", alias))
|
||||||
|
}
|
||||||
|
if len(cfg.SyncSecret) < 16 {
|
||||||
|
panic(fmt.Sprintf("SSO_CLIENTS[%s].sync_secret must be at least 16 characters", alias))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if SSOUserSyncDrift <= 0 {
|
||||||
|
panic("SSO_USER_SYNC_SIGNATURE_DRIFT_SECONDS must be greater than zero in production")
|
||||||
|
}
|
||||||
|
if SSOUserSyncNonceTTL <= 0 {
|
||||||
|
panic("SSO_USER_SYNC_NONCE_TTL_SECONDS must be greater than zero in production")
|
||||||
|
}
|
||||||
|
if SSOUserSyncMaxBodyBytes <= 0 {
|
||||||
|
panic("SSO_USER_SYNC_MAX_BODY_BYTES must be greater than zero in production")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ func FiberConfig() fiber.Config {
|
|||||||
CaseSensitive: true,
|
CaseSensitive: true,
|
||||||
ServerHeader: "Fiber",
|
ServerHeader: "Fiber",
|
||||||
AppName: "Fiber API",
|
AppName: "Fiber API",
|
||||||
|
BodyLimit: 8 * 1024 * 1024,
|
||||||
ErrorHandler: utils.ErrorHandler,
|
ErrorHandler: utils.ErrorHandler,
|
||||||
JSONEncoder: sonic.Marshal,
|
JSONEncoder: sonic.Marshal,
|
||||||
JSONDecoder: sonic.Unmarshal,
|
JSONDecoder: sonic.Unmarshal,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||||
@@ -13,15 +14,30 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Connect(dbHost, dbName string) *gorm.DB {
|
func Connect(dbHost, dbName string) *gorm.DB {
|
||||||
dsn := fmt.Sprintf(
|
parts := []string{
|
||||||
"host=%s user=%s password=%s dbname=%s port=%d sslmode=disable TimeZone=Asia/Shanghai",
|
fmt.Sprintf("host=%s", dbHost),
|
||||||
dbHost, config.DBUser, config.DBPassword, dbName, config.DBPort,
|
fmt.Sprintf("user=%s", config.DBUser),
|
||||||
)
|
fmt.Sprintf("password=%s", config.DBPassword),
|
||||||
|
fmt.Sprintf("dbname=%s", dbName),
|
||||||
|
fmt.Sprintf("port=%d", config.DBPort),
|
||||||
|
fmt.Sprintf("sslmode=%s", config.DBSSLMode),
|
||||||
|
"TimeZone=Asia/Shanghai",
|
||||||
|
}
|
||||||
|
if config.DBSSLRootCert != "" {
|
||||||
|
parts = append(parts, fmt.Sprintf("sslrootcert=%s", config.DBSSLRootCert))
|
||||||
|
}
|
||||||
|
if config.DBSSLCert != "" {
|
||||||
|
parts = append(parts, fmt.Sprintf("sslcert=%s", config.DBSSLCert))
|
||||||
|
}
|
||||||
|
if config.DBSSLKey != "" {
|
||||||
|
parts = append(parts, fmt.Sprintf("sslkey=%s", config.DBSSLKey))
|
||||||
|
}
|
||||||
|
dsn := strings.Join(parts, " ")
|
||||||
|
|
||||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||||
Logger: logger.Default.LogMode(logger.Info),
|
Logger: logger.Default.LogMode(logger.Info),
|
||||||
SkipDefaultTransaction: true,
|
SkipDefaultTransaction: true,
|
||||||
PrepareStmt: true,
|
PrepareStmt: false,
|
||||||
TranslateError: true,
|
TranslateError: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
CREATE TABLE users (
|
CREATE TABLE users (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
id_user BIGINT NOT NULL,
|
id_user BIGINT NOT NULL,
|
||||||
name VARCHAR NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
email VARCHAR NOT NULL,
|
email VARCHAR(50) NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
deleted_at TIMESTAMPTZ
|
deleted_at TIMESTAMPTZ
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -20,28 +20,24 @@ WHERE
|
|||||||
-- FLAGS
|
-- FLAGS
|
||||||
CREATE TABLE flags (
|
CREATE TABLE flags (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
flagable_id BIGINT NOT NULL,
|
flagable_id BIGINT NOT NULL,
|
||||||
flagable_type VARCHAR(50) NOT NULL,
|
flagable_type VARCHAR(50) NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW ()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE UNIQUE INDEX flags_unique_flagable ON flags (
|
CREATE UNIQUE INDEX flags_unique_flagable ON flags (name, flagable_id, flagable_type);
|
||||||
name,
|
|
||||||
flagable_id,
|
|
||||||
flagable_type
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX flags_flagable_lookup ON flags (flagable_type, flagable_id);
|
CREATE INDEX flags_flagable_lookup ON flags (flagable_type, flagable_id);
|
||||||
|
|
||||||
-- PRODUCT CATEGORIES
|
-- PRODUCT CATEGORIES
|
||||||
CREATE TABLE product_categories (
|
CREATE TABLE product_categories (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
code VARCHAR(10) NOT NULL,
|
code VARCHAR(10) NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
);
|
);
|
||||||
@@ -57,9 +53,9 @@ WHERE
|
|||||||
-- UOM
|
-- UOM
|
||||||
CREATE TABLE uoms (
|
CREATE TABLE uoms (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
);
|
);
|
||||||
@@ -71,12 +67,12 @@ WHERE
|
|||||||
-- BANKS
|
-- BANKS
|
||||||
CREATE TABLE banks (
|
CREATE TABLE banks (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
alias VARCHAR(5) NOT NULL,
|
alias VARCHAR(5) NOT NULL,
|
||||||
owner VARCHAR,
|
owner VARCHAR(50),
|
||||||
account_number VARCHAR(50) NOT NULL,
|
account_number VARCHAR(50) NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
);
|
);
|
||||||
@@ -88,9 +84,9 @@ WHERE
|
|||||||
-- AREAS
|
-- AREAS
|
||||||
CREATE TABLE areas (
|
CREATE TABLE areas (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
);
|
);
|
||||||
@@ -102,11 +98,11 @@ WHERE
|
|||||||
-- LOCATIONS
|
-- LOCATIONS
|
||||||
CREATE TABLE locations (
|
CREATE TABLE locations (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
address TEXT NOT NULL,
|
address TEXT NOT NULL,
|
||||||
area_id BIGINT NOT NULL REFERENCES areas (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
area_id BIGINT NOT NULL REFERENCES areas (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
);
|
);
|
||||||
@@ -118,11 +114,11 @@ WHERE
|
|||||||
-- KANDANG
|
-- KANDANG
|
||||||
CREATE TABLE kandangs (
|
CREATE TABLE kandangs (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
location_id BIGINT NOT NULL REFERENCES locations (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
location_id BIGINT NOT NULL REFERENCES locations (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
pic_id BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
pic_id BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
);
|
);
|
||||||
@@ -134,13 +130,13 @@ WHERE
|
|||||||
-- WAREHOUSES
|
-- WAREHOUSES
|
||||||
CREATE TABLE warehouses (
|
CREATE TABLE warehouses (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
type VARCHAR(50) NOT NULL,
|
type VARCHAR(50) NOT NULL,
|
||||||
area_id BIGINT NOT NULL REFERENCES areas (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
area_id BIGINT NOT NULL REFERENCES areas (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
location_id BIGINT REFERENCES locations (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
location_id BIGINT REFERENCES locations (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
kandang_id BIGINT REFERENCES kandangs (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
kandang_id BIGINT REFERENCES kandangs (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
);
|
);
|
||||||
@@ -152,16 +148,16 @@ WHERE
|
|||||||
-- CUSTOMERS
|
-- CUSTOMERS
|
||||||
CREATE TABLE customers (
|
CREATE TABLE customers (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
pic_id BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
pic_id BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
type VARCHAR(50) NOT NULL,
|
type VARCHAR(50) NOT NULL,
|
||||||
address TEXT NOT NULL,
|
address TEXT NOT NULL,
|
||||||
phone VARCHAR(20) NOT NULL,
|
phone VARCHAR(20) NOT NULL,
|
||||||
email VARCHAR NOT NULL,
|
email VARCHAR(50) NOT NULL,
|
||||||
account_number VARCHAR(50) NOT NULL,
|
account_number VARCHAR(50) NOT NULL,
|
||||||
balance NUMERIC(15, 3) DEFAULT 0,
|
balance NUMERIC(15, 3) DEFAULT 0,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
);
|
);
|
||||||
@@ -173,10 +169,10 @@ WHERE
|
|||||||
-- NONSTOCK
|
-- NONSTOCK
|
||||||
CREATE TABLE nonstocks (
|
CREATE TABLE nonstocks (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
uom_id BIGINT NOT NULL REFERENCES uoms (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
uom_id BIGINT NOT NULL REFERENCES uoms (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
);
|
);
|
||||||
@@ -188,9 +184,9 @@ WHERE
|
|||||||
-- FCR
|
-- FCR
|
||||||
CREATE TABLE fcrs (
|
CREATE TABLE fcrs (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
);
|
);
|
||||||
@@ -205,29 +201,29 @@ CREATE TABLE fcr_standards (
|
|||||||
weight NUMERIC(15, 3) NOT NULL,
|
weight NUMERIC(15, 3) NOT NULL,
|
||||||
fcr_number NUMERIC(15, 3) NOT NULL,
|
fcr_number NUMERIC(15, 3) NOT NULL,
|
||||||
mortality NUMERIC(15, 3) NOT NULL,
|
mortality NUMERIC(15, 3) NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
deleted_at TIMESTAMPTZ
|
deleted_at TIMESTAMPTZ
|
||||||
);
|
);
|
||||||
|
|
||||||
-- SUPPLIERS
|
-- SUPPLIERS
|
||||||
CREATE TABLE suppliers (
|
CREATE TABLE suppliers (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
alias VARCHAR(5) NOT NULL,
|
alias VARCHAR(5) NOT NULL,
|
||||||
pic VARCHAR NOT NULL,
|
pic VARCHAR(50) NOT NULL,
|
||||||
type VARCHAR(50) NOT NULL,
|
type VARCHAR(50) NOT NULL,
|
||||||
category VARCHAR(20) NOT NULL,
|
category VARCHAR(20) NOT NULL,
|
||||||
hatchery VARCHAR,
|
hatchery VARCHAR(50),
|
||||||
phone VARCHAR(20) NOT NULL,
|
phone VARCHAR(20) NOT NULL,
|
||||||
email VARCHAR NOT NULL,
|
email VARCHAR(50) NOT NULL,
|
||||||
address TEXT NOT NULL,
|
address TEXT NOT NULL,
|
||||||
npwp VARCHAR(50),
|
npwp VARCHAR(50),
|
||||||
account_number VARCHAR(50),
|
account_number VARCHAR(50),
|
||||||
balance NUMERIC(15, 3) DEFAULT 0,
|
balance NUMERIC(15, 3) DEFAULT 0,
|
||||||
due_date INT NOT NULL,
|
due_date INT NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
);
|
);
|
||||||
@@ -239,15 +235,15 @@ WHERE
|
|||||||
CREATE TABLE nonstock_suppliers (
|
CREATE TABLE nonstock_suppliers (
|
||||||
nonstock_id BIGINT NOT NULL REFERENCES nonstocks (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
nonstock_id BIGINT NOT NULL REFERENCES nonstocks (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
supplier_id BIGINT NOT NULL REFERENCES suppliers (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
supplier_id BIGINT NOT NULL REFERENCES suppliers (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
PRIMARY KEY (nonstock_id, supplier_id)
|
PRIMARY KEY (nonstock_id, supplier_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- PRODUCTS
|
-- PRODUCTS
|
||||||
CREATE TABLE products (
|
CREATE TABLE products (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
brand VARCHAR NOT NULL,
|
brand VARCHAR(50) NOT NULL,
|
||||||
sku VARCHAR(100),
|
sku VARCHAR(100),
|
||||||
uom_id BIGINT NOT NULL REFERENCES uoms (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
uom_id BIGINT NOT NULL REFERENCES uoms (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
product_category_id BIGINT NOT NULL REFERENCES product_categories (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
product_category_id BIGINT NOT NULL REFERENCES product_categories (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
@@ -255,8 +251,8 @@ CREATE TABLE products (
|
|||||||
selling_price NUMERIC(15, 3),
|
selling_price NUMERIC(15, 3),
|
||||||
tax NUMERIC(15, 3),
|
tax NUMERIC(15, 3),
|
||||||
expiry_period INT,
|
expiry_period INT,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
);
|
);
|
||||||
@@ -272,15 +268,15 @@ WHERE
|
|||||||
CREATE TABLE product_suppliers (
|
CREATE TABLE product_suppliers (
|
||||||
product_id BIGINT NOT NULL REFERENCES products (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
product_id BIGINT NOT NULL REFERENCES products (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
supplier_id BIGINT NOT NULL REFERENCES suppliers (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
supplier_id BIGINT NOT NULL REFERENCES suppliers (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
PRIMARY KEY (product_id, supplier_id)
|
PRIMARY KEY (product_id, supplier_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- PROJECTS
|
-- PROJECTS
|
||||||
CREATE TABLE projects (
|
CREATE TABLE projects (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
);
|
);
|
||||||
@@ -292,8 +288,8 @@ CREATE TABLE product_warehouses (
|
|||||||
warehouse_id BIGINT NOT NULL REFERENCES warehouses (id),
|
warehouse_id BIGINT NOT NULL REFERENCES warehouses (id),
|
||||||
quantity INTEGER NOT NULL DEFAULT 0,
|
quantity INTEGER NOT NULL DEFAULT 0,
|
||||||
created_by BIGINT NOT NULL REFERENCES users (id),
|
created_by BIGINT NOT NULL REFERENCES users (id),
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
deleted_at TIMESTAMPTZ
|
deleted_at TIMESTAMPTZ
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -320,8 +316,8 @@ CREATE TABLE stock_logs (
|
|||||||
note TEXT,
|
note TEXT,
|
||||||
product_warehouse_id BIGINT NOT NULL REFERENCES product_warehouses (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
product_warehouse_id BIGINT NOT NULL REFERENCES product_warehouses (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
created_by BIGINT NOT NULL REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
created_by BIGINT NOT NULL REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
deleted_at TIMESTAMPTZ
|
deleted_at TIMESTAMPTZ
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
DROP CONSTRAINT IF EXISTS users_id_user_key;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD CONSTRAINT users_id_user_key UNIQUE (id_user);
|
||||||
@@ -29,7 +29,7 @@ ADD CONSTRAINT fk_project_chickins_kandang FOREIGN KEY (project_flock_kandang_id
|
|||||||
|
|
||||||
-- Relasi ke product_warehouses
|
-- Relasi ke product_warehouses
|
||||||
ALTER TABLE project_chickins
|
ALTER TABLE project_chickins
|
||||||
ADD CONSTRAINT fk_project_chickins_warehouse FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses (id) ON DELETE RESTRICT ON UPDATE CASCADE;
|
ADD CONSTRAINT fk_project_chickins_warehouse FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses (id) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- Relasi ke users
|
-- Relasi ke users
|
||||||
ALTER TABLE project_chickins
|
ALTER TABLE project_chickins
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS purchase_items;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS purchase_items (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
purchase_id BIGINT NOT NULL,
|
||||||
|
product_id BIGINT NOT NULL,
|
||||||
|
warehouse_id BIGINT NOT NULL,
|
||||||
|
product_warehouse_id BIGINT,
|
||||||
|
received_date TIMESTAMPTZ,
|
||||||
|
travel_number VARCHAR,
|
||||||
|
travel_number_docs VARCHAR,
|
||||||
|
vehicle_number VARCHAR,
|
||||||
|
sub_qty NUMERIC(15, 3) NOT NULL,
|
||||||
|
total_qty NUMERIC(15, 3) NOT NULL DEFAULT 0,
|
||||||
|
total_used NUMERIC(15, 3) NOT NULL DEFAULT 0,
|
||||||
|
price NUMERIC(15, 3) NOT NULL DEFAULT 0,
|
||||||
|
total_price NUMERIC(15, 3) NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT uq_purchase_items_purchase_product_warehouse
|
||||||
|
UNIQUE (purchase_id, product_id, warehouse_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'products') THEN
|
||||||
|
EXECUTE
|
||||||
|
'ALTER TABLE purchase_items
|
||||||
|
ADD CONSTRAINT fk_purchase_items_product
|
||||||
|
FOREIGN KEY (product_id)
|
||||||
|
REFERENCES products(id)
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'warehouses') THEN
|
||||||
|
EXECUTE
|
||||||
|
'ALTER TABLE purchase_items
|
||||||
|
ADD CONSTRAINT fk_purchase_items_warehouse
|
||||||
|
FOREIGN KEY (warehouse_id)
|
||||||
|
REFERENCES warehouses(id)
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN
|
||||||
|
EXECUTE
|
||||||
|
'ALTER TABLE purchase_items
|
||||||
|
ADD CONSTRAINT fk_purchase_items_product_warehouse
|
||||||
|
FOREIGN KEY (product_warehouse_id)
|
||||||
|
REFERENCES product_warehouses(id)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_items_product_id ON purchase_items (product_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_items_warehouse_id ON purchase_items (warehouse_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_items_product_warehouse_id ON purchase_items (product_warehouse_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_items_purchase_id ON purchase_items (purchase_id);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'fk_purchase_items_purchase'
|
||||||
|
AND conrelid = 'purchase_items'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE purchase_items
|
||||||
|
DROP CONSTRAINT fk_purchase_items_purchase;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS purchases;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS purchases (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
pr_number VARCHAR NOT NULL,
|
||||||
|
po_number VARCHAR NULL,
|
||||||
|
po_date TIMESTAMPTZ NULL,
|
||||||
|
supplier_id BIGINT NOT NULL,
|
||||||
|
credit_term INT NOT NULL,
|
||||||
|
due_date TIMESTAMPTZ,
|
||||||
|
grand_total NUMERIC(15, 3) NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_by BIGINT NOT NULL,
|
||||||
|
CONSTRAINT uq_purchases_pr_number UNIQUE (pr_number),
|
||||||
|
CONSTRAINT uq_purchases_po_number UNIQUE (po_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'suppliers') THEN
|
||||||
|
EXECUTE
|
||||||
|
'ALTER TABLE purchases
|
||||||
|
ADD CONSTRAINT fk_purchases_supplier
|
||||||
|
FOREIGN KEY (supplier_id)
|
||||||
|
REFERENCES suppliers(id)
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN
|
||||||
|
EXECUTE
|
||||||
|
'ALTER TABLE purchases
|
||||||
|
ADD CONSTRAINT fk_purchases_created_by
|
||||||
|
FOREIGN KEY (created_by)
|
||||||
|
REFERENCES users(id)
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_tables WHERE tablename = 'purchase_items'
|
||||||
|
) AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'fk_purchase_items_purchase'
|
||||||
|
) THEN
|
||||||
|
EXECUTE
|
||||||
|
'ALTER TABLE purchase_items
|
||||||
|
ADD CONSTRAINT fk_purchase_items_purchase
|
||||||
|
FOREIGN KEY (purchase_id)
|
||||||
|
REFERENCES purchases(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchases_supplier_id ON purchases (supplier_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchases_created_by ON purchases (created_by);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchases_po_date ON purchases (po_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchases_deleted_at ON purchases (deleted_at);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
DROP TABLE IF EXISTS marketing_delivery_products CASCADE;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS marketing_products CASCADE;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS marketings CASCADE;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
CREATE TABLE marketings (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
so_number VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
customer_id BIGINT NOT NULL,
|
||||||
|
so_docs VARCHAR(20),
|
||||||
|
so_date DATE NOT NULL,
|
||||||
|
sales_person_id BIGINT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
created_by BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'customers') THEN
|
||||||
|
ALTER TABLE marketings
|
||||||
|
ADD CONSTRAINT fk_marketings_customer_id
|
||||||
|
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE RESTRICT;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN
|
||||||
|
ALTER TABLE marketings
|
||||||
|
ADD CONSTRAINT fk_marketings_sales_person_id
|
||||||
|
FOREIGN KEY (sales_person_id) REFERENCES users(id) ON DELETE RESTRICT;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN
|
||||||
|
ALTER TABLE marketings
|
||||||
|
ADD CONSTRAINT fk_marketings_created_by
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX idx_marketings_customer_id ON marketings (customer_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_marketings_sales_person_id ON marketings (sales_person_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_marketings_created_by ON marketings (created_by);
|
||||||
|
|
||||||
|
CREATE INDEX idx_marketings_so_date ON marketings (so_date);
|
||||||
|
|
||||||
|
CREATE INDEX idx_marketings_deleted_at ON marketings (deleted_at);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS marketing_products CASCADE;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
CREATE TABLE marketing_products (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
marketing_id BIGINT NOT NULL,
|
||||||
|
product_warehouse_id BIGINT NOT NULL,
|
||||||
|
qty NUMERIC(15, 3) NOT NULL,
|
||||||
|
unit_price NUMERIC(15, 3) NOT NULL,
|
||||||
|
avg_weight NUMERIC(15, 3) NOT NULL,
|
||||||
|
total_weight NUMERIC(15, 3) NOT NULL,
|
||||||
|
total_price NUMERIC(15, 3) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'marketings') THEN
|
||||||
|
ALTER TABLE marketing_products
|
||||||
|
ADD CONSTRAINT fk_marketing_products_marketing_id
|
||||||
|
FOREIGN KEY (marketing_id) REFERENCES marketings(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN
|
||||||
|
ALTER TABLE marketing_products
|
||||||
|
ADD CONSTRAINT fk_marketing_products_product_warehouse_id
|
||||||
|
FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id) ON DELETE RESTRICT;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX idx_marketing_products_marketing_id ON marketing_products (marketing_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_marketing_products_product_warehouse_id ON marketing_products (product_warehouse_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_marketing_products_deleted_at ON marketing_products (deleted_at);
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
DROP TABLE IF EXISTS marketing_delivery_products CASCADE;
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
CREATE TABLE marketing_delivery_products (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
marketing_product_id BIGINT UNIQUE NOT NULL,
|
||||||
|
qty NUMERIC(15, 3) NOT NULL,
|
||||||
|
unit_price NUMERIC(15, 3) NOT NULL,
|
||||||
|
total_weight NUMERIC(15, 3) NOT NULL,
|
||||||
|
avg_weight NUMERIC(15, 3) NOT NULL,
|
||||||
|
total_price NUMERIC(15, 3) NOT NULL,
|
||||||
|
delivery_date DATE,
|
||||||
|
vehicle_number VARCHAR(50),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'marketing_products') THEN
|
||||||
|
ALTER TABLE marketing_delivery_products
|
||||||
|
ADD CONSTRAINT fk_marketing_delivery_products_marketing_product_id
|
||||||
|
FOREIGN KEY (marketing_product_id) REFERENCES marketing_products(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX idx_marketing_delivery_products_marketing_product_id ON marketing_delivery_products (marketing_product_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_marketing_delivery_products_delivery_date ON marketing_delivery_products (delivery_date);
|
||||||
|
|
||||||
|
CREATE INDEX idx_marketing_delivery_products_deleted_at ON marketing_delivery_products (deleted_at);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
DROP INDEX IF EXISTS stock_allocations_released_at_idx;
|
||||||
|
DROP INDEX IF EXISTS stock_allocations_status_idx;
|
||||||
|
DROP INDEX IF EXISTS stock_allocations_usage_lookup;
|
||||||
|
DROP INDEX IF EXISTS stock_allocations_lookup;
|
||||||
|
DROP INDEX IF EXISTS stock_allocations_product_warehouse_id_idx;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS stock_allocations;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS stock_allocations (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
product_warehouse_id BIGINT NOT NULL REFERENCES product_warehouses(id),
|
||||||
|
stockable_type VARCHAR(100) NOT NULL,
|
||||||
|
stockable_id BIGINT NOT NULL,
|
||||||
|
usable_type VARCHAR(100) NOT NULL,
|
||||||
|
usable_id BIGINT NOT NULL,
|
||||||
|
qty NUMERIC(15,3) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
note TEXT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
released_at TIMESTAMPTZ NULL,
|
||||||
|
deleted_at TIMESTAMPTZ NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS stock_allocations_product_warehouse_id_idx
|
||||||
|
ON stock_allocations (product_warehouse_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS stock_allocations_lookup
|
||||||
|
ON stock_allocations (stockable_type, stockable_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS stock_allocations_usage_lookup
|
||||||
|
ON stock_allocations (usable_type, usable_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS stock_allocations_status_idx
|
||||||
|
ON stock_allocations (status);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS stock_allocations_released_at_idx
|
||||||
|
ON stock_allocations (released_at);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE kandangs
|
||||||
|
DROP COLUMN IF EXISTS capacity;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE kandangs
|
||||||
|
ADD COLUMN capacity NUMERIC(15,3) NOT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP SEQUENCE IF EXISTS expenses_ref_seq;
|
||||||
|
DROP TABLE IF EXISTS expenses;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
CREATE TABLE expenses (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
reference_number VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
supplier_id BIGINT NOT NULL,
|
||||||
|
category VARCHAR(50) NOT NULL CHECK (
|
||||||
|
category IN ('BOP', 'NON-BOP')
|
||||||
|
),
|
||||||
|
po_number VARCHAR(50) NULL,
|
||||||
|
document_path JSON,
|
||||||
|
realization_document_path JSON,
|
||||||
|
expense_date DATE NOT NULL,
|
||||||
|
realization_date DATE,
|
||||||
|
grand_total NUMERIC(15, 3) DEFAULT 0,
|
||||||
|
note TEXT,
|
||||||
|
created_by BIGINT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE SEQUENCE expenses_ref_seq INCREMENT BY 1 START WITH 1;
|
||||||
|
|
||||||
|
-- Tambahkan Foreign Key ke suppliers
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'suppliers') THEN
|
||||||
|
ALTER TABLE expenses
|
||||||
|
ADD CONSTRAINT fk_expenses_supplier_id
|
||||||
|
FOREIGN KEY (supplier_id) REFERENCES suppliers(id);
|
||||||
|
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Tambahkan Foreign Key ke users (created_by)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN
|
||||||
|
ALTER TABLE expenses
|
||||||
|
ADD CONSTRAINT fk_expenses_created_by
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Index
|
||||||
|
CREATE INDEX idx_expenses_supplier_id ON expenses (supplier_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_expenses_expense_date ON expenses (expense_date);
|
||||||
|
|
||||||
|
CREATE INDEX idx_expenses_deleted_at ON expenses (deleted_at);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE IF EXISTS expense_nonstocks;
|
||||||
|
|
||||||
|
DROP SEQUENCE expenses_ref_seq;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
CREATE TABLE expense_nonstocks (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
expense_id BIGINT NOT NULL,
|
||||||
|
project_flock_kandang_id BIGINT NULL,
|
||||||
|
kandang_id BIGINT NULL,
|
||||||
|
nonstock_id BIGINT,
|
||||||
|
qty NUMERIC(15, 3) NOT NULL,
|
||||||
|
unit_price NUMERIC(15, 3) NOT NULL,
|
||||||
|
total_price NUMERIC(15, 3) NOT NULL,
|
||||||
|
note TEXT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tambahkan Foreign Key ke expenses
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'expenses') THEN
|
||||||
|
ALTER TABLE expense_nonstocks
|
||||||
|
ADD CONSTRAINT fk_expense_nonstocks_expense_id
|
||||||
|
FOREIGN KEY (expense_id) REFERENCES expenses(id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Tambahkan Foreign Key ke project_flock_kandangs
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') THEN
|
||||||
|
ALTER TABLE expense_nonstocks
|
||||||
|
ADD CONSTRAINT fk_expense_nonstocks_kandang_id
|
||||||
|
FOREIGN KEY (project_flock_kandang_id) REFERENCES project_flock_kandangs(id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Tambahkan Foreign key ke kandang_id
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'kandangs') THEN
|
||||||
|
ALTER TABLE expense_nonstocks
|
||||||
|
ADD CONSTRAINT fk_expense_nonstocks_kandang_id_2
|
||||||
|
FOREIGN KEY (kandang_id) REFERENCES kandangs(id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Tambahkan Foreign Key ke nonstocks
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'nonstocks') THEN
|
||||||
|
ALTER TABLE expense_nonstocks
|
||||||
|
ADD CONSTRAINT fk_expense_nonstocks_nonstock_id
|
||||||
|
FOREIGN KEY (nonstock_id) REFERENCES nonstocks(id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Index
|
||||||
|
CREATE INDEX idx_expense_nonstocks_expense_id ON expense_nonstocks (expense_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_expense_nonstocks_nonstock_id ON expense_nonstocks (nonstock_id);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS expense_realizations;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
CREATE TABLE expense_realizations (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
expense_nonstock_id BIGINT UNIQUE,
|
||||||
|
realization_qty NUMERIC(15, 3) NOT NULL,
|
||||||
|
realization_unit_price NUMERIC(15, 3) NOT NULL,
|
||||||
|
realization_total_price NUMERIC(15, 3) NOT NULL,
|
||||||
|
realization_date DATE NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
created_by BIGINT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tambahkan Foreign Key ke expense_nonstocks
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'expense_nonstocks') THEN
|
||||||
|
ALTER TABLE expense_realizations
|
||||||
|
ADD CONSTRAINT fk_expense_realizations_nonstock_id
|
||||||
|
FOREIGN KEY (expense_nonstock_id) REFERENCES expense_nonstocks(id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Tambahkan Foreign Key ke users (created_by)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN
|
||||||
|
ALTER TABLE expense_realizations
|
||||||
|
ADD CONSTRAINT fk_expense_realizations_created_by
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Index
|
||||||
|
CREATE INDEX idx_expense_realizations_nonstock_id ON expense_realizations (expense_nonstock_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_expense_realizations_date ON expense_realizations (realization_date);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE project_flock_kandangs
|
||||||
|
DROP COLUMN IF EXISTS period;
|
||||||
|
|
||||||
|
ALTER TABLE project_flocks
|
||||||
|
ADD COLUMN IF NOT EXISTS period INT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS project_flocks_base_period_unique
|
||||||
|
ON project_flocks (
|
||||||
|
LOWER(TRIM(regexp_replace(flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g'))),
|
||||||
|
period
|
||||||
|
)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE project_flock_kandangs
|
||||||
|
ADD COLUMN IF NOT EXISTS period INT;
|
||||||
|
|
||||||
|
UPDATE project_flock_kandangs pfk
|
||||||
|
SET period = pf.period
|
||||||
|
FROM project_flocks pf
|
||||||
|
WHERE pfk.project_flock_id = pf.id
|
||||||
|
AND (pfk.period IS NULL OR pfk.period = 0)
|
||||||
|
AND pf.period IS NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE project_flock_kandangs
|
||||||
|
ALTER COLUMN period SET DEFAULT 0;
|
||||||
|
|
||||||
|
UPDATE project_flock_kandangs
|
||||||
|
SET period = 0
|
||||||
|
WHERE period IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE project_flock_kandangs
|
||||||
|
ALTER COLUMN period SET NOT NULL;
|
||||||
|
|
||||||
|
-- Drop period from project_flocks as the source of truth
|
||||||
|
DROP INDEX IF EXISTS project_flocks_base_period_unique;
|
||||||
|
|
||||||
|
ALTER TABLE project_flocks
|
||||||
|
DROP COLUMN IF EXISTS period;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
-- Add back timestamp columns to marketing_products table
|
||||||
|
ALTER TABLE marketing_products
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- Add back timestamp columns to marketing_delivery_products table
|
||||||
|
ALTER TABLE marketing_delivery_products
|
||||||
|
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;
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
-- Drop timestamp columns from marketing_products table if it exists
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'marketing_products' AND column_name = 'created_at') THEN
|
||||||
|
ALTER TABLE marketing_products DROP COLUMN created_at;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'marketing_products' AND column_name = 'updated_at') THEN
|
||||||
|
ALTER TABLE marketing_products DROP COLUMN updated_at;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'marketing_products' AND column_name = 'deleted_at') THEN
|
||||||
|
ALTER TABLE marketing_products DROP COLUMN deleted_at;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Drop timestamp columns from marketing_delivery_products table if it exists
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'marketing_delivery_products' AND column_name = 'created_at') THEN
|
||||||
|
ALTER TABLE marketing_delivery_products DROP COLUMN created_at;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'marketing_delivery_products' AND column_name = 'updated_at') THEN
|
||||||
|
ALTER TABLE marketing_delivery_products DROP COLUMN updated_at;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'marketing_delivery_products' AND column_name = 'deleted_at') THEN
|
||||||
|
ALTER TABLE marketing_delivery_products DROP COLUMN deleted_at;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
-- ============================
|
||||||
|
-- EXPENSES
|
||||||
|
-- ============================
|
||||||
|
ALTER TABLE expenses DROP COLUMN IF EXISTS grand_total;
|
||||||
|
|
||||||
|
ALTER TABLE expenses RENAME COLUMN note TO notes;
|
||||||
|
|
||||||
|
ALTER TABLE expenses RENAME COLUMN expense_date TO transaction_date;
|
||||||
|
|
||||||
|
-- ============================
|
||||||
|
-- EXPENSE_REALIZATIONS
|
||||||
|
-- ============================
|
||||||
|
ALTER TABLE expense_realizations
|
||||||
|
RENAME COLUMN realization_qty TO qty;
|
||||||
|
|
||||||
|
ALTER TABLE expense_realizations
|
||||||
|
RENAME COLUMN realization_unit_price TO price;
|
||||||
|
|
||||||
|
ALTER TABLE expense_realizations RENAME COLUMN note TO notes;
|
||||||
|
|
||||||
|
ALTER TABLE expense_realizations
|
||||||
|
DROP COLUMN IF EXISTS realization_total_price;
|
||||||
|
|
||||||
|
ALTER TABLE expense_realizations
|
||||||
|
DROP COLUMN IF EXISTS realization_date;
|
||||||
|
|
||||||
|
ALTER TABLE expense_realizations DROP COLUMN IF EXISTS created_by;
|
||||||
|
|
||||||
|
ALTER TABLE expense_realizations
|
||||||
|
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW();
|
||||||
|
|
||||||
|
-- ============================
|
||||||
|
-- EXPENSE_NONSTOCKS
|
||||||
|
-- ============================
|
||||||
|
ALTER TABLE expense_nonstocks RENAME COLUMN note TO notes;
|
||||||
|
|
||||||
|
ALTER TABLE expense_nonstocks DROP COLUMN IF EXISTS total_price;
|
||||||
|
|
||||||
|
ALTER TABLE expense_nonstocks RENAME COLUMN unit_price TO price;
|
||||||
|
|
||||||
|
ALTER TABLE expense_nonstocks DROP COLUMN IF EXISTS created_by;
|
||||||
|
|
||||||
|
ALTER TABLE expense_nonstocks
|
||||||
|
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW();
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP Table IF EXISTS project_budgets;
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
CREATE TABLE project_budgets (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
project_flock_id BIGINT NOT NULL,
|
||||||
|
nonstock_id BIGINT NOT NULL,
|
||||||
|
qty NUMERIC(15, 3) NOT NULL,
|
||||||
|
price NUMERIC(15, 3) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tambahkan Foreign Key ke project_flocks
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flocks') THEN
|
||||||
|
ALTER TABLE project_budgets
|
||||||
|
ADD CONSTRAINT fk_project_budgets_project_flock_id
|
||||||
|
FOREIGN KEY (project_flock_id) REFERENCES project_flocks(id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
-- Tambahkan Foreign Key ke nonstocks
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'nonstocks') THEN
|
||||||
|
ALTER TABLE project_budgets
|
||||||
|
ADD CONSTRAINT fk_project_budgets_nonstock_id
|
||||||
|
FOREIGN KEY (nonstock_id) REFERENCES nonstocks(id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
-- Index
|
||||||
|
CREATE INDEX idx_project_budgets_project_flock_id ON project_budgets (project_flock_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_project_budgets_nonstock_id ON project_budgets (nonstock_id);
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_project_flock_kandangs_closed_at;
|
||||||
|
ALTER TABLE project_flock_kandangs
|
||||||
|
DROP COLUMN IF EXISTS closed_at;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE project_flock_kandangs
|
||||||
|
ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_project_flock_kandangs_closed_at
|
||||||
|
ON project_flock_kandangs (closed_at);
|
||||||
@@ -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);
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Drop new indexes and FK
|
||||||
|
DROP INDEX IF EXISTS idx_product_warehouses_project_flock_kandang_id;
|
||||||
|
DROP INDEX IF EXISTS idx_product_warehouses_unique;
|
||||||
|
|
||||||
|
ALTER TABLE product_warehouses
|
||||||
|
DROP CONSTRAINT IF EXISTS fk_product_warehouses_project_flock_kandang_id,
|
||||||
|
ALTER COLUMN project_flock_kandang_id DROP NOT NULL,
|
||||||
|
DROP COLUMN IF EXISTS project_flock_kandang_id;
|
||||||
|
|
||||||
|
-- Revert qty to integer quantity
|
||||||
|
ALTER TABLE product_warehouses
|
||||||
|
RENAME COLUMN qty TO quantity;
|
||||||
|
|
||||||
|
ALTER TABLE product_warehouses
|
||||||
|
ALTER COLUMN quantity TYPE INTEGER USING quantity::integer,
|
||||||
|
ALTER COLUMN quantity SET DEFAULT 0,
|
||||||
|
ALTER COLUMN quantity SET NOT NULL;
|
||||||
|
|
||||||
|
-- Restore audit/soft-delete columns
|
||||||
|
ALTER TABLE product_warehouses
|
||||||
|
ADD COLUMN IF NOT EXISTS created_by BIGINT REFERENCES users (id),
|
||||||
|
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Recreate prior indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_product_warehouses_deleted_at ON product_warehouses (deleted_at);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_product_warehouses_unique
|
||||||
|
ON product_warehouses (product_id, warehouse_id)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
+41
@@ -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;
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Remove grading details from recording_eggs
|
||||||
|
ALTER TABLE recording_eggs
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_recording_eggs_qty;
|
||||||
|
|
||||||
|
ALTER TABLE recording_eggs
|
||||||
|
DROP COLUMN IF EXISTS weight;
|
||||||
|
|
||||||
|
ALTER TABLE recording_eggs
|
||||||
|
ADD CONSTRAINT chk_recording_eggs_qty CHECK (qty >= 0);
|
||||||
|
|
||||||
|
-- Restore grading_eggs table for rollback scenarios
|
||||||
|
CREATE TABLE grading_eggs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
recording_egg_id BIGINT NOT NULL,
|
||||||
|
qty NUMERIC(15,3) NOT NULL,
|
||||||
|
grade VARCHAR,
|
||||||
|
created_by BIGINT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT fk_grading_eggs_recording_egg
|
||||||
|
FOREIGN KEY (recording_egg_id) REFERENCES recording_eggs(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_grading_eggs_created_by
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id),
|
||||||
|
CONSTRAINT chk_grading_eggs_qty CHECK (qty >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_grading_eggs_recording_egg
|
||||||
|
ON grading_eggs (recording_egg_id);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Remove separate grading table and move grading details into recording_eggs
|
||||||
|
DROP INDEX IF EXISTS idx_grading_eggs_recording_egg;
|
||||||
|
DROP TABLE IF EXISTS grading_eggs;
|
||||||
|
|
||||||
|
ALTER TABLE recording_eggs
|
||||||
|
ADD COLUMN IF NOT EXISTS weight NUMERIC(10,3);
|
||||||
|
|
||||||
|
ALTER TABLE recording_eggs
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_recording_eggs_qty;
|
||||||
|
|
||||||
|
ALTER TABLE recording_eggs
|
||||||
|
ADD CONSTRAINT chk_recording_eggs_qty CHECK (
|
||||||
|
qty >= 0 AND (weight IS NULL OR weight >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Drop function and sequence for sales order numbers
|
||||||
|
DROP SEQUENCE IF EXISTS so_number_seq;
|
||||||
|
DROP FUNCTION IF EXISTS generate_so_number();
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user