mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-22 14:25:45 +00:00
Compare commits
1270 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c08fadb7a | |||
| cae7f3ef63 | |||
| fbe0634d46 | |||
| bca02800d6 | |||
| 42793d94bd | |||
| 45e430f01d | |||
| cff5837ff9 | |||
| 5e2187c46b | |||
| d1612e5c65 | |||
| 30a47ffc71 | |||
| b79738dbe1 | |||
| 3eb225cca8 | |||
| 1369bf0e36 | |||
| a9a84539eb | |||
| 3702d41954 | |||
| ffd96105ce | |||
| 3d75251c96 | |||
| 361d14bd3e | |||
| 7c848bc50d | |||
| ddcf13e2ff | |||
| e8c33f818b | |||
| 3daed7e248 | |||
| cde4647b15 | |||
| abc0ac8258 | |||
| aad4f7dc28 | |||
| bfe7b5129f | |||
| a6995f8e18 | |||
| 450d1e8cee | |||
| b58e9a10b1 | |||
| aa9863646e | |||
| 2a3154042c | |||
| 80f190b69b | |||
| 7923352535 | |||
| 079ae01b94 | |||
| ee7fa71139 | |||
| f2827d5352 | |||
| 18cf180982 | |||
| 010240066a | |||
| fc0b45b433 | |||
| 4d85d6f320 | |||
| e3cfb2648b | |||
| ba8b512293 | |||
| 7bd9ec9ef8 | |||
| 037f9fc71b | |||
| 8fa41e379d | |||
| 7d9c752432 | |||
| 6342a28f09 | |||
| 945b6aba0a | |||
| 23e49a00e4 | |||
| 480c899f6a | |||
| ba4a5324ed | |||
| 4899cee98f | |||
| 2a39342d55 | |||
| f29f09d7b9 | |||
| 4254cbf576 | |||
| 34a3fc44a8 | |||
| fdfc5e069d | |||
| 6880010424 | |||
| 9cc9146641 | |||
| 8be4b54127 | |||
| 9ce69ddeb0 | |||
| 7b4bf94329 | |||
| 524dc385ff | |||
| 5ffb72507b | |||
| 0bf9844efc | |||
| c4add1501d | |||
| f81e2f7c01 | |||
| 030284a9b5 | |||
| a55aa873a6 | |||
| be00837148 | |||
| 434ae2f246 | |||
| b6f369a5ec | |||
| 63cf0c6fac | |||
| c48f0411d3 | |||
| 6510bccc76 | |||
| d226d5f7f3 | |||
| f40c643876 | |||
| 325825a709 | |||
| 3b3ee8b796 | |||
| 18cb116a51 | |||
| bf4aa5ccea | |||
| 2713210bcc | |||
| 1ab1909998 | |||
| d76f72050e | |||
| 41c910677f | |||
| 5d1eb60fb2 | |||
| c4c414aa94 | |||
| c9dee7d1c4 | |||
| 131949874a | |||
| d0f3392738 | |||
| b2e70fa6eb | |||
| 5ba10113c3 | |||
| 29956528e5 | |||
| 9dcccabc6a | |||
| 333cb9e136 | |||
| 5c36ef79cb | |||
| da0ec225f1 | |||
| a1b1841695 | |||
| cb9c202b29 | |||
| ec7ec2219c | |||
| b8164e2e08 | |||
| 3c43426fbc | |||
| 131aa4fcb5 | |||
| c1acee7980 | |||
| 810aab1448 | |||
| 3a8cc47fa0 | |||
| b93150e8df | |||
| fc532e1202 | |||
| 71c7207bdb | |||
| 96bef248cb | |||
| 1dc1feed03 | |||
| 5ebdc9dfe4 | |||
| 65962f8e2b | |||
| 17ed266131 | |||
| c9fb4077a6 | |||
| 26e782b212 | |||
| 1fc6cb051d | |||
| e6159e4f90 | |||
| e80874bad7 | |||
| c5a27ef3a6 | |||
| 45cc057dd4 | |||
| 566567e328 | |||
| 3cb2e15629 | |||
| 1c5b013b9f | |||
| 83650d4486 | |||
| 970332f0be | |||
| b8fa79a2ab | |||
| b1c829bbf8 | |||
| f8ca404bbb | |||
| fca96df3d9 | |||
| 073b098843 | |||
| 650f8e0fdb | |||
| 7c6a401ac2 | |||
| 345fe32433 | |||
| 7f8013c5ed | |||
| ec17633b84 | |||
| 73dfeb64b0 | |||
| ee216327bb | |||
| b2f235dcde | |||
| 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 | |||
| 0bcda8ad82 | |||
| aadc19a3ca | |||
| a2de21e351 | |||
| a8903b3598 | |||
| 4d009978ae | |||
| daca97f113 | |||
| 88f1381f4b | |||
| 625642c709 | |||
| f6f4cc5a10 | |||
| 5fb7a78a5a | |||
| bc1dac2a15 | |||
| a3334c6bb0 | |||
| b73f13ee76 | |||
| 9d6a69dc4d | |||
| 0b35012413 | |||
| 0ac174fdc6 | |||
| 4bf9b12680 | |||
| c3930ab555 | |||
| 2028cee274 | |||
| 1c22c0f01c | |||
| aabad2b082 | |||
| 5e28721651 | |||
| 95547ad7c7 | |||
| 3fd96834f9 | |||
| 3da05eea02 | |||
| 1e788e46f5 | |||
| e0d42fe6d3 | |||
| 28f57525f4 | |||
| ba6c9f61d2 | |||
| 62496f78a8 | |||
| 36ba4f34bb | |||
| 691573fbe5 | |||
| 7f623c0c1f | |||
| ad93cbba7a | |||
| 756fc431b3 | |||
| 71ce855feb | |||
| e323f42c11 | |||
| 5705e39f53 | |||
| 27c9c8cda7 | |||
| ad46f8aca0 | |||
| cad9328e5d | |||
| ac875a328e | |||
| 8ad923a90a | |||
| 0d5044b7bf | |||
| dd2832b8fc | |||
| b43979bbba | |||
| 18db58a87b | |||
| 119a5e4e25 | |||
| 04068c2a8b | |||
| 1a56b37e4e | |||
| 4340828fec | |||
| f74b6476de | |||
| 0db1aaaab7 | |||
| 3011735458 | |||
| d40aac8960 | |||
| 18672f541e | |||
| 58aed76bbb | |||
| 77ec805931 | |||
| 505db703d8 | |||
| cc19b626e1 | |||
| fc157dfd79 | |||
| 2b258908ef | |||
| f407ef6a0c | |||
| 248ca1d522 | |||
| d41f1b9495 | |||
| 6efab80686 | |||
| 6253ca46bc | |||
| aa1fd1c35b | |||
| 1f10e96288 | |||
| ae69b138bf | |||
| b2dd9a6e13 | |||
| 9bc66798d4 | |||
| 1d95976360 | |||
| 114f1a7c24 | |||
| 14cc7ef2ae | |||
| 357b5709f5 | |||
| 474c42770b | |||
| 90de167fcd | |||
| 7183df6938 | |||
| b862fc4113 | |||
| f59cdd821a | |||
| 74e5542726 | |||
| 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 | |||
| 2f5ddfe8a6 | |||
| 75b822eb19 | |||
| ceba7c5543 | |||
| b32789e515 | |||
| 3b661919c0 | |||
| 74146e90c6 | |||
| 1a9936eaa1 | |||
| c257a2cb28 | |||
| 8a403e803c | |||
| a7611ad0b2 | |||
| 0707876a81 | |||
| c191776e6c | |||
| 4bdcadb898 | |||
| 4f9cd5131f | |||
| 703c1548c9 | |||
| 0042cf11ce | |||
| 616250d38b | |||
| a312b32aa2 | |||
| 9b6637066e | |||
| 4496d211a1 | |||
| 2c93558d05 | |||
| d5b3120ea3 | |||
| 4a85643aef | |||
| 22bd0e5891 | |||
| b860a68db2 | |||
| 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 | |||
| a38491fef1 | |||
| b234778634 | |||
| 59e71856ac | |||
| 1ee97b91a5 | |||
| 3a5c49c511 | |||
| 48730e1b74 | |||
| f97d404121 | |||
| 3ecf39814e | |||
| 8220e34302 | |||
| 770adbd3ff | |||
| 50119ac538 | |||
| 98e0d56c64 | |||
| f5c0441337 | |||
| c72db5bd18 | |||
| 86f37a89c1 | |||
| 20f1be2ef8 | |||
| 6ab6ee8070 | |||
| 4f4c6d66d4 | |||
| 672c76d26d | |||
| 4b39f52d5a | |||
| f869943573 | |||
| 219a6a39ed | |||
| c91d84b652 | |||
| bf14ab7865 | |||
| b459245c5c | |||
| 31bb28f7da | |||
| a390d1d23a | |||
| 614da067f7 | |||
| 7f00a5a7a4 | |||
| 9e08b9c44c | |||
| d4a0d5c68b | |||
| c4448594e2 | |||
| fb831208f4 | |||
| 054ad2ad20 | |||
| cb4d27de7e | |||
| 8ae614540f | |||
| cedd5365d8 | |||
| 63fb7d37f1 | |||
| 313276001c | |||
| 3065669d60 | |||
| 29b5a2aa81 | |||
| ef99a4a3c1 | |||
| 3a162972ba | |||
| 7f2175a8cf | |||
| 222d53aa37 | |||
| aeeb5a38c1 | |||
| 79b3dd47b8 | |||
| 69ded31eb1 | |||
| 171191c97d | |||
| 587cfabb4a | |||
| 3ede6461cf | |||
| 1dfd1f747e | |||
| 40665b0d8f | |||
| 94f4929749 | |||
| ad815b3412 | |||
| d41e16cab9 | |||
| 22e4728738 | |||
| 501b6f8440 | |||
| 3ea5bf6787 | |||
| 0a4e06614b | |||
| df7cf86711 | |||
| 26825ab831 | |||
| 1b02b660b9 | |||
| 4c6ead4272 | |||
| f33eb7fcc7 | |||
| 00837e0da2 | |||
| 346ae15314 | |||
| 45f41f87ff | |||
| 02defcb86a | |||
| 0c791898ff | |||
| 1dbf3ce93e | |||
| dae9fe9e01 | |||
| b330fbfeb1 | |||
| 56b1134872 | |||
| 6dd45c3289 | |||
| e2818b11f0 | |||
| ddcda59239 | |||
| 85dfc33191 | |||
| bb60e987e5 | |||
| d5e8487f44 | |||
| ab8c5d2ec4 | |||
| 452403d71e | |||
| 5d676d5993 | |||
| 3ed2c9027a | |||
| 8d0bd3724d | |||
| 95d6eb3445 | |||
| 69f5ec8775 | |||
| 445789edfe | |||
| 0dedd1149e | |||
| 04ffa66a59 | |||
| e4799fa2dd | |||
| 55b14f5fc7 | |||
| 13c04460f0 | |||
| 7f39ad8fed | |||
| 0824f03a61 | |||
| a4ac431ed4 | |||
| b1b63d266a | |||
| 1afbdea4ff | |||
| 542e503360 | |||
| 9b2b62429c | |||
| ee033b8fe6 | |||
| a1f579f616 | |||
| 748c959dbe | |||
| 7b99b39529 | |||
| c61bccb700 | |||
| 81d6b2c6fc | |||
| 5c3787886b | |||
| 4218298234 | |||
| 83c3e61113 | |||
| f15e0d62e3 | |||
| 68a670a2bd | |||
| a45c20d2ff | |||
| 79700420d4 | |||
| c9b4b3008e | |||
| 47823963ae | |||
| 151f66b4cc | |||
| 8c0790627a | |||
| 62a1011a4b | |||
| b44322e448 | |||
| 3a8a1318f5 | |||
| 3ec05eb76f | |||
| 9f26d5c784 | |||
| 6c7ab8a0f8 | |||
| f6f62246c6 | |||
| 0ffb8a44f2 | |||
| 4107cf19ec | |||
| d1b377ddac | |||
| 9b016dc30a | |||
| 7392d8a679 | |||
| 6628356958 | |||
| 5283aed996 | |||
| ce28429efd | |||
| 05bf2f4fff | |||
| a0a416c330 | |||
| cd4c908334 | |||
| 81cbb230f3 | |||
| 91b320d489 | |||
| a0bdc7b23c | |||
| e239246d02 | |||
| 6c387b420c | |||
| ae84c9d8cc | |||
| 6bddbbf9d9 | |||
| d04b9278d2 | |||
| 1684d69fae | |||
| 3376f538fe | |||
| d6c9747c54 | |||
| e4646faf30 | |||
| 2d49ffe4cd | |||
| e8905be856 | |||
| fac5f382ec | |||
| dbc1f79a36 | |||
| 1053b779e4 | |||
| 94a6d41a61 | |||
| 9444ad56dc | |||
| c136206f2d | |||
| 4d26e6b7b4 | |||
| 10506238ae | |||
| c43544e5e8 |
@@ -0,0 +1,13 @@
|
|||||||
|
# .air.toml
|
||||||
|
root = "."
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
cmd = "go build -buildvcs=false -o ./tmp/main ./cmd/api"
|
||||||
|
bin = "tmp/main"
|
||||||
|
full_bin = "APP_ENV=dev ./tmp/main"
|
||||||
|
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||||
|
exclude_dir = ["vendor", "tmp"]
|
||||||
|
|
||||||
|
[log]
|
||||||
|
time = true
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Git & CI
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.github
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
/tmp
|
||||||
|
/bin
|
||||||
|
/out
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Go specific
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
*.prof
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Dependencies cache (biar tidak kebawa)
|
||||||
|
vendor/
|
||||||
|
go-build-cache/
|
||||||
|
go-mod-cache/
|
||||||
|
|
||||||
|
# Editor/IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Env & secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!/.env.example
|
||||||
|
|
||||||
|
# Docker sendiri
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*.yml
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Air temp dir
|
||||||
|
tmp/
|
||||||
|
|
||||||
|
# Binaries
|
||||||
|
main
|
||||||
|
bin/
|
||||||
|
*.exe
|
||||||
|
*.out
|
||||||
|
.air.toml
|
||||||
|
Makefile
|
||||||
|
docker-compose.local.yml
|
||||||
|
docker-compose.yaml
|
||||||
|
Dockerfile
|
||||||
|
Dockerfile.local
|
||||||
|
.gitlab-ci.yml
|
||||||
|
# Go build cache
|
||||||
|
.gocache/
|
||||||
|
vendor
|
||||||
|
|
||||||
|
# Logs & reports
|
||||||
|
*.log
|
||||||
|
*.txt
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# IDE / editor files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.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"
|
||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
## Config for golangci-lint v2 schema
|
||||||
|
version: "2"
|
||||||
|
|
||||||
|
run:
|
||||||
|
timeout: 3m
|
||||||
|
|
||||||
|
linters:
|
||||||
|
default: none
|
||||||
|
enable:
|
||||||
|
## enabled by default
|
||||||
|
- errcheck
|
||||||
|
- govet
|
||||||
|
- ineffassign
|
||||||
|
- staticcheck
|
||||||
|
- unused
|
||||||
|
## disabled by default
|
||||||
|
- asasalint
|
||||||
|
- asciicheck
|
||||||
|
- bidichk
|
||||||
|
- bodyclose
|
||||||
|
- canonicalheader
|
||||||
|
- cyclop
|
||||||
|
- dupl
|
||||||
|
- durationcheck
|
||||||
|
- errname
|
||||||
|
- errorlint
|
||||||
|
- exhaustive
|
||||||
|
- fatcontext
|
||||||
|
- forbidigo
|
||||||
|
- funlen
|
||||||
|
- gocheckcompilerdirectives
|
||||||
|
#- gochecknoglobals
|
||||||
|
#- gochecknoinits
|
||||||
|
- gochecksumtype
|
||||||
|
- gocognit
|
||||||
|
- goconst
|
||||||
|
- gocritic
|
||||||
|
- gocyclo
|
||||||
|
#- godot
|
||||||
|
- gomoddirectives
|
||||||
|
- gomodguard
|
||||||
|
- goprintffuncname
|
||||||
|
- gosec
|
||||||
|
- intrange
|
||||||
|
- lll
|
||||||
|
- loggercheck
|
||||||
|
- makezero
|
||||||
|
- mirror
|
||||||
|
#- mnd
|
||||||
|
- musttag
|
||||||
|
- nakedret
|
||||||
|
- nestif
|
||||||
|
- nilerr
|
||||||
|
- nilnil
|
||||||
|
- noctx
|
||||||
|
- nolintlint
|
||||||
|
- nonamedreturns
|
||||||
|
- nosprintfhostport
|
||||||
|
- perfsprint
|
||||||
|
- predeclared
|
||||||
|
- promlinter
|
||||||
|
- protogetter
|
||||||
|
- reassign
|
||||||
|
- revive
|
||||||
|
- rowserrcheck
|
||||||
|
- sloglint
|
||||||
|
- spancheck
|
||||||
|
- sqlclosecheck
|
||||||
|
- testableexamples
|
||||||
|
#- testifylint
|
||||||
|
- testpackage
|
||||||
|
- tparallel
|
||||||
|
- unconvert
|
||||||
|
- unparam
|
||||||
|
- usestdlibvars
|
||||||
|
- usetesting
|
||||||
|
- wastedassign
|
||||||
|
- whitespace
|
||||||
|
settings:
|
||||||
|
cyclop:
|
||||||
|
max-complexity: 30
|
||||||
|
package-average: 10.0
|
||||||
|
errcheck:
|
||||||
|
check-type-assertions: true
|
||||||
|
exhaustive:
|
||||||
|
check:
|
||||||
|
- switch
|
||||||
|
- map
|
||||||
|
exhaustruct:
|
||||||
|
exclude:
|
||||||
|
- "^net/http.Client$"
|
||||||
|
- "^net/http.Cookie$"
|
||||||
|
- "^net/http.Request$"
|
||||||
|
- "^net/http.Response$"
|
||||||
|
- "^net/http.Server$"
|
||||||
|
- "^net/http.Transport$"
|
||||||
|
- "^net/url.URL$"
|
||||||
|
- "^os/exec.Cmd$"
|
||||||
|
- "^reflect.StructField$"
|
||||||
|
- "^github.com/Shopify/sarama.Config$"
|
||||||
|
- "^github.com/Shopify/sarama.ProducerMessage$"
|
||||||
|
- "^github.com/mitchellh/mapstructure.DecoderConfig$"
|
||||||
|
- "^github.com/prometheus/client_golang/.+Opts$"
|
||||||
|
- "^github.com/spf13/cobra.Command$"
|
||||||
|
- "^github.com/spf13/cobra.CompletionOptions$"
|
||||||
|
- "^github.com/stretchr/testify/mock.Mock$"
|
||||||
|
- "^github.com/testcontainers/testcontainers-go.+Request$"
|
||||||
|
- "^github.com/testcontainers/testcontainers-go.FromDockerfile$"
|
||||||
|
- "^golang.org/x/tools/go/analysis.Analyzer$"
|
||||||
|
- "^google.golang.org/protobuf/.+Options$"
|
||||||
|
- "^gopkg.in/yaml.v3.Node$"
|
||||||
|
funlen:
|
||||||
|
lines: 100
|
||||||
|
statements: 50
|
||||||
|
ignore-comments: true
|
||||||
|
gocognit:
|
||||||
|
min-complexity: 20
|
||||||
|
gocritic:
|
||||||
|
settings:
|
||||||
|
captLocal:
|
||||||
|
paramsOnly: false
|
||||||
|
underef:
|
||||||
|
skipRecvDeref: false
|
||||||
|
gomodguard:
|
||||||
|
blocked:
|
||||||
|
modules:
|
||||||
|
- github.com/golang/protobuf:
|
||||||
|
recommendations:
|
||||||
|
- google.golang.org/protobuf
|
||||||
|
reason: "see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules"
|
||||||
|
- github.com/satori/go.uuid:
|
||||||
|
recommendations:
|
||||||
|
- github.com/google/uuid
|
||||||
|
reason: "satori's package is not maintained"
|
||||||
|
- github.com/gofrs/uuid:
|
||||||
|
recommendations:
|
||||||
|
- github.com/gofrs/uuid/v5
|
||||||
|
reason: "gofrs' package was not go module before v5"
|
||||||
|
govet:
|
||||||
|
enable-all: true
|
||||||
|
disable:
|
||||||
|
- fieldalignment
|
||||||
|
settings:
|
||||||
|
shadow:
|
||||||
|
strict: true
|
||||||
|
inamedparam:
|
||||||
|
skip-single-param: true
|
||||||
|
mnd:
|
||||||
|
ignored-functions:
|
||||||
|
- args.Error
|
||||||
|
- flag.Arg
|
||||||
|
- flag.Duration.*
|
||||||
|
- flag.Float.*
|
||||||
|
- flag.Int.*
|
||||||
|
- flag.Uint.*
|
||||||
|
- os.Chmod
|
||||||
|
- os.Mkdir.*
|
||||||
|
- os.OpenFile
|
||||||
|
- os.WriteFile
|
||||||
|
- prometheus.ExponentialBuckets.*
|
||||||
|
- prometheus.LinearBuckets
|
||||||
|
nakedret:
|
||||||
|
max-func-lines: 0
|
||||||
|
nolintlint:
|
||||||
|
allow-no-explanation:
|
||||||
|
- funlen
|
||||||
|
- gocognit
|
||||||
|
- lll
|
||||||
|
require-explanation: true
|
||||||
|
require-specific: true
|
||||||
|
perfsprint:
|
||||||
|
strconcat: false
|
||||||
|
rowserrcheck:
|
||||||
|
packages:
|
||||||
|
- github.com/jmoiron/sqlx
|
||||||
|
sloglint:
|
||||||
|
no-global: "all"
|
||||||
|
context: "scope"
|
||||||
|
exclusions:
|
||||||
|
rules:
|
||||||
|
- source: "(noinspection|TODO)"
|
||||||
|
linters:
|
||||||
|
- godot
|
||||||
|
- source: "//noinspection"
|
||||||
|
linters:
|
||||||
|
- gocritic
|
||||||
|
- path: "example\\.go"
|
||||||
|
linters:
|
||||||
|
- lll
|
||||||
|
- path: "_test\\.go"
|
||||||
|
linters:
|
||||||
|
- bodyclose
|
||||||
|
- dupl
|
||||||
|
- funlen
|
||||||
|
- goconst
|
||||||
|
- gosec
|
||||||
|
- noctx
|
||||||
|
- wrapcheck
|
||||||
|
- lll
|
||||||
|
- testpackage
|
||||||
|
|
||||||
|
issues:
|
||||||
|
max-same-issues: 50
|
||||||
+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,93 +1,114 @@
|
|||||||
# LTI API
|
# Lumbung Telur Indonesia ERP API (lti-api)
|
||||||
|
|
||||||
|
RESTful API for **Lumbung Telur Indonesia ERP**, built with **Go, Fiber, GORM** and **PostgreSQL**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Getting started
|
## 📦 Tech Stack
|
||||||
|
|
||||||
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
|
- **Go + Fiber** — Web framework
|
||||||
|
- **GORM** — ORM for PostgreSQL
|
||||||
|
- **PostgreSQL** — Relational database
|
||||||
|
- **go-playground/validator** — Input validation
|
||||||
|
- **JWT** — Authentication
|
||||||
|
- **Logrus** — Logging
|
||||||
|
- **Fiber middleware** — Rate limiting, CORS, recovery, logger
|
||||||
|
- **Air** — Hot reload for development
|
||||||
|
- **Docker + Docker Compose** — Containerization
|
||||||
|
- **golang-migrate** — Database migration tool
|
||||||
|
|
||||||
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
|
---
|
||||||
|
|
||||||
## Add your files
|
## 🚀 Getting Started
|
||||||
|
|
||||||
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
|
### 1. Clone Project
|
||||||
- [ ] [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
|
|
||||||
|
|
||||||
```
|
```bash
|
||||||
cd existing_repo
|
git clone https://gitlab.com/mbugroup/lti-api.git
|
||||||
git remote add origin https://gitlab.com/mbugroup/lti-api.git
|
cd lti-api
|
||||||
git branch -M main
|
|
||||||
git push -uf origin main
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Integrate with your tools
|
### 2. Install Dependencies
|
||||||
|
|
||||||
- [ ] [Set up project integrations](https://gitlab.com/mbugroup/lti-api/-/settings/integrations)
|
```bash
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
## Collaborate with your team
|
### 3. Configure Environment
|
||||||
|
|
||||||
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
|
Copy .env.example to .env and adjust the variables (e.g. DATABASE_URL, JWT secrets, etc).
|
||||||
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
|
|
||||||
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
|
|
||||||
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
|
|
||||||
- [ ] [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
|
|
||||||
|
|
||||||
## Test and Deploy
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
Use the built-in continuous integration in GitLab.
|
### 5. Setup Docker
|
||||||
|
|
||||||
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/)
|
Run initial docker.
|
||||||
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
|
|
||||||
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
|
|
||||||
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
|
|
||||||
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
|
|
||||||
|
|
||||||
***
|
```bash
|
||||||
|
make docker-local
|
||||||
|
```
|
||||||
|
|
||||||
# Editing this README
|
### 4. Migrate Database
|
||||||
|
|
||||||
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
|
Run initial migrations and generate views.
|
||||||
|
|
||||||
## Suggestions for a good README
|
```bash
|
||||||
|
make migrate-up
|
||||||
|
```
|
||||||
|
|
||||||
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
|
### 5. Run App
|
||||||
|
|
||||||
## Name
|
Run project via Docker
|
||||||
Choose a self-explaining name for your project.
|
|
||||||
|
|
||||||
## Description
|
### 6. Create New Module
|
||||||
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
|
|
||||||
|
|
||||||
## Badges
|
```bash
|
||||||
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
|
make gen feat=user
|
||||||
|
```
|
||||||
|
|
||||||
## Visuals
|
output:
|
||||||
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
|
|
||||||
|
|
||||||
## Installation
|
```bash
|
||||||
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
|
cmd/
|
||||||
|
├── api/
|
||||||
|
│ └── main.go # Application entrypoint (initialize Fiber, load config, connect DB, register route)
|
||||||
|
|
||||||
## Usage
|
internal/
|
||||||
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
|
├── config/ # App config (env loader, logger, app settings)
|
||||||
|
│
|
||||||
|
├── database/ # Database connection + migration setup
|
||||||
|
│
|
||||||
|
├── middleware/ # Global Fiber middleware (auth, logger, recovery, rate limiting)
|
||||||
|
│
|
||||||
|
├── modules/ # Feature modules (users, products, suppliers, etc.)
|
||||||
|
│ ├── <module>/
|
||||||
|
│ │ ├── controllers/ # HTTP handler layer (receive request, call service, return response)
|
||||||
|
│ │ ├── dto/ # Data Transfer Objects (request & response payloads, separate from models)
|
||||||
|
│ │ ├── models/ # GORM models (represent database tables/entities)
|
||||||
|
│ │ ├── repositories/ # Data access layer (queries to DB, CRUD abstraction)
|
||||||
|
│ │ ├── services/ # Business logic layer (process rules, orchestrate repository calls)
|
||||||
|
│ │ ├── validation/ # Request validation (custom rules per module)
|
||||||
|
│ │ ├── module.go # Module bootstrapper (wire controller, service, repository together)
|
||||||
|
│ │ └── route.go # Module route (register module routes into Fiber app)
|
||||||
|
│
|
||||||
|
├── repository/ # Shared repositories (reusable DB access layer across multiple modules)
|
||||||
|
│
|
||||||
|
├── response/ # Standardized API responses (success, error, pagination)
|
||||||
|
│
|
||||||
|
├── utils/ # Helper functions (JWT, hashing, constants, enums, etc.)
|
||||||
|
│
|
||||||
|
├── validation/ # Shared request validation structs & rules
|
||||||
|
│
|
||||||
|
└── route/ # Central route aggregator (load all module routes into main app)
|
||||||
|
```
|
||||||
|
|
||||||
## Support
|
## ✨ Author
|
||||||
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
|
|
||||||
|
|
||||||
## Roadmap
|
IT Development PT Mitra Berlian Unggas Group
|
||||||
If you have ideas for releases in the future, it is a good idea to list them in the README.
|
|
||||||
|
|
||||||
## Contributing
|
## 📃 License
|
||||||
State if you are open to contributions and what your requirements are for accepting them.
|
|
||||||
|
|
||||||
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
|
> This project is private. All rights reserved.
|
||||||
|
# mr test Sat 7 Feb 2026 00:14:58 WIB
|
||||||
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
|
|
||||||
|
|
||||||
## Authors and acknowledgment
|
|
||||||
Show your appreciation to those who have contributed to the project.
|
|
||||||
|
|
||||||
## License
|
|
||||||
For open source projects, say how it is licensed.
|
|
||||||
|
|
||||||
## Project status
|
|
||||||
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
levelAllNoFlagProducts = 1
|
||||||
|
levelProductName = 2
|
||||||
|
levelProductWarehouse = 3
|
||||||
|
qtyEpsilon = 1e-6
|
||||||
|
)
|
||||||
|
|
||||||
|
type targetRow struct {
|
||||||
|
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
||||||
|
ProductID uint `gorm:"column:product_id"`
|
||||||
|
ProductName string `gorm:"column:product_name"`
|
||||||
|
CurrentQty float64 `gorm:"column:current_qty"`
|
||||||
|
ComputedQty float64 `gorm:"column:computed_qty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
level int
|
||||||
|
productName string
|
||||||
|
productWarehouseID uint
|
||||||
|
apply bool
|
||||||
|
)
|
||||||
|
|
||||||
|
flag.IntVar(
|
||||||
|
&level,
|
||||||
|
"level",
|
||||||
|
levelAllNoFlagProducts,
|
||||||
|
"CLI level: 1=all products without flags, 2=specific product name (with flags), 3=specific product warehouse id",
|
||||||
|
)
|
||||||
|
flag.StringVar(&productName, "product-name", "", "Product name (required for level 2)")
|
||||||
|
flag.UintVar(&productWarehouseID, "product-warehouse-id", 0, "Product warehouse id (required for level 3)")
|
||||||
|
flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
productName = strings.TrimSpace(productName)
|
||||||
|
if err := validateFlags(level, productName, productWarehouseID); err != nil {
|
||||||
|
log.Fatalf("invalid flags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
db := database.Connect(config.DBHost, config.DBName)
|
||||||
|
|
||||||
|
targets, err := loadTargets(ctx, db, level, productName, productWarehouseID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to load target product warehouses: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Mode: %s\n", modeLabel(apply))
|
||||||
|
fmt.Printf("Level: %d (%s)\n", level, levelLabel(level))
|
||||||
|
if productName != "" {
|
||||||
|
fmt.Printf("Filter product_name: %s\n", productName)
|
||||||
|
}
|
||||||
|
if productWarehouseID > 0 {
|
||||||
|
fmt.Printf("Filter product_warehouse_id: %d\n", productWarehouseID)
|
||||||
|
}
|
||||||
|
fmt.Printf("Targets found: %d\n\n", len(targets))
|
||||||
|
|
||||||
|
if len(targets) == 0 {
|
||||||
|
fmt.Println("No matching product warehouse rows to process")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range targets {
|
||||||
|
fmt.Printf(
|
||||||
|
"PLAN pw=%d product_id=%d product=%q current_qty=%.3f computed_qty=%.3f delta=%.3f\n",
|
||||||
|
row.ProductWarehouseID,
|
||||||
|
row.ProductID,
|
||||||
|
row.ProductName,
|
||||||
|
row.CurrentQty,
|
||||||
|
row.ComputedQty,
|
||||||
|
row.ComputedQty-row.CurrentQty,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !apply {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("Summary: planned=%d updated=0 skipped=0 failed=0\n", len(targets))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := 0
|
||||||
|
skipped := 0
|
||||||
|
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
for _, row := range targets {
|
||||||
|
if nearlyEqual(row.CurrentQty, row.ComputedQty) {
|
||||||
|
fmt.Printf(
|
||||||
|
"SKIP pw=%d reason=no_change current_qty=%.3f computed_qty=%.3f\n",
|
||||||
|
row.ProductWarehouseID,
|
||||||
|
row.CurrentQty,
|
||||||
|
row.ComputedQty,
|
||||||
|
)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Table("product_warehouses").
|
||||||
|
Where("id = ?", row.ProductWarehouseID).
|
||||||
|
Update("qty", row.ComputedQty).Error; err != nil {
|
||||||
|
return fmt.Errorf("update qty for product_warehouse_id=%d: %w", row.ProductWarehouseID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(
|
||||||
|
"DONE pw=%d product_id=%d product=%q old_qty=%.3f new_qty=%.3f\n",
|
||||||
|
row.ProductWarehouseID,
|
||||||
|
row.ProductID,
|
||||||
|
row.ProductName,
|
||||||
|
row.CurrentQty,
|
||||||
|
row.ComputedQty,
|
||||||
|
)
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("Summary: planned=%d updated=%d skipped=%d failed=1\n", len(targets), updated, skipped)
|
||||||
|
log.Printf("error: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("Summary: planned=%d updated=%d skipped=%d failed=0\n", len(targets), updated, skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateFlags(level int, productName string, productWarehouseID uint) error {
|
||||||
|
switch level {
|
||||||
|
case levelAllNoFlagProducts:
|
||||||
|
if productName != "" {
|
||||||
|
return errors.New("--product-name cannot be used on level 1")
|
||||||
|
}
|
||||||
|
if productWarehouseID > 0 {
|
||||||
|
return errors.New("--product-warehouse-id cannot be used on level 1")
|
||||||
|
}
|
||||||
|
case levelProductName:
|
||||||
|
if productName == "" {
|
||||||
|
return errors.New("--product-name is required on level 2")
|
||||||
|
}
|
||||||
|
if productWarehouseID > 0 {
|
||||||
|
return errors.New("--product-warehouse-id cannot be used on level 2")
|
||||||
|
}
|
||||||
|
case levelProductWarehouse:
|
||||||
|
if productWarehouseID == 0 {
|
||||||
|
return errors.New("--product-warehouse-id is required on level 3")
|
||||||
|
}
|
||||||
|
if productName != "" {
|
||||||
|
return errors.New("--product-name cannot be used on level 3")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported --level=%d (allowed: 1, 2, 3)", level)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTargets(
|
||||||
|
ctx context.Context,
|
||||||
|
db *gorm.DB,
|
||||||
|
level int,
|
||||||
|
productName string,
|
||||||
|
productWarehouseID uint,
|
||||||
|
) ([]targetRow, error) {
|
||||||
|
switch level {
|
||||||
|
case levelAllNoFlagProducts:
|
||||||
|
return loadTargetsLevel1ByProductWithoutFlags(ctx, db)
|
||||||
|
case levelProductName:
|
||||||
|
return loadTargetsLevel2ByProductWarehouseWithFlags(ctx, db, productName)
|
||||||
|
case levelProductWarehouse:
|
||||||
|
return loadTargetByProductWarehouseID(ctx, db, productWarehouseID)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported level %d", level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTargetsLevel1ByProductWithoutFlags(ctx context.Context, db *gorm.DB) ([]targetRow, error) {
|
||||||
|
rows := make([]targetRow, 0)
|
||||||
|
if err := db.WithContext(ctx).
|
||||||
|
Table("product_warehouses pw").
|
||||||
|
Select(`
|
||||||
|
pw.id AS product_warehouse_id,
|
||||||
|
pw.product_id AS product_id,
|
||||||
|
p.name AS product_name,
|
||||||
|
COALESCE(pw.qty, 0) AS current_qty,
|
||||||
|
COALESCE(SUM(pi.total_qty), 0) AS computed_qty
|
||||||
|
`).
|
||||||
|
Joins("JOIN products p ON p.id = pw.product_id").
|
||||||
|
Joins("LEFT JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
|
||||||
|
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
|
||||||
|
Where("p.deleted_at IS NULL").
|
||||||
|
Where("f.id IS NULL").
|
||||||
|
Group("pw.id, pw.product_id, p.name, pw.qty").
|
||||||
|
Order("pw.id ASC").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTargetsLevel2ByProductWarehouseWithFlags(
|
||||||
|
ctx context.Context,
|
||||||
|
db *gorm.DB,
|
||||||
|
productName string,
|
||||||
|
) ([]targetRow, error) {
|
||||||
|
rows := make([]targetRow, 0)
|
||||||
|
if err := db.WithContext(ctx).
|
||||||
|
Table("product_warehouses pw").
|
||||||
|
Select(`
|
||||||
|
pw.id AS product_warehouse_id,
|
||||||
|
pw.product_id AS product_id,
|
||||||
|
p.name AS product_name,
|
||||||
|
COALESCE(pw.qty, 0) AS current_qty,
|
||||||
|
COALESCE(SUM(pi.total_qty), 0) AS computed_qty
|
||||||
|
`).
|
||||||
|
Joins("JOIN products p ON p.id = pw.product_id").
|
||||||
|
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
|
||||||
|
Where("p.deleted_at IS NULL").
|
||||||
|
Where(`
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM flags f
|
||||||
|
WHERE f.flagable_id = p.id
|
||||||
|
AND f.flagable_type = ?
|
||||||
|
)
|
||||||
|
`, entity.FlagableTypeProduct).
|
||||||
|
Where("LOWER(p.name) = LOWER(?)", productName).
|
||||||
|
Group("pw.id, pw.product_id, p.name, pw.qty").
|
||||||
|
Order("pw.id ASC").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTargetByProductWarehouseID(ctx context.Context, db *gorm.DB, productWarehouseID uint) ([]targetRow, error) {
|
||||||
|
rows := make([]targetRow, 0)
|
||||||
|
if err := db.WithContext(ctx).
|
||||||
|
Table("product_warehouses pw").
|
||||||
|
Select(`
|
||||||
|
pw.id AS product_warehouse_id,
|
||||||
|
pw.product_id AS product_id,
|
||||||
|
p.name AS product_name,
|
||||||
|
COALESCE(pw.qty, 0) AS current_qty,
|
||||||
|
COALESCE(SUM(pi.total_qty), 0) AS computed_qty
|
||||||
|
`).
|
||||||
|
Joins("JOIN products p ON p.id = pw.product_id").
|
||||||
|
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
|
||||||
|
Where("pw.id = ?", productWarehouseID).
|
||||||
|
Group("pw.id, pw.product_id, p.name, pw.qty").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func modeLabel(apply bool) string {
|
||||||
|
if apply {
|
||||||
|
return "APPLY"
|
||||||
|
}
|
||||||
|
return "DRY-RUN"
|
||||||
|
}
|
||||||
|
|
||||||
|
func levelLabel(level int) string {
|
||||||
|
switch level {
|
||||||
|
case levelAllNoFlagProducts:
|
||||||
|
return "all products without flags (source: purchase_items by product_warehouse_id)"
|
||||||
|
case levelProductName:
|
||||||
|
return "specific product name with flags (source: purchase_items by product_warehouse_id)"
|
||||||
|
case levelProductWarehouse:
|
||||||
|
return "specific product_warehouse_id (source: purchase_items by product_warehouse_id)"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nearlyEqual(a, b float64) bool {
|
||||||
|
return math.Abs(a-b) <= qtyEpsilon
|
||||||
|
}
|
||||||
+213
@@ -0,0 +1,213 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/cache"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||||
|
"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/utils"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/compress"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/helmet"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
app := setupFiberApp()
|
||||||
|
db := setupDatabase()
|
||||||
|
defer closeDatabase(db)
|
||||||
|
rdb := setupRedis()
|
||||||
|
defer rdb.Close()
|
||||||
|
setupSSO(ctx, rdb)
|
||||||
|
setupRoutes(app, db, rdb)
|
||||||
|
|
||||||
|
address := fmt.Sprintf("%s:%d", config.AppHost, config.AppPort)
|
||||||
|
|
||||||
|
// Start server and handle graceful shutdown
|
||||||
|
serverErrors := make(chan error, 1)
|
||||||
|
go startServer(app, address, serverErrors)
|
||||||
|
handleGracefulShutdown(ctx, app, serverErrors)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupRedis() *redis.Client {
|
||||||
|
opt, err := redis.ParseURL(config.RedisURL)
|
||||||
|
if err != nil {
|
||||||
|
utils.Log.Fatalf("Redis URL parse error: %v", err)
|
||||||
|
}
|
||||||
|
rdb := redis.NewClient(opt)
|
||||||
|
if err := rdb.Ping(context.Background()).Err(); err != nil {
|
||||||
|
utils.Log.Fatalf("Redis ping failed: %v", err)
|
||||||
|
}
|
||||||
|
cache.SetRedis(rdb)
|
||||||
|
utils.Log.Infof("Redis connected: %s", config.RedisURL)
|
||||||
|
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 {
|
||||||
|
app := fiber.New(config.FiberConfig())
|
||||||
|
|
||||||
|
// Middleware setup
|
||||||
|
app.Use(middleware.LoggerConfig())
|
||||||
|
app.Use(helmet.New())
|
||||||
|
app.Use(compress.New())
|
||||||
|
app.Use(middleware.RecoverConfig())
|
||||||
|
|
||||||
|
origins := "*"
|
||||||
|
if len(config.CORSAllowOrigins) > 0 {
|
||||||
|
origins = strings.Join(config.CORSAllowOrigins, ",")
|
||||||
|
}
|
||||||
|
if config.CORSAllowCredentials && (origins == "" || origins == "*") {
|
||||||
|
origins = "http://localhost:3000"
|
||||||
|
}
|
||||||
|
app.Use(cors.New(cors.Config{
|
||||||
|
AllowOrigins: origins,
|
||||||
|
AllowMethods: strings.Join(config.CORSAllowMethods, ","),
|
||||||
|
AllowHeaders: strings.Join(config.CORSAllowHeaders, ","),
|
||||||
|
ExposeHeaders: strings.Join(config.CORSExposeHeaders, ","),
|
||||||
|
AllowCredentials: config.CORSAllowCredentials,
|
||||||
|
MaxAge: config.CORSMaxAge,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupDatabase() *gorm.DB {
|
||||||
|
db := database.Connect(config.DBHost, config.DBName)
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupRoutes(app *fiber.App, db *gorm.DB, rdb *redis.Client) {
|
||||||
|
|
||||||
|
// route.Routes(app, db)
|
||||||
|
// app.Use(utils.NotFoundHandler)
|
||||||
|
app.Get("/healthz", func(c *fiber.Ctx) error {
|
||||||
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||||
|
"status": "ok",
|
||||||
|
"service": "api",
|
||||||
|
"version": config.Version,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.Get("/readyz", func(c *fiber.Ctx) error {
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"status": "error", "db": "unavailable", "redis": "unknown",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Context(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
dbOK := sqlDB.PingContext(ctx) == nil
|
||||||
|
redisOK := rdb.Ping(ctx).Err() == nil
|
||||||
|
|
||||||
|
status := fiber.StatusOK
|
||||||
|
statusText := "ok"
|
||||||
|
if !dbOK || !redisOK {
|
||||||
|
status = fiber.StatusServiceUnavailable
|
||||||
|
statusText = "degraded"
|
||||||
|
}
|
||||||
|
body := fiber.Map{
|
||||||
|
"status": statusText,
|
||||||
|
"db": map[bool]string{true: "up", false: "down"}[dbOK],
|
||||||
|
"redis": map[bool]string{true: "up", false: "down"}[redisOK],
|
||||||
|
}
|
||||||
|
return c.Status(status).JSON(body)
|
||||||
|
})
|
||||||
|
|
||||||
|
route.Routes(app, db)
|
||||||
|
app.Use(utils.NotFoundHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startServer(app *fiber.App, address string, errs chan<- error) {
|
||||||
|
if err := app.Listen(address); err != nil {
|
||||||
|
errs <- fmt.Errorf("error starting server: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeDatabase(db *gorm.DB) {
|
||||||
|
sqlDB, errDB := db.DB()
|
||||||
|
if errDB != nil {
|
||||||
|
utils.Log.Errorf("Error getting database instance: %v", errDB)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sqlDB.Close(); err != nil {
|
||||||
|
utils.Log.Errorf("Error closing database connection: %v", err)
|
||||||
|
} else {
|
||||||
|
utils.Log.Info("Database connection closed successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGracefulShutdown(ctx context.Context, app *fiber.App, serverErrors <-chan error) {
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-serverErrors:
|
||||||
|
utils.Log.Fatalf("Server error: %v", err)
|
||||||
|
case <-quit:
|
||||||
|
utils.Log.Info("Shutting down server...")
|
||||||
|
if err := app.Shutdown(); err != nil {
|
||||||
|
utils.Log.Fatalf("Error during server shutdown: %v", err)
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
utils.Log.Info("Server exiting due to context cancellation")
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Log.Info("Server exited")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||||
|
fifoStockV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
|
||||||
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateAdjustmentGatherAgainstAllowedIDsEligible(t *testing.T) {
|
||||||
|
result := validateAdjustmentGatherAgainstAllowedIDs(100, []uint{11, 12}, []commonSvc.FifoStockV2GatherRow{
|
||||||
|
{SourceTable: "adjustment_stocks", SourceID: 11, AvailableQuantity: 70},
|
||||||
|
{SourceTable: "adjustment_stocks", SourceID: 12, AvailableQuantity: 40},
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.Status != "eligible" {
|
||||||
|
t.Fatalf("expected eligible, got %+v", result)
|
||||||
|
}
|
||||||
|
if result.VerifiedQty != 100 {
|
||||||
|
t.Fatalf("expected verified qty 100, got %v", result.VerifiedQty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAdjustmentGatherAgainstAllowedIDsRejectsMixedSource(t *testing.T) {
|
||||||
|
result := validateAdjustmentGatherAgainstAllowedIDs(100, []uint{11}, []commonSvc.FifoStockV2GatherRow{
|
||||||
|
{SourceTable: "adjustment_stocks", SourceID: 11, AvailableQuantity: 60},
|
||||||
|
{SourceTable: "recording_eggs", SourceID: 21, AvailableQuantity: 50},
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.Status != "skipped" {
|
||||||
|
t.Fatalf("expected skipped, got %+v", result)
|
||||||
|
}
|
||||||
|
if result.Reason != "mixed_fifo_source_recording_eggs" {
|
||||||
|
t.Fatalf("unexpected reason: %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAdjustmentMigrationPlanUsesValidator(t *testing.T) {
|
||||||
|
opts := &adjustmentCommandOptions{RunID: "egg-adjustment-cutover-test"}
|
||||||
|
farmID := uint(25)
|
||||||
|
farmName := "Gudang Farm Jamali"
|
||||||
|
rows := []adjustmentLegacyEggRow{
|
||||||
|
{
|
||||||
|
LocationID: 16,
|
||||||
|
LocationName: "Jamali",
|
||||||
|
SourceWarehouseID: 46,
|
||||||
|
SourceWarehouseName: "Gudang Jamali 1",
|
||||||
|
FarmWarehouseID: &farmID,
|
||||||
|
FarmWarehouseName: &farmName,
|
||||||
|
ProductWarehouseID: 101,
|
||||||
|
ProductID: 8,
|
||||||
|
ProductName: "Telur Utuh",
|
||||||
|
RemainingQty: 120,
|
||||||
|
CurrentPWQty: 150,
|
||||||
|
AdjustmentIDs: []uint{1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
LocationID: 16,
|
||||||
|
LocationName: "Jamali",
|
||||||
|
SourceWarehouseID: 46,
|
||||||
|
SourceWarehouseName: "Gudang Jamali 1",
|
||||||
|
FarmWarehouseID: &farmID,
|
||||||
|
FarmWarehouseName: &farmName,
|
||||||
|
ProductWarehouseID: 102,
|
||||||
|
ProductID: 9,
|
||||||
|
ProductName: "Telur Putih",
|
||||||
|
RemainingQty: 20,
|
||||||
|
CurrentPWQty: 40,
|
||||||
|
AdjustmentIDs: []uint{2},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
LocationID: 16,
|
||||||
|
LocationName: "Jamali",
|
||||||
|
SourceWarehouseID: 46,
|
||||||
|
SourceWarehouseName: "Gudang Jamali 1",
|
||||||
|
ProductWarehouseID: 103,
|
||||||
|
ProductID: 10,
|
||||||
|
ProductName: "Telur Pecah",
|
||||||
|
RemainingQty: 10,
|
||||||
|
CurrentPWQty: 10,
|
||||||
|
AdjustmentIDs: []uint{3},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
validator := &fakeAdjustmentCandidateValidator{
|
||||||
|
byProduct: map[string]adjustmentCandidateValidation{
|
||||||
|
"Telur Utuh": {Status: "eligible", VerifiedQty: 120},
|
||||||
|
"Telur Putih": {Status: "skipped", Reason: "mixed_fifo_source_recording_eggs", VerifiedQty: 10},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
reportRows, groups := buildAdjustmentMigrationPlan(context.Background(), opts, map[uint]adjustmentLocationTiming{
|
||||||
|
16: {LocationID: 16, LocationName: "Jamali", Status: "CLEAN_CUTOVER"},
|
||||||
|
}, rows, validator)
|
||||||
|
|
||||||
|
if len(reportRows) != 3 {
|
||||||
|
t.Fatalf("expected 3 report rows, got %d", len(reportRows))
|
||||||
|
}
|
||||||
|
if len(groups) != 1 || len(groups[0].Rows) != 1 {
|
||||||
|
t.Fatalf("expected only one eligible grouped row, got %+v", groups)
|
||||||
|
}
|
||||||
|
if reportRows[0].Status != "eligible" || reportRows[0].VerifiedQty != 120 {
|
||||||
|
t.Fatalf("unexpected first row: %+v", reportRows[0])
|
||||||
|
}
|
||||||
|
if reportRows[1].Reason != "mixed_fifo_source_recording_eggs" {
|
||||||
|
t.Fatalf("unexpected second row reason: %+v", reportRows[1])
|
||||||
|
}
|
||||||
|
if reportRows[2].Reason != "missing_farm_warehouse" {
|
||||||
|
t.Fatalf("expected missing farm warehouse skip, got %+v", reportRows[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteAdjustmentApplyRevalidatesRowsAndAppliesSubset(t *testing.T) {
|
||||||
|
opts := &adjustmentCommandOptions{
|
||||||
|
RunID: "egg-adjustment-cutover-apply",
|
||||||
|
CutoverDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC),
|
||||||
|
ActorID: 99,
|
||||||
|
}
|
||||||
|
group := adjustmentTransferGroup{
|
||||||
|
LocationID: 16,
|
||||||
|
LocationName: "Jamali",
|
||||||
|
SourceWarehouseID: 46,
|
||||||
|
SourceWarehouseName: "Gudang Jamali 1",
|
||||||
|
FarmWarehouseID: 25,
|
||||||
|
FarmWarehouseName: "Gudang Farm Jamali",
|
||||||
|
Rows: []*adjustmentMigrationReportRow{
|
||||||
|
{LocationID: 16, LocationName: "Jamali", SourceWarehouseID: 46, SourceWarehouseName: "Gudang Jamali 1", FarmWarehouseID: uintPtr(25), FarmWarehouseName: strPtr("Gudang Farm Jamali"), ProductWarehouseID: 101, ProductID: 8, ProductName: "Telur Utuh", RemainingQty: 120, CurrentPWQty: 150, AdjustmentIDs: []uint{1}, Status: "eligible"},
|
||||||
|
{LocationID: 16, LocationName: "Jamali", SourceWarehouseID: 46, SourceWarehouseName: "Gudang Jamali 1", FarmWarehouseID: uintPtr(25), FarmWarehouseName: strPtr("Gudang Farm Jamali"), ProductWarehouseID: 102, ProductID: 9, ProductName: "Telur Putih", RemainingQty: 20, CurrentPWQty: 40, AdjustmentIDs: []uint{2}, Status: "eligible"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
validator := &fakeAdjustmentCandidateValidator{
|
||||||
|
byProduct: map[string]adjustmentCandidateValidation{
|
||||||
|
"Telur Utuh": {Status: "eligible", VerifiedQty: 120},
|
||||||
|
"Telur Putih": {Status: "skipped", Reason: "mixed_fifo_source_recording_eggs", VerifiedQty: 10},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
executor := &fakeAdjustmentSystemTransferExecutor{
|
||||||
|
createResponses: []*entity.StockTransfer{
|
||||||
|
{Id: 1001, MovementNumber: "PND-LTI-1001"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := executeAdjustmentApply(context.Background(), executor, validator, opts, []adjustmentTransferGroup{group})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no fatal apply error, got %v", err)
|
||||||
|
}
|
||||||
|
if summary.GroupsApplied != 1 {
|
||||||
|
t.Fatalf("expected 1 applied group, got %+v", summary)
|
||||||
|
}
|
||||||
|
if summary.RowsApplied != 1 || summary.RowsFailed != 1 {
|
||||||
|
t.Fatalf("unexpected summary: %+v", summary)
|
||||||
|
}
|
||||||
|
if len(executor.createRequests) != 1 {
|
||||||
|
t.Fatalf("expected 1 create request, got %d", len(executor.createRequests))
|
||||||
|
}
|
||||||
|
if len(executor.createRequests[0].Products) != 1 || executor.createRequests[0].Products[0].ProductID != 8 {
|
||||||
|
t.Fatalf("expected only Telur Utuh to be transferred, got %+v", executor.createRequests[0].Products)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeAdjustmentCandidateValidator struct {
|
||||||
|
byProduct map[string]adjustmentCandidateValidation
|
||||||
|
errByProduct map[string]error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAdjustmentCandidateValidator) ValidateCandidate(ctx context.Context, row adjustmentLegacyEggRow) (adjustmentCandidateValidation, error) {
|
||||||
|
if err, ok := f.errByProduct[row.ProductName]; ok {
|
||||||
|
return adjustmentCandidateValidation{}, err
|
||||||
|
}
|
||||||
|
if result, ok := f.byProduct[row.ProductName]; ok {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
return adjustmentCandidateValidation{Status: "eligible", VerifiedQty: row.RemainingQty}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeAdjustmentSystemTransferExecutor struct {
|
||||||
|
createRequests []*transferSvc.SystemTransferRequest
|
||||||
|
createResponses []*entity.StockTransfer
|
||||||
|
createErrors []error
|
||||||
|
deletedTransferIDs []uint
|
||||||
|
deleteErrors map[uint]error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAdjustmentSystemTransferExecutor) CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error) {
|
||||||
|
f.createRequests = append(f.createRequests, req)
|
||||||
|
idx := len(f.createRequests) - 1
|
||||||
|
if idx < len(f.createErrors) && f.createErrors[idx] != nil {
|
||||||
|
return nil, f.createErrors[idx]
|
||||||
|
}
|
||||||
|
if idx < len(f.createResponses) && f.createResponses[idx] != nil {
|
||||||
|
return f.createResponses[idx], nil
|
||||||
|
}
|
||||||
|
return &entity.StockTransfer{Id: uint64(1000 + idx), MovementNumber: "PND-LTI-DEFAULT"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAdjustmentSystemTransferExecutor) DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error {
|
||||||
|
f.deletedTransferIDs = append(f.deletedTransferIDs, id)
|
||||||
|
if f.deleteErrors == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return f.deleteErrors[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
func uintPtr(v uint) *uint { return &v }
|
||||||
|
func strPtr(v string) *string { return &v }
|
||||||
|
|
||||||
|
var _ adjustmentCandidateValidator = (*fakeAdjustmentCandidateValidator)(nil)
|
||||||
|
var _ adjustmentSystemTransferExecutor = (*fakeAdjustmentSystemTransferExecutor)(nil)
|
||||||
|
var _ commonSvc.FifoStockV2Lane = fifoStockV2.LaneStockable
|
||||||
@@ -0,0 +1,825 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"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"
|
||||||
|
pwRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
|
||||||
|
transferRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories"
|
||||||
|
transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services"
|
||||||
|
warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
|
||||||
|
pfkRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
|
||||||
|
stockLogRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cutoverReasonPrefix = "EGG_FARM_CUTOVER"
|
||||||
|
outputModeTable = "table"
|
||||||
|
outputModeJSON = "json"
|
||||||
|
)
|
||||||
|
|
||||||
|
type commandOptions struct {
|
||||||
|
Apply bool
|
||||||
|
DryRun bool
|
||||||
|
RollbackRunID string
|
||||||
|
LocationID uint
|
||||||
|
LocationName string
|
||||||
|
CutoverDate time.Time
|
||||||
|
CutoverDateRaw string
|
||||||
|
IncludeOverlap bool
|
||||||
|
Output string
|
||||||
|
ActorID uint
|
||||||
|
RunID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type locationTiming struct {
|
||||||
|
LocationID uint
|
||||||
|
LocationName string
|
||||||
|
FirstKandangDate *time.Time
|
||||||
|
LastKandangDate *time.Time
|
||||||
|
FirstFarmDate *time.Time
|
||||||
|
LastFarmDate *time.Time
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
type legacyEggStockRow struct {
|
||||||
|
LocationID uint
|
||||||
|
LocationName string
|
||||||
|
SourceWarehouseID uint
|
||||||
|
SourceWarehouseName string
|
||||||
|
FarmWarehouseID *uint
|
||||||
|
FarmWarehouseName *string
|
||||||
|
ProductWarehouseID uint
|
||||||
|
ProductID uint
|
||||||
|
ProductName string
|
||||||
|
OnHandQty float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type migrationReportRow struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
LocationID uint `json:"location_id"`
|
||||||
|
LocationName string `json:"location_name"`
|
||||||
|
SourceWarehouseID uint `json:"source_warehouse_id"`
|
||||||
|
SourceWarehouseName string `json:"source_warehouse_name"`
|
||||||
|
FarmWarehouseID *uint `json:"farm_warehouse_id,omitempty"`
|
||||||
|
FarmWarehouseName *string `json:"farm_warehouse_name,omitempty"`
|
||||||
|
ProductWarehouseID uint `json:"product_warehouse_id"`
|
||||||
|
ProductID uint `json:"product_id"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
Qty float64 `json:"qty"`
|
||||||
|
LocationStatus string `json:"location_status"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
TransferID *uint64 `json:"transfer_id,omitempty"`
|
||||||
|
MovementNumber *string `json:"movement_number,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type applySummary struct {
|
||||||
|
RowsPlanned int `json:"rows_planned"`
|
||||||
|
RowsApplied int `json:"rows_applied"`
|
||||||
|
RowsSkipped int `json:"rows_skipped"`
|
||||||
|
RowsFailed int `json:"rows_failed"`
|
||||||
|
GroupsPlanned int `json:"groups_planned"`
|
||||||
|
GroupsApplied int `json:"groups_applied"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rollbackDetailRow struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
TransferID uint64 `json:"transfer_id"`
|
||||||
|
MovementNumber string `json:"movement_number"`
|
||||||
|
LocationName string `json:"location_name"`
|
||||||
|
SourceWarehouseName string `json:"source_warehouse_name"`
|
||||||
|
FarmWarehouseName string `json:"farm_warehouse_name"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
Qty float64 `json:"qty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type systemTransferExecutor interface {
|
||||||
|
CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error)
|
||||||
|
DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type transferGroup struct {
|
||||||
|
LocationID uint
|
||||||
|
LocationName string
|
||||||
|
SourceWarehouseID uint
|
||||||
|
SourceWarehouseName string
|
||||||
|
FarmWarehouseID uint
|
||||||
|
FarmWarehouseName string
|
||||||
|
Rows []*migrationReportRow
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
opts, err := parseFlags()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid flags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db := database.Connect(config.DBHost, config.DBName)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if strings.TrimSpace(opts.RollbackRunID) != "" {
|
||||||
|
rows, err := loadRollbackDetails(ctx, db, opts.RollbackRunID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to load rollback details: %v", err)
|
||||||
|
}
|
||||||
|
if !opts.Apply {
|
||||||
|
for i := range rows {
|
||||||
|
rows[i].Status = "eligible"
|
||||||
|
}
|
||||||
|
renderRollbackReport(opts.Output, rows)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := executeRollback(ctx, newSystemTransferService(db), rows, opts.ActorID); err != nil {
|
||||||
|
log.Fatalf("rollback failed: %v", err)
|
||||||
|
}
|
||||||
|
renderRollbackReport(opts.Output, rows)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timings, err := loadLocationTimings(ctx, db, opts)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to load location timings: %v", err)
|
||||||
|
}
|
||||||
|
legacyRows, err := loadLegacyEggStocks(ctx, db, opts)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to load legacy egg stocks: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reportRows, groups := buildMigrationPlan(opts, timings, legacyRows)
|
||||||
|
if !opts.Apply {
|
||||||
|
renderMigrationReport(opts.Output, reportRows, summarizeApply(reportRows, groups, 0))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := executeApply(ctx, newSystemTransferService(db), opts, groups)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("apply failed: %v", err)
|
||||||
|
}
|
||||||
|
finalRows := flattenGroups(groups, reportRows)
|
||||||
|
summary = summarizeApply(finalRows, groups, summary.GroupsApplied)
|
||||||
|
renderMigrationReport(opts.Output, finalRows, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFlags() (*commandOptions, error) {
|
||||||
|
var opts commandOptions
|
||||||
|
flag.BoolVar(&opts.Apply, "apply", false, "Apply migration. If false, run as dry-run")
|
||||||
|
flag.BoolVar(&opts.DryRun, "dry-run", true, "Run as dry-run")
|
||||||
|
flag.StringVar(&opts.RollbackRunID, "rollback-run-id", "", "Rollback all transfers created by the provided run id")
|
||||||
|
flag.UintVar(&opts.LocationID, "location-id", 0, "Filter by location id")
|
||||||
|
flag.StringVar(&opts.LocationName, "location-name", "", "Filter by exact location name")
|
||||||
|
flag.StringVar(&opts.CutoverDateRaw, "cutover-date", "", "Cutover date in YYYY-MM-DD format")
|
||||||
|
flag.BoolVar(&opts.IncludeOverlap, "include-overlap", false, "Include overlap locations in plan/apply")
|
||||||
|
flag.StringVar(&opts.Output, "output", outputModeTable, "Output format: table or json")
|
||||||
|
flag.UintVar(&opts.ActorID, "actor-id", 1, "Actor id used for created/deleted transfers")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
opts.LocationName = strings.TrimSpace(opts.LocationName)
|
||||||
|
opts.RollbackRunID = strings.TrimSpace(opts.RollbackRunID)
|
||||||
|
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
|
||||||
|
if opts.Output == "" {
|
||||||
|
opts.Output = outputModeTable
|
||||||
|
}
|
||||||
|
if opts.Output != outputModeTable && opts.Output != outputModeJSON {
|
||||||
|
return nil, fmt.Errorf("unsupported --output=%s", opts.Output)
|
||||||
|
}
|
||||||
|
if opts.Apply {
|
||||||
|
opts.DryRun = false
|
||||||
|
}
|
||||||
|
if opts.LocationID > 0 && opts.LocationName != "" {
|
||||||
|
return nil, errors.New("use either --location-id or --location-name, not both")
|
||||||
|
}
|
||||||
|
if opts.RollbackRunID != "" {
|
||||||
|
if opts.LocationID > 0 || opts.LocationName != "" {
|
||||||
|
return nil, errors.New("location filters are not supported with --rollback-run-id")
|
||||||
|
}
|
||||||
|
if opts.CutoverDateRaw != "" {
|
||||||
|
return nil, errors.New("--cutover-date is not used with --rollback-run-id")
|
||||||
|
}
|
||||||
|
} else if opts.Apply {
|
||||||
|
if opts.LocationID == 0 && opts.LocationName == "" {
|
||||||
|
return nil, errors.New("apply mode requires --location-id or --location-name for safety")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(opts.CutoverDateRaw) == "" {
|
||||||
|
return nil, errors.New("--cutover-date is required in apply mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(opts.CutoverDateRaw) == "" {
|
||||||
|
opts.CutoverDate = normalizeDateOnly(time.Now().In(time.FixedZone("Asia/Jakarta", 7*3600)))
|
||||||
|
} else {
|
||||||
|
t, err := time.Parse("2006-01-02", opts.CutoverDateRaw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid --cutover-date: %w", err)
|
||||||
|
}
|
||||||
|
opts.CutoverDate = normalizeDateOnly(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.RunID = buildRunID()
|
||||||
|
return &opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSystemTransferService(db *gorm.DB) systemTransferExecutor {
|
||||||
|
validate := validator.New()
|
||||||
|
stockTransferRepo := transferRepo.NewStockTransferRepository(db)
|
||||||
|
stockTransferDetailRepo := transferRepo.NewStockTransferDetailRepository(db)
|
||||||
|
stockTransferDeliveryRepo := transferRepo.NewStockTransferDeliveryRepository(db)
|
||||||
|
stockTransferDeliveryItemRepo := transferRepo.NewStockTransferDeliveryItemRepository(db)
|
||||||
|
stockLogsRepo := stockLogRepo.NewStockLogRepository(db)
|
||||||
|
productWarehouseRepo := pwRepo.NewProductWarehouseRepository(db)
|
||||||
|
warehouseRepository := warehouseRepo.NewWarehouseRepository(db)
|
||||||
|
projectFlockKandangRepo := pfkRepo.NewProjectFlockKandangRepository(db)
|
||||||
|
projectFlockPopulationRepo := pfkRepo.NewProjectFlockPopulationRepository(db)
|
||||||
|
fifoSvc := service.NewFifoStockV2Service(db, logrus.StandardLogger())
|
||||||
|
|
||||||
|
return transferSvc.NewTransferService(
|
||||||
|
validate,
|
||||||
|
stockTransferRepo,
|
||||||
|
stockTransferDetailRepo,
|
||||||
|
stockTransferDeliveryRepo,
|
||||||
|
stockTransferDeliveryItemRepo,
|
||||||
|
stockLogsRepo,
|
||||||
|
productWarehouseRepo,
|
||||||
|
nil,
|
||||||
|
warehouseRepository,
|
||||||
|
projectFlockKandangRepo,
|
||||||
|
projectFlockPopulationRepo,
|
||||||
|
nil,
|
||||||
|
fifoSvc,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadLocationTimings(ctx context.Context, db *gorm.DB, opts *commandOptions) (map[uint]locationTiming, error) {
|
||||||
|
type row struct {
|
||||||
|
LocationID uint `gorm:"column:location_id"`
|
||||||
|
LocationName string `gorm:"column:location_name"`
|
||||||
|
FirstKandangDate *time.Time `gorm:"column:first_kandang_date"`
|
||||||
|
LastKandangDate *time.Time `gorm:"column:last_kandang_date"`
|
||||||
|
FirstFarmDate *time.Time `gorm:"column:first_farm_date"`
|
||||||
|
LastFarmDate *time.Time `gorm:"column:last_farm_date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
query := db.WithContext(ctx).
|
||||||
|
Table("recording_eggs re").
|
||||||
|
Select(`
|
||||||
|
pf.location_id AS location_id,
|
||||||
|
l.name AS location_name,
|
||||||
|
MIN(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS first_kandang_date,
|
||||||
|
MAX(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS last_kandang_date,
|
||||||
|
MIN(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS first_farm_date,
|
||||||
|
MAX(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS last_farm_date
|
||||||
|
`).
|
||||||
|
Joins("JOIN recordings r ON r.id = re.recording_id").
|
||||||
|
Joins("JOIN project_flock_kandangs pk ON pk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)").
|
||||||
|
Joins("JOIN project_flocks pf ON pf.id = pk.project_flock_id").
|
||||||
|
Joins("JOIN locations l ON l.id = pf.location_id").
|
||||||
|
Joins("JOIN product_warehouses pw ON pw.id = re.product_warehouse_id").
|
||||||
|
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
|
||||||
|
Group("pf.location_id, l.name")
|
||||||
|
query = applyTimingLocationFilter(query, opts)
|
||||||
|
|
||||||
|
var rows []row
|
||||||
|
if err := query.Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[uint]locationTiming, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
status := "KANDANG_ONLY"
|
||||||
|
if row.FirstFarmDate != nil {
|
||||||
|
status = "OVERLAP"
|
||||||
|
if row.LastKandangDate == nil || row.FirstFarmDate.After(normalizeDateOnly(*row.LastKandangDate)) {
|
||||||
|
status = "CLEAN_CUTOVER"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[row.LocationID] = locationTiming{
|
||||||
|
LocationID: row.LocationID,
|
||||||
|
LocationName: row.LocationName,
|
||||||
|
FirstKandangDate: normalizeDatePtr(row.FirstKandangDate),
|
||||||
|
LastKandangDate: normalizeDatePtr(row.LastKandangDate),
|
||||||
|
FirstFarmDate: normalizeDatePtr(row.FirstFarmDate),
|
||||||
|
LastFarmDate: normalizeDatePtr(row.LastFarmDate),
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadLegacyEggStocks(ctx context.Context, db *gorm.DB, opts *commandOptions) ([]legacyEggStockRow, error) {
|
||||||
|
type row struct {
|
||||||
|
LocationID uint `gorm:"column:location_id"`
|
||||||
|
LocationName string `gorm:"column:location_name"`
|
||||||
|
SourceWarehouseID uint `gorm:"column:source_warehouse_id"`
|
||||||
|
SourceWarehouseName string `gorm:"column:source_warehouse_name"`
|
||||||
|
FarmWarehouseID *uint `gorm:"column:farm_warehouse_id"`
|
||||||
|
FarmWarehouseName *string `gorm:"column:farm_warehouse_name"`
|
||||||
|
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
||||||
|
ProductID uint `gorm:"column:product_id"`
|
||||||
|
ProductName string `gorm:"column:product_name"`
|
||||||
|
OnHandQty float64 `gorm:"column:on_hand_qty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
firstFarmSub := db.WithContext(ctx).
|
||||||
|
Table("warehouses fw").
|
||||||
|
Select("fw.location_id AS location_id, MIN(fw.id) AS farm_warehouse_id").
|
||||||
|
Where("fw.deleted_at IS NULL").
|
||||||
|
Where("fw.type = ?", "LOKASI").
|
||||||
|
Group("fw.location_id")
|
||||||
|
|
||||||
|
query := db.WithContext(ctx).
|
||||||
|
Table("product_warehouses pw").
|
||||||
|
Select(`
|
||||||
|
kw.location_id AS location_id,
|
||||||
|
l.name AS location_name,
|
||||||
|
kw.id AS source_warehouse_id,
|
||||||
|
kw.name AS source_warehouse_name,
|
||||||
|
fw.id AS farm_warehouse_id,
|
||||||
|
fw.name AS farm_warehouse_name,
|
||||||
|
pw.id AS product_warehouse_id,
|
||||||
|
pw.product_id AS product_id,
|
||||||
|
p.name AS product_name,
|
||||||
|
COALESCE(pw.qty, 0) AS on_hand_qty
|
||||||
|
`).
|
||||||
|
Joins("JOIN warehouses kw ON kw.id = pw.warehouse_id AND kw.deleted_at IS NULL").
|
||||||
|
Joins("JOIN locations l ON l.id = kw.location_id").
|
||||||
|
Joins("JOIN products p ON p.id = pw.product_id").
|
||||||
|
Joins("LEFT JOIN product_categories pc ON pc.id = p.product_category_id").
|
||||||
|
Joins("LEFT JOIN (?) ff ON ff.location_id = kw.location_id", firstFarmSub).
|
||||||
|
Joins("LEFT JOIN warehouses fw ON fw.id = ff.farm_warehouse_id").
|
||||||
|
Where("kw.type = ?", "KANDANG").
|
||||||
|
Where(`
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM recording_eggs re
|
||||||
|
WHERE re.product_warehouse_id = pw.id
|
||||||
|
)
|
||||||
|
`).
|
||||||
|
Where(`
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM flags f
|
||||||
|
WHERE f.flagable_type = ?
|
||||||
|
AND f.flagable_id = p.id
|
||||||
|
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM flags f_any
|
||||||
|
WHERE f_any.flagable_type = ?
|
||||||
|
AND f_any.flagable_id = p.id
|
||||||
|
)
|
||||||
|
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
|
||||||
|
)
|
||||||
|
`, entity.FlagableTypeProduct, entity.FlagableTypeProduct).
|
||||||
|
Order("l.name ASC, kw.name ASC, p.name ASC")
|
||||||
|
query = applyLegacyStockLocationFilter(query, opts)
|
||||||
|
|
||||||
|
var rows []row
|
||||||
|
if err := query.Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]legacyEggStockRow, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
result = append(result, legacyEggStockRow{
|
||||||
|
LocationID: row.LocationID,
|
||||||
|
LocationName: row.LocationName,
|
||||||
|
SourceWarehouseID: row.SourceWarehouseID,
|
||||||
|
SourceWarehouseName: row.SourceWarehouseName,
|
||||||
|
FarmWarehouseID: row.FarmWarehouseID,
|
||||||
|
FarmWarehouseName: row.FarmWarehouseName,
|
||||||
|
ProductWarehouseID: row.ProductWarehouseID,
|
||||||
|
ProductID: row.ProductID,
|
||||||
|
ProductName: row.ProductName,
|
||||||
|
OnHandQty: row.OnHandQty,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMigrationPlan(
|
||||||
|
opts *commandOptions,
|
||||||
|
timings map[uint]locationTiming,
|
||||||
|
rows []legacyEggStockRow,
|
||||||
|
) ([]migrationReportRow, []transferGroup) {
|
||||||
|
reportRows := make([]migrationReportRow, 0, len(rows))
|
||||||
|
groupMap := make(map[string]*transferGroup)
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
locationStatus := "UNKNOWN"
|
||||||
|
if timing, ok := timings[row.LocationID]; ok {
|
||||||
|
locationStatus = timing.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
report := migrationReportRow{
|
||||||
|
RunID: opts.RunID,
|
||||||
|
LocationID: row.LocationID,
|
||||||
|
LocationName: row.LocationName,
|
||||||
|
SourceWarehouseID: row.SourceWarehouseID,
|
||||||
|
SourceWarehouseName: row.SourceWarehouseName,
|
||||||
|
FarmWarehouseID: row.FarmWarehouseID,
|
||||||
|
FarmWarehouseName: row.FarmWarehouseName,
|
||||||
|
ProductWarehouseID: row.ProductWarehouseID,
|
||||||
|
ProductID: row.ProductID,
|
||||||
|
ProductName: row.ProductName,
|
||||||
|
Qty: row.OnHandQty,
|
||||||
|
LocationStatus: locationStatus,
|
||||||
|
Status: "eligible",
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case row.FarmWarehouseID == nil || row.FarmWarehouseName == nil:
|
||||||
|
report.Status = "skipped"
|
||||||
|
report.Reason = "missing_farm_warehouse"
|
||||||
|
case row.OnHandQty <= 0:
|
||||||
|
report.Status = "skipped"
|
||||||
|
report.Reason = "non_positive_qty"
|
||||||
|
case locationStatus == "OVERLAP" && !opts.IncludeOverlap:
|
||||||
|
report.Status = "skipped"
|
||||||
|
report.Reason = "overlap_location"
|
||||||
|
}
|
||||||
|
|
||||||
|
reportRows = append(reportRows, report)
|
||||||
|
if report.Status != "eligible" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
groupKey := fmt.Sprintf("%d:%d", row.SourceWarehouseID, *row.FarmWarehouseID)
|
||||||
|
group := groupMap[groupKey]
|
||||||
|
if group == nil {
|
||||||
|
group = &transferGroup{
|
||||||
|
LocationID: row.LocationID,
|
||||||
|
LocationName: row.LocationName,
|
||||||
|
SourceWarehouseID: row.SourceWarehouseID,
|
||||||
|
SourceWarehouseName: row.SourceWarehouseName,
|
||||||
|
FarmWarehouseID: *row.FarmWarehouseID,
|
||||||
|
FarmWarehouseName: derefString(row.FarmWarehouseName),
|
||||||
|
}
|
||||||
|
groupMap[groupKey] = group
|
||||||
|
}
|
||||||
|
group.Rows = append(group.Rows, &reportRows[len(reportRows)-1])
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := make([]transferGroup, 0, len(groupMap))
|
||||||
|
for _, group := range groupMap {
|
||||||
|
sort.Slice(group.Rows, func(i, j int) bool {
|
||||||
|
return group.Rows[i].ProductName < group.Rows[j].ProductName
|
||||||
|
})
|
||||||
|
groups = append(groups, *group)
|
||||||
|
}
|
||||||
|
sort.Slice(groups, func(i, j int) bool {
|
||||||
|
if groups[i].LocationName == groups[j].LocationName {
|
||||||
|
return groups[i].SourceWarehouseName < groups[j].SourceWarehouseName
|
||||||
|
}
|
||||||
|
return groups[i].LocationName < groups[j].LocationName
|
||||||
|
})
|
||||||
|
|
||||||
|
return reportRows, groups
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeApply(
|
||||||
|
ctx context.Context,
|
||||||
|
svc systemTransferExecutor,
|
||||||
|
opts *commandOptions,
|
||||||
|
groups []transferGroup,
|
||||||
|
) (applySummary, error) {
|
||||||
|
summary := applySummary{GroupsPlanned: len(groups)}
|
||||||
|
for _, group := range groups {
|
||||||
|
products := make([]transferSvc.SystemTransferProduct, 0, len(group.Rows))
|
||||||
|
for _, row := range group.Rows {
|
||||||
|
products = append(products, transferSvc.SystemTransferProduct{
|
||||||
|
ProductID: row.ProductID,
|
||||||
|
ProductQty: row.Qty,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
reason := buildCutoverReason(opts.RunID, group.LocationName, opts.CutoverDate)
|
||||||
|
transfer, err := svc.CreateSystemTransfer(ctx, &transferSvc.SystemTransferRequest{
|
||||||
|
TransferReason: reason,
|
||||||
|
TransferDate: opts.CutoverDate,
|
||||||
|
SourceWarehouseID: group.SourceWarehouseID,
|
||||||
|
DestinationWarehouseID: group.FarmWarehouseID,
|
||||||
|
Products: products,
|
||||||
|
ActorID: opts.ActorID,
|
||||||
|
StockLogNotes: reason,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
for _, row := range group.Rows {
|
||||||
|
row.Status = "failed"
|
||||||
|
row.Reason = err.Error()
|
||||||
|
summary.RowsFailed++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.GroupsApplied++
|
||||||
|
for _, row := range group.Rows {
|
||||||
|
row.Status = "applied"
|
||||||
|
row.TransferID = &transfer.Id
|
||||||
|
row.MovementNumber = &transfer.MovementNumber
|
||||||
|
summary.RowsApplied++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, group := range groups {
|
||||||
|
summary.RowsPlanned += len(group.Rows)
|
||||||
|
}
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeRollback(
|
||||||
|
ctx context.Context,
|
||||||
|
svc systemTransferExecutor,
|
||||||
|
rows []rollbackDetailRow,
|
||||||
|
actorID uint,
|
||||||
|
) error {
|
||||||
|
if actorID == 0 {
|
||||||
|
return fmt.Errorf("actor id is required for rollback")
|
||||||
|
}
|
||||||
|
|
||||||
|
byTransfer := make(map[uint64][]int)
|
||||||
|
for idx, row := range rows {
|
||||||
|
byTransfer[row.TransferID] = append(byTransfer[row.TransferID], idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
transferIDs := make([]uint64, 0, len(byTransfer))
|
||||||
|
for transferID := range byTransfer {
|
||||||
|
transferIDs = append(transferIDs, transferID)
|
||||||
|
}
|
||||||
|
sort.Slice(transferIDs, func(i, j int) bool { return transferIDs[i] > transferIDs[j] })
|
||||||
|
|
||||||
|
var firstErr error
|
||||||
|
for _, transferID := range transferIDs {
|
||||||
|
err := svc.DeleteSystemTransfer(ctx, uint(transferID), actorID)
|
||||||
|
for _, idx := range byTransfer[transferID] {
|
||||||
|
if err != nil {
|
||||||
|
rows[idx].Status = "failed"
|
||||||
|
rows[idx].Reason = err.Error()
|
||||||
|
} else {
|
||||||
|
rows[idx].Status = "rolled_back"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRollbackDetails(ctx context.Context, db *gorm.DB, runID string) ([]rollbackDetailRow, error) {
|
||||||
|
type row struct {
|
||||||
|
TransferID uint64 `gorm:"column:transfer_id"`
|
||||||
|
MovementNumber string `gorm:"column:movement_number"`
|
||||||
|
LocationName string `gorm:"column:location_name"`
|
||||||
|
SourceWarehouseName string `gorm:"column:source_warehouse_name"`
|
||||||
|
FarmWarehouseName string `gorm:"column:farm_warehouse_name"`
|
||||||
|
ProductName string `gorm:"column:product_name"`
|
||||||
|
Qty float64 `gorm:"column:qty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
needle := buildRunReasonMatcher(runID)
|
||||||
|
var dbRows []row
|
||||||
|
err := db.WithContext(ctx).
|
||||||
|
Table("stock_transfers st").
|
||||||
|
Select(`
|
||||||
|
st.id AS transfer_id,
|
||||||
|
st.movement_number AS movement_number,
|
||||||
|
COALESCE(loc.name, '') AS location_name,
|
||||||
|
ws.name AS source_warehouse_name,
|
||||||
|
wd.name AS farm_warehouse_name,
|
||||||
|
p.name AS product_name,
|
||||||
|
COALESCE(std.total_qty, std.usage_qty, 0) AS qty
|
||||||
|
`).
|
||||||
|
Joins("JOIN warehouses ws ON ws.id = st.from_warehouse_id").
|
||||||
|
Joins("JOIN warehouses wd ON wd.id = st.to_warehouse_id").
|
||||||
|
Joins("LEFT JOIN locations loc ON loc.id = COALESCE(ws.location_id, wd.location_id)").
|
||||||
|
Joins("JOIN stock_transfer_details std ON std.stock_transfer_id = st.id AND std.deleted_at IS NULL").
|
||||||
|
Joins("JOIN products p ON p.id = std.product_id").
|
||||||
|
Where("st.deleted_at IS NULL").
|
||||||
|
Where("st.reason LIKE ?", needle).
|
||||||
|
Order("st.id DESC, std.id ASC").
|
||||||
|
Scan(&dbRows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]rollbackDetailRow, 0, len(dbRows))
|
||||||
|
for _, row := range dbRows {
|
||||||
|
rows = append(rows, rollbackDetailRow{
|
||||||
|
RunID: runID,
|
||||||
|
TransferID: row.TransferID,
|
||||||
|
MovementNumber: row.MovementNumber,
|
||||||
|
LocationName: row.LocationName,
|
||||||
|
SourceWarehouseName: row.SourceWarehouseName,
|
||||||
|
FarmWarehouseName: row.FarmWarehouseName,
|
||||||
|
ProductName: row.ProductName,
|
||||||
|
Qty: row.Qty,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyTimingLocationFilter(db *gorm.DB, opts *commandOptions) *gorm.DB {
|
||||||
|
if opts == nil {
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case opts.LocationID > 0:
|
||||||
|
return db.Where("pf.location_id = ?", opts.LocationID)
|
||||||
|
case opts.LocationName != "":
|
||||||
|
return db.Where("LOWER(l.name) = LOWER(?)", opts.LocationName)
|
||||||
|
default:
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyLegacyStockLocationFilter(db *gorm.DB, opts *commandOptions) *gorm.DB {
|
||||||
|
if opts == nil {
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case opts.LocationID > 0:
|
||||||
|
return db.Where("kw.location_id = ?", opts.LocationID)
|
||||||
|
case opts.LocationName != "":
|
||||||
|
return db.Where("LOWER(l.name) = LOWER(?)", opts.LocationName)
|
||||||
|
default:
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCutoverReason(runID, locationName string, cutoverDate time.Time) string {
|
||||||
|
locationName = strings.ReplaceAll(strings.TrimSpace(locationName), "|", "/")
|
||||||
|
return fmt.Sprintf("%s|run_id=%s|location=%s|cutover_date=%s", cutoverReasonPrefix, runID, locationName, cutoverDate.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRunReasonMatcher(runID string) string {
|
||||||
|
return fmt.Sprintf("%s|run_id=%s|%%", cutoverReasonPrefix, strings.TrimSpace(runID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRunID() string {
|
||||||
|
return fmt.Sprintf("egg-cutover-%s", time.Now().UTC().Format("20060102T150405.000000000Z"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDateOnly(value time.Time) time.Time {
|
||||||
|
return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDatePtr(value *time.Time) *time.Time {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
normalized := normalizeDateOnly(*value)
|
||||||
|
return &normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func derefString(value *string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarizeApply(rows []migrationReportRow, groups []transferGroup, appliedGroups int) applySummary {
|
||||||
|
summary := applySummary{
|
||||||
|
GroupsPlanned: len(groups),
|
||||||
|
GroupsApplied: appliedGroups,
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
switch row.Status {
|
||||||
|
case "eligible":
|
||||||
|
summary.RowsPlanned++
|
||||||
|
case "applied":
|
||||||
|
summary.RowsPlanned++
|
||||||
|
summary.RowsApplied++
|
||||||
|
case "failed":
|
||||||
|
summary.RowsPlanned++
|
||||||
|
summary.RowsFailed++
|
||||||
|
case "skipped":
|
||||||
|
summary.RowsSkipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func flattenGroups(groups []transferGroup, fallback []migrationReportRow) []migrationReportRow {
|
||||||
|
if len(groups) == 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
rows := make([]migrationReportRow, 0, len(fallback))
|
||||||
|
for _, group := range groups {
|
||||||
|
for _, row := range group.Rows {
|
||||||
|
rows = append(rows, *row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, row := range fallback {
|
||||||
|
if row.Status == "skipped" {
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(rows, func(i, j int) bool {
|
||||||
|
if rows[i].LocationName == rows[j].LocationName {
|
||||||
|
if rows[i].SourceWarehouseName == rows[j].SourceWarehouseName {
|
||||||
|
return rows[i].ProductName < rows[j].ProductName
|
||||||
|
}
|
||||||
|
return rows[i].SourceWarehouseName < rows[j].SourceWarehouseName
|
||||||
|
}
|
||||||
|
return rows[i].LocationName < rows[j].LocationName
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderMigrationReport(mode string, rows []migrationReportRow, summary applySummary) {
|
||||||
|
if mode == outputModeJSON {
|
||||||
|
payload := map[string]any{
|
||||||
|
"rows": rows,
|
||||||
|
"summary": summary,
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
_ = enc.Encode(payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "RUN_ID\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tQTY\tLOCATION_STATUS\tSTATUS\tREASON\tTRANSFER_ID\tMOVEMENT_NUMBER")
|
||||||
|
for _, row := range rows {
|
||||||
|
transferID := "-"
|
||||||
|
if row.TransferID != nil {
|
||||||
|
transferID = fmt.Sprintf("%d", *row.TransferID)
|
||||||
|
}
|
||||||
|
movementNumber := "-"
|
||||||
|
if row.MovementNumber != nil {
|
||||||
|
movementNumber = *row.MovementNumber
|
||||||
|
}
|
||||||
|
fmt.Fprintf(
|
||||||
|
w,
|
||||||
|
"%s\t%s\t%s\t%s\t%s\t%.3f\t%s\t%s\t%s\t%s\t%s\n",
|
||||||
|
row.RunID,
|
||||||
|
row.LocationName,
|
||||||
|
row.SourceWarehouseName,
|
||||||
|
derefString(row.FarmWarehouseName),
|
||||||
|
row.ProductName,
|
||||||
|
row.Qty,
|
||||||
|
row.LocationStatus,
|
||||||
|
row.Status,
|
||||||
|
row.Reason,
|
||||||
|
transferID,
|
||||||
|
movementNumber,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ = w.Flush()
|
||||||
|
fmt.Printf("\nSummary: rows_planned=%d rows_applied=%d rows_skipped=%d rows_failed=%d groups_planned=%d groups_applied=%d\n",
|
||||||
|
summary.RowsPlanned, summary.RowsApplied, summary.RowsSkipped, summary.RowsFailed, summary.GroupsPlanned, summary.GroupsApplied)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderRollbackReport(mode string, rows []rollbackDetailRow) {
|
||||||
|
if mode == outputModeJSON {
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
_ = enc.Encode(map[string]any{"rows": rows})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "RUN_ID\tTRANSFER_ID\tMOVEMENT_NUMBER\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tQTY\tSTATUS\tREASON")
|
||||||
|
for _, row := range rows {
|
||||||
|
fmt.Fprintf(
|
||||||
|
w,
|
||||||
|
"%s\t%d\t%s\t%s\t%s\t%s\t%s\t%.3f\t%s\t%s\n",
|
||||||
|
row.RunID,
|
||||||
|
row.TransferID,
|
||||||
|
row.MovementNumber,
|
||||||
|
row.LocationName,
|
||||||
|
row.SourceWarehouseName,
|
||||||
|
row.FarmWarehouseName,
|
||||||
|
row.ProductName,
|
||||||
|
row.Qty,
|
||||||
|
row.Status,
|
||||||
|
row.Reason,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ = w.Flush()
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildMigrationPlanSkipsOverlapAndGroupsEligibleRows(t *testing.T) {
|
||||||
|
opts := &commandOptions{
|
||||||
|
RunID: "egg-cutover-test",
|
||||||
|
IncludeOverlap: false,
|
||||||
|
}
|
||||||
|
timings := map[uint]locationTiming{
|
||||||
|
16: {LocationID: 16, LocationName: "Jamali", Status: "CLEAN_CUTOVER"},
|
||||||
|
17: {LocationID: 17, LocationName: "Cijangkar", Status: "OVERLAP"},
|
||||||
|
}
|
||||||
|
farmID := uint(25)
|
||||||
|
farmName := "Gudang Farm Jamali"
|
||||||
|
|
||||||
|
rows := []legacyEggStockRow{
|
||||||
|
{
|
||||||
|
LocationID: 16,
|
||||||
|
LocationName: "Jamali",
|
||||||
|
SourceWarehouseID: 46,
|
||||||
|
SourceWarehouseName: "Gudang Jamali 1",
|
||||||
|
FarmWarehouseID: &farmID,
|
||||||
|
FarmWarehouseName: &farmName,
|
||||||
|
ProductWarehouseID: 101,
|
||||||
|
ProductID: 8,
|
||||||
|
ProductName: "Telur Utuh",
|
||||||
|
OnHandQty: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
LocationID: 16,
|
||||||
|
LocationName: "Jamali",
|
||||||
|
SourceWarehouseID: 46,
|
||||||
|
SourceWarehouseName: "Gudang Jamali 1",
|
||||||
|
FarmWarehouseID: &farmID,
|
||||||
|
FarmWarehouseName: &farmName,
|
||||||
|
ProductWarehouseID: 102,
|
||||||
|
ProductID: 9,
|
||||||
|
ProductName: "Telur Putih",
|
||||||
|
OnHandQty: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
LocationID: 17,
|
||||||
|
LocationName: "Cijangkar",
|
||||||
|
SourceWarehouseID: 51,
|
||||||
|
SourceWarehouseName: "Gudang Cijangkar 1",
|
||||||
|
FarmWarehouseID: &farmID,
|
||||||
|
FarmWarehouseName: &farmName,
|
||||||
|
ProductWarehouseID: 103,
|
||||||
|
ProductID: 10,
|
||||||
|
ProductName: "Telur Jumbo",
|
||||||
|
OnHandQty: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
LocationID: 16,
|
||||||
|
LocationName: "Jamali",
|
||||||
|
SourceWarehouseID: 46,
|
||||||
|
SourceWarehouseName: "Gudang Jamali 1",
|
||||||
|
ProductWarehouseID: 104,
|
||||||
|
ProductID: 11,
|
||||||
|
ProductName: "Telur Papacal",
|
||||||
|
OnHandQty: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
LocationID: 16,
|
||||||
|
LocationName: "Jamali",
|
||||||
|
SourceWarehouseID: 46,
|
||||||
|
SourceWarehouseName: "Gudang Jamali 1",
|
||||||
|
FarmWarehouseID: &farmID,
|
||||||
|
FarmWarehouseName: &farmName,
|
||||||
|
ProductWarehouseID: 105,
|
||||||
|
ProductID: 12,
|
||||||
|
ProductName: "Telur Retak",
|
||||||
|
OnHandQty: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
reportRows, groups := buildMigrationPlan(opts, timings, rows)
|
||||||
|
|
||||||
|
if len(reportRows) != 5 {
|
||||||
|
t.Fatalf("expected 5 report rows, got %d", len(reportRows))
|
||||||
|
}
|
||||||
|
if len(groups) != 1 {
|
||||||
|
t.Fatalf("expected 1 eligible transfer group, got %d", len(groups))
|
||||||
|
}
|
||||||
|
if len(groups[0].Rows) != 2 {
|
||||||
|
t.Fatalf("expected 2 eligible products in the transfer group, got %d", len(groups[0].Rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
statusByProduct := make(map[string]string, len(reportRows))
|
||||||
|
reasonByProduct := make(map[string]string, len(reportRows))
|
||||||
|
for _, row := range reportRows {
|
||||||
|
statusByProduct[row.ProductName] = row.Status
|
||||||
|
reasonByProduct[row.ProductName] = row.Reason
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusByProduct["Telur Utuh"] != "eligible" || statusByProduct["Telur Putih"] != "eligible" {
|
||||||
|
t.Fatalf("expected Jamali egg rows to stay eligible, got statuses %+v", statusByProduct)
|
||||||
|
}
|
||||||
|
if reasonByProduct["Telur Jumbo"] != "overlap_location" {
|
||||||
|
t.Fatalf("expected overlap location skip, got %q", reasonByProduct["Telur Jumbo"])
|
||||||
|
}
|
||||||
|
if reasonByProduct["Telur Papacal"] != "missing_farm_warehouse" {
|
||||||
|
t.Fatalf("expected missing farm warehouse skip, got %q", reasonByProduct["Telur Papacal"])
|
||||||
|
}
|
||||||
|
if reasonByProduct["Telur Retak"] != "non_positive_qty" {
|
||||||
|
t.Fatalf("expected non positive qty skip, got %q", reasonByProduct["Telur Retak"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteApplyBuildsTaggedSystemTransfersAndSummaries(t *testing.T) {
|
||||||
|
opts := &commandOptions{
|
||||||
|
RunID: "egg-cutover-apply",
|
||||||
|
CutoverDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC),
|
||||||
|
ActorID: 99,
|
||||||
|
}
|
||||||
|
groups := []transferGroup{
|
||||||
|
{
|
||||||
|
LocationID: 16,
|
||||||
|
LocationName: "Jamali",
|
||||||
|
SourceWarehouseID: 46,
|
||||||
|
SourceWarehouseName: "Gudang Jamali 1",
|
||||||
|
FarmWarehouseID: 25,
|
||||||
|
FarmWarehouseName: "Gudang Farm Jamali",
|
||||||
|
Rows: []*migrationReportRow{
|
||||||
|
{ProductID: 8, ProductName: "Telur Utuh", Qty: 120},
|
||||||
|
{ProductID: 9, ProductName: "Telur Putih", Qty: 20},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
LocationID: 18,
|
||||||
|
LocationName: "Tamansari",
|
||||||
|
SourceWarehouseID: 91,
|
||||||
|
SourceWarehouseName: "Gudang Tamansari 1",
|
||||||
|
FarmWarehouseID: 31,
|
||||||
|
FarmWarehouseName: "Gudang Farm Tamansari",
|
||||||
|
Rows: []*migrationReportRow{
|
||||||
|
{ProductID: 10, ProductName: "Telur Jumbo", Qty: 10},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
executor := &fakeSystemTransferExecutor{
|
||||||
|
createResponses: []*entity.StockTransfer{
|
||||||
|
{Id: 1001, MovementNumber: "PND-LTI-1001"},
|
||||||
|
},
|
||||||
|
createErrors: []error{
|
||||||
|
nil,
|
||||||
|
errors.New("destination warehouse locked"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := executeApply(context.Background(), executor, opts, groups)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no fatal apply error, got %v", err)
|
||||||
|
}
|
||||||
|
if summary.GroupsPlanned != 2 || summary.GroupsApplied != 1 {
|
||||||
|
t.Fatalf("unexpected group summary: %+v", summary)
|
||||||
|
}
|
||||||
|
if summary.RowsApplied != 2 || summary.RowsFailed != 1 {
|
||||||
|
t.Fatalf("unexpected row summary: %+v", summary)
|
||||||
|
}
|
||||||
|
if len(executor.createRequests) != 2 {
|
||||||
|
t.Fatalf("expected 2 create requests, got %d", len(executor.createRequests))
|
||||||
|
}
|
||||||
|
if !strings.Contains(executor.createRequests[0].TransferReason, "EGG_FARM_CUTOVER|run_id=egg-cutover-apply|location=Jamali|cutover_date=2026-04-07") {
|
||||||
|
t.Fatalf("unexpected transfer reason: %s", executor.createRequests[0].TransferReason)
|
||||||
|
}
|
||||||
|
if executor.createRequests[0].MovementNumber != "" {
|
||||||
|
t.Fatalf("apply path should let transfer service generate movement number, got %q", executor.createRequests[0].MovementNumber)
|
||||||
|
}
|
||||||
|
if groups[0].Rows[0].Status != "applied" || groups[0].Rows[1].Status != "applied" {
|
||||||
|
t.Fatalf("expected first group rows to be applied, got %+v", groups[0].Rows)
|
||||||
|
}
|
||||||
|
if groups[1].Rows[0].Status != "failed" {
|
||||||
|
t.Fatalf("expected second group row to fail, got %+v", groups[1].Rows[0])
|
||||||
|
}
|
||||||
|
if groups[0].Rows[0].TransferID == nil || *groups[0].Rows[0].TransferID != 1001 {
|
||||||
|
t.Fatalf("expected first row to keep created transfer id, got %+v", groups[0].Rows[0].TransferID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteRollbackDeletesTransfersDescendingAndMarksFailures(t *testing.T) {
|
||||||
|
executor := &fakeSystemTransferExecutor{
|
||||||
|
deleteErrors: map[uint]error{
|
||||||
|
101: errors.New("already consumed downstream"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rows := []rollbackDetailRow{
|
||||||
|
{TransferID: 100, ProductName: "Telur Utuh"},
|
||||||
|
{TransferID: 101, ProductName: "Telur Jumbo"},
|
||||||
|
{TransferID: 100, ProductName: "Telur Putih"},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := executeRollback(context.Background(), executor, rows, 99)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected rollback to return the first transfer error")
|
||||||
|
}
|
||||||
|
if err.Error() != "already consumed downstream" {
|
||||||
|
t.Fatalf("unexpected rollback error: %v", err)
|
||||||
|
}
|
||||||
|
if len(executor.deletedTransferIDs) != 2 {
|
||||||
|
t.Fatalf("expected 2 delete calls, got %d", len(executor.deletedTransferIDs))
|
||||||
|
}
|
||||||
|
if executor.deletedTransferIDs[0] != 101 || executor.deletedTransferIDs[1] != 100 {
|
||||||
|
t.Fatalf("expected delete order [101 100], got %v", executor.deletedTransferIDs)
|
||||||
|
}
|
||||||
|
if rows[0].Status != "rolled_back" || rows[2].Status != "rolled_back" {
|
||||||
|
t.Fatalf("expected transfer 100 rows to be rolled back, got %+v", rows)
|
||||||
|
}
|
||||||
|
if rows[1].Status != "failed" {
|
||||||
|
t.Fatalf("expected transfer 101 row to fail, got %+v", rows[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeSystemTransferExecutor struct {
|
||||||
|
createRequests []*transferSvc.SystemTransferRequest
|
||||||
|
createResponses []*entity.StockTransfer
|
||||||
|
createErrors []error
|
||||||
|
deletedTransferIDs []uint
|
||||||
|
deleteErrors map[uint]error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSystemTransferExecutor) CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error) {
|
||||||
|
f.createRequests = append(f.createRequests, req)
|
||||||
|
idx := len(f.createRequests) - 1
|
||||||
|
if idx < len(f.createErrors) && f.createErrors[idx] != nil {
|
||||||
|
return nil, f.createErrors[idx]
|
||||||
|
}
|
||||||
|
if idx < len(f.createResponses) && f.createResponses[idx] != nil {
|
||||||
|
return f.createResponses[idx], nil
|
||||||
|
}
|
||||||
|
return &entity.StockTransfer{Id: uint64(1000 + idx), MovementNumber: "PND-LTI-DEFAULT"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSystemTransferExecutor) DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error {
|
||||||
|
f.deletedTransferIDs = append(f.deletedTransferIDs, id)
|
||||||
|
if f.deleteErrors == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return f.deleteErrors[id]
|
||||||
|
}
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||||
|
recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const metricEpsilon = 1e-9
|
||||||
|
|
||||||
|
type normalizeOptions struct {
|
||||||
|
Apply bool
|
||||||
|
RecordingID uint
|
||||||
|
ProjectFlockKandangID uint
|
||||||
|
From *time.Time
|
||||||
|
To *time.Time
|
||||||
|
BatchSize int
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
type normalizeStats struct {
|
||||||
|
Processed int
|
||||||
|
Changed int
|
||||||
|
Updated int
|
||||||
|
Skipped int
|
||||||
|
Failed int
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingMetricRow struct {
|
||||||
|
ID uint `gorm:"column:id"`
|
||||||
|
ProjectFlockKandangID uint `gorm:"column:project_flock_kandangs_id"`
|
||||||
|
RecordDatetime time.Time `gorm:"column:record_datetime"`
|
||||||
|
HenHouse *float64 `gorm:"column:hen_house"`
|
||||||
|
EggMass *float64 `gorm:"column:egg_mass"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
apply bool
|
||||||
|
recordingID uint
|
||||||
|
projectFlockKandangID uint
|
||||||
|
fromRaw string
|
||||||
|
toRaw string
|
||||||
|
batchSize int
|
||||||
|
limit int
|
||||||
|
)
|
||||||
|
|
||||||
|
flag.BoolVar(&apply, "apply", false, "Apply update. If false, run as dry-run")
|
||||||
|
flag.UintVar(&recordingID, "recording-id", 0, "Target a single recording ID")
|
||||||
|
flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Filter by project_flock_kandangs_id")
|
||||||
|
flag.StringVar(&fromRaw, "from", "", "Lower bound record_datetime (RFC3339 / YYYY-MM-DD)")
|
||||||
|
flag.StringVar(&toRaw, "to", "", "Upper bound record_datetime (RFC3339 / YYYY-MM-DD)")
|
||||||
|
flag.IntVar(&batchSize, "batch-size", 200, "Batch size when scanning recordings")
|
||||||
|
flag.IntVar(&limit, "limit", 0, "Max recordings to process (0 = no limit)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if batchSize <= 0 {
|
||||||
|
log.Fatal("--batch-size must be > 0")
|
||||||
|
}
|
||||||
|
if limit < 0 {
|
||||||
|
log.Fatal("--limit cannot be negative")
|
||||||
|
}
|
||||||
|
|
||||||
|
from, err := parseTimeBound(strings.TrimSpace(fromRaw), false)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid --from: %v", err)
|
||||||
|
}
|
||||||
|
to, err := parseTimeBound(strings.TrimSpace(toRaw), true)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid --to: %v", err)
|
||||||
|
}
|
||||||
|
if from != nil && to != nil && to.Before(*from) {
|
||||||
|
log.Fatal("--to cannot be before --from")
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := normalizeOptions{
|
||||||
|
Apply: apply,
|
||||||
|
RecordingID: recordingID,
|
||||||
|
ProjectFlockKandangID: projectFlockKandangID,
|
||||||
|
From: from,
|
||||||
|
To: to,
|
||||||
|
BatchSize: batchSize,
|
||||||
|
Limit: limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
db := database.Connect(config.DBHost, config.DBName)
|
||||||
|
repo := recordingRepo.NewRecordingRepository(db)
|
||||||
|
|
||||||
|
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
|
||||||
|
fmt.Printf("Filter recording_id: %s\n", displayUint(opts.RecordingID))
|
||||||
|
fmt.Printf("Filter project_flock_kandangs_id: %s\n", displayUint(opts.ProjectFlockKandangID))
|
||||||
|
fmt.Printf("Filter from: %s\n", displayTime(opts.From))
|
||||||
|
fmt.Printf("Filter to: %s\n", displayTime(opts.To))
|
||||||
|
fmt.Printf("Batch size: %d\n", opts.BatchSize)
|
||||||
|
fmt.Printf("Limit: %d\n\n", opts.Limit)
|
||||||
|
|
||||||
|
stats, err := normalizeRecordings(ctx, db, repo, opts)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("normalize failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf(
|
||||||
|
"Summary: processed=%d changed=%d updated=%d skipped=%d failed=%d\n",
|
||||||
|
stats.Processed,
|
||||||
|
stats.Changed,
|
||||||
|
stats.Updated,
|
||||||
|
stats.Skipped,
|
||||||
|
stats.Failed,
|
||||||
|
)
|
||||||
|
|
||||||
|
if stats.Failed > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRecordings(
|
||||||
|
ctx context.Context,
|
||||||
|
db *gorm.DB,
|
||||||
|
repo recordingRepo.RecordingRepository,
|
||||||
|
opts normalizeOptions,
|
||||||
|
) (normalizeStats, error) {
|
||||||
|
stats := normalizeStats{}
|
||||||
|
lastID := uint(0)
|
||||||
|
initialChickCache := make(map[uint]float64)
|
||||||
|
|
||||||
|
for {
|
||||||
|
batchLimit := opts.BatchSize
|
||||||
|
if opts.Limit > 0 {
|
||||||
|
remaining := opts.Limit - stats.Processed
|
||||||
|
if remaining <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if remaining < batchLimit {
|
||||||
|
batchLimit = remaining
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := loadRecordingBatch(ctx, db, opts, lastID, batchLimit)
|
||||||
|
if err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
stats.Processed++
|
||||||
|
lastID = row.ID
|
||||||
|
|
||||||
|
initialChick, ok := initialChickCache[row.ProjectFlockKandangID]
|
||||||
|
if !ok {
|
||||||
|
initialChick, err = repo.GetTotalChickinByProjectFlockKandang(db.WithContext(ctx), row.ProjectFlockKandangID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("FAIL rec=%d error=getTotalChickinByProjectFlockKandang: %v\n", row.ID, err)
|
||||||
|
stats.Failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
initialChickCache[row.ProjectFlockKandangID] = initialChick
|
||||||
|
}
|
||||||
|
|
||||||
|
_, totalEggWeightGrams, err := repo.GetEggSummaryByRecording(db.WithContext(ctx), row.ID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("FAIL rec=%d error=getEggSummaryByRecording: %v\n", row.ID, err)
|
||||||
|
stats.Failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cumulativeEggQty, err := repo.GetCumulativeEggQtyByProjectFlockKandang(db.WithContext(ctx), row.ProjectFlockKandangID, row.RecordDatetime)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("FAIL rec=%d error=getCumulativeEggQtyByProjectFlockKandang: %v\n", row.ID, err)
|
||||||
|
stats.Failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
newHenHouse, newEggMass := computeNormalizedMetrics(initialChick, cumulativeEggQty, totalEggWeightGrams)
|
||||||
|
henHouseChanged := metricChanged(row.HenHouse, newHenHouse)
|
||||||
|
eggMassChanged := metricChanged(row.EggMass, newEggMass)
|
||||||
|
|
||||||
|
if !henHouseChanged && !eggMassChanged {
|
||||||
|
stats.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.Changed++
|
||||||
|
fmt.Printf(
|
||||||
|
"PLAN rec=%d pfk=%d at=%s hen_house:%s->%s egg_mass:%s->%s\n",
|
||||||
|
row.ID,
|
||||||
|
row.ProjectFlockKandangID,
|
||||||
|
row.RecordDatetime.UTC().Format(time.RFC3339),
|
||||||
|
displayFloat(row.HenHouse),
|
||||||
|
displayFloat(newHenHouse),
|
||||||
|
displayFloat(row.EggMass),
|
||||||
|
displayFloat(newEggMass),
|
||||||
|
)
|
||||||
|
|
||||||
|
if !opts.Apply {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := updateRecordingMetrics(ctx, db, row.ID, newHenHouse, newEggMass); err != nil {
|
||||||
|
fmt.Printf("FAIL rec=%d error=updateRecordingMetrics: %v\n", row.ID, err)
|
||||||
|
stats.Failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(
|
||||||
|
"DONE rec=%d hen_house=%s egg_mass=%s\n",
|
||||||
|
row.ID,
|
||||||
|
displayFloat(newHenHouse),
|
||||||
|
displayFloat(newEggMass),
|
||||||
|
)
|
||||||
|
stats.Updated++
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.RecordingID > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRecordingBatch(
|
||||||
|
ctx context.Context,
|
||||||
|
db *gorm.DB,
|
||||||
|
opts normalizeOptions,
|
||||||
|
lastID uint,
|
||||||
|
limit int,
|
||||||
|
) ([]recordingMetricRow, error) {
|
||||||
|
query := db.WithContext(ctx).
|
||||||
|
Table("recordings").
|
||||||
|
Select("id, project_flock_kandangs_id, record_datetime, hen_house, egg_mass").
|
||||||
|
Where("recordings.deleted_at IS NULL")
|
||||||
|
|
||||||
|
if opts.RecordingID > 0 {
|
||||||
|
query = query.Where("recordings.id = ?", opts.RecordingID)
|
||||||
|
}
|
||||||
|
if opts.ProjectFlockKandangID > 0 {
|
||||||
|
query = query.Where("recordings.project_flock_kandangs_id = ?", opts.ProjectFlockKandangID)
|
||||||
|
}
|
||||||
|
if opts.From != nil {
|
||||||
|
query = query.Where("recordings.record_datetime >= ?", *opts.From)
|
||||||
|
}
|
||||||
|
if opts.To != nil {
|
||||||
|
query = query.Where("recordings.record_datetime <= ?", *opts.To)
|
||||||
|
}
|
||||||
|
if opts.RecordingID == 0 && lastID > 0 {
|
||||||
|
query = query.Where("recordings.id > ?", lastID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []recordingMetricRow
|
||||||
|
err := query.
|
||||||
|
Order("recordings.id ASC").
|
||||||
|
Limit(limit).
|
||||||
|
Scan(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeNormalizedMetrics(initialChick, cumulativeEggQty, totalEggWeightGrams float64) (*float64, *float64) {
|
||||||
|
var henHouse *float64
|
||||||
|
if initialChick > 0 && cumulativeEggQty >= 0 {
|
||||||
|
value := cumulativeEggQty / initialChick
|
||||||
|
henHouse = &value
|
||||||
|
}
|
||||||
|
|
||||||
|
var eggMass *float64
|
||||||
|
if initialChick > 0 && totalEggWeightGrams > 0 {
|
||||||
|
value := totalEggWeightGrams / initialChick
|
||||||
|
eggMass = &value
|
||||||
|
}
|
||||||
|
|
||||||
|
return henHouse, eggMass
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateRecordingMetrics(ctx context.Context, db *gorm.DB, recordingID uint, henHouse, eggMass *float64) error {
|
||||||
|
updates := map[string]any{}
|
||||||
|
if henHouse == nil {
|
||||||
|
updates["hen_house"] = gorm.Expr("NULL")
|
||||||
|
} else {
|
||||||
|
updates["hen_house"] = *henHouse
|
||||||
|
}
|
||||||
|
if eggMass == nil {
|
||||||
|
updates["egg_mass"] = gorm.Expr("NULL")
|
||||||
|
} else {
|
||||||
|
updates["egg_mass"] = *eggMass
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.WithContext(ctx).
|
||||||
|
Table("recordings").
|
||||||
|
Where("id = ?", recordingID).
|
||||||
|
Updates(updates).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func metricChanged(oldValue, newValue *float64) bool {
|
||||||
|
if oldValue == nil && newValue == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if oldValue == nil || newValue == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return !nearlyEqual(*oldValue, *newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nearlyEqual(a, b float64) bool {
|
||||||
|
scale := math.Max(1, math.Max(math.Abs(a), math.Abs(b)))
|
||||||
|
return math.Abs(a-b) <= metricEpsilon*scale
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTimeBound(raw string, isUpper bool) (*time.Time, error) {
|
||||||
|
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" {
|
||||||
|
if isUpper {
|
||||||
|
endOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC)
|
||||||
|
return &endOfDay, nil
|
||||||
|
}
|
||||||
|
startOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
return &startOfDay, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
t := parsed.UTC()
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("unsupported format %q", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func modeLabel(apply bool) string {
|
||||||
|
if apply {
|
||||||
|
return "APPLY"
|
||||||
|
}
|
||||||
|
return "DRY-RUN"
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayFloat(v *float64) string {
|
||||||
|
if v == nil {
|
||||||
|
return "NULL"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.6f", *v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayTime(v *time.Time) string {
|
||||||
|
if v == nil {
|
||||||
|
return "<nil>"
|
||||||
|
}
|
||||||
|
return v.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayUint(v uint) string {
|
||||||
|
if v == 0 {
|
||||||
|
return "<all>"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", v)
|
||||||
|
}
|
||||||
@@ -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,282 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
const qtyEpsilon = 1e-6
|
||||||
|
|
||||||
|
const (
|
||||||
|
levelAll = 1
|
||||||
|
levelByProductName = 2
|
||||||
|
levelByProductWarehouse = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
type reflowRow struct {
|
||||||
|
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
||||||
|
ProductID uint `gorm:"column:product_id"`
|
||||||
|
ProductName string `gorm:"column:product_name"`
|
||||||
|
CurrentQty float64 `gorm:"column:current_qty"`
|
||||||
|
SumTotalQty float64 `gorm:"column:sum_total_qty"`
|
||||||
|
SumAllocatedQty float64 `gorm:"column:sum_allocated_qty"`
|
||||||
|
ComputedQty float64 `gorm:"column:computed_qty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
apply bool
|
||||||
|
level int
|
||||||
|
productName string
|
||||||
|
productWarehouseID uint
|
||||||
|
)
|
||||||
|
|
||||||
|
flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run")
|
||||||
|
flag.IntVar(&level, "level", levelAll, "CLI level: 1=all product_warehouse scope, 2=product name scope, 3=product_warehouse_id scope")
|
||||||
|
flag.StringVar(&productName, "product-name", "", "Product name (required for level 2)")
|
||||||
|
flag.UintVar(&productWarehouseID, "product-warehouse-id", 0, "Product warehouse id (required for level 3)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
productName = strings.TrimSpace(productName)
|
||||||
|
if err := validateFlags(level, productName, productWarehouseID); err != nil {
|
||||||
|
log.Fatalf("invalid flags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
db := database.Connect(config.DBHost, config.DBName)
|
||||||
|
|
||||||
|
rows, err := loadReflowRows(ctx, db, level, productName, productWarehouseID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to calculate reflow qty: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Mode: %s\n", modeLabel(apply))
|
||||||
|
fmt.Printf("Level: %d (%s)\n", level, levelLabel(level))
|
||||||
|
if productName != "" {
|
||||||
|
fmt.Printf("Filter product_name: %s\n", productName)
|
||||||
|
}
|
||||||
|
if productWarehouseID > 0 {
|
||||||
|
fmt.Printf("Filter product_warehouse_id: %d\n", productWarehouseID)
|
||||||
|
}
|
||||||
|
fmt.Printf("Targets found: %d\n\n", len(rows))
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
fmt.Println("No product warehouse found from purchase_items scope")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
negativePlan := 0
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.ComputedQty < 0 {
|
||||||
|
negativePlan++
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(
|
||||||
|
"PLAN pw=%d product_id=%d product=%q current_qty=%.3f total_qty=%.3f allocated_qty=%.3f computed_qty=%.3f delta=%.3f\n",
|
||||||
|
row.ProductWarehouseID,
|
||||||
|
row.ProductID,
|
||||||
|
row.ProductName,
|
||||||
|
row.CurrentQty,
|
||||||
|
row.SumTotalQty,
|
||||||
|
row.SumAllocatedQty,
|
||||||
|
row.ComputedQty,
|
||||||
|
row.ComputedQty-row.CurrentQty,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !apply {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("Summary: planned=%d updated=0 skipped=0 failed=0 negative_plan=%d\n", len(rows), negativePlan)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := 0
|
||||||
|
skipped := 0
|
||||||
|
negativeUpdated := 0
|
||||||
|
|
||||||
|
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
for _, row := range rows {
|
||||||
|
if nearlyEqual(row.CurrentQty, row.ComputedQty) {
|
||||||
|
fmt.Printf(
|
||||||
|
"SKIP pw=%d reason=no_change current_qty=%.3f computed_qty=%.3f\n",
|
||||||
|
row.ProductWarehouseID,
|
||||||
|
row.CurrentQty,
|
||||||
|
row.ComputedQty,
|
||||||
|
)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Table("product_warehouses").
|
||||||
|
Where("id = ?", row.ProductWarehouseID).
|
||||||
|
Update("qty", row.ComputedQty).Error; err != nil {
|
||||||
|
return fmt.Errorf("update qty for product_warehouse_id=%d: %w", row.ProductWarehouseID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if row.ComputedQty < 0 {
|
||||||
|
negativeUpdated++
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(
|
||||||
|
"DONE pw=%d product_id=%d product=%q old_qty=%.3f new_qty=%.3f\n",
|
||||||
|
row.ProductWarehouseID,
|
||||||
|
row.ProductID,
|
||||||
|
row.ProductName,
|
||||||
|
row.CurrentQty,
|
||||||
|
row.ComputedQty,
|
||||||
|
)
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf(
|
||||||
|
"Summary: planned=%d updated=%d skipped=%d failed=1 negative_plan=%d negative_updated=%d\n",
|
||||||
|
len(rows),
|
||||||
|
updated,
|
||||||
|
skipped,
|
||||||
|
negativePlan,
|
||||||
|
negativeUpdated,
|
||||||
|
)
|
||||||
|
log.Printf("error: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf(
|
||||||
|
"Summary: planned=%d updated=%d skipped=%d failed=0 negative_plan=%d negative_updated=%d\n",
|
||||||
|
len(rows),
|
||||||
|
updated,
|
||||||
|
skipped,
|
||||||
|
negativePlan,
|
||||||
|
negativeUpdated,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateFlags(level int, productName string, productWarehouseID uint) error {
|
||||||
|
switch level {
|
||||||
|
case levelAll:
|
||||||
|
if productName != "" {
|
||||||
|
return errors.New("--product-name cannot be used on level 1")
|
||||||
|
}
|
||||||
|
if productWarehouseID > 0 {
|
||||||
|
return errors.New("--product-warehouse-id cannot be used on level 1")
|
||||||
|
}
|
||||||
|
case levelByProductName:
|
||||||
|
if productName == "" {
|
||||||
|
return errors.New("--product-name is required on level 2")
|
||||||
|
}
|
||||||
|
if productWarehouseID > 0 {
|
||||||
|
return errors.New("--product-warehouse-id cannot be used on level 2")
|
||||||
|
}
|
||||||
|
case levelByProductWarehouse:
|
||||||
|
if productWarehouseID == 0 {
|
||||||
|
return errors.New("--product-warehouse-id is required on level 3")
|
||||||
|
}
|
||||||
|
if productName != "" {
|
||||||
|
return errors.New("--product-name cannot be used on level 3")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported --level=%d (allowed: 1, 2, 3)", level)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadReflowRows(
|
||||||
|
ctx context.Context,
|
||||||
|
db *gorm.DB,
|
||||||
|
level int,
|
||||||
|
productName string,
|
||||||
|
productWarehouseID uint,
|
||||||
|
) ([]reflowRow, error) {
|
||||||
|
allocSub := db.WithContext(ctx).
|
||||||
|
Table("stock_allocations sa").
|
||||||
|
Select(`
|
||||||
|
sa.stockable_id,
|
||||||
|
COALESCE(SUM(sa.qty), 0) AS used_qty
|
||||||
|
`).
|
||||||
|
Where("sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
|
||||||
|
Where("sa.status = ?", entity.StockAllocationStatusActive).
|
||||||
|
Where("sa.deleted_at IS NULL").
|
||||||
|
Group("sa.stockable_id")
|
||||||
|
|
||||||
|
calcSub := db.WithContext(ctx).
|
||||||
|
Table("purchase_items pi").
|
||||||
|
Select(`
|
||||||
|
pi.product_warehouse_id,
|
||||||
|
COALESCE(SUM(pi.total_qty), 0) AS sum_total_qty,
|
||||||
|
COALESCE(SUM(COALESCE(alloc.used_qty, 0)), 0) AS sum_allocated_qty,
|
||||||
|
COALESCE(SUM(COALESCE(pi.total_qty, 0) - COALESCE(alloc.used_qty, 0)), 0) AS computed_qty
|
||||||
|
`).
|
||||||
|
Joins("LEFT JOIN (?) alloc ON alloc.stockable_id = pi.id", allocSub).
|
||||||
|
Where("pi.product_warehouse_id IS NOT NULL").
|
||||||
|
Group("pi.product_warehouse_id")
|
||||||
|
|
||||||
|
query := db.WithContext(ctx).
|
||||||
|
Table("product_warehouses pw").
|
||||||
|
Select(`
|
||||||
|
pw.id AS product_warehouse_id,
|
||||||
|
pw.product_id AS product_id,
|
||||||
|
p.name AS product_name,
|
||||||
|
COALESCE(pw.qty, 0) AS current_qty,
|
||||||
|
calc.sum_total_qty,
|
||||||
|
calc.sum_allocated_qty,
|
||||||
|
calc.computed_qty
|
||||||
|
`).
|
||||||
|
Joins("JOIN products p ON p.id = pw.product_id").
|
||||||
|
Joins("JOIN (?) calc ON calc.product_warehouse_id = pw.id", calcSub).
|
||||||
|
Order("pw.id ASC")
|
||||||
|
|
||||||
|
switch level {
|
||||||
|
case levelByProductName:
|
||||||
|
query = query.Where("LOWER(p.name) = LOWER(?)", productName)
|
||||||
|
case levelByProductWarehouse:
|
||||||
|
query = query.Where("pw.id = ?", productWarehouseID)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]reflowRow, 0)
|
||||||
|
if err := query.Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func modeLabel(apply bool) string {
|
||||||
|
if apply {
|
||||||
|
return "APPLY"
|
||||||
|
}
|
||||||
|
return "DRY-RUN"
|
||||||
|
}
|
||||||
|
|
||||||
|
func levelLabel(level int) string {
|
||||||
|
switch level {
|
||||||
|
case levelAll:
|
||||||
|
return "all product_warehouse from purchase_items"
|
||||||
|
case levelByProductName:
|
||||||
|
return "specific product name"
|
||||||
|
case levelByProductWarehouse:
|
||||||
|
return "specific product_warehouse_id"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nearlyEqual(a, b float64) bool {
|
||||||
|
return math.Abs(a-b) <= qtyEpsilon
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/database/seed"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
db := database.Connect(config.DBHost, config.DBName)
|
||||||
|
|
||||||
|
if err := seed.Run(db); err != nil {
|
||||||
|
log.Fatalf("❌ Failed run seeder: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("✅ Seed Successfully")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Farm Stock Attribution Design Note
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Allow farm-level physical stock to be used directly by kandang-level operations without forcing transfers, while keeping kandang attribution, FIFO-v2 compatibility, traceability, and HPP/COGS intact.
|
||||||
|
|
||||||
|
## Core Model
|
||||||
|
|
||||||
|
- Physical stock stays on the real `product_warehouse_id` that was consumed or received.
|
||||||
|
- Kandang attribution comes from the transaction or allocation path, not from `product_warehouses.project_flock_kandang_id`.
|
||||||
|
- Existing kandang-bound warehouses remain valid for historical and current kandang-only flows.
|
||||||
|
- Shared farm warehouses must stay shareable; application code must stop silently converting them into kandang-owned warehouses.
|
||||||
|
|
||||||
|
## Attribution Rules
|
||||||
|
|
||||||
|
- `recording_stocks`: consumer kandang is the parent `recordings.project_flock_kandangs_id`; physical stock source remains `recording_stocks.product_warehouse_id`.
|
||||||
|
- `recording_depletions`: source kandang is the recording kandang and is stored explicitly for compatibility; physical source remains `source_product_warehouse_id`, destination stock remains `product_warehouse_id`.
|
||||||
|
- `recording_eggs`: producer kandang is the recording kandang and is stored explicitly for compatibility; physical stock remains `product_warehouse_id`, which may be a farm warehouse.
|
||||||
|
- `marketing_delivery_products`: outbound kandang attribution comes from active `stock_allocations` to `PROJECT_FLOCK_POPULATION`, `RECORDING_DEPLETION`, or `RECORDING_EGG`, with product-warehouse kandang ownership only as a fallback for historical/non-FIFO rows.
|
||||||
|
|
||||||
|
## Reporting and HPP
|
||||||
|
|
||||||
|
- Feed and OVK cost attribution should continue to follow recording-level consumption plus FIFO allocations to incoming stock.
|
||||||
|
- Egg and live-bird sales attribution should be derived from `stock_allocations` back to the originating kandang transactions or populations.
|
||||||
|
- Queries that filter or group by kandang must use explicit transaction attribution or FIFO allocation provenance, not warehouse ownership, when pooled farm stock is involved.
|
||||||
|
|
||||||
|
## Live-Data Safety
|
||||||
|
|
||||||
|
- Schema changes are additive and nullable.
|
||||||
|
- Historical rows are backfilled only when attribution is deterministic from existing rows.
|
||||||
|
- No FIFO-v2 route-rule behavior is changed unless the current code is only resyncing or constraining allocation metadata around already-created FIFO allocations.
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
# Runbook Cutover Stok Telur Historis Kandang ke Gudang Farm
|
||||||
|
|
||||||
|
## Tujuan
|
||||||
|
|
||||||
|
Runbook ini dipakai untuk memindahkan **stok telur historis yang masih on-hand di gudang kandang** ke **gudang farm** secara aman, audit-able, dan reversible.
|
||||||
|
|
||||||
|
Cutover dilakukan dengan **transfer stok eksplisit**, bukan dengan mengubah `recording_eggs.product_warehouse_id` historis.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Runbook ini hanya untuk:
|
||||||
|
- stok telur historis kandang-level yang masih punya saldo on-hand
|
||||||
|
- lokasi yang masuk kategori **clean cutover**
|
||||||
|
- lokasi yang sudah punya gudang farm
|
||||||
|
|
||||||
|
Runbook ini **tidak** dipakai untuk:
|
||||||
|
- lokasi overlap seperti `Cijangkar`
|
||||||
|
- koreksi histori `recording_eggs`
|
||||||
|
- migrasi stok non-telur
|
||||||
|
|
||||||
|
## Kebijakan yang Dikunci
|
||||||
|
|
||||||
|
- Sumber qty yang dipindah adalah **`product_warehouses.qty` saat cutover**
|
||||||
|
- Perintah dijalankan **per lokasi**
|
||||||
|
- Wajib mulai dari `dry-run`
|
||||||
|
- `--apply` hanya boleh dijalankan setelah review dry-run dan SQL checklist
|
||||||
|
- Lokasi overlap tidak ikut otomatis kecuali ada approval khusus dan `--include-overlap`
|
||||||
|
- Rollback hanya boleh dilakukan jika transfer hasil cutover belum dipakai transaksi turunan
|
||||||
|
|
||||||
|
## Lokasi Fase 1
|
||||||
|
|
||||||
|
Lokasi yang boleh dieksekusi pada fase pertama:
|
||||||
|
- `Jamali`
|
||||||
|
- `Cantilan`
|
||||||
|
- `Darawati`
|
||||||
|
- `Tamansari`
|
||||||
|
|
||||||
|
Lokasi yang harus ditahan:
|
||||||
|
- `Cijangkar`
|
||||||
|
|
||||||
|
## Prasyarat
|
||||||
|
|
||||||
|
Sebelum eksekusi, pastikan:
|
||||||
|
- backend sudah ter-deploy dengan command [main.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/cmd/migrate-legacy-egg-stock-to-farm/main.go)
|
||||||
|
- reusable transfer core sudah ikut ter-deploy:
|
||||||
|
- [transfer.service.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/transfer.service.go)
|
||||||
|
- [system_transfer.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/system_transfer.go)
|
||||||
|
- migrasi farm stock attribution sebelumnya sudah terpasang
|
||||||
|
- akses database target sudah tersedia
|
||||||
|
- environment target memakai SSL bila RDS mewajibkan, contoh:
|
||||||
|
- `DB_SSLMODE=require`
|
||||||
|
|
||||||
|
## Catatan Output Command
|
||||||
|
|
||||||
|
Mode `--output table` adalah mode operasional yang direkomendasikan.
|
||||||
|
|
||||||
|
Mode `--output json` bisa dipakai, tetapi pada environment saat ini output JSON masih dapat didahului log bootstrap aplikasi atau SQL logger. Untuk review manual gunakan `table`. Untuk parsing otomatis, filter payload mulai dari `{`.
|
||||||
|
|
||||||
|
## Format Command
|
||||||
|
|
||||||
|
### Dry-run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
|
||||||
|
--location-name Jamali \
|
||||||
|
--output table
|
||||||
|
```
|
||||||
|
|
||||||
|
### Apply
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
|
||||||
|
--location-name Jamali \
|
||||||
|
--cutover-date 2026-04-07 \
|
||||||
|
--apply \
|
||||||
|
--output table
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rollback Preview
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
|
||||||
|
--rollback-run-id <run_id> \
|
||||||
|
--output table
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rollback Apply
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
|
||||||
|
--rollback-run-id <run_id> \
|
||||||
|
--apply \
|
||||||
|
--output table
|
||||||
|
```
|
||||||
|
|
||||||
|
## Arti `run_id`
|
||||||
|
|
||||||
|
Setiap dry-run/apply menghasilkan `run_id`, misalnya:
|
||||||
|
|
||||||
|
```text
|
||||||
|
egg-cutover-20260407T130344.220407000Z
|
||||||
|
```
|
||||||
|
|
||||||
|
`run_id` ini wajib disimpan karena dipakai untuk:
|
||||||
|
- audit hasil cutover
|
||||||
|
- query verifikasi
|
||||||
|
- rollback
|
||||||
|
|
||||||
|
## Prosedur Eksekusi Per Lokasi
|
||||||
|
|
||||||
|
### 1. Persiapan
|
||||||
|
|
||||||
|
Tentukan:
|
||||||
|
- `location_name`
|
||||||
|
- `cutover_date`
|
||||||
|
- operator yang bertanggung jawab
|
||||||
|
|
||||||
|
Contoh:
|
||||||
|
- lokasi: `Jamali`
|
||||||
|
- cutover date: `2026-04-07`
|
||||||
|
|
||||||
|
### 2. Jalankan Dry-run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
|
||||||
|
--location-name Jamali \
|
||||||
|
--output table
|
||||||
|
```
|
||||||
|
|
||||||
|
Yang harus dicek pada hasil dry-run:
|
||||||
|
- status lokasi `CLEAN_CUTOVER`
|
||||||
|
- semua baris yang akan dipindah punya `status=eligible`
|
||||||
|
- gudang tujuan adalah gudang farm lokasi tersebut
|
||||||
|
- qty yang dipindah masuk akal dan sesuai saldo on-hand aktual
|
||||||
|
- tidak ada `missing_farm_warehouse`
|
||||||
|
- tidak ada `overlap_location`
|
||||||
|
|
||||||
|
### 3. Jalankan Checklist SQL Before
|
||||||
|
|
||||||
|
Gunakan file:
|
||||||
|
- [legacy_egg_cutover_verification_checklist.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_verification_checklist.sql)
|
||||||
|
|
||||||
|
Minimal pastikan:
|
||||||
|
- lokasi memang clean cutover
|
||||||
|
- stok telur kandang positif masih ada
|
||||||
|
- gudang farm ada
|
||||||
|
- belum ada transfer `EGG_FARM_CUTOVER` aktif untuk lokasi yang sama pada run yang akan dipakai
|
||||||
|
|
||||||
|
### 4. Simpan Evidence Sebelum Apply
|
||||||
|
|
||||||
|
Simpan:
|
||||||
|
- output dry-run
|
||||||
|
- hasil query before
|
||||||
|
- nama operator
|
||||||
|
- waktu eksekusi
|
||||||
|
|
||||||
|
Disarankan simpan dalam ticket / change record.
|
||||||
|
|
||||||
|
### 5. Jalankan Apply
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
|
||||||
|
--location-name Jamali \
|
||||||
|
--cutover-date 2026-04-07 \
|
||||||
|
--apply \
|
||||||
|
--output table
|
||||||
|
```
|
||||||
|
|
||||||
|
Setelah apply, simpan:
|
||||||
|
- `run_id`
|
||||||
|
- seluruh row dengan `transfer_id`
|
||||||
|
- movement number yang terbentuk
|
||||||
|
|
||||||
|
### 6. Jalankan Checklist SQL After
|
||||||
|
|
||||||
|
Masih menggunakan file:
|
||||||
|
- [legacy_egg_cutover_verification_checklist.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_verification_checklist.sql)
|
||||||
|
|
||||||
|
Minimal pastikan:
|
||||||
|
- transfer header/detail tercatat untuk `run_id`
|
||||||
|
- qty source berkurang sesuai transfer
|
||||||
|
- qty farm bertambah sesuai transfer
|
||||||
|
- total gabungan source+dest per produk per lokasi tetap sama
|
||||||
|
- stok eligible tidak lagi tersedia di gudang kandang
|
||||||
|
- stok telur sekarang tersedia di gudang farm
|
||||||
|
|
||||||
|
### 7. Smoke Test UI
|
||||||
|
|
||||||
|
Lakukan minimal:
|
||||||
|
- buka product stock farm untuk lokasi tersebut
|
||||||
|
- pastikan produk telur hasil migrasi muncul
|
||||||
|
- buat SO farm-level dan pastikan opsi produk telur tersedia
|
||||||
|
- pastikan recording telur baru setelah cutover tetap langsung masuk ke gudang farm
|
||||||
|
|
||||||
|
### 8. Tutup Eksekusi
|
||||||
|
|
||||||
|
Catat hasil akhir:
|
||||||
|
- sukses/gagal
|
||||||
|
- `run_id`
|
||||||
|
- lokasi
|
||||||
|
- tanggal cutover
|
||||||
|
- operator
|
||||||
|
- link ke evidence SQL/UI
|
||||||
|
|
||||||
|
## Kriteria Go / No-Go
|
||||||
|
|
||||||
|
### Boleh lanjut apply bila:
|
||||||
|
|
||||||
|
- dry-run menunjukkan hanya row yang memang expected
|
||||||
|
- lokasi `CLEAN_CUTOVER`
|
||||||
|
- gudang farm valid
|
||||||
|
- query before menunjukkan tidak ada anomaly blocking
|
||||||
|
|
||||||
|
### Wajib stop bila:
|
||||||
|
|
||||||
|
- lokasi terdeteksi `OVERLAP`
|
||||||
|
- ada qty aneh atau tidak sesuai data lapangan
|
||||||
|
- gudang farm tidak ada
|
||||||
|
- ada transfer lama serupa yang belum direkonsiliasi
|
||||||
|
- setelah apply terjadi selisih total source+dest
|
||||||
|
|
||||||
|
## Rollback Runbook
|
||||||
|
|
||||||
|
### Kapan rollback boleh dilakukan
|
||||||
|
|
||||||
|
Rollback boleh jika:
|
||||||
|
- transfer hasil cutover belum dipakai transaksi turunan
|
||||||
|
- verifikasi after menunjukkan issue yang membuat hasil cutover tidak dapat diterima
|
||||||
|
|
||||||
|
### Langkah rollback
|
||||||
|
|
||||||
|
1. Preview rollback:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
|
||||||
|
--rollback-run-id <run_id> \
|
||||||
|
--output table
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Jalankan query rollback readiness pada file audit/helper SQL.
|
||||||
|
3. Jika aman, apply rollback:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
|
||||||
|
--rollback-run-id <run_id> \
|
||||||
|
--apply \
|
||||||
|
--output table
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Jalankan ulang query verifikasi after rollback.
|
||||||
|
|
||||||
|
### Kapan rollback akan gagal by design
|
||||||
|
|
||||||
|
Rollback memang harus gagal jika:
|
||||||
|
- transfer hasil cutover sudah dipakai sales/recording/transaksi turunan
|
||||||
|
- sudah ada `stock_allocations` consume aktif terhadap `STOCK_TRANSFER_IN`
|
||||||
|
|
||||||
|
## Urutan Rollout yang Direkomendasikan
|
||||||
|
|
||||||
|
### Dev
|
||||||
|
|
||||||
|
1. Dry-run per lokasi
|
||||||
|
2. Review SQL before
|
||||||
|
3. Apply per lokasi
|
||||||
|
4. SQL after
|
||||||
|
5. Smoke UI
|
||||||
|
6. Simpan `run_id`
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
1. Freeze operasional lokasi target bila perlu
|
||||||
|
2. Dry-run
|
||||||
|
3. Review by dev + ops + finance/stock owner
|
||||||
|
4. Apply
|
||||||
|
5. SQL after
|
||||||
|
6. Smoke UI
|
||||||
|
7. Release lokasi berikutnya
|
||||||
|
|
||||||
|
## Referensi
|
||||||
|
|
||||||
|
- Command cutover: [main.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/cmd/migrate-legacy-egg-stock-to-farm/main.go)
|
||||||
|
- Test command: [main_test.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/cmd/migrate-legacy-egg-stock-to-farm/main_test.go)
|
||||||
|
- Core reusable transfer: [system_transfer.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/system_transfer.go)
|
||||||
|
- Transfer service refactor: [transfer.service.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/transfer.service.go)
|
||||||
|
- Checklist SQL: [legacy_egg_cutover_verification_checklist.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_verification_checklist.sql)
|
||||||
|
- Helper query audit: [legacy_egg_cutover_audit_queries.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_audit_queries.sql)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
ID;Kategori;Area;Judul;Tipe;Prioritas;Setup/Precondition;Langkah Uji;Hasil yang Diharapkan
|
||||||
|
TC-A01;Migrasi dan Keamanan Data;Database;Migrasi aman pada DB tidak kosong;Integration;High;Gunakan snapshot DB staging yang sudah berisi recording, depletion, telur, penjualan, dan closing.;1. Jalankan migrasi 20260330110000_add_recording_attribution_fields_for_farm_stock.up.sql. 2. Inspect schema hasil migrasi.;Kolom recording_depletions.source_project_flock_kandang_id dan recording_eggs.project_flock_kandang_id tersedia dan nullable, index dan FK tersedia, tidak ada data historis yang terhapus atau berubah destruktif.
|
||||||
|
TC-A02;Migrasi dan Keamanan Data;Database;Backfill deterministik berjalan;Integration;High;Ada data historis recording dengan recordings.project_flock_kandangs_id yang valid.;1. Query recording_depletions dan recording_eggs yang lama. 2. Bandingkan dengan kandang pada parent recording.;source_project_flock_kandang_id dan project_flock_kandang_id terisi sama dengan kandang parent recording untuk row yang sebelumnya null.
|
||||||
|
TC-A03;Migrasi dan Keamanan Data;Reporting;Report historis kandang-only tidak berubah;Regression;High;Gunakan snapshot yang hanya memiliki data stok historis milik kandang, tanpa pooled stock farm-level.;1. Jalankan closing/report/HPP sebelum deploy. 2. Jalankan lagi sesudah deploy pada snapshot yang sama. 3. Bandingkan hasil.;Total dan hasil report tetap sama untuk skenario historis kandang-only.
|
||||||
|
TC-B01;Purchase dan Warehouse;Purchase;Purchase pakan langsung ke gudang farm;UAT;High;Tersedia PO atau purchase request untuk produk Pakan Starter.;1. Buat purchase ke Gudang Farm A. 2. Approve dan receive purchase.;Stok masuk ke product_warehouse level farm, tidak perlu transfer paksa ke kandang, FIFO/HPP purchase tetap benar.
|
||||||
|
TC-B02;Purchase dan Warehouse;Purchase;Purchase pakan langsung ke gudang kandang;Regression;High;Tersedia PO atau purchase request untuk produk Pakan Starter.;1. Buat purchase ke Gudang Kandang A1. 2. Approve dan receive purchase.;Stok masuk ke gudang kandang dan perilaku tetap sama seperti flow lama.
|
||||||
|
TC-B03;Purchase dan Warehouse;Purchase;Purchase OVK langsung ke gudang farm;UAT;High;Tersedia PO atau purchase request untuk produk OVK A.;1. Buat purchase ke Gudang Farm A. 2. Approve dan receive purchase.;Stok OVK masuk ke gudang farm dan bisa dipakai kemudian pada recording.
|
||||||
|
TC-B04;Purchase dan Warehouse;Product Warehouse;Gudang farm shared tidak diubah diam-diam menjadi milik kandang;Regression;High;Sudah ada row product_warehouse level farm untuk Pakan Starter di Gudang Farm A.;1. Trigger flow yang memanggil ensure/find product warehouse untuk produk yang sama. 2. Inspect row existing.;Row farm-level tetap farm-level, project_flock_kandang_id tidak dibackfill diam-diam, row khusus kandang dibuat terpisah bila memang diperlukan.
|
||||||
|
TC-C01;Recording Stock Consumption;Recording;Recording kandang memakai pakan dari gudang kandang;Regression;High;Stok pakan tersedia di Gudang Kandang A1.;1. Buka recording untuk Kandang A1. 2. Pilih pakan dari gudang kandang. 3. Submit dan approve.;Recording berhasil, stok keluar dari product_warehouse kandang, atribusi kandang tetap A1, HPP pemakaian muncul di closing/HPP A1.
|
||||||
|
TC-C02;Recording Stock Consumption;Recording;Recording kandang memakai pakan dari gudang farm;UAT;High;Stok pakan hanya tersedia di Gudang Farm A.;1. Buka recording untuk Kandang A1. 2. Pilih stok pakan farm-level. 3. Submit dan approve.;Recording berhasil tanpa transfer ke kandang, stok fisik berkurang dari gudang farm, usage/HPP tetap teratribusi ke Kandang A1, closing farm dan kandang tetap bisa dihitung.
|
||||||
|
TC-C03;Recording Stock Consumption;Recording;Recording kandang memakai OVK dari gudang farm;UAT;High;Stok OVK hanya tersedia di Gudang Farm A.;1. Buka recording untuk Kandang A1. 2. Pilih stok OVK farm-level. 3. Submit dan approve.;Stok OVK keluar dari gudang farm dan biaya pemakaian teratribusi ke kandang yang dipilih.
|
||||||
|
TC-C04;Recording Stock Consumption;Frontend Recording;Selector recording menampilkan opsi stok farm dan kandang dengan jelas;UI Regression;Medium;Produk yang sama tersedia di Gudang Farm A dan Gudang Kandang A1.;1. Buka form recording untuk A1. 2. Buka selector pakan.;Kedua opsi terlihat, label membedakan gudang atau scope dengan jelas, farm stock tidak tersembunyi secara salah.
|
||||||
|
TC-C05;Recording Stock Consumption;Recording;Recording A1 tidak boleh memakai stok kandang A2;Negative;High;Pakan Starter tersedia di Gudang Kandang A2.;1. Buka recording untuk A1. 2. Periksa opsi stok yang bisa dipilih.;Opsi Gudang Kandang A2 tidak bisa dipilih, stok farm tetap bisa dipilih.
|
||||||
|
TC-C06;Recording Stock Consumption;Recording;Perilaku pending stock dan usage lama tetap berjalan;Regression;Medium;Tidak ada setup khusus selain data recording yang valid.;1. Buat usage stock. 2. Buka kembali halaman edit dan detail.;Tampilan dan perhitungan pending atau usage tetap benar, tidak ada regresi pada route FIFO-v2.
|
||||||
|
TC-D01;Recording Telur dan Atribusi;Recording;Recording telur ke gudang kandang tetap berjalan;Regression;High;Kandang A1 aktif dan gudang telur kandang tersedia.;1. Record telur untuk A1 ke Gudang Kandang A1. 2. Approve.;Stok telur di gudang kandang bertambah dan asal kandang tetap A1.
|
||||||
|
TC-D02;Recording Telur dan Atribusi;Recording;Recording telur di kandang menyimpan stok ke gudang farm;UAT;High;Egg product warehouse tersedia di Gudang Farm A.;1. Record telur untuk A1. 2. Pilih Gudang Farm A sebagai gudang telur. 3. Submit dan approve.;Stok telur fisik masuk ke gudang farm, recording_eggs.project_flock_kandang_id bernilai A1, tidak ada transfer paksa ke kandang.
|
||||||
|
TC-D03;Recording Telur dan Atribusi;Reporting;Stok telur pooled di farm tetap punya jejak asal kandang;Integration;High;A1 record 100 telur ke gudang farm dan A2 record 150 telur ke gudang farm yang sama.;1. Inspect row telur yang tersimpan. 2. Inspect hasil costing atau report setelahnya.;Stok fisik pooled di gudang farm, tetapi asal kandang tetap bisa dibedakan per row atau allocation, HPP per kandang tetap dapat dihitung.
|
||||||
|
TC-D04;Recording Telur dan Atribusi;Recording Detail;Known gap pada detail recording dipahami;Known Limitation;Low;Sudah menjalankan TC-D02.;1. Buka detail recording setelah transaksi telur ke gudang farm.;Logika bisnis tetap berjalan, tetapi detail API atau UI mungkin belum menampilkan egg-origin secara eksplisit karena detail DTO belum diperluas.
|
||||||
|
TC-E01;Depletion dan Atribusi Populasi;Recording;Depletion dari gudang ayam milik kandang normal;Regression;High;A1 memiliki populasi ayam di gudang kandang.;1. Buat depletion. 2. Approve.;Depletion berhasil, alokasi populasi ter-resolve ke A1, HPP atau usage tetap benar.
|
||||||
|
TC-E02;Depletion dan Atribusi Populasi;Recording;Depletion dari sumber ayam fisik farm-level dengan source kandang A1;UAT;High;Stok ayam secara fisik ada di gudang farm dan punya jejak sumber ke A1.;1. Buat depletion untuk A1. 2. Gunakan path source atau farm-level yang didukung backend. 3. Approve.;source_product_warehouse_id menunjuk ke sumber fisik yang benar, source_project_flock_kandang_id bernilai A1, alokasi populasi berhasil tanpa mengasumsikan gudang fisik milik A1.
|
||||||
|
TC-E03;Depletion dan Atribusi Populasi;Recording;Depletion gagal bila sumber populasi tidak dapat diatribusikan;Negative;High;Buat kasus stok ayam farm-level tanpa source kandang yang valid.;1. Coba approve depletion.;Backend menolak dengan error yang jelas dan tidak ada silent misattribution.
|
||||||
|
TC-F01;Marketing dan Penjualan;Sales Order;Sales order dari gudang kandang tetap berjalan;Regression;High;Stok produk tersedia di Gudang Kandang A1.;1. Buat SO dari Gudang Kandang A1. 2. Lakukan delivery.;Perilaku lama tetap berjalan normal.
|
||||||
|
TC-F02;Marketing dan Penjualan;Sales Order;Sales order dari gudang farm untuk telur;UAT;High;Stok telur farm-level tersedia dan berasal dari A1.;1. Buat SO menggunakan Gudang Farm A. 2. Lakukan delivery.;SO dan DO berhasil, stok fisik berkurang dari gudang farm, HPP dan COGS telur tetap teratribusi ke kandang penghasil melalui allocation.
|
||||||
|
TC-F03;Marketing dan Penjualan;Sales Order;Sales order dari gudang farm untuk telur pooled A1 dan A2;Integration;High;Stok telur pooled tersedia di gudang farm dari A1 dan A2.;1. Buat penjualan. 2. Lakukan delivery. 3. Inspect closing atau report.;Stok fisik berkurang sekali dari gudang farm, revenue dan HPP terbagi benar ke A1 dan A2, tidak bergantung pada pw.project_flock_kandang_id.
|
||||||
|
TC-F04;Marketing dan Penjualan;Sales Order;Sales order dari gudang farm untuk ayam atau culling;UAT;High;Stok ayam atau culling farm-level tersedia dengan jejak sumber dari A1 dan A2.;1. Buat SO dari gudang farm. 2. Buat DO dan approve.;allocatePopulationForMarketingDelivery menurunkan atribusi kandang dari source groups atau allocation, tidak gagal karena gudang jual tidak punya project_flock_kandang_id, HPP dan COGS teratribusi ke kandang sumber.
|
||||||
|
TC-F05;Marketing dan Penjualan;Frontend Marketing;UI sales menampilkan semantik Gudang Fisik;UI Regression;Medium;Tidak ada setup khusus selain akses ke form SO.;1. Buka form SO. 2. Periksa label selector gudang dan label tabel produk.;UI menggunakan label Gudang Fisik, bukan Kandang yang menyesatkan, dan label produk memuat detail produk serta gudang atau scope.
|
||||||
|
TC-F06;Marketing dan Penjualan;Delivery Order;Layar delivery order tetap kompatibel;Regression;Medium;Sudah ada SO dari gudang farm.;1. Lakukan delivery untuk SO farm-level. 2. Periksa tabel dan detail DO.;Tidak ada masalah payload, gudang fisik tampil dengan benar, dan tidak ada kebingungan akibat wording lama berbasis kandang.
|
||||||
|
TC-G01;Report, Closing, dan HPP;Daily Marketing Report;Daily marketing report untuk penjualan telur farm-level;UAT;Medium;Sudah menjalankan TC-F02.;1. Jalankan daily marketing report. 2. Uji export.;Row muncul pada gudang fisik yang benar, report tidak menyiratkan gudang sama dengan kandang, export berjalan.
|
||||||
|
TC-G02;Report, Closing, dan HPP;Closing Sales;Closing sales untuk penjualan pooled farm-level;UAT;High;Ada penjualan pooled telur atau ayam dari gudang farm.;1. Buka closing sales.;Penjualan bisa tampil teratribusi per kandang, label menunjukkan Kandang Atribusi, HPP dan revenue tetap benar secara matematis.
|
||||||
|
TC-G03;Report, Closing, dan HPP;HPP per Kandang;HPP per kandang mencakup konsumsi pakan atau OVK dari gudang farm;UAT;High;A1 sudah memakai pakan atau OVK dari gudang farm.;1. Jalankan report HPP per kandang.;Biaya usage muncul di A1 dan tidak hilang walaupun gudang fisiknya level farm.
|
||||||
|
TC-G04;Report, Closing, dan HPP;Closing Sapronak;Outgoing sapronak menampilkan gudang fisik dengan benar;UI Regression;Medium;Ada data outgoing sapronak yang valid.;1. Buka tabel closing outgoing sapronak.;Header jelas menunjukkan Gudang Asal (Fisik) dan Gudang Tujuan (Fisik).
|
||||||
|
TC-G05;Report, Closing, dan HPP;Compatibility;Data historis kandang-owned dan pooled data baru dapat coexist;Regression;High;Dalam satu date range ada transaksi lama kandang-owned dan transaksi baru pooled farm-level.;1. Jalankan closing. 2. Jalankan report. 3. Jalankan HPP.;Kedua jenis data diproses dengan benar, tidak ada double count dan tidak ada atribusi yang hilang.
|
||||||
|
TC-H01;FIFO-v2 dan Integritas Allocation;FIFO-v2;Kontrak FIFO-v2 tidak berubah;Integration;High;Gunakan data uji yang mencakup recording stock, depletion, egg, dan marketing.;1. Verifikasi route FIFO untuk RECORDING_STOCK_OUT, RECORDING_DEPLETION_OUT, RECORDING_DEPLETION_IN, RECORDING_EGG_IN, dan MARKETING_OUT. 2. Bandingkan dengan RFC.md dan seed config FIFO-v2.;Tidak ada perubahan semantik route yang tidak disengaja.
|
||||||
|
TC-H02;FIFO-v2 dan Integritas Allocation;Stock Allocation;Stock allocation tetap konsisten untuk pakan dari gudang farm;Integration;High;Sudah menjalankan TC-C02.;1. Inspect stock_allocations setelah transaksi.;Allocation consume terbentuk dengan benar dan tidak ada row allocation yatim atau rusak.
|
||||||
|
TC-H03;FIFO-v2 dan Integritas Allocation;Stock Allocation;Stock allocation tetap konsisten untuk penjualan telur pooled;Integration;High;Sudah menjalankan TC-F03.;1. Inspect stock_allocations. 2. Inspect row atribusi turunannya.;Allocation mendukung atribusi HPP kembali ke kandang sumber.
|
||||||
|
TC-H04;FIFO-v2 dan Integritas Allocation;Population Allocation;Population allocation tetap konsisten untuk penjualan ayam pooled;Integration;High;Sudah menjalankan TC-F04.;1. Inspect population allocations.;Penggunaan kandang sumber teralokasi dengan benar dan tidak fallback ke atribusi null saat source tersedia.
|
||||||
|
TC-I01;Negative dan Guard Cases;Recording;Recording dari stok farm-level dengan qty tidak cukup;Negative;High;Stok farm-level tersedia tetapi qty lebih kecil dari pemakaian yang diinput.;1. Buat recording dengan qty melebihi stok. 2. Submit atau approve.;Muncul validation atau business error dan tidak ada korupsi parsial.
|
||||||
|
TC-I02;Negative dan Guard Cases;Marketing;Marketing dari stok farm-level dengan qty tidak cukup;Negative;High;Stok farm-level tersedia tetapi qty lebih kecil dari qty penjualan.;1. Buat SO atau DO dengan qty melebihi stok. 2. Submit atau approve.;Delivery atau approval diblok dan stok tetap konsisten.
|
||||||
|
TC-I03;Negative dan Guard Cases;Frontend Selector;Opsi produk sama di gudang berbeda tidak salah terpilih;UI Regression;Medium;Produk yang sama tersedia di gudang farm dan gudang kandang.;1. Pilih masing-masing opsi secara eksplisit di UI. 2. Save. 3. Buka kembali edit atau detail.;Opsi yang terpilih jelas dan tetap stabil setelah save atau edit.
|
||||||
|
TC-I04;Negative dan Guard Cases;Product Warehouse;Row gudang shared tidak diatribusikan ulang oleh flow maintenance;Regression;High;Ada row shared farm warehouse yang sudah aktif.;1. Jalankan flow yang menyentuh logic ensure/find product warehouse. 2. Cek ulang row farm shared.;Tidak ada mutasi diam-diam pada project_flock_kandang_id.
|
||||||
|
TC-J01;Regression Frontend dan UX;Recording Form;Form recording menampilkan opsi stok farm dan kandang hanya dalam scope farm yang sama;UI Regression;Medium;Ada stok di gudang farm, gudang kandang saat ini, dan gudang kandang lain.;1. Buka form recording untuk kandang tertentu. 2. Periksa opsi stock selector.;Gudang farm dan gudang kandang saat ini terlihat, gudang kandang lain tersembunyi.
|
||||||
|
TC-J02;Regression Frontend dan UX;Recording Form;Selector recording telur mengizinkan gudang farm;UI Regression;Medium;Egg warehouse tersedia di gudang farm.;1. Buka form recording telur. 2. Buka selector tujuan telur.;Gudang farm terlihat sebagai opsi tujuan telur.
|
||||||
|
TC-J03;Regression Frontend dan UX;Sales Form;Form sales memakai semantik gudang secara konsisten;UI Regression;Medium;Akses ke halaman marketing tersedia.;1. Buka form sales. 2. Periksa label selector dan summary table.;Label menggunakan Gudang Fisik secara konsisten dan tidak ada wording Kandang yang menyesatkan untuk stok fisik.
|
||||||
|
TC-J04;Regression Frontend dan UX;Marketing Modal;Modal list marketing menampilkan label gudang fisik;UI Regression;Low;Akses ke modal product list tersedia.;1. Buka modal product list di marketing.;Kolom menampilkan label Gudang Fisik.
|
||||||
|
TC-K01;Known Limitation;Recording Detail;Detail recording belum menampilkan source atau origin attribution baru;Known Limitation;Low;Sudah ada recording telur farm-level dan depletion dengan source attribution.;1. Buat transaksi. 2. Buka detail recording.;Transaksi berjalan dan atribusi tersimpan di DB, tetapi detail API atau UI mungkin belum menampilkan field source atau origin tersebut
|
||||||
|
Binary file not shown.
@@ -0,0 +1,343 @@
|
|||||||
|
-- Legacy Egg Cutover Audit Helper Queries
|
||||||
|
-- Ad-hoc query pack for investigation, audit, dry-run review, and rollback readiness.
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- AUDIT-01 All locations classified by kandang/farm egg posting timing
|
||||||
|
-- =====================================================================
|
||||||
|
WITH timing AS (
|
||||||
|
SELECT
|
||||||
|
pf.location_id AS location_id,
|
||||||
|
l.name AS location_name,
|
||||||
|
MIN(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS first_kandang_date,
|
||||||
|
MAX(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS last_kandang_date,
|
||||||
|
MIN(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS first_farm_date,
|
||||||
|
MAX(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS last_farm_date
|
||||||
|
FROM recording_eggs re
|
||||||
|
JOIN recordings r ON r.id = re.recording_id
|
||||||
|
JOIN project_flock_kandangs pk ON pk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)
|
||||||
|
JOIN project_flocks pf ON pf.id = pk.project_flock_id
|
||||||
|
JOIN locations l ON l.id = pf.location_id
|
||||||
|
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
|
||||||
|
JOIN warehouses w ON w.id = pw.warehouse_id
|
||||||
|
GROUP BY pf.location_id, l.name
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
location_id,
|
||||||
|
location_name,
|
||||||
|
first_kandang_date,
|
||||||
|
last_kandang_date,
|
||||||
|
first_farm_date,
|
||||||
|
last_farm_date,
|
||||||
|
CASE
|
||||||
|
WHEN first_farm_date IS NULL THEN 'KANDANG_ONLY'
|
||||||
|
WHEN last_kandang_date IS NULL OR first_farm_date > last_kandang_date THEN 'CLEAN_CUTOVER'
|
||||||
|
ELSE 'OVERLAP'
|
||||||
|
END AS location_status
|
||||||
|
FROM timing
|
||||||
|
ORDER BY location_name;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- AUDIT-02 All legacy kandang egg product warehouses with positive on-hand
|
||||||
|
-- =====================================================================
|
||||||
|
WITH first_farm AS (
|
||||||
|
SELECT location_id, MIN(id) AS farm_warehouse_id
|
||||||
|
FROM warehouses
|
||||||
|
WHERE type = 'LOKASI'
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
GROUP BY location_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
l.id AS location_id,
|
||||||
|
l.name AS location_name,
|
||||||
|
kw.id AS source_warehouse_id,
|
||||||
|
kw.name AS source_warehouse_name,
|
||||||
|
fw.id AS farm_warehouse_id,
|
||||||
|
fw.name AS farm_warehouse_name,
|
||||||
|
pw.id AS product_warehouse_id,
|
||||||
|
p.id AS product_id,
|
||||||
|
p.name AS product_name,
|
||||||
|
COALESCE(pw.qty, 0) AS on_hand_qty
|
||||||
|
FROM product_warehouses pw
|
||||||
|
JOIN warehouses kw
|
||||||
|
ON kw.id = pw.warehouse_id
|
||||||
|
AND kw.type = 'KANDANG'
|
||||||
|
AND kw.deleted_at IS NULL
|
||||||
|
JOIN locations l ON l.id = kw.location_id
|
||||||
|
JOIN products p ON p.id = pw.product_id
|
||||||
|
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
|
||||||
|
LEFT JOIN first_farm ff ON ff.location_id = kw.location_id
|
||||||
|
LEFT JOIN warehouses fw ON fw.id = ff.farm_warehouse_id
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM flags f
|
||||||
|
WHERE f.flagable_type = 'products'
|
||||||
|
AND f.flagable_id = p.id
|
||||||
|
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM flags f_any
|
||||||
|
WHERE f_any.flagable_type = 'products'
|
||||||
|
AND f_any.flagable_id = p.id
|
||||||
|
)
|
||||||
|
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AND COALESCE(pw.qty, 0) > 0
|
||||||
|
ORDER BY l.name, kw.name, p.name;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- AUDIT-03 Totals per location for phase sizing
|
||||||
|
-- =====================================================================
|
||||||
|
WITH candidates AS (
|
||||||
|
SELECT
|
||||||
|
l.name AS location_name,
|
||||||
|
COALESCE(pw.qty, 0) AS on_hand_qty
|
||||||
|
FROM product_warehouses pw
|
||||||
|
JOIN warehouses kw
|
||||||
|
ON kw.id = pw.warehouse_id
|
||||||
|
AND kw.type = 'KANDANG'
|
||||||
|
AND kw.deleted_at IS NULL
|
||||||
|
JOIN locations l ON l.id = kw.location_id
|
||||||
|
JOIN products p ON p.id = pw.product_id
|
||||||
|
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM flags f
|
||||||
|
WHERE f.flagable_type = 'products'
|
||||||
|
AND f.flagable_id = p.id
|
||||||
|
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM flags f_any
|
||||||
|
WHERE f_any.flagable_type = 'products'
|
||||||
|
AND f_any.flagable_id = p.id
|
||||||
|
)
|
||||||
|
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AND COALESCE(pw.qty, 0) > 0
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
location_name,
|
||||||
|
COUNT(*) AS positive_rows,
|
||||||
|
SUM(on_hand_qty) AS total_on_hand_qty
|
||||||
|
FROM candidates
|
||||||
|
GROUP BY location_name
|
||||||
|
ORDER BY location_name;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- AUDIT-04 Locations missing farm warehouse
|
||||||
|
-- =====================================================================
|
||||||
|
SELECT
|
||||||
|
l.id AS location_id,
|
||||||
|
l.name AS location_name
|
||||||
|
FROM locations l
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM warehouses kw
|
||||||
|
WHERE kw.location_id = l.id
|
||||||
|
AND kw.type = 'KANDANG'
|
||||||
|
AND kw.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM warehouses fw
|
||||||
|
WHERE fw.location_id = l.id
|
||||||
|
AND fw.type = 'LOKASI'
|
||||||
|
AND fw.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
ORDER BY l.name;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- AUDIT-05 Legacy recording_eggs still pointing to kandang warehouse
|
||||||
|
-- =====================================================================
|
||||||
|
SELECT
|
||||||
|
l.name AS location_name,
|
||||||
|
kw.name AS kandang_warehouse_name,
|
||||||
|
p.name AS product_name,
|
||||||
|
COUNT(*) AS recording_rows
|
||||||
|
FROM recording_eggs re
|
||||||
|
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
|
||||||
|
JOIN warehouses kw ON kw.id = pw.warehouse_id
|
||||||
|
JOIN locations l ON l.id = kw.location_id
|
||||||
|
JOIN products p ON p.id = pw.product_id
|
||||||
|
WHERE kw.type = 'KANDANG'
|
||||||
|
GROUP BY l.name, kw.name, p.name
|
||||||
|
ORDER BY l.name, kw.name, p.name;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- AUDIT-06 Farm-level recording_eggs already present
|
||||||
|
-- =====================================================================
|
||||||
|
SELECT
|
||||||
|
l.name AS location_name,
|
||||||
|
fw.name AS farm_warehouse_name,
|
||||||
|
p.name AS product_name,
|
||||||
|
COUNT(*) AS recording_rows
|
||||||
|
FROM recording_eggs re
|
||||||
|
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
|
||||||
|
JOIN warehouses fw ON fw.id = pw.warehouse_id
|
||||||
|
JOIN locations l ON l.id = fw.location_id
|
||||||
|
JOIN products p ON p.id = pw.product_id
|
||||||
|
WHERE fw.type = 'LOKASI'
|
||||||
|
GROUP BY l.name, fw.name, p.name
|
||||||
|
ORDER BY l.name, fw.name, p.name;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- AUDIT-07 Transfers created by cutover reason, grouped by run_id
|
||||||
|
-- =====================================================================
|
||||||
|
SELECT
|
||||||
|
SPLIT_PART(SPLIT_PART(st.reason, '|run_id=', 2), '|', 1) AS run_id,
|
||||||
|
COUNT(DISTINCT st.id) AS transfer_count,
|
||||||
|
COUNT(std.id) AS detail_count,
|
||||||
|
SUM(COALESCE(std.total_qty, std.usage_qty, 0)) AS total_moved_qty,
|
||||||
|
MIN(st.transfer_date) AS first_transfer_date,
|
||||||
|
MAX(st.transfer_date) AS last_transfer_date
|
||||||
|
FROM stock_transfers st
|
||||||
|
JOIN stock_transfer_details std
|
||||||
|
ON std.stock_transfer_id = st.id
|
||||||
|
AND std.deleted_at IS NULL
|
||||||
|
WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=%'
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY first_transfer_date DESC, run_id DESC;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- AUDIT-08 Detailed summary per run_id
|
||||||
|
-- Replace <run_id> before running.
|
||||||
|
-- =====================================================================
|
||||||
|
SELECT
|
||||||
|
st.id AS transfer_id,
|
||||||
|
st.movement_number,
|
||||||
|
st.transfer_date,
|
||||||
|
ws.name AS source_warehouse_name,
|
||||||
|
wd.name AS farm_warehouse_name,
|
||||||
|
p.name AS product_name,
|
||||||
|
COALESCE(std.total_qty, std.usage_qty, 0) AS moved_qty,
|
||||||
|
st.deleted_at
|
||||||
|
FROM stock_transfers st
|
||||||
|
JOIN stock_transfer_details std
|
||||||
|
ON std.stock_transfer_id = st.id
|
||||||
|
AND std.deleted_at IS NULL
|
||||||
|
JOIN products p ON p.id = std.product_id
|
||||||
|
JOIN warehouses ws ON ws.id = st.from_warehouse_id
|
||||||
|
JOIN warehouses wd ON wd.id = st.to_warehouse_id
|
||||||
|
WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
|
||||||
|
ORDER BY st.id, p.name;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- AUDIT-09 Downstream consumption check per run_id
|
||||||
|
-- Replace <run_id> before running.
|
||||||
|
-- =====================================================================
|
||||||
|
SELECT
|
||||||
|
st.id AS transfer_id,
|
||||||
|
st.movement_number,
|
||||||
|
p.name AS product_name,
|
||||||
|
sa.usable_type,
|
||||||
|
sa.usable_id,
|
||||||
|
sa.qty,
|
||||||
|
sa.function_code,
|
||||||
|
sa.flag_group_code
|
||||||
|
FROM stock_transfers st
|
||||||
|
JOIN stock_transfer_details std
|
||||||
|
ON std.stock_transfer_id = st.id
|
||||||
|
AND std.deleted_at IS NULL
|
||||||
|
JOIN products p ON p.id = std.product_id
|
||||||
|
JOIN stock_allocations sa
|
||||||
|
ON sa.stockable_type = 'STOCK_TRANSFER_IN'
|
||||||
|
AND sa.stockable_id = std.id
|
||||||
|
AND sa.status = 'ACTIVE'
|
||||||
|
AND sa.allocation_purpose = 'CONSUME'
|
||||||
|
AND sa.deleted_at IS NULL
|
||||||
|
WHERE st.deleted_at IS NULL
|
||||||
|
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
|
||||||
|
ORDER BY st.id, p.name, sa.usable_type, sa.usable_id;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- AUDIT-10 Stock log reconciliation per cutover transfer detail
|
||||||
|
-- Replace <run_id> before running.
|
||||||
|
-- =====================================================================
|
||||||
|
SELECT
|
||||||
|
st.id AS transfer_id,
|
||||||
|
st.movement_number,
|
||||||
|
p.name AS product_name,
|
||||||
|
std.id AS transfer_detail_id,
|
||||||
|
COALESCE(std.total_qty, std.usage_qty, 0) AS moved_qty,
|
||||||
|
SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END) AS total_logged_out,
|
||||||
|
SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END) AS total_logged_in
|
||||||
|
FROM stock_transfers st
|
||||||
|
JOIN stock_transfer_details std
|
||||||
|
ON std.stock_transfer_id = st.id
|
||||||
|
AND std.deleted_at IS NULL
|
||||||
|
JOIN products p ON p.id = std.product_id
|
||||||
|
LEFT JOIN stock_logs sl
|
||||||
|
ON sl.loggable_type = 'TRANSFER'
|
||||||
|
AND sl.loggable_id = std.id
|
||||||
|
WHERE st.deleted_at IS NULL
|
||||||
|
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
|
||||||
|
GROUP BY st.id, st.movement_number, p.name, std.id, COALESCE(std.total_qty, std.usage_qty, 0)
|
||||||
|
ORDER BY st.id, p.name;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- AUDIT-11 New recording eggs still posting to kandang after cutoff date
|
||||||
|
-- Replace values before running.
|
||||||
|
-- =====================================================================
|
||||||
|
SELECT
|
||||||
|
DATE(r.record_datetime) AS record_date,
|
||||||
|
l.name AS location_name,
|
||||||
|
kw.name AS kandang_warehouse_name,
|
||||||
|
p.name AS product_name,
|
||||||
|
re.qty
|
||||||
|
FROM recording_eggs re
|
||||||
|
JOIN recordings r ON r.id = re.recording_id
|
||||||
|
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
|
||||||
|
JOIN warehouses kw ON kw.id = pw.warehouse_id
|
||||||
|
JOIN locations l ON l.id = kw.location_id
|
||||||
|
JOIN products p ON p.id = pw.product_id
|
||||||
|
WHERE kw.type = 'KANDANG'
|
||||||
|
AND LOWER(l.name) = LOWER('<location_name>')
|
||||||
|
AND DATE(r.record_datetime) >= DATE('<cutover_date>')
|
||||||
|
ORDER BY r.record_datetime ASC, kw.name, p.name;
|
||||||
|
|
||||||
|
-- Expectation:
|
||||||
|
-- - after deploy and cutover, this should ideally return 0 rows for the location
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- AUDIT-12 Combined kandang + farm egg stock per location after cutover
|
||||||
|
-- Replace <location_name> before running.
|
||||||
|
-- =====================================================================
|
||||||
|
SELECT
|
||||||
|
l.name AS location_name,
|
||||||
|
w.type AS warehouse_type,
|
||||||
|
p.name AS product_name,
|
||||||
|
SUM(COALESCE(pw.qty, 0)) AS total_qty
|
||||||
|
FROM product_warehouses pw
|
||||||
|
JOIN warehouses w ON w.id = pw.warehouse_id
|
||||||
|
JOIN locations l ON l.id = w.location_id
|
||||||
|
JOIN products p ON p.id = pw.product_id
|
||||||
|
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
|
||||||
|
WHERE LOWER(l.name) = LOWER('<location_name>')
|
||||||
|
AND (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM flags f
|
||||||
|
WHERE f.flagable_type = 'products'
|
||||||
|
AND f.flagable_id = p.id
|
||||||
|
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM flags f_any
|
||||||
|
WHERE f_any.flagable_type = 'products'
|
||||||
|
AND f_any.flagable_id = p.id
|
||||||
|
)
|
||||||
|
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
GROUP BY l.name, w.type, p.name
|
||||||
|
ORDER BY w.type, p.name;
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
-- Legacy Egg Cutover Verification Checklist
|
||||||
|
-- Usage:
|
||||||
|
-- 1. Replace the values below before executing.
|
||||||
|
-- 2. Run section BEFORE before --apply.
|
||||||
|
-- 3. Run section AFTER after --apply.
|
||||||
|
-- 4. Run rollback checks if needed.
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- PARAMETERS
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- Replace manually before running.
|
||||||
|
-- Example:
|
||||||
|
-- location_name = Jamali
|
||||||
|
-- cutover_date = 2026-04-07
|
||||||
|
-- run_id = egg-cutover-20260407T130344.220407000Z
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- BEFORE APPLY
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- [BEFORE-01] Identify target location and farm warehouse
|
||||||
|
SELECT
|
||||||
|
l.id AS location_id,
|
||||||
|
l.name AS location_name,
|
||||||
|
fw.id AS farm_warehouse_id,
|
||||||
|
fw.name AS farm_warehouse_name
|
||||||
|
FROM locations l
|
||||||
|
LEFT JOIN warehouses fw
|
||||||
|
ON fw.location_id = l.id
|
||||||
|
AND fw.type = 'LOKASI'
|
||||||
|
AND fw.deleted_at IS NULL
|
||||||
|
WHERE LOWER(l.name) = LOWER('<location_name>')
|
||||||
|
ORDER BY fw.id ASC;
|
||||||
|
|
||||||
|
-- Expectation:
|
||||||
|
-- - exactly one target location
|
||||||
|
-- - at least one farm warehouse exists
|
||||||
|
|
||||||
|
-- [BEFORE-02] Verify location timing status (must be CLEAN_CUTOVER for phase 1)
|
||||||
|
WITH timing AS (
|
||||||
|
SELECT
|
||||||
|
pf.location_id AS location_id,
|
||||||
|
l.name AS location_name,
|
||||||
|
MIN(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS first_kandang_date,
|
||||||
|
MAX(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS last_kandang_date,
|
||||||
|
MIN(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS first_farm_date,
|
||||||
|
MAX(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS last_farm_date
|
||||||
|
FROM recording_eggs re
|
||||||
|
JOIN recordings r ON r.id = re.recording_id
|
||||||
|
JOIN project_flock_kandangs pk ON pk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)
|
||||||
|
JOIN project_flocks pf ON pf.id = pk.project_flock_id
|
||||||
|
JOIN locations l ON l.id = pf.location_id
|
||||||
|
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
|
||||||
|
JOIN warehouses w ON w.id = pw.warehouse_id
|
||||||
|
WHERE LOWER(l.name) = LOWER('<location_name>')
|
||||||
|
GROUP BY pf.location_id, l.name
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
location_id,
|
||||||
|
location_name,
|
||||||
|
first_kandang_date,
|
||||||
|
last_kandang_date,
|
||||||
|
first_farm_date,
|
||||||
|
last_farm_date,
|
||||||
|
CASE
|
||||||
|
WHEN first_farm_date IS NULL THEN 'KANDANG_ONLY'
|
||||||
|
WHEN last_kandang_date IS NULL OR first_farm_date > last_kandang_date THEN 'CLEAN_CUTOVER'
|
||||||
|
ELSE 'OVERLAP'
|
||||||
|
END AS location_status
|
||||||
|
FROM timing;
|
||||||
|
|
||||||
|
-- Expectation:
|
||||||
|
-- - phase 1 location must be CLEAN_CUTOVER
|
||||||
|
|
||||||
|
-- [BEFORE-03] Candidate source rows that should be migrated
|
||||||
|
WITH first_farm AS (
|
||||||
|
SELECT location_id, MIN(id) AS farm_warehouse_id
|
||||||
|
FROM warehouses
|
||||||
|
WHERE type = 'LOKASI'
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
GROUP BY location_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
l.id AS location_id,
|
||||||
|
l.name AS location_name,
|
||||||
|
kw.id AS source_warehouse_id,
|
||||||
|
kw.name AS source_warehouse_name,
|
||||||
|
fw.id AS farm_warehouse_id,
|
||||||
|
fw.name AS farm_warehouse_name,
|
||||||
|
pw.id AS product_warehouse_id,
|
||||||
|
p.id AS product_id,
|
||||||
|
p.name AS product_name,
|
||||||
|
COALESCE(pw.qty, 0) AS on_hand_qty
|
||||||
|
FROM product_warehouses pw
|
||||||
|
JOIN warehouses kw
|
||||||
|
ON kw.id = pw.warehouse_id
|
||||||
|
AND kw.type = 'KANDANG'
|
||||||
|
AND kw.deleted_at IS NULL
|
||||||
|
JOIN locations l ON l.id = kw.location_id
|
||||||
|
JOIN products p ON p.id = pw.product_id
|
||||||
|
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
|
||||||
|
LEFT JOIN first_farm ff ON ff.location_id = kw.location_id
|
||||||
|
LEFT JOIN warehouses fw ON fw.id = ff.farm_warehouse_id
|
||||||
|
WHERE LOWER(l.name) = LOWER('<location_name>')
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM recording_eggs re
|
||||||
|
WHERE re.product_warehouse_id = pw.id
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM flags f
|
||||||
|
WHERE f.flagable_type = 'products'
|
||||||
|
AND f.flagable_id = p.id
|
||||||
|
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM flags f_any
|
||||||
|
WHERE f_any.flagable_type = 'products'
|
||||||
|
AND f_any.flagable_id = p.id
|
||||||
|
)
|
||||||
|
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AND COALESCE(pw.qty, 0) > 0
|
||||||
|
ORDER BY kw.name, p.name;
|
||||||
|
|
||||||
|
-- Expectation:
|
||||||
|
-- - every row here should match dry-run eligible rows
|
||||||
|
|
||||||
|
-- [BEFORE-04] Totals per source warehouse and product
|
||||||
|
WITH candidates AS (
|
||||||
|
SELECT
|
||||||
|
kw.name AS source_warehouse_name,
|
||||||
|
p.name AS product_name,
|
||||||
|
COALESCE(pw.qty, 0) AS on_hand_qty
|
||||||
|
FROM product_warehouses pw
|
||||||
|
JOIN warehouses kw
|
||||||
|
ON kw.id = pw.warehouse_id
|
||||||
|
AND kw.type = 'KANDANG'
|
||||||
|
AND kw.deleted_at IS NULL
|
||||||
|
JOIN locations l ON l.id = kw.location_id
|
||||||
|
JOIN products p ON p.id = pw.product_id
|
||||||
|
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
|
||||||
|
WHERE LOWER(l.name) = LOWER('<location_name>')
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM flags f
|
||||||
|
WHERE f.flagable_type = 'products'
|
||||||
|
AND f.flagable_id = p.id
|
||||||
|
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM flags f_any
|
||||||
|
WHERE f_any.flagable_type = 'products'
|
||||||
|
AND f_any.flagable_id = p.id
|
||||||
|
)
|
||||||
|
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AND COALESCE(pw.qty, 0) > 0
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
source_warehouse_name,
|
||||||
|
product_name,
|
||||||
|
SUM(on_hand_qty) AS total_qty
|
||||||
|
FROM candidates
|
||||||
|
GROUP BY source_warehouse_name, product_name
|
||||||
|
ORDER BY source_warehouse_name, product_name;
|
||||||
|
|
||||||
|
-- [BEFORE-05] Current farm egg stock before cutover
|
||||||
|
SELECT
|
||||||
|
fw.name AS farm_warehouse_name,
|
||||||
|
p.name AS product_name,
|
||||||
|
COALESCE(pw.qty, 0) AS farm_on_hand_qty
|
||||||
|
FROM warehouses fw
|
||||||
|
JOIN locations l ON l.id = fw.location_id
|
||||||
|
JOIN product_warehouses pw ON pw.warehouse_id = fw.id
|
||||||
|
JOIN products p ON p.id = pw.product_id
|
||||||
|
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
|
||||||
|
WHERE LOWER(l.name) = LOWER('<location_name>')
|
||||||
|
AND fw.type = 'LOKASI'
|
||||||
|
AND fw.deleted_at IS NULL
|
||||||
|
AND (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM flags f
|
||||||
|
WHERE f.flagable_type = 'products'
|
||||||
|
AND f.flagable_id = p.id
|
||||||
|
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM flags f_any
|
||||||
|
WHERE f_any.flagable_type = 'products'
|
||||||
|
AND f_any.flagable_id = p.id
|
||||||
|
)
|
||||||
|
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY p.name;
|
||||||
|
|
||||||
|
-- [BEFORE-06] Existing cutover transfers for this location
|
||||||
|
SELECT
|
||||||
|
st.id,
|
||||||
|
st.movement_number,
|
||||||
|
st.transfer_date,
|
||||||
|
st.reason,
|
||||||
|
ws.name AS source_warehouse_name,
|
||||||
|
wd.name AS farm_warehouse_name,
|
||||||
|
st.deleted_at
|
||||||
|
FROM stock_transfers st
|
||||||
|
JOIN warehouses ws ON ws.id = st.from_warehouse_id
|
||||||
|
JOIN warehouses wd ON wd.id = st.to_warehouse_id
|
||||||
|
LEFT JOIN locations l ON l.id = COALESCE(ws.location_id, wd.location_id)
|
||||||
|
WHERE LOWER(COALESCE(l.name, '')) = LOWER('<location_name>')
|
||||||
|
AND st.reason LIKE 'EGG_FARM_CUTOVER|%'
|
||||||
|
ORDER BY st.id DESC;
|
||||||
|
|
||||||
|
-- Expectation:
|
||||||
|
-- - no unexpected older active cutover transfers for the same location
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- AFTER APPLY
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- [AFTER-01] Transfer headers created by run_id
|
||||||
|
SELECT
|
||||||
|
st.id,
|
||||||
|
st.movement_number,
|
||||||
|
st.transfer_date,
|
||||||
|
st.reason,
|
||||||
|
ws.name AS source_warehouse_name,
|
||||||
|
wd.name AS farm_warehouse_name,
|
||||||
|
st.deleted_at
|
||||||
|
FROM stock_transfers st
|
||||||
|
JOIN warehouses ws ON ws.id = st.from_warehouse_id
|
||||||
|
JOIN warehouses wd ON wd.id = st.to_warehouse_id
|
||||||
|
WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
|
||||||
|
ORDER BY st.id ASC;
|
||||||
|
|
||||||
|
-- [AFTER-02] Transfer detail rows created by run_id
|
||||||
|
SELECT
|
||||||
|
st.id AS transfer_id,
|
||||||
|
st.movement_number,
|
||||||
|
ws.name AS source_warehouse_name,
|
||||||
|
wd.name AS farm_warehouse_name,
|
||||||
|
p.name AS product_name,
|
||||||
|
COALESCE(std.total_qty, std.usage_qty, 0) AS moved_qty,
|
||||||
|
std.source_product_warehouse_id,
|
||||||
|
std.dest_product_warehouse_id
|
||||||
|
FROM stock_transfers st
|
||||||
|
JOIN stock_transfer_details std
|
||||||
|
ON std.stock_transfer_id = st.id
|
||||||
|
AND std.deleted_at IS NULL
|
||||||
|
JOIN products p ON p.id = std.product_id
|
||||||
|
JOIN warehouses ws ON ws.id = st.from_warehouse_id
|
||||||
|
JOIN warehouses wd ON wd.id = st.to_warehouse_id
|
||||||
|
WHERE st.deleted_at IS NULL
|
||||||
|
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
|
||||||
|
ORDER BY st.id, p.name;
|
||||||
|
|
||||||
|
-- [AFTER-03] Stock logs created by run_id transfer details
|
||||||
|
SELECT
|
||||||
|
st.id AS transfer_id,
|
||||||
|
st.movement_number,
|
||||||
|
p.name AS product_name,
|
||||||
|
sl.product_warehouse_id,
|
||||||
|
sl.increase,
|
||||||
|
sl.decrease,
|
||||||
|
sl.stock,
|
||||||
|
sl.created_at
|
||||||
|
FROM stock_transfers st
|
||||||
|
JOIN stock_transfer_details std
|
||||||
|
ON std.stock_transfer_id = st.id
|
||||||
|
AND std.deleted_at IS NULL
|
||||||
|
JOIN products p ON p.id = std.product_id
|
||||||
|
JOIN stock_logs sl
|
||||||
|
ON sl.loggable_type = 'TRANSFER'
|
||||||
|
AND sl.loggable_id = std.id
|
||||||
|
WHERE st.deleted_at IS NULL
|
||||||
|
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
|
||||||
|
ORDER BY st.id, p.name, sl.id;
|
||||||
|
|
||||||
|
-- Expectation:
|
||||||
|
-- - every detail has one stock log decrease from source and one stock log increase to destination
|
||||||
|
|
||||||
|
-- [AFTER-04] Source rows after cutover
|
||||||
|
SELECT
|
||||||
|
kw.name AS source_warehouse_name,
|
||||||
|
p.name AS product_name,
|
||||||
|
COALESCE(pw.qty, 0) AS source_qty_after
|
||||||
|
FROM product_warehouses pw
|
||||||
|
JOIN warehouses kw ON kw.id = pw.warehouse_id
|
||||||
|
JOIN locations l ON l.id = kw.location_id
|
||||||
|
JOIN products p ON p.id = pw.product_id
|
||||||
|
WHERE LOWER(l.name) = LOWER('<location_name>')
|
||||||
|
AND kw.type = 'KANDANG'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id
|
||||||
|
)
|
||||||
|
ORDER BY kw.name, p.name;
|
||||||
|
|
||||||
|
-- Expectation:
|
||||||
|
-- - rows that were transferred should now be 0 or no longer available for use
|
||||||
|
|
||||||
|
-- [AFTER-05] Farm rows after cutover
|
||||||
|
SELECT
|
||||||
|
fw.name AS farm_warehouse_name,
|
||||||
|
p.name AS product_name,
|
||||||
|
COALESCE(pw.qty, 0) AS farm_qty_after
|
||||||
|
FROM product_warehouses pw
|
||||||
|
JOIN warehouses fw ON fw.id = pw.warehouse_id
|
||||||
|
JOIN locations l ON l.id = fw.location_id
|
||||||
|
JOIN products p ON p.id = pw.product_id
|
||||||
|
WHERE LOWER(l.name) = LOWER('<location_name>')
|
||||||
|
AND fw.type = 'LOKASI'
|
||||||
|
ORDER BY fw.name, p.name;
|
||||||
|
|
||||||
|
-- Expectation:
|
||||||
|
-- - farm qty increases by the moved amount
|
||||||
|
|
||||||
|
-- [AFTER-06] Reconciliation: total moved by run
|
||||||
|
SELECT
|
||||||
|
p.name AS product_name,
|
||||||
|
SUM(COALESCE(std.total_qty, std.usage_qty, 0)) AS total_moved_qty
|
||||||
|
FROM stock_transfers st
|
||||||
|
JOIN stock_transfer_details std
|
||||||
|
ON std.stock_transfer_id = st.id
|
||||||
|
AND std.deleted_at IS NULL
|
||||||
|
JOIN products p ON p.id = std.product_id
|
||||||
|
WHERE st.deleted_at IS NULL
|
||||||
|
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
|
||||||
|
GROUP BY p.name
|
||||||
|
ORDER BY p.name;
|
||||||
|
|
||||||
|
-- [AFTER-07] Farm stock available for SO after cutover
|
||||||
|
SELECT
|
||||||
|
fw.name AS farm_warehouse_name,
|
||||||
|
p.name AS product_name,
|
||||||
|
COALESCE(pw.qty, 0) AS available_qty
|
||||||
|
FROM product_warehouses pw
|
||||||
|
JOIN warehouses fw ON fw.id = pw.warehouse_id
|
||||||
|
JOIN locations l ON l.id = fw.location_id
|
||||||
|
JOIN products p ON p.id = pw.product_id
|
||||||
|
WHERE LOWER(l.name) = LOWER('<location_name>')
|
||||||
|
AND fw.type = 'LOKASI'
|
||||||
|
AND COALESCE(pw.qty, 0) > 0
|
||||||
|
ORDER BY p.name;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- ROLLBACK CHECKS
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- [ROLLBACK-01] Check downstream consumption guard before rollback
|
||||||
|
SELECT
|
||||||
|
st.id AS transfer_id,
|
||||||
|
st.movement_number,
|
||||||
|
p.name AS product_name,
|
||||||
|
sa.usable_type,
|
||||||
|
sa.usable_id,
|
||||||
|
sa.qty,
|
||||||
|
sa.function_code,
|
||||||
|
sa.flag_group_code
|
||||||
|
FROM stock_transfers st
|
||||||
|
JOIN stock_transfer_details std
|
||||||
|
ON std.stock_transfer_id = st.id
|
||||||
|
AND std.deleted_at IS NULL
|
||||||
|
JOIN products p ON p.id = std.product_id
|
||||||
|
JOIN stock_allocations sa
|
||||||
|
ON sa.stockable_type = 'STOCK_TRANSFER_IN'
|
||||||
|
AND sa.stockable_id = std.id
|
||||||
|
AND sa.status = 'ACTIVE'
|
||||||
|
AND sa.allocation_purpose = 'CONSUME'
|
||||||
|
AND sa.deleted_at IS NULL
|
||||||
|
WHERE st.deleted_at IS NULL
|
||||||
|
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
|
||||||
|
ORDER BY st.id, p.name, sa.usable_type, sa.usable_id;
|
||||||
|
|
||||||
|
-- Expectation:
|
||||||
|
-- - rollback only safe if this query returns 0 rows
|
||||||
|
|
||||||
|
-- [ROLLBACK-02] Verify run is fully rolled back
|
||||||
|
SELECT
|
||||||
|
st.id,
|
||||||
|
st.movement_number,
|
||||||
|
st.deleted_at
|
||||||
|
FROM stock_transfers st
|
||||||
|
WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
|
||||||
|
ORDER BY st.id;
|
||||||
|
|
||||||
|
-- Expectation:
|
||||||
|
-- - after rollback, deleted_at should be filled for all transfers in the run
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
module gitlab.com/mbugroup/lti-api.git
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/MicahParks/keyfunc/v2 v2.1.0
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.40.0
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.2
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.2
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1
|
||||||
|
github.com/bytedance/sonic v1.12.1
|
||||||
|
github.com/glebarez/sqlite v1.11.0
|
||||||
|
github.com/go-playground/validator/v10 v10.27.0
|
||||||
|
github.com/gofiber/contrib/jwt v1.0.10
|
||||||
|
github.com/gofiber/fiber/v2 v2.52.5
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jackc/pgconn v1.14.1
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5
|
||||||
|
github.com/redis/go-redis/v9 v9.14.0
|
||||||
|
github.com/sirupsen/logrus v1.9.3
|
||||||
|
github.com/spf13/viper v1.19.0
|
||||||
|
github.com/xuri/excelize/v2 v2.9.0
|
||||||
|
golang.org/x/crypto v0.33.0
|
||||||
|
gorm.io/driver/postgres v1.5.9
|
||||||
|
gorm.io/gorm v1.25.11
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 // indirect
|
||||||
|
github.com/aws/smithy-go v1.23.2 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.2.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/google/go-cmp v0.6.0 // indirect
|
||||||
|
github.com/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/pgproto3/v2 v2.3.2 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/klauspost/compress v1.17.9 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.3 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/magiconair/properties v1.8.7 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
|
github.com/philhofer/fwd v1.1.2 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||||
|
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
||||||
|
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||||
|
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||||
|
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||||
|
github.com/spf13/afero v1.11.0 // indirect
|
||||||
|
github.com/spf13/cast v1.6.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
github.com/stretchr/testify v1.11.1 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/tinylib/msgp v1.1.8 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasthttp v1.55.0 // indirect
|
||||||
|
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||||
|
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d // indirect
|
||||||
|
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 // indirect
|
||||||
|
go.uber.org/atomic v1.9.0 // indirect
|
||||||
|
go.uber.org/multierr v1.9.0 // indirect
|
||||||
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||||
|
golang.org/x/net v0.35.0 // indirect
|
||||||
|
golang.org/x/sync v0.11.0 // indirect
|
||||||
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
|
golang.org/x/text v0.22.0 // indirect
|
||||||
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
modernc.org/libc v1.22.5 // indirect
|
||||||
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
|
modernc.org/memory v1.5.0 // indirect
|
||||||
|
modernc.org/sqlite v1.23.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k=
|
||||||
|
github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k=
|
||||||
|
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||||
|
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.2 h1:4liUsdEpUUPZs5WVapsJLx5NPmQhQdez7nYFcovrytk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.2/go.mod h1:l0hs06IFz1eCT+jTacU/qZtC33nvcnLADAPL/XyrkZI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.2 h1:qZry8VUyTK4VIo5aEdUcBjPZHL2v4FyQ3QEOaWcFLu4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.2/go.mod h1:YUqm5a1/kBnoK+/NY5WEiMocZihKSo15/tJdmdXnM5g=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 h1:ITi7qiDSv/mSGDSWNpZ4k4Ve0DQR6Ug2SJQ8zEHoDXg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14/go.mod h1:k1xtME53H1b6YpZt74YmwlONMWf4ecM+lut1WQLAF/U=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 h1:Hjkh7kE6D81PgrHlE/m9gx+4TyyeLHuY8xJs7yXN5C4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5/go.mod h1:nPRXgyCfAurhyaTMoBMwRBYBhaHI4lNPAnJmjM0Tslc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 h1:FzQE21lNtUor0Fb7QNgnEyiRCBlolLTX/Z1j65S7teM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14/go.mod h1:s1ydyWG9pm3ZwmmYN21HKyG9WzAZhYVW85wMHs5FV6w=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1 h1:OgQy/+0+Kc3khtqiEOk23xQAglXi3Tj0y5doOxbi5tg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1/go.mod h1:wYNqY3L02Z3IgRYxOBPH9I1zD9Cjh9hI5QOy/eOjQvw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 h1:MxMBdKTYBjPQChlJhi4qlEueqB1p1KcbTEa7tD5aqPs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 h1:ksUT5KtgpZd3SAiFJNJ0AFEJVva3gjBmN7eXUZjzUwQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 h1:GtsxyiF3Nd3JahRBJbxLCCdYW9ltGQYrFWg8XdkGDd8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 h1:a5UTtD4mHBU3t0o6aHQZFJTNKVfxFWfPX7J0Lr7G+uY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso=
|
||||||
|
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
|
||||||
|
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/bytedance/sonic v1.12.1 h1:jWl5Qz1fy7X1ioY74WqO0KjAMtAGQs4sYnjiEBiyX24=
|
||||||
|
github.com/bytedance/sonic v1.12.1/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
|
github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM=
|
||||||
|
github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
|
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/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.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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||||
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
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/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/go.mod h1:1qBENE6sZ6PPT4xIpBzx1VxeyROQO7sj48OlM1I9qdU=
|
||||||
|
github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo=
|
||||||
|
github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||||
|
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/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/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/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/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||||
|
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
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/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/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/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/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/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/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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||||
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
|
github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
|
||||||
|
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.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
|
||||||
|
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
||||||
|
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
||||||
|
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
|
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
|
||||||
|
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
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/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/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/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/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||||
|
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||||
|
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||||
|
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||||
|
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||||
|
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
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/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.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
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.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.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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=
|
||||||
|
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
|
||||||
|
github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
|
||||||
|
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||||
|
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||||
|
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d h1:llb0neMWDQe87IzJLS4Ci7psK/lVsjIS2otl+1WyRyY=
|
||||||
|
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||||
|
github.com/xuri/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/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/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/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/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-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.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/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||||
|
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||||
|
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||||
|
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||||
|
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
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-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-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.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/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-20220722155255-886fb9371eb4/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/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-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-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
|
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-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.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.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.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.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/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-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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
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-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 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/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/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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
|
||||||
|
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||||
|
gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
|
||||||
|
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||||
|
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||||
|
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||||
|
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
|
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
|
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||||
|
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
|
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||||
|
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||||
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
Vendored
BIN
Binary file not shown.
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ApprovalRepository interface {
|
||||||
|
BaseRepository[entity.Approval]
|
||||||
|
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)
|
||||||
|
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 {
|
||||||
|
*BaseRepositoryImpl[entity.Approval]
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApprovalRepository(db *gorm.DB) ApprovalRepository {
|
||||||
|
return &approvalRepositoryImpl{
|
||||||
|
BaseRepositoryImpl: NewBaseRepository[entity.Approval](db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *approvalRepositoryImpl) FindByTarget(
|
||||||
|
ctx context.Context,
|
||||||
|
workflow string,
|
||||||
|
approvableID uint,
|
||||||
|
modifier func(*gorm.DB) *gorm.DB,
|
||||||
|
) ([]entity.Approval, error) {
|
||||||
|
var approvals []entity.Approval
|
||||||
|
|
||||||
|
q := r.DB().WithContext(ctx).Where("approvable_type = ? AND approvable_id = ?", workflow, approvableID)
|
||||||
|
if modifier != nil {
|
||||||
|
q = modifier(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := q.Order("action_at ASC").Find(&approvals).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return approvals, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *approvalRepositoryImpl) LatestByTarget(
|
||||||
|
ctx context.Context,
|
||||||
|
workflow string,
|
||||||
|
approvableID uint,
|
||||||
|
modifier func(*gorm.DB) *gorm.DB,
|
||||||
|
) (*entity.Approval, error) {
|
||||||
|
var approval entity.Approval
|
||||||
|
|
||||||
|
q := r.DB().WithContext(ctx).
|
||||||
|
Where("approvable_type = ? AND approvable_id = ?", workflow, approvableID).
|
||||||
|
Order("action_at DESC")
|
||||||
|
|
||||||
|
if modifier != nil {
|
||||||
|
q = modifier(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := q.Limit(1).First(&approval).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &approval, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *approvalRepositoryImpl) LatestByTargets(
|
||||||
|
ctx context.Context,
|
||||||
|
workflow string,
|
||||||
|
approvableIDs []uint,
|
||||||
|
modifier func(*gorm.DB) *gorm.DB,
|
||||||
|
) (map[uint]entity.Approval, error) {
|
||||||
|
if len(approvableIDs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[uint]entity.Approval, len(approvableIDs))
|
||||||
|
|
||||||
|
q := r.DB().WithContext(ctx).
|
||||||
|
Select("DISTINCT ON (approvable_id) *").
|
||||||
|
Where("approvable_type = ? AND approvable_id IN ?", workflow, approvableIDs).
|
||||||
|
Order("approvable_id, action_at DESC")
|
||||||
|
|
||||||
|
if modifier != nil {
|
||||||
|
q = modifier(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
var approvals []entity.Approval
|
||||||
|
if err := q.Find(&approvals).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, approval := range approvals {
|
||||||
|
if _, exists := result[approval.ApprovableId]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[approval.ApprovableId] = approval
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BaseRepository[T any] interface {
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
CreateMany(ctx context.Context, entities []*T, modifier func(*gorm.DB) *gorm.DB) error
|
||||||
|
|
||||||
|
UpdateOne(ctx context.Context, id uint, entity *T, modifier func(*gorm.DB) *gorm.DB) error
|
||||||
|
UpdateMany(ctx context.Context, entities []*T, modifier func(*gorm.DB) *gorm.DB) error
|
||||||
|
PatchOne(ctx context.Context, id uint, updates map[string]any, modifier func(*gorm.DB) *gorm.DB) error
|
||||||
|
|
||||||
|
DeleteOne(ctx context.Context, id uint) error
|
||||||
|
DeleteMany(ctx context.Context, modifier func(*gorm.DB) *gorm.DB) error
|
||||||
|
|
||||||
|
Upsert(ctx context.Context, entity *T, conflictColumns []clause.Column, modifier func(*gorm.DB) *gorm.DB) error
|
||||||
|
|
||||||
|
WithTx(tx *gorm.DB) BaseRepository[T]
|
||||||
|
DB() *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseRepositoryImpl[T any] struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBaseRepository[T any](db *gorm.DB) *BaseRepositoryImpl[T] {
|
||||||
|
return &BaseRepositoryImpl[T]{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BaseRepositoryImpl[T]) GetAll(
|
||||||
|
ctx context.Context,
|
||||||
|
offset, limit int,
|
||||||
|
modifier func(*gorm.DB) *gorm.DB,
|
||||||
|
) ([]T, int64, error) {
|
||||||
|
var entities []T
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
q := r.db.WithContext(ctx).Model(new(T))
|
||||||
|
if modifier != nil {
|
||||||
|
q = modifier(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := q.Offset(offset).Limit(limit).Find(&entities).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BaseRepositoryImpl[T]) GetByID(
|
||||||
|
ctx context.Context,
|
||||||
|
id uint,
|
||||||
|
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, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return entity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BaseRepositoryImpl[T]) GetByIDs(
|
||||||
|
ctx context.Context,
|
||||||
|
ids []uint,
|
||||||
|
modifier func(*gorm.DB) *gorm.DB,
|
||||||
|
) ([]T, error) {
|
||||||
|
var entities []T
|
||||||
|
q := r.db.WithContext(ctx).Model(new(T))
|
||||||
|
if modifier != nil {
|
||||||
|
q = modifier(q)
|
||||||
|
}
|
||||||
|
if err := q.Where("id IN ?", ids).Find(&entities).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entities) == 0 {
|
||||||
|
return nil, gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ----
|
||||||
|
func (r *BaseRepositoryImpl[T]) CreateOne(
|
||||||
|
ctx context.Context,
|
||||||
|
entity *T,
|
||||||
|
modifier func(*gorm.DB) *gorm.DB,
|
||||||
|
) error {
|
||||||
|
q := r.db.WithContext(ctx)
|
||||||
|
if modifier != nil {
|
||||||
|
q = modifier(q)
|
||||||
|
}
|
||||||
|
return q.Create(entity).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BaseRepositoryImpl[T]) CreateMany(
|
||||||
|
ctx context.Context,
|
||||||
|
entities []*T,
|
||||||
|
modifier func(*gorm.DB) *gorm.DB,
|
||||||
|
) error {
|
||||||
|
q := r.db.WithContext(ctx)
|
||||||
|
if modifier != nil {
|
||||||
|
q = modifier(q)
|
||||||
|
}
|
||||||
|
return q.Create(&entities).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- UPDATE ----
|
||||||
|
func (r *BaseRepositoryImpl[T]) UpdateOne(
|
||||||
|
ctx context.Context,
|
||||||
|
id uint,
|
||||||
|
entity *T,
|
||||||
|
modifier func(*gorm.DB) *gorm.DB,
|
||||||
|
) error {
|
||||||
|
q := r.db.WithContext(ctx).Model(new(T)).Where("id = ?", id)
|
||||||
|
if modifier != nil {
|
||||||
|
q = modifier(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := q.Updates(entity)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BaseRepositoryImpl[T]) UpdateMany(
|
||||||
|
ctx context.Context,
|
||||||
|
entities []*T,
|
||||||
|
modifier func(*gorm.DB) *gorm.DB,
|
||||||
|
) error {
|
||||||
|
q := r.db.WithContext(ctx)
|
||||||
|
if modifier != nil {
|
||||||
|
q = modifier(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := q.Save(&entities)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BaseRepositoryImpl[T]) PatchOne(
|
||||||
|
ctx context.Context,
|
||||||
|
id uint,
|
||||||
|
updates map[string]any,
|
||||||
|
modifier func(*gorm.DB) *gorm.DB,
|
||||||
|
) error {
|
||||||
|
q := r.db.WithContext(ctx)
|
||||||
|
if modifier != nil {
|
||||||
|
q = modifier(q)
|
||||||
|
}
|
||||||
|
q = q.Model(new(T)).Where("id = ?", id)
|
||||||
|
|
||||||
|
result := q.Updates(updates)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DELETE ----
|
||||||
|
func (r *BaseRepositoryImpl[T]) DeleteOne(ctx context.Context, id uint) error {
|
||||||
|
result := r.db.WithContext(ctx).Delete(new(T), id)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BaseRepositoryImpl[T]) DeleteMany(ctx context.Context, modifier func(*gorm.DB) *gorm.DB) error {
|
||||||
|
q := r.db.WithContext(ctx).Model(new(T))
|
||||||
|
if modifier != nil {
|
||||||
|
q = modifier(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := q.Delete(new(T))
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- UPSERT ----
|
||||||
|
func (r *BaseRepositoryImpl[T]) Upsert(
|
||||||
|
ctx context.Context,
|
||||||
|
entity *T,
|
||||||
|
conflictColumns []clause.Column,
|
||||||
|
modifier func(*gorm.DB) *gorm.DB,
|
||||||
|
) error {
|
||||||
|
q := r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||||
|
Columns: conflictColumns,
|
||||||
|
UpdateAll: true,
|
||||||
|
})
|
||||||
|
if modifier != nil {
|
||||||
|
q = modifier(q)
|
||||||
|
}
|
||||||
|
return q.Create(entity).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BaseRepositoryImpl[T]) WithTx(tx *gorm.DB) BaseRepository[T] {
|
||||||
|
return &BaseRepositoryImpl[T]{db: tx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BaseRepositoryImpl[T]) DB() *gorm.DB {
|
||||||
|
return r.db
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DocumentRepository interface {
|
||||||
|
BaseRepository[entity.Document]
|
||||||
|
ListByTarget(ctx context.Context, documentableType string, documentableID uint64, modifier func(*gorm.DB) *gorm.DB) ([]entity.Document, error)
|
||||||
|
DeleteByTarget(ctx context.Context, documentableType string, documentableID uint64, modifier func(*gorm.DB) *gorm.DB) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type documentRepositoryImpl struct {
|
||||||
|
*BaseRepositoryImpl[entity.Document]
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDocumentRepository(db *gorm.DB) DocumentRepository {
|
||||||
|
return &documentRepositoryImpl{
|
||||||
|
BaseRepositoryImpl: NewBaseRepository[entity.Document](db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *documentRepositoryImpl) ListByTarget(
|
||||||
|
ctx context.Context,
|
||||||
|
documentableType string,
|
||||||
|
documentableID uint64,
|
||||||
|
modifier func(*gorm.DB) *gorm.DB,
|
||||||
|
) ([]entity.Document, error) {
|
||||||
|
var documents []entity.Document
|
||||||
|
|
||||||
|
q := r.DB().WithContext(ctx).
|
||||||
|
Where("documentable_type = ? AND documentable_id = ?", documentableType, documentableID)
|
||||||
|
|
||||||
|
if modifier != nil {
|
||||||
|
q = modifier(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := q.Order("created_at ASC").Find(&documents).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return documents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *documentRepositoryImpl) DeleteByTarget(
|
||||||
|
ctx context.Context,
|
||||||
|
documentableType string,
|
||||||
|
documentableID uint64,
|
||||||
|
modifier func(*gorm.DB) *gorm.DB,
|
||||||
|
) error {
|
||||||
|
q := r.DB().WithContext(ctx).
|
||||||
|
Where("documentable_type = ? AND documentable_id = ?", documentableType, documentableID)
|
||||||
|
|
||||||
|
if modifier != nil {
|
||||||
|
q = modifier(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
return q.Delete(&entity.Document{}).Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exists reports whether a record with the given ID exists for type T.
|
||||||
|
func Exists[T any](ctx context.Context, db *gorm.DB, id uint) (bool, error) {
|
||||||
|
var marker int
|
||||||
|
err := db.WithContext(ctx).
|
||||||
|
Model(new(T)).
|
||||||
|
Select("1").
|
||||||
|
Where("id = ?", id).
|
||||||
|
Limit(1).
|
||||||
|
Take(&marker).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExistsByName[T any](ctx context.Context, db *gorm.DB, name string, excludeID *uint) (bool, error) {
|
||||||
|
q := db.WithContext(ctx).
|
||||||
|
Model(new(T)).
|
||||||
|
Select("1").
|
||||||
|
Where("name = ?", name).
|
||||||
|
Where("deleted_at IS NULL")
|
||||||
|
if excludeID != nil {
|
||||||
|
q = q.Where("id <> ?", *excludeID)
|
||||||
|
}
|
||||||
|
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 true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExistsByField[T any](ctx context.Context, db *gorm.DB, field string, value any, excludeID *uint) (bool, error) {
|
||||||
|
if field == "" {
|
||||||
|
return false, fmt.Errorf("field is required")
|
||||||
|
}
|
||||||
|
q := db.WithContext(ctx).
|
||||||
|
Model(new(T)).
|
||||||
|
Select("1").
|
||||||
|
Where(fmt.Sprintf("%s = ?", field), value).
|
||||||
|
Where("deleted_at IS NULL")
|
||||||
|
if excludeID != nil {
|
||||||
|
q = q.Where("id <> ?", *excludeID)
|
||||||
|
}
|
||||||
|
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 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MarketingDeliveryAttributionRow struct {
|
||||||
|
MarketingDeliveryProductID uint `gorm:"column:marketing_delivery_product_id"`
|
||||||
|
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
|
||||||
|
ProjectFlockID uint `gorm:"column:project_flock_id"`
|
||||||
|
ProjectFlockCategory string `gorm:"column:project_flock_category"`
|
||||||
|
AllocatedQty float64 `gorm:"column:allocated_qty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarketingDeliveryAttributionRowsQuery(db *gorm.DB) *gorm.DB {
|
||||||
|
sql := `
|
||||||
|
WITH mapped AS (
|
||||||
|
SELECT
|
||||||
|
sa.usable_id AS marketing_delivery_product_id,
|
||||||
|
pc.project_flock_kandang_id AS project_flock_kandang_id,
|
||||||
|
pfk.project_flock_id AS project_flock_id,
|
||||||
|
pf.category AS project_flock_category,
|
||||||
|
SUM(sa.qty) AS allocated_qty
|
||||||
|
FROM stock_allocations sa
|
||||||
|
JOIN project_flock_populations pfp
|
||||||
|
ON pfp.id = sa.stockable_id
|
||||||
|
AND sa.stockable_type = ?
|
||||||
|
JOIN project_chickins pc ON pc.id = pfp.project_chickin_id
|
||||||
|
JOIN project_flock_kandangs pfk ON pfk.id = pc.project_flock_kandang_id
|
||||||
|
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
|
||||||
|
WHERE sa.usable_type = ?
|
||||||
|
AND sa.status = ?
|
||||||
|
AND sa.allocation_purpose = ?
|
||||||
|
GROUP BY sa.usable_id, pc.project_flock_kandang_id, pfk.project_flock_id, pf.category
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
sa.usable_id AS marketing_delivery_product_id,
|
||||||
|
COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id) AS project_flock_kandang_id,
|
||||||
|
pfk.project_flock_id AS project_flock_id,
|
||||||
|
pf.category AS project_flock_category,
|
||||||
|
SUM(sa.qty) AS allocated_qty
|
||||||
|
FROM stock_allocations sa
|
||||||
|
JOIN recording_eggs re
|
||||||
|
ON re.id = sa.stockable_id
|
||||||
|
AND sa.stockable_type = ?
|
||||||
|
LEFT JOIN recordings r ON r.id = re.recording_id
|
||||||
|
JOIN project_flock_kandangs pfk ON pfk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)
|
||||||
|
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
|
||||||
|
WHERE sa.usable_type = ?
|
||||||
|
AND sa.status = ?
|
||||||
|
AND sa.allocation_purpose = ?
|
||||||
|
GROUP BY sa.usable_id, COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id), pfk.project_flock_id, pf.category
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
sa.usable_id AS marketing_delivery_product_id,
|
||||||
|
COALESCE(rd.source_project_flock_kandang_id, r.project_flock_kandangs_id) AS project_flock_kandang_id,
|
||||||
|
pfk.project_flock_id AS project_flock_id,
|
||||||
|
pf.category AS project_flock_category,
|
||||||
|
SUM(sa.qty) AS allocated_qty
|
||||||
|
FROM stock_allocations sa
|
||||||
|
JOIN recording_depletions rd
|
||||||
|
ON rd.id = sa.stockable_id
|
||||||
|
AND sa.stockable_type = ?
|
||||||
|
LEFT JOIN recordings r ON r.id = rd.recording_id
|
||||||
|
JOIN project_flock_kandangs pfk ON pfk.id = COALESCE(rd.source_project_flock_kandang_id, r.project_flock_kandangs_id)
|
||||||
|
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
|
||||||
|
WHERE sa.usable_type = ?
|
||||||
|
AND sa.status = ?
|
||||||
|
AND sa.allocation_purpose = ?
|
||||||
|
GROUP BY sa.usable_id, COALESCE(rd.source_project_flock_kandang_id, r.project_flock_kandangs_id), pfk.project_flock_id, pf.category
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
sa.usable_id AS marketing_delivery_product_id,
|
||||||
|
pi.project_flock_kandang_id AS project_flock_kandang_id,
|
||||||
|
pfk.project_flock_id AS project_flock_id,
|
||||||
|
pf.category AS project_flock_category,
|
||||||
|
SUM(sa.qty) AS allocated_qty
|
||||||
|
FROM stock_allocations sa
|
||||||
|
JOIN purchase_items pi
|
||||||
|
ON pi.id = sa.stockable_id
|
||||||
|
AND sa.stockable_type = ?
|
||||||
|
JOIN project_flock_kandangs pfk ON pfk.id = pi.project_flock_kandang_id
|
||||||
|
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
|
||||||
|
WHERE sa.usable_type = ?
|
||||||
|
AND sa.status = ?
|
||||||
|
AND sa.allocation_purpose = ?
|
||||||
|
AND pi.project_flock_kandang_id IS NOT NULL
|
||||||
|
GROUP BY sa.usable_id, pi.project_flock_kandang_id, pfk.project_flock_id, pf.category
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
sa.usable_id AS marketing_delivery_product_id,
|
||||||
|
source_pw.project_flock_kandang_id AS project_flock_kandang_id,
|
||||||
|
pfk.project_flock_id AS project_flock_id,
|
||||||
|
pf.category AS project_flock_category,
|
||||||
|
SUM(sa.qty) AS allocated_qty
|
||||||
|
FROM stock_allocations sa
|
||||||
|
JOIN stock_transfer_details std
|
||||||
|
ON std.id = sa.stockable_id
|
||||||
|
AND sa.stockable_type = ?
|
||||||
|
JOIN product_warehouses source_pw ON source_pw.id = std.source_product_warehouse_id
|
||||||
|
JOIN project_flock_kandangs pfk ON pfk.id = source_pw.project_flock_kandang_id
|
||||||
|
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
|
||||||
|
WHERE sa.usable_type = ?
|
||||||
|
AND sa.status = ?
|
||||||
|
AND sa.allocation_purpose = ?
|
||||||
|
AND source_pw.project_flock_kandang_id IS NOT NULL
|
||||||
|
GROUP BY sa.usable_id, source_pw.project_flock_kandang_id, pfk.project_flock_id, pf.category
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
sa.usable_id AS marketing_delivery_product_id,
|
||||||
|
ltt.target_project_flock_kandang_id AS project_flock_kandang_id,
|
||||||
|
pfk.project_flock_id AS project_flock_id,
|
||||||
|
pf.category AS project_flock_category,
|
||||||
|
SUM(sa.qty) AS allocated_qty
|
||||||
|
FROM stock_allocations sa
|
||||||
|
JOIN laying_transfer_targets ltt
|
||||||
|
ON ltt.id = sa.stockable_id
|
||||||
|
AND sa.stockable_type = ?
|
||||||
|
JOIN project_flock_kandangs pfk ON pfk.id = ltt.target_project_flock_kandang_id
|
||||||
|
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
|
||||||
|
WHERE sa.usable_type = ?
|
||||||
|
AND sa.status = ?
|
||||||
|
AND sa.allocation_purpose = ?
|
||||||
|
GROUP BY sa.usable_id, ltt.target_project_flock_kandang_id, pfk.project_flock_id, pf.category
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
src.marketing_delivery_product_id,
|
||||||
|
src.project_flock_kandang_id,
|
||||||
|
src.project_flock_id,
|
||||||
|
src.project_flock_category,
|
||||||
|
SUM(src.allocated_qty) AS allocated_qty
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
mapped.marketing_delivery_product_id,
|
||||||
|
mapped.project_flock_kandang_id,
|
||||||
|
mapped.project_flock_id,
|
||||||
|
mapped.project_flock_category,
|
||||||
|
mapped.allocated_qty
|
||||||
|
FROM mapped
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
mdp.id AS marketing_delivery_product_id,
|
||||||
|
pw.project_flock_kandang_id AS project_flock_kandang_id,
|
||||||
|
pfk.project_flock_id AS project_flock_id,
|
||||||
|
pf.category AS project_flock_category,
|
||||||
|
COALESCE(mdp.usage_qty, 0) AS allocated_qty
|
||||||
|
FROM marketing_delivery_products mdp
|
||||||
|
JOIN marketing_products mp ON mp.id = mdp.marketing_product_id
|
||||||
|
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
|
||||||
|
JOIN project_flock_kandangs pfk ON pfk.id = pw.project_flock_kandang_id
|
||||||
|
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
|
||||||
|
LEFT JOIN mapped ON mapped.marketing_delivery_product_id = mdp.id
|
||||||
|
WHERE mapped.marketing_delivery_product_id IS NULL
|
||||||
|
AND pw.project_flock_kandang_id IS NOT NULL
|
||||||
|
AND COALESCE(mdp.usage_qty, 0) > 0
|
||||||
|
) src
|
||||||
|
GROUP BY
|
||||||
|
src.marketing_delivery_product_id,
|
||||||
|
src.project_flock_kandang_id,
|
||||||
|
src.project_flock_id,
|
||||||
|
src.project_flock_category
|
||||||
|
`
|
||||||
|
|
||||||
|
return db.Raw(
|
||||||
|
sql,
|
||||||
|
fifo.StockableKeyProjectFlockPopulation.String(),
|
||||||
|
fifo.UsableKeyMarketingDelivery.String(),
|
||||||
|
entity.StockAllocationStatusActive,
|
||||||
|
entity.StockAllocationPurposeConsume,
|
||||||
|
fifo.StockableKeyRecordingEgg.String(),
|
||||||
|
fifo.UsableKeyMarketingDelivery.String(),
|
||||||
|
entity.StockAllocationStatusActive,
|
||||||
|
entity.StockAllocationPurposeConsume,
|
||||||
|
fifo.StockableKeyRecordingDepletion.String(),
|
||||||
|
fifo.UsableKeyMarketingDelivery.String(),
|
||||||
|
entity.StockAllocationStatusActive,
|
||||||
|
entity.StockAllocationPurposeConsume,
|
||||||
|
fifo.StockableKeyPurchaseItems.String(),
|
||||||
|
fifo.UsableKeyMarketingDelivery.String(),
|
||||||
|
entity.StockAllocationStatusActive,
|
||||||
|
entity.StockAllocationPurposeConsume,
|
||||||
|
fifo.StockableKeyStockTransferIn.String(),
|
||||||
|
fifo.UsableKeyMarketingDelivery.String(),
|
||||||
|
entity.StockAllocationStatusActive,
|
||||||
|
entity.StockAllocationPurposeConsume,
|
||||||
|
fifo.StockableKeyTransferToLayingIn.String(),
|
||||||
|
fifo.UsableKeyMarketingDelivery.String(),
|
||||||
|
entity.StockAllocationStatusActive,
|
||||||
|
entity.StockAllocationPurposeConsume,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarketingDeliverySingleAttributionQuery(db *gorm.DB) *gorm.DB {
|
||||||
|
return db.
|
||||||
|
Table("(?) AS mda", MarketingDeliveryAttributionRowsQuery(db)).
|
||||||
|
Select(`
|
||||||
|
mda.marketing_delivery_product_id,
|
||||||
|
CASE
|
||||||
|
WHEN COUNT(DISTINCT mda.project_flock_kandang_id) = 1 THEN MIN(mda.project_flock_kandang_id)
|
||||||
|
ELSE NULL
|
||||||
|
END AS attributed_project_flock_kandang_id
|
||||||
|
`).
|
||||||
|
Group("mda.marketing_delivery_product_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarketingDeliveryAttributionFilterSQL(column string) string {
|
||||||
|
return fmt.Sprintf("EXISTS (SELECT 1 FROM (?) AS mda WHERE mda.marketing_delivery_product_id = %s)", column)
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMarketingDeliveryAttributionRowsQueryIncludesMappedAndFallbackRows(t *testing.T) {
|
||||||
|
db := setupMarketingAttributionTestDB(t)
|
||||||
|
|
||||||
|
statements := []string{
|
||||||
|
`INSERT INTO project_flocks (id, category) VALUES (1, 'LAYING')`,
|
||||||
|
`INSERT INTO project_flock_kandangs (id, project_flock_id) VALUES (101, 1), (102, 1)`,
|
||||||
|
`INSERT INTO project_chickins (id, project_flock_kandang_id) VALUES (201, 101), (202, 102)`,
|
||||||
|
`INSERT INTO project_flock_populations (id, project_chickin_id) VALUES (301, 201), (302, 202)`,
|
||||||
|
`INSERT INTO product_warehouses (id, project_flock_kandang_id) VALUES (401, NULL), (402, 101)`,
|
||||||
|
`INSERT INTO marketing_products (id, product_warehouse_id) VALUES (501, 401), (502, 402), (503, 401)`,
|
||||||
|
`INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty) VALUES (601, 501, 100), (602, 502, 25), (603, 503, 12)`,
|
||||||
|
`INSERT INTO recording_eggs (id, recording_id, project_flock_kandang_id) VALUES (701, NULL, 101)`,
|
||||||
|
`INSERT INTO stock_allocations (id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, allocation_purpose) VALUES
|
||||||
|
(1, 401, 'PROJECT_FLOCK_POPULATION', 301, 'MARKETING_DELIVERY', 601, 60, 'ACTIVE', 'CONSUME'),
|
||||||
|
(2, 401, 'PROJECT_FLOCK_POPULATION', 302, 'MARKETING_DELIVERY', 601, 40, 'ACTIVE', 'CONSUME'),
|
||||||
|
(3, 401, 'RECORDING_EGG', 701, 'MARKETING_DELIVERY', 603, 12, 'ACTIVE', 'CONSUME')`,
|
||||||
|
}
|
||||||
|
for _, stmt := range statements {
|
||||||
|
if err := db.Exec(stmt).Error; err != nil {
|
||||||
|
t.Fatalf("failed seeding fixtures: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []MarketingDeliveryAttributionRow
|
||||||
|
if err := db.Table("(?) AS mda", MarketingDeliveryAttributionRowsQuery(db)).
|
||||||
|
Order("mda.marketing_delivery_product_id ASC, mda.project_flock_kandang_id ASC").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
t.Fatalf("failed scanning attribution rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) != 4 {
|
||||||
|
t.Fatalf("expected 4 attribution rows, got %d", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].MarketingDeliveryProductID != 601 || rows[0].ProjectFlockKandangID != 101 || rows[0].AllocatedQty != 60 {
|
||||||
|
t.Fatalf("unexpected first attribution row: %+v", rows[0])
|
||||||
|
}
|
||||||
|
if rows[1].MarketingDeliveryProductID != 601 || rows[1].ProjectFlockKandangID != 102 || rows[1].AllocatedQty != 40 {
|
||||||
|
t.Fatalf("unexpected second attribution row: %+v", rows[1])
|
||||||
|
}
|
||||||
|
if rows[2].MarketingDeliveryProductID != 602 || rows[2].ProjectFlockKandangID != 101 || rows[2].AllocatedQty != 25 {
|
||||||
|
t.Fatalf("unexpected fallback attribution row: %+v", rows[2])
|
||||||
|
}
|
||||||
|
if rows[3].MarketingDeliveryProductID != 603 || rows[3].ProjectFlockKandangID != 101 || rows[3].AllocatedQty != 12 {
|
||||||
|
t.Fatalf("unexpected egg attribution row: %+v", rows[3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarketingDeliverySingleAttributionQueryOnlyReturnsSingleSourceRows(t *testing.T) {
|
||||||
|
db := setupMarketingAttributionTestDB(t)
|
||||||
|
|
||||||
|
statements := []string{
|
||||||
|
`INSERT INTO project_flocks (id, category) VALUES (1, 'LAYING')`,
|
||||||
|
`INSERT INTO project_flock_kandangs (id, project_flock_id) VALUES (101, 1), (102, 1)`,
|
||||||
|
`INSERT INTO project_chickins (id, project_flock_kandang_id) VALUES (201, 101), (202, 102)`,
|
||||||
|
`INSERT INTO project_flock_populations (id, project_chickin_id) VALUES (301, 201), (302, 202)`,
|
||||||
|
`INSERT INTO product_warehouses (id, project_flock_kandang_id) VALUES (401, NULL), (402, 101)`,
|
||||||
|
`INSERT INTO marketing_products (id, product_warehouse_id) VALUES (501, 401), (502, 402), (503, 401)`,
|
||||||
|
`INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty) VALUES (601, 501, 100), (602, 502, 25), (603, 503, 12)`,
|
||||||
|
`INSERT INTO recording_eggs (id, recording_id, project_flock_kandang_id) VALUES (701, NULL, 101)`,
|
||||||
|
`INSERT INTO stock_allocations (id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, allocation_purpose) VALUES
|
||||||
|
(1, 401, 'PROJECT_FLOCK_POPULATION', 301, 'MARKETING_DELIVERY', 601, 60, 'ACTIVE', 'CONSUME'),
|
||||||
|
(2, 401, 'PROJECT_FLOCK_POPULATION', 302, 'MARKETING_DELIVERY', 601, 40, 'ACTIVE', 'CONSUME'),
|
||||||
|
(3, 401, 'RECORDING_EGG', 701, 'MARKETING_DELIVERY', 603, 12, 'ACTIVE', 'CONSUME')`,
|
||||||
|
}
|
||||||
|
for _, stmt := range statements {
|
||||||
|
if err := db.Exec(stmt).Error; err != nil {
|
||||||
|
t.Fatalf("failed seeding fixtures: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type singleRow struct {
|
||||||
|
MarketingDeliveryProductID uint `gorm:"column:marketing_delivery_product_id"`
|
||||||
|
AttributedProjectFlockKandangID *uint `gorm:"column:attributed_project_flock_kandang_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []singleRow
|
||||||
|
if err := db.Table("(?) AS mda", MarketingDeliverySingleAttributionQuery(db)).
|
||||||
|
Order("mda.marketing_delivery_product_id ASC").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
t.Fatalf("failed scanning single attribution rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) != 3 {
|
||||||
|
t.Fatalf("expected 3 rows, got %d", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].MarketingDeliveryProductID != 601 || rows[0].AttributedProjectFlockKandangID != nil {
|
||||||
|
t.Fatalf("expected pooled delivery 601 to have nil single attribution, got %+v", rows[0])
|
||||||
|
}
|
||||||
|
if rows[1].MarketingDeliveryProductID != 602 || rows[1].AttributedProjectFlockKandangID == nil || *rows[1].AttributedProjectFlockKandangID != 101 {
|
||||||
|
t.Fatalf("expected fallback delivery 602 to map to kandang 101, got %+v", rows[1])
|
||||||
|
}
|
||||||
|
if rows[2].MarketingDeliveryProductID != 603 || rows[2].AttributedProjectFlockKandangID == nil || *rows[2].AttributedProjectFlockKandangID != 101 {
|
||||||
|
t.Fatalf("expected egg delivery 603 to map to kandang 101, got %+v", rows[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupMarketingAttributionTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed opening sqlite db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statements := []string{
|
||||||
|
`CREATE TABLE stock_allocations (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
product_warehouse_id INTEGER,
|
||||||
|
stockable_type TEXT,
|
||||||
|
stockable_id INTEGER,
|
||||||
|
usable_type TEXT,
|
||||||
|
usable_id INTEGER,
|
||||||
|
qty NUMERIC(15,3),
|
||||||
|
status TEXT,
|
||||||
|
allocation_purpose TEXT
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE project_flock_populations (id INTEGER PRIMARY KEY, project_chickin_id INTEGER)`,
|
||||||
|
`CREATE TABLE project_chickins (id INTEGER PRIMARY KEY, project_flock_kandang_id INTEGER)`,
|
||||||
|
`CREATE TABLE project_flock_kandangs (id INTEGER PRIMARY KEY, project_flock_id INTEGER)`,
|
||||||
|
`CREATE TABLE project_flocks (id INTEGER PRIMARY KEY, category TEXT)`,
|
||||||
|
`CREATE TABLE marketing_delivery_products (id INTEGER PRIMARY KEY, marketing_product_id INTEGER, usage_qty NUMERIC(15,3))`,
|
||||||
|
`CREATE TABLE marketing_products (id INTEGER PRIMARY KEY, product_warehouse_id INTEGER)`,
|
||||||
|
`CREATE TABLE product_warehouses (id INTEGER PRIMARY KEY, project_flock_kandang_id INTEGER NULL)`,
|
||||||
|
`CREATE TABLE recording_eggs (id INTEGER PRIMARY KEY, recording_id INTEGER, project_flock_kandang_id INTEGER NULL)`,
|
||||||
|
`CREATE TABLE recordings (id INTEGER PRIMARY KEY, project_flock_kandangs_id INTEGER NULL)`,
|
||||||
|
`CREATE TABLE recording_depletions (id INTEGER PRIMARY KEY, recording_id INTEGER, source_project_flock_kandang_id INTEGER NULL)`,
|
||||||
|
`CREATE TABLE purchase_items (id INTEGER PRIMARY KEY, project_flock_kandang_id INTEGER NULL)`,
|
||||||
|
`CREATE TABLE stock_transfer_details (id INTEGER PRIMARY KEY, source_product_warehouse_id INTEGER NULL)`,
|
||||||
|
`CREATE TABLE laying_transfer_targets (id INTEGER PRIMARY KEY, target_project_flock_kandang_id INTEGER NULL)`,
|
||||||
|
}
|
||||||
|
for _, stmt := range statements {
|
||||||
|
if err := db.Exec(stmt).Error; err != nil {
|
||||||
|
t.Fatalf("failed preparing schema: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||||
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ApprovalService interface {
|
||||||
|
RegisterWorkflowSteps(workflow approvalutils.ApprovalWorkflowKey, steps map[approvalutils.ApprovalStep]string) error
|
||||||
|
WorkflowSteps(workflow approvalutils.ApprovalWorkflowKey) map[approvalutils.ApprovalStep]string
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
type approvalService struct {
|
||||||
|
repo commonRepo.ApprovalRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApprovalService(repo commonRepo.ApprovalRepository) ApprovalService {
|
||||||
|
return &approvalService{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *approvalService) RegisterWorkflowSteps(workflow approvalutils.ApprovalWorkflowKey, steps map[approvalutils.ApprovalStep]string) error {
|
||||||
|
return approvalutils.RegisterWorkflowSteps(workflow, steps)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *approvalService) WorkflowSteps(workflow approvalutils.ApprovalWorkflowKey) map[approvalutils.ApprovalStep]string {
|
||||||
|
return approvalutils.WorkflowSteps(workflow)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *approvalService) WorkflowStepName(workflow approvalutils.ApprovalWorkflowKey, step approvalutils.ApprovalStep) (string, bool) {
|
||||||
|
return approvalutils.ApprovalStepName(workflow, step)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *approvalService) CreateApproval(
|
||||||
|
ctx context.Context,
|
||||||
|
workflow approvalutils.ApprovalWorkflowKey,
|
||||||
|
approvableID uint,
|
||||||
|
step approvalutils.ApprovalStep,
|
||||||
|
action *entity.ApprovalAction,
|
||||||
|
actorID uint,
|
||||||
|
note *string,
|
||||||
|
) (*entity.Approval, error) {
|
||||||
|
record, err := approvalutils.NewApproval(workflow, approvableID, step, action, actorID, note)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.CreateOne(ctx, record, nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.decorateApproval(workflow, record)
|
||||||
|
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *approvalService) List(
|
||||||
|
ctx context.Context,
|
||||||
|
module string,
|
||||||
|
approvableID *uint,
|
||||||
|
page, limit int,
|
||||||
|
search string,
|
||||||
|
orderByDate string,
|
||||||
|
) ([]entity.Approval, int64, error) {
|
||||||
|
module = strings.TrimSpace(strings.ToUpper(module))
|
||||||
|
search = strings.TrimSpace(search)
|
||||||
|
orderByDate = strings.TrimSpace(strings.ToUpper(orderByDate))
|
||||||
|
if orderByDate != "ASC" && orderByDate != "DESC" {
|
||||||
|
orderByDate = "DESC"
|
||||||
|
}
|
||||||
|
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
|
||||||
|
records, total, err := s.repo.GetAll(
|
||||||
|
ctx,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
func(db *gorm.DB) *gorm.DB {
|
||||||
|
query := db.
|
||||||
|
Where("approvable_type = ?", module).
|
||||||
|
Order("action_at " + orderByDate).
|
||||||
|
Preload("ActionUser")
|
||||||
|
|
||||||
|
if approvableID != nil {
|
||||||
|
query = query.Where("approvable_id = ?", *approvableID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if search != "" {
|
||||||
|
like := "%" + strings.ToLower(search) + "%"
|
||||||
|
query = query.Where("(LOWER(step_name) LIKE ? OR LOWER(action) LIKE ? OR LOWER(notes) LIKE ?)", like, like, like)
|
||||||
|
}
|
||||||
|
|
||||||
|
return query
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if s.isApprovalTableMissing(err) {
|
||||||
|
return nil, 0, nil
|
||||||
|
}
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
workflow := approvalutils.ApprovalWorkflowKey(module)
|
||||||
|
for i := range records {
|
||||||
|
s.decorateApproval(workflow, &records[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
return records, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *approvalService) ListByTarget(
|
||||||
|
ctx context.Context,
|
||||||
|
workflow approvalutils.ApprovalWorkflowKey,
|
||||||
|
approvableID uint,
|
||||||
|
modifier func(*gorm.DB) *gorm.DB,
|
||||||
|
) ([]entity.Approval, error) {
|
||||||
|
records, err := s.repo.FindByTarget(ctx, workflow.String(), approvableID, modifier)
|
||||||
|
if err != nil {
|
||||||
|
if s.isApprovalTableMissing(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range records {
|
||||||
|
s.decorateApproval(workflow, &records[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *approvalService) LatestByTarget(
|
||||||
|
ctx context.Context,
|
||||||
|
workflow approvalutils.ApprovalWorkflowKey,
|
||||||
|
approvableID uint,
|
||||||
|
modifier func(*gorm.DB) *gorm.DB,
|
||||||
|
) (*entity.Approval, error) {
|
||||||
|
record, err := s.repo.LatestByTarget(ctx, workflow.String(), approvableID, modifier)
|
||||||
|
if err != nil {
|
||||||
|
if s.isApprovalTableMissing(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if record == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
s.decorateApproval(workflow, record)
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *approvalService) LatestByTargets(
|
||||||
|
ctx context.Context,
|
||||||
|
workflow approvalutils.ApprovalWorkflowKey,
|
||||||
|
approvableIDs []uint,
|
||||||
|
modifier func(*gorm.DB) *gorm.DB,
|
||||||
|
) (map[uint]*entity.Approval, error) {
|
||||||
|
records, err := s.repo.LatestByTargets(ctx, workflow.String(), approvableIDs, modifier)
|
||||||
|
if err != nil {
|
||||||
|
if s.isApprovalTableMissing(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[uint]*entity.Approval, len(records))
|
||||||
|
for approvableID, approval := range records {
|
||||||
|
approvalCopy := approval
|
||||||
|
s.decorateApproval(workflow, &approvalCopy)
|
||||||
|
result[approvableID] = &approvalCopy
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *approvalService) decorateApproval(workflow approvalutils.ApprovalWorkflowKey, approval *entity.Approval) {
|
||||||
|
if approval == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentName := strings.TrimSpace(approval.StepName)
|
||||||
|
if currentName == "" {
|
||||||
|
if name, ok := approvalutils.ApprovalStepName(workflow, approvalutils.ApprovalStep(approval.StepNumber)); ok {
|
||||||
|
approval.StepName = name
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
approval.StepName = currentName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *approvalService) isApprovalTableMissing(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
errMsg := strings.ToLower(err.Error())
|
||||||
|
|
||||||
|
if strings.Contains(errMsg, "no such table: approvals") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
schemaIssues := []string{
|
||||||
|
`relation "approvals" does not exist`,
|
||||||
|
`column "step_name" does not exist`,
|
||||||
|
`column "step_number" does not exist`,
|
||||||
|
`column "action" does not exist`,
|
||||||
|
`column "status" does not exist`,
|
||||||
|
`column "step" does not exist`,
|
||||||
|
}
|
||||||
|
for _, issue := range schemaIssues {
|
||||||
|
if strings.Contains(errMsg, issue) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -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,41 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RelationCheck describes a foreign-key style dependency that must exist before processing.
|
||||||
|
type RelationCheck struct {
|
||||||
|
Name string
|
||||||
|
ID *uint
|
||||||
|
Exists func(context.Context, uint) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureRelations validates that each RelationCheck is satisfied, returning consistent Fiber errors.
|
||||||
|
func EnsureRelations(ctx context.Context, checks ...RelationCheck) error {
|
||||||
|
for _, check := range checks {
|
||||||
|
if check.ID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := check.Exists(ctx, *check.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fiber.NewError(
|
||||||
|
fiber.StatusInternalServerError,
|
||||||
|
fmt.Sprintf("Failed to check %s", strings.ToLower(check.Name)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return fiber.NewError(
|
||||||
|
fiber.StatusNotFound,
|
||||||
|
fmt.Sprintf("%s with id %d not found", check.Name, *check.ID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FifoPendingPolicyInput struct {
|
||||||
|
Lane string
|
||||||
|
FlagGroupCode string
|
||||||
|
FunctionCode string
|
||||||
|
LegacyTypeKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FifoPendingPolicyResult struct {
|
||||||
|
AllowPending bool
|
||||||
|
RuleSource string
|
||||||
|
Found bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveFifoPendingPolicy(ctx context.Context, tx *gorm.DB, input FifoPendingPolicyInput) (*FifoPendingPolicyResult, error) {
|
||||||
|
if tx == nil {
|
||||||
|
return nil, gorm.ErrInvalidDB
|
||||||
|
}
|
||||||
|
|
||||||
|
lane := strings.ToUpper(strings.TrimSpace(input.Lane))
|
||||||
|
flagGroupCode := strings.ToUpper(strings.TrimSpace(input.FlagGroupCode))
|
||||||
|
functionCode := strings.ToUpper(strings.TrimSpace(input.FunctionCode))
|
||||||
|
legacyTypeKey := strings.ToUpper(strings.TrimSpace(input.LegacyTypeKey))
|
||||||
|
if lane == "" {
|
||||||
|
return &FifoPendingPolicyResult{
|
||||||
|
AllowPending: false,
|
||||||
|
RuleSource: "SAFE_DEFAULT_BLOCK",
|
||||||
|
Found: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type overconsumeRuleRow struct {
|
||||||
|
Allow bool `gorm:"column:allow_overconsume"`
|
||||||
|
}
|
||||||
|
var overconsume overconsumeRuleRow
|
||||||
|
overconsumeErr := tx.WithContext(ctx).
|
||||||
|
Table("fifo_stock_v2_overconsume_rules").
|
||||||
|
Select("allow_overconsume").
|
||||||
|
Where("is_active = TRUE").
|
||||||
|
Where("lane = ?", 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(&overconsume).Error
|
||||||
|
if overconsumeErr == nil {
|
||||||
|
return &FifoPendingPolicyResult{
|
||||||
|
AllowPending: overconsume.Allow,
|
||||||
|
RuleSource: "OVERCONSUME_RULE",
|
||||||
|
Found: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(overconsumeErr, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, overconsumeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type routeRuleRow struct {
|
||||||
|
AllowPendingDefault bool `gorm:"column:allow_pending_default"`
|
||||||
|
}
|
||||||
|
var routeRule routeRuleRow
|
||||||
|
routeQuery := tx.WithContext(ctx).
|
||||||
|
Table("fifo_stock_v2_route_rules").
|
||||||
|
Select("allow_pending_default").
|
||||||
|
Where("is_active = TRUE").
|
||||||
|
Where("lane = ?", lane).
|
||||||
|
Where("flag_group_code = ?", flagGroupCode)
|
||||||
|
if legacyTypeKey != "" {
|
||||||
|
routeQuery = routeQuery.Where("legacy_type_key = ?", legacyTypeKey)
|
||||||
|
}
|
||||||
|
if functionCode != "" {
|
||||||
|
routeQuery = routeQuery.Where("function_code = ?", functionCode)
|
||||||
|
}
|
||||||
|
routeErr := routeQuery.
|
||||||
|
Order("id ASC").
|
||||||
|
Limit(1).
|
||||||
|
Take(&routeRule).Error
|
||||||
|
if routeErr == nil {
|
||||||
|
return &FifoPendingPolicyResult{
|
||||||
|
AllowPending: routeRule.AllowPendingDefault,
|
||||||
|
RuleSource: "ROUTE_RULE_DEFAULT",
|
||||||
|
Found: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(routeErr, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, routeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FifoPendingPolicyResult{
|
||||||
|
AllowPending: false,
|
||||||
|
RuleSource: "SAFE_DEFAULT_BLOCK",
|
||||||
|
Found: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -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,802 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
if (usableType == "STOCK_TRANSFER_OUT" || functionCode == "STOCK_TRANSFER_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...)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
latestQuery := 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 <> ''")
|
||||||
|
if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" {
|
||||||
|
latestQuery = latestQuery.Where("function_code = ?", code)
|
||||||
|
}
|
||||||
|
err := latestQuery.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
|
||||||
|
}
|
||||||
|
|
||||||
|
rulesQuery := tx.WithContext(ctx).
|
||||||
|
Table("fifo_stock_v2_route_rules").
|
||||||
|
Where("is_active = TRUE").
|
||||||
|
Where("lane = ?", string(LaneUsable)).
|
||||||
|
Where("legacy_type_key = ?", req.Usable.LegacyTypeKey)
|
||||||
|
if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" {
|
||||||
|
rulesQuery = rulesQuery.Where("function_code = ?", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rules []routeRule
|
||||||
|
err = rulesQuery.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 && req.ProductWarehouseID != 0 {
|
||||||
|
type candidateRow struct {
|
||||||
|
FlagGroupCode string `gorm:"column:flag_group_code"`
|
||||||
|
}
|
||||||
|
var candidates []candidateRow
|
||||||
|
byProductQuery := tx.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("rr.lane = ?", string(LaneUsable)).
|
||||||
|
Where("rr.legacy_type_key = ?", req.Usable.LegacyTypeKey).
|
||||||
|
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 = 'products'
|
||||||
|
AND fm.flag_group_code = rr.flag_group_code
|
||||||
|
)
|
||||||
|
`, req.ProductWarehouseID)
|
||||||
|
if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" {
|
||||||
|
byProductQuery = byProductQuery.Where("rr.function_code = ?", code)
|
||||||
|
}
|
||||||
|
if err := byProductQuery.Order("rr.flag_group_code ASC").Scan(&candidates).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(candidates) == 1 {
|
||||||
|
return strings.TrimSpace(candidates[0].FlagGroupCode), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
reUpper = regexp.MustCompile(`[A-Z]`)
|
||||||
|
reLower = regexp.MustCompile(`[a-z]`)
|
||||||
|
reDigit = regexp.MustCompile(`[0-9]`)
|
||||||
|
reSym = regexp.MustCompile(`[^A-Za-z0-9]`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func Password(fl validator.FieldLevel) bool {
|
||||||
|
pw := fl.Field().String()
|
||||||
|
pw = strings.TrimSpace(pw)
|
||||||
|
|
||||||
|
if len(pw) < 8 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !reUpper.MatchString(pw) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !reLower.MatchString(pw) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !reDigit.MatchString(pw) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !reSym.MatchString(pw) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.Contains(pw, " ") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
parent := fl.Parent()
|
||||||
|
if parent.IsValid() && parent.Kind() == reflect.Struct {
|
||||||
|
emailField := parent.FieldByName("Email")
|
||||||
|
if emailField.IsValid() && emailField.Kind() == reflect.String {
|
||||||
|
if email := emailField.String(); email != "" {
|
||||||
|
if i := strings.IndexByte(email, '@'); i > 0 {
|
||||||
|
local := strings.ToLower(email[:i])
|
||||||
|
if local != "" && strings.Contains(strings.ToLower(pw), local) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequiredStrict(fl validator.FieldLevel) bool {
|
||||||
|
field := fl.Field()
|
||||||
|
|
||||||
|
switch field.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
return field.String() != ""
|
||||||
|
case reflect.Ptr:
|
||||||
|
return !field.IsNil()
|
||||||
|
}
|
||||||
|
|
||||||
|
return field.IsValid() && !field.IsZero()
|
||||||
|
}
|
||||||
|
|
||||||
|
func OmitemptyStrict(fl validator.FieldLevel) bool {
|
||||||
|
field := fl.Field()
|
||||||
|
|
||||||
|
if !field.IsValid() || field.IsZero() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if field.Kind() == reflect.String {
|
||||||
|
return field.String() != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
)
|
||||||
|
|
||||||
|
var customMessages = map[string]string{
|
||||||
|
"required": "Field %s is required",
|
||||||
|
"required_strict": "Field %s is required and cannot be null or empty",
|
||||||
|
"omitempty_strict": "Field %s cannot be null or empty when provided",
|
||||||
|
|
||||||
|
"email": "Invalid email address for field %s",
|
||||||
|
"min": "Field %s must have a minimum length of %s characters",
|
||||||
|
"max": "Field %s must have a maximum length of %s characters",
|
||||||
|
"len": "Field %s must be exactly %s characters long",
|
||||||
|
"number": "Field %s must be a number",
|
||||||
|
"positive": "Field %s must be a positive number",
|
||||||
|
"alphanum": "Field %s must contain only alphanumeric characters",
|
||||||
|
"oneof": "Invalid value for field %s",
|
||||||
|
"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) (string, map[string]string) {
|
||||||
|
var validationErrors validator.ValidationErrors
|
||||||
|
if errors.As(err, &validationErrors) {
|
||||||
|
return generateErrorMessages(validationErrors)
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateErrorMessages(validationErrors validator.ValidationErrors) (string, map[string]string) {
|
||||||
|
errorsMap := make(map[string]string)
|
||||||
|
var firstMessage string
|
||||||
|
for i, err := range validationErrors {
|
||||||
|
fieldName := err.StructNamespace()
|
||||||
|
tag := err.Tag()
|
||||||
|
|
||||||
|
customMessage := customMessages[tag]
|
||||||
|
var msg string
|
||||||
|
if customMessage != "" {
|
||||||
|
msg = formatErrorMessage(customMessage, err, tag)
|
||||||
|
} else {
|
||||||
|
msg = defaultErrorMessage(err)
|
||||||
|
}
|
||||||
|
errorsMap[fieldName] = msg
|
||||||
|
if i == 0 {
|
||||||
|
firstMessage = msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstMessage, errorsMap
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatErrorMessage(customMessage string, err validator.FieldError, tag string) string {
|
||||||
|
if tag == "min" || tag == "max" || tag == "len" || tag == "gt" {
|
||||||
|
return fmt.Sprintf(customMessage, err.Field(), err.Param())
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(customMessage, err.Field())
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultErrorMessage(err validator.FieldError) string {
|
||||||
|
return fmt.Sprintf("Field validation for '%s' failed on the '%s' tag", err.Field(), err.Tag())
|
||||||
|
}
|
||||||
|
|
||||||
|
func Validator() *validator.Validate {
|
||||||
|
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 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := validate.RegisterValidation("required_strict", RequiredStrict); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := validate.RegisterValidation("omitempty_strict", OmitemptyStrict); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
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.
@@ -0,0 +1,315 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||||
|
|
||||||
|
"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 (
|
||||||
|
IsProd bool
|
||||||
|
AppHost string
|
||||||
|
Version string
|
||||||
|
LogLevel string
|
||||||
|
AppPort int
|
||||||
|
DBHost string
|
||||||
|
DBUser string
|
||||||
|
DBPassword string
|
||||||
|
DBName string
|
||||||
|
DBPort int
|
||||||
|
DBSSLMode string
|
||||||
|
DBSSLRootCert string
|
||||||
|
DBSSLCert string
|
||||||
|
DBSSLKey string
|
||||||
|
JWTSecret string
|
||||||
|
JWTAccessExp int
|
||||||
|
JWTRefreshExp int
|
||||||
|
JWTResetPasswordExp int
|
||||||
|
JWTVerifyEmailExp int
|
||||||
|
RedisURL string
|
||||||
|
CORSAllowOrigins []string
|
||||||
|
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() {
|
||||||
|
loadConfig()
|
||||||
|
|
||||||
|
// server configuration
|
||||||
|
IsProd = viper.GetString("APP_ENV") == "prod"
|
||||||
|
AppHost = viper.GetString("APP_HOST")
|
||||||
|
AppPort = viper.GetInt("APP_PORT")
|
||||||
|
Version = viper.GetString("VERSION")
|
||||||
|
LogLevel = viper.GetString("LOG_LEVEL")
|
||||||
|
|
||||||
|
// database configuration
|
||||||
|
DBHost = viper.GetString("DB_HOST")
|
||||||
|
DBUser = viper.GetString("DB_USER")
|
||||||
|
DBPassword = viper.GetString("DB_PASSWORD")
|
||||||
|
DBName = viper.GetString("DB_NAME")
|
||||||
|
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
|
||||||
|
JWTSecret = viper.GetString("JWT_SECRET")
|
||||||
|
JWTAccessExp = viper.GetInt("JWT_ACCESS_EXP_MINUTES")
|
||||||
|
JWTRefreshExp = viper.GetInt("JWT_REFRESH_EXP_DAYS")
|
||||||
|
JWTResetPasswordExp = viper.GetInt("JWT_RESET_PASSWORD_EXP_MINUTES")
|
||||||
|
JWTVerifyEmailExp = viper.GetInt("JWT_VERIFY_EMAIL_EXP_MINUTES")
|
||||||
|
|
||||||
|
// Cors
|
||||||
|
CORSAllowOrigins = parseList("CORS_ALLOW_ORIGINS")
|
||||||
|
CORSAllowMethods = parseListWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
|
||||||
|
CORSAllowHeaders = parseListWithDefault("CORS_ALLOW_HEADERS", "Content-Type,Authorization,X-Requested-With")
|
||||||
|
CORSExposeHeaders = parseList("CORS_EXPOSE_HEADERS")
|
||||||
|
CORSAllowCredentials = viper.GetBool("CORS_ALLOW_CREDENTIALS")
|
||||||
|
CORSMaxAge = viper.GetInt("CORS_MAX_AGE")
|
||||||
|
|
||||||
|
// Redis
|
||||||
|
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() {
|
||||||
|
viper.AutomaticEnv()
|
||||||
|
|
||||||
|
viper.SetConfigFile(".env")
|
||||||
|
if err := viper.ReadInConfig(); err == nil {
|
||||||
|
utils.Log.Info("Config file loaded from .env")
|
||||||
|
} else {
|
||||||
|
utils.Log.Warn("No .env file found, using environment variables only")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseList(key string) []string {
|
||||||
|
raw := strings.TrimSpace(viper.GetString(key))
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(raw, "[") {
|
||||||
|
var arr []string
|
||||||
|
if json.Unmarshal([]byte(raw), &arr) == nil {
|
||||||
|
for i := range arr {
|
||||||
|
arr[i] = strings.TrimSpace(arr[i])
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseListWithDefault(key, def string) []string {
|
||||||
|
if v := parseList(key); len(v) > 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
// fallback ke default CSV
|
||||||
|
parts := strings.Split(def, ",")
|
||||||
|
for i := range parts {
|
||||||
|
parts[i] = strings.TrimSpace(parts[i])
|
||||||
|
}
|
||||||
|
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 LayingWeekStart() int {
|
||||||
|
return TransferToLayingGrowingMaxWeek
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||||
|
|
||||||
|
"github.com/bytedance/sonic"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func FiberConfig() fiber.Config {
|
||||||
|
return fiber.Config{
|
||||||
|
Prefork: IsProd,
|
||||||
|
CaseSensitive: true,
|
||||||
|
ServerHeader: "Fiber",
|
||||||
|
AppName: "Fiber API",
|
||||||
|
BodyLimit: 8 * 1024 * 1024,
|
||||||
|
ErrorHandler: utils.ErrorHandler,
|
||||||
|
JSONEncoder: sonic.Marshal,
|
||||||
|
JSONDecoder: sonic.Unmarshal,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArf9cLsf3m4TituVqDwvM
|
||||||
|
yaUwQ0rzDfOcmF/N+rHvgMMv1yyR4FcozoGk1NFfL/4jDIVm9FLUS68foPDo0iu5
|
||||||
|
shNY0pwSsps9lcyWxQVhUVJzh489S53hU799PiDrUPBxYTcpy3EO/jX0HOZJs5dl
|
||||||
|
N/4C54LYrVdXyleG82NLNjcMnNGr3VGc6zE7B3YYd9/daPyr+QBpeUL5BIzUZbeu
|
||||||
|
sI0NMIxucaqxMKWF62CDWTrwfSSoFOubI9FZ9tkkWro01wVFK35GseQCsDtEmJ9v
|
||||||
|
kb81LvfM2AcPLr+g1kN8dVeZLNNQTMrmxaWXFiwwEgayJ8q01pHfgAxg42ariKEK
|
||||||
|
fX9kFx/3Rs80qsXhQNEkoCOwQBRNwrRxRzNfVkvuE0aRVoO6PVFE1gDOLUV2fJJs
|
||||||
|
QUpAWMzZ/+e/N+1gKMtbaCbz2dLqnA6KkdMdHe79dMFVGx2ZnRFbyALzM3S5XgNV
|
||||||
|
QtVvTri2PW/6ZH41T6MpLUANzuwaIEys1Az+8VLxOgBugb63xoORB2JDsebxEfsS
|
||||||
|
HBllECnBJVuBndkJRSnbqGjCKq4sl2xXo83nZ+2eNmZO/vkTxREl8aVp3DgaHWxp
|
||||||
|
OQIlZwbP9lsruTqSnQfH3/hLemrOhSh/hXfFguw3oOQjfeFwJBD8u7vGOl2vBi3C
|
||||||
|
hvb8hFdjzoUXAJLxWPl5+E0CAwEAAQ==
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
var allRoles = map[string][]string{
|
||||||
|
"user": {},
|
||||||
|
"admin": {"getUsers", "manageUsers"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var Roles = getKeys(allRoles)
|
||||||
|
var RoleRights = allRoles
|
||||||
|
|
||||||
|
func getKeys(m map[string][]string) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
const (
|
||||||
|
TokenTypeAccess = "access"
|
||||||
|
TokenTypeRefresh = "refresh"
|
||||||
|
TokenTypeResetPassword = "resetPassword"
|
||||||
|
TokenTypeVerifyEmail = "verifyEmail"
|
||||||
|
)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||||
|
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Connect(dbHost, dbName string) *gorm.DB {
|
||||||
|
parts := []string{
|
||||||
|
fmt.Sprintf("host=%s", dbHost),
|
||||||
|
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{
|
||||||
|
Logger: logger.Default.LogMode(logger.Info),
|
||||||
|
SkipDefaultTransaction: true,
|
||||||
|
PrepareStmt: false,
|
||||||
|
TranslateError: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
utils.Log.Errorf("Failed to connect to database: %+v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, errDB := db.DB()
|
||||||
|
if errDB != nil {
|
||||||
|
utils.Log.Errorf("Failed to connect to database: %+v", errDB)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config connection pooling
|
||||||
|
sqlDB.SetMaxIdleConns(10)
|
||||||
|
sqlDB.SetMaxOpenConns(100)
|
||||||
|
sqlDB.SetConnMaxLifetime(60 * time.Minute)
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
CREATE DATABASE IF NOT EXISTS db_lti_erp;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
DROP TABLE IF EXISTS stock_logs;
|
||||||
|
DROP INDEX IF EXISTS idx_product_warehouses_unique;
|
||||||
|
DROP INDEX IF EXISTS idx_product_warehouses_deleted_at;
|
||||||
|
DROP INDEX IF EXISTS idx_product_warehouses_warehouse_id;
|
||||||
|
DROP INDEX IF EXISTS idx_product_warehouses_product_id;
|
||||||
|
DROP TABLE IF EXISTS product_warehouses;
|
||||||
|
DROP TABLE IF EXISTS fcr_standards;
|
||||||
|
DROP INDEX IF EXISTS suppliers_name_unique;
|
||||||
|
DROP TABLE IF EXISTS product_suppliers;
|
||||||
|
DROP INDEX IF EXISTS products_sku_unique;
|
||||||
|
DROP INDEX IF EXISTS products_name_unique;
|
||||||
|
DROP TABLE IF EXISTS products;
|
||||||
|
DROP INDEX IF EXISTS flags_flagable_lookup;
|
||||||
|
DROP INDEX IF EXISTS flags_unique_flagable;
|
||||||
|
DROP TABLE IF EXISTS flags;
|
||||||
|
DROP INDEX IF EXISTS customers_name_unique;
|
||||||
|
DROP INDEX IF EXISTS customers_email_unique;
|
||||||
|
DROP TABLE IF EXISTS customers;
|
||||||
|
DROP INDEX IF EXISTS warehouses_name_unique;
|
||||||
|
DROP INDEX IF EXISTS product_categories_code_unique;
|
||||||
|
DROP INDEX IF EXISTS product_categories_name_unique;
|
||||||
|
DROP TABLE IF EXISTS product_categories;
|
||||||
|
DROP INDEX IF EXISTS nonstocks_name_unique;
|
||||||
|
DROP TABLE IF EXISTS nonstock_suppliers;
|
||||||
|
DROP TABLE IF EXISTS nonstocks;
|
||||||
|
DROP INDEX IF EXISTS banks_name_unique;
|
||||||
|
DROP TABLE IF EXISTS banks;
|
||||||
|
DROP INDEX IF EXISTS kandangs_name_unique;
|
||||||
|
DROP TABLE IF EXISTS warehouses;
|
||||||
|
DROP TABLE IF EXISTS kandangs;
|
||||||
|
DROP INDEX IF EXISTS locations_name_unique;
|
||||||
|
DROP TABLE IF EXISTS locations;
|
||||||
|
DROP INDEX IF EXISTS areas_name_unique;
|
||||||
|
DROP TABLE IF EXISTS areas;
|
||||||
|
DROP INDEX IF EXISTS uoms_name_unique;
|
||||||
|
DROP TABLE IF EXISTS uoms;
|
||||||
|
DROP TABLE IF EXISTS suppliers;
|
||||||
|
DROP INDEX IF EXISTS fcrs_name_unique;
|
||||||
|
DROP TABLE IF EXISTS fcrs;
|
||||||
|
DROP TABLE IF EXISTS projects;
|
||||||
|
DROP INDEX IF EXISTS users_id_user_unique;
|
||||||
|
DROP INDEX IF EXISTS users_email_unique;
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
-- USERS
|
||||||
|
CREATE TABLE users (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
id_user BIGINT NOT NULL,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
email VARCHAR(50) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX users_id_user_unique ON users (id_user)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX users_email_unique ON users (email)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- FLAGS
|
||||||
|
CREATE TABLE flags (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
flagable_id BIGINT NOT NULL,
|
||||||
|
flagable_type VARCHAR(50) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW ()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX flags_unique_flagable ON flags (name, flagable_id, flagable_type);
|
||||||
|
|
||||||
|
CREATE INDEX flags_flagable_lookup ON flags (flagable_type, flagable_id);
|
||||||
|
|
||||||
|
-- PRODUCT CATEGORIES
|
||||||
|
CREATE TABLE product_categories (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
code VARCHAR(10) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX product_categories_name_unique ON product_categories (name)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX product_categories_code_unique ON product_categories (code)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- UOM
|
||||||
|
CREATE TABLE uoms (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX uoms_name_unique ON uoms (name)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- BANKS
|
||||||
|
CREATE TABLE banks (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
alias VARCHAR(5) NOT NULL,
|
||||||
|
owner VARCHAR(50),
|
||||||
|
account_number VARCHAR(50) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX banks_name_unique ON banks (name)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- AREAS
|
||||||
|
CREATE TABLE areas (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX areas_name_unique ON areas (name)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- LOCATIONS
|
||||||
|
CREATE TABLE locations (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
area_id BIGINT NOT NULL REFERENCES areas (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX locations_name_unique ON locations (name)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- KANDANG
|
||||||
|
CREATE TABLE kandangs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
location_id BIGINT NOT NULL REFERENCES locations (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
pic_id BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX kandangs_name_unique ON kandangs (name)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- WAREHOUSES
|
||||||
|
CREATE TABLE warehouses (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
area_id BIGINT NOT NULL REFERENCES areas (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
location_id BIGINT REFERENCES locations (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
kandang_id BIGINT REFERENCES kandangs (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX warehouses_name_unique ON warehouses (name)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- CUSTOMERS
|
||||||
|
CREATE TABLE customers (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
pic_id BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
phone VARCHAR(20) NOT NULL,
|
||||||
|
email VARCHAR(50) NOT NULL,
|
||||||
|
account_number VARCHAR(50) NOT NULL,
|
||||||
|
balance NUMERIC(15, 3) DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX customers_name_unique ON customers (name)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- NONSTOCK
|
||||||
|
CREATE TABLE nonstocks (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
uom_id BIGINT NOT NULL REFERENCES uoms (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX nonstocks_name_unique ON nonstocks (name)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- FCR
|
||||||
|
CREATE TABLE fcrs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX fcrs_name_unique ON fcrs (name)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE fcr_standards (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
fcr_id BIGINT NOT NULL REFERENCES fcrs (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
weight NUMERIC(15, 3) NOT NULL,
|
||||||
|
fcr_number NUMERIC(15, 3) NOT NULL,
|
||||||
|
mortality NUMERIC(15, 3) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- SUPPLIERS
|
||||||
|
CREATE TABLE suppliers (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
alias VARCHAR(5) NOT NULL,
|
||||||
|
pic VARCHAR(50) NOT NULL,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
category VARCHAR(20) NOT NULL,
|
||||||
|
hatchery VARCHAR(50),
|
||||||
|
phone VARCHAR(20) NOT NULL,
|
||||||
|
email VARCHAR(50) NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
npwp VARCHAR(50),
|
||||||
|
account_number VARCHAR(50),
|
||||||
|
balance NUMERIC(15, 3) DEFAULT 0,
|
||||||
|
due_date INT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX suppliers_name_unique ON suppliers (name)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE nonstock_suppliers (
|
||||||
|
nonstock_id BIGINT NOT NULL REFERENCES nonstocks (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
supplier_id BIGINT NOT NULL REFERENCES suppliers (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
PRIMARY KEY (nonstock_id, supplier_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- PRODUCTS
|
||||||
|
CREATE TABLE products (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
brand VARCHAR(50) NOT NULL,
|
||||||
|
sku VARCHAR(100),
|
||||||
|
uom_id BIGINT NOT NULL REFERENCES uoms (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
product_category_id BIGINT NOT NULL REFERENCES product_categories (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
product_price NUMERIC(15, 3) NOT NULL,
|
||||||
|
selling_price NUMERIC(15, 3),
|
||||||
|
tax NUMERIC(15, 3),
|
||||||
|
expiry_period INT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX products_name_unique ON products (name)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX products_sku_unique ON products (sku)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE product_suppliers (
|
||||||
|
product_id BIGINT NOT NULL REFERENCES products (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
supplier_id BIGINT NOT NULL REFERENCES suppliers (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
PRIMARY KEY (product_id, supplier_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- PROJECTS
|
||||||
|
CREATE TABLE projects (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- PRODUCT WAREHOUSES TABLE
|
||||||
|
CREATE TABLE product_warehouses (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
product_id BIGINT NOT NULL REFERENCES products (id),
|
||||||
|
warehouse_id BIGINT NOT NULL REFERENCES warehouses (id),
|
||||||
|
quantity INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_by BIGINT NOT NULL REFERENCES users (id),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- INDEXES
|
||||||
|
CREATE INDEX idx_product_warehouses_product_id ON product_warehouses (product_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_product_warehouses_warehouse_id ON product_warehouses (warehouse_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_product_warehouses_deleted_at ON product_warehouses (deleted_at);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_product_warehouses_unique ON product_warehouses (product_id, warehouse_id)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- STOCK LOGS
|
||||||
|
CREATE TABLE stock_logs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
transaction_type VARCHAR(20) NOT NULL,
|
||||||
|
quantity NUMERIC(15, 3) NOT NULL,
|
||||||
|
before_quantity NUMERIC(15, 3) NOT NULL,
|
||||||
|
after_quantity NUMERIC(15, 3) NOT NULL,
|
||||||
|
log_type VARCHAR(50) NOT NULL,
|
||||||
|
log_id BIGINT,
|
||||||
|
note TEXT,
|
||||||
|
product_warehouse_id BIGINT NOT NULL REFERENCES product_warehouses (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
created_by BIGINT NOT NULL REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW (),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for better performance
|
||||||
|
CREATE INDEX stock_logs_product_warehouse_id_idx ON stock_logs (product_warehouse_id);
|
||||||
|
|
||||||
|
CREATE INDEX stock_logs_log_type_log_id_idx ON stock_logs (log_type, log_id);
|
||||||
|
|
||||||
|
CREATE INDEX stock_logs_created_by_idx ON stock_logs (created_by);
|
||||||
|
|
||||||
|
CREATE INDEX stock_logs_created_at_idx ON stock_logs (created_at);
|
||||||
|
|
||||||
|
CREATE INDEX stock_logs_deleted_at_idx ON stock_logs (deleted_at);
|
||||||
+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);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- DROP TABLE: STOCK_TRANSFERS DAN SEQUENCE-NYA
|
||||||
|
DROP TABLE IF EXISTS stock_transfers CASCADE;
|
||||||
|
|
||||||
|
DROP SEQUENCE IF EXISTS stock_transfer_seq CASCADE;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- ===============================================================
|
||||||
|
-- STOCK TRANSFERS (HEADER)
|
||||||
|
-- ===============================================================
|
||||||
|
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS stock_transfer_seq START 1;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS stock_transfers (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
movement_number VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
from_warehouse_id BIGINT NOT NULL,
|
||||||
|
to_warehouse_id BIGINT NOT NULL,
|
||||||
|
area_id BIGINT,
|
||||||
|
reason TEXT,
|
||||||
|
transfer_date DATE NOT NULL,
|
||||||
|
created_by BIGINT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- FOREIGN KEYS (dijalankan setelah semua tabel parent ada)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'warehouses') THEN
|
||||||
|
ALTER TABLE stock_transfers
|
||||||
|
ADD CONSTRAINT fk_stock_transfers_from_warehouse
|
||||||
|
FOREIGN KEY (from_warehouse_id)
|
||||||
|
REFERENCES warehouses(id)
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE stock_transfers
|
||||||
|
ADD CONSTRAINT fk_stock_transfers_to_warehouse
|
||||||
|
FOREIGN KEY (to_warehouse_id)
|
||||||
|
REFERENCES warehouses(id)
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'areas') THEN
|
||||||
|
ALTER TABLE stock_transfers
|
||||||
|
ADD CONSTRAINT fk_stock_transfers_area
|
||||||
|
FOREIGN KEY (area_id)
|
||||||
|
REFERENCES areas(id)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN
|
||||||
|
ALTER TABLE stock_transfers
|
||||||
|
ADD CONSTRAINT fk_stock_transfers_created_by
|
||||||
|
FOREIGN KEY (created_by)
|
||||||
|
REFERENCES users(id)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- INDEXES
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_transfers_from_warehouse_id ON stock_transfers(from_warehouse_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_transfers_to_warehouse_id ON stock_transfers(to_warehouse_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_transfers_transfer_date ON stock_transfers(transfer_date);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- DROP TABLE: STOCK_TRANSFER_DETAILS
|
||||||
|
DROP TABLE IF EXISTS stock_transfer_details CASCADE;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
-- ===============================================================
|
||||||
|
-- STOCK TRANSFER DETAILS (PRODUK)
|
||||||
|
-- ===============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS stock_transfer_details (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
stock_transfer_id BIGINT NOT NULL,
|
||||||
|
product_id BIGINT NOT NULL,
|
||||||
|
quantity NUMERIC(15, 3) NOT NULL CHECK (quantity > 0),
|
||||||
|
before_quantity NUMERIC(15, 3),
|
||||||
|
after_quantity NUMERIC(15, 3),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ===============================================================
|
||||||
|
-- FOREIGN KEYS (dengan pengecekan tabel agar anti gagal)
|
||||||
|
-- ===============================================================
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'stock_transfers') THEN
|
||||||
|
EXECUTE
|
||||||
|
'ALTER TABLE stock_transfer_details
|
||||||
|
ADD CONSTRAINT fk_stock_transfer_details_transfer
|
||||||
|
FOREIGN KEY (stock_transfer_id)
|
||||||
|
REFERENCES stock_transfers(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'products') THEN
|
||||||
|
EXECUTE
|
||||||
|
'ALTER TABLE stock_transfer_details
|
||||||
|
ADD CONSTRAINT fk_stock_transfer_details_product
|
||||||
|
FOREIGN KEY (product_id)
|
||||||
|
REFERENCES products(id)
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ===============================================================
|
||||||
|
-- INDEXES
|
||||||
|
-- ===============================================================
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_transfer_id ON stock_transfer_details (stock_transfer_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_product_id ON stock_transfer_details (product_id);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- DROP TABLE: STOCK_TRANSFER_DELIVERIES
|
||||||
|
DROP TABLE IF EXISTS stock_transfer_deliveries CASCADE;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
-- ===============================================================
|
||||||
|
-- STOCK TRANSFER DELIVERIES (EKSPEDISI)
|
||||||
|
-- ===============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS stock_transfer_deliveries (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
stock_transfer_id BIGINT NOT NULL,
|
||||||
|
supplier_id BIGINT,
|
||||||
|
vehicle_plate VARCHAR(20),
|
||||||
|
driver_name VARCHAR(100),
|
||||||
|
document_number VARCHAR(50),
|
||||||
|
document_path TEXT,
|
||||||
|
shipping_cost_item NUMERIC(15,3),
|
||||||
|
shipping_cost_total NUMERIC(15,3),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- FOREIGN KEYS
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'stock_transfers') THEN
|
||||||
|
ALTER TABLE stock_transfer_deliveries
|
||||||
|
ADD CONSTRAINT fk_stock_transfer_deliveries_transfer
|
||||||
|
FOREIGN KEY (stock_transfer_id)
|
||||||
|
REFERENCES stock_transfers(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'suppliers') THEN
|
||||||
|
ALTER TABLE stock_transfer_deliveries
|
||||||
|
ADD CONSTRAINT fk_stock_transfer_deliveries_supplier
|
||||||
|
FOREIGN KEY (supplier_id)
|
||||||
|
REFERENCES suppliers(id)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- INDEXES
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_transfer_deliveries_transfer_id ON stock_transfer_deliveries(stock_transfer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_transfer_deliveries_supplier_id ON stock_transfer_deliveries(supplier_id);
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
-- DROP PIVOT TABLE: STOCK_TRANSFER_DELIVERY_ITEMS
|
||||||
|
DROP TABLE IF EXISTS stock_transfer_delivery_items CASCADE;
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
-- ===============================================================
|
||||||
|
-- STOCK TRANSFER DELIVERY ITEMS (PIVOT)
|
||||||
|
-- ===============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS stock_transfer_delivery_items (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
stock_transfer_delivery_id BIGINT NOT NULL,
|
||||||
|
stock_transfer_detail_id BIGINT NOT NULL,
|
||||||
|
quantity NUMERIC(15, 3) NOT NULL CHECK (quantity > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- FOREIGN KEYS
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'stock_transfer_deliveries') THEN
|
||||||
|
ALTER TABLE stock_transfer_delivery_items
|
||||||
|
ADD CONSTRAINT fk_delivery_items_delivery
|
||||||
|
FOREIGN KEY (stock_transfer_delivery_id)
|
||||||
|
REFERENCES stock_transfer_deliveries(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'stock_transfer_details') THEN
|
||||||
|
ALTER TABLE stock_transfer_delivery_items
|
||||||
|
ADD CONSTRAINT fk_delivery_items_detail
|
||||||
|
FOREIGN KEY (stock_transfer_detail_id)
|
||||||
|
REFERENCES stock_transfer_details(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- INDEXES
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_transfer_delivery_items_delivery_id ON stock_transfer_delivery_items (stock_transfer_delivery_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_transfer_delivery_items_detail_id ON stock_transfer_delivery_items (stock_transfer_detail_id);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE kandangs
|
||||||
|
DROP COLUMN IF EXISTS status;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE kandangs
|
||||||
|
ADD COLUMN status VARCHAR(20);
|
||||||
|
|
||||||
|
UPDATE kandangs
|
||||||
|
SET status = 'NON_ACTIVE'
|
||||||
|
WHERE status IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE kandangs
|
||||||
|
ALTER COLUMN status SET NOT NULL;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE kandangs
|
||||||
|
DROP COLUMN IF EXISTS project_flock_id;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS project_flocks;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS flocks;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
CREATE TABLE flocks (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX flocks_name_unique ON flocks (name)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE project_flocks (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
flock_id BIGINT NOT NULL REFERENCES flocks (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
area_id BIGINT NOT NULL REFERENCES areas (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
product_category_id BIGINT NOT NULL REFERENCES product_categories (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
fcr_id BIGINT NOT NULL REFERENCES fcrs (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
location_id BIGINT NOT NULL REFERENCES locations (id) ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
period INT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE kandangs
|
||||||
|
ADD COLUMN project_flock_id BIGINT REFERENCES project_flocks (id) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS approvals_approvable_lookup;
|
||||||
|
DROP TABLE IF EXISTS approvals;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE approvals (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
approvable_type VARCHAR(50) NOT NULL,
|
||||||
|
approvable_id BIGINT NOT NULL,
|
||||||
|
step SMALLINT NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
action_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX approvals_approvable_lookup ON approvals (approvable_type, approvable_id);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
ALTER TABLE approvals
|
||||||
|
RENAME COLUMN action TO status;
|
||||||
|
|
||||||
|
UPDATE approvals
|
||||||
|
SET status = 'PENDING'
|
||||||
|
WHERE status IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE approvals
|
||||||
|
ALTER COLUMN status SET NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE approvals
|
||||||
|
RENAME COLUMN step_number TO step;
|
||||||
|
|
||||||
|
ALTER TABLE approvals
|
||||||
|
DROP COLUMN step_name;
|
||||||
|
|
||||||
|
ALTER TABLE approvals
|
||||||
|
RENAME COLUMN action_at TO created_at;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
ALTER TABLE approvals
|
||||||
|
RENAME COLUMN status TO action;
|
||||||
|
|
||||||
|
ALTER TABLE approvals
|
||||||
|
ALTER COLUMN action DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE approvals
|
||||||
|
RENAME COLUMN step TO step_number;
|
||||||
|
|
||||||
|
ALTER TABLE approvals
|
||||||
|
ADD COLUMN step_name VARCHAR NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE approvals
|
||||||
|
RENAME COLUMN created_at TO action_at;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS project_flock_kandangs;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE project_flock_kandangs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
project_flock_id BIGINT NOT NULL REFERENCES project_flocks (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
kandang_id BIGINT NOT NULL REFERENCES kandangs (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
detached_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_project_flock_kandangs_project ON project_flock_kandangs (project_flock_id);
|
||||||
|
CREATE INDEX idx_project_flock_kandangs_kandang ON project_flock_kandangs (kandang_id);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_project_flock_kandangs_active ON project_flock_kandangs (project_flock_id, kandang_id)
|
||||||
|
WHERE
|
||||||
|
detached_at IS NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS project_chickins;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS project_chickins (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
project_flock_kandang_id BIGINT NOT NULL,
|
||||||
|
product_warehouse_id BIGINT NOT NULL,
|
||||||
|
chick_in_date DATE NOT NULL,
|
||||||
|
usage_qty NUMERIC(15, 3) NOT NULL,
|
||||||
|
pending_usage_qty NUMERIC(15, 3) DEFAULT 0,
|
||||||
|
notes TEXT,
|
||||||
|
created_by BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('project_flock_kandangs') IS NOT NULL THEN
|
||||||
|
ALTER TABLE project_chickins
|
||||||
|
ADD CONSTRAINT fk_project_chickins_kandang
|
||||||
|
FOREIGN KEY (project_flock_kandang_id)
|
||||||
|
REFERENCES project_flock_kandangs(id)
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF to_regclass('product_warehouses') IS NOT NULL THEN
|
||||||
|
ALTER TABLE project_chickins
|
||||||
|
ADD CONSTRAINT fk_project_chickins_warehouse
|
||||||
|
FOREIGN KEY (product_warehouse_id)
|
||||||
|
REFERENCES product_warehouses(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF to_regclass('users') IS NOT NULL THEN
|
||||||
|
ALTER TABLE project_chickins
|
||||||
|
ADD CONSTRAINT fk_project_chickins_created_by
|
||||||
|
FOREIGN KEY (created_by)
|
||||||
|
REFERENCES users(id)
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chickins_kandang_id ON project_chickins (project_flock_kandang_id)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chickins_warehouse_id ON project_chickins (product_warehouse_id)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chickins_created_by ON project_chickins (created_by);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chickins_kandang_deleted ON project_chickins (
|
||||||
|
project_flock_kandang_id,
|
||||||
|
deleted_at
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chickins_deleted_at ON project_chickins (deleted_at);
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS project_flock_populations;
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS project_flock_populations (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
project_chickin_id BIGINT NOT NULL,
|
||||||
|
product_warehouse_id BIGINT NOT NULL,
|
||||||
|
total_qty NUMERIC(15, 3) NOT NULL,
|
||||||
|
total_used_qty NUMERIC(15, 3) DEFAULT 0,
|
||||||
|
notes TEXT,
|
||||||
|
created_by BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('project_chickins') IS NOT NULL THEN
|
||||||
|
ALTER TABLE project_flock_populations
|
||||||
|
ADD CONSTRAINT fk_project_flock_populations_chickin
|
||||||
|
FOREIGN KEY (project_chickin_id)
|
||||||
|
REFERENCES project_chickins(id)
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF to_regclass('product_warehouses') IS NOT NULL THEN
|
||||||
|
ALTER TABLE project_flock_populations
|
||||||
|
ADD CONSTRAINT fk_project_flock_populations_warehouse
|
||||||
|
FOREIGN KEY (product_warehouse_id)
|
||||||
|
REFERENCES product_warehouses(id)
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF to_regclass('users') IS NOT NULL THEN
|
||||||
|
ALTER TABLE project_flock_populations
|
||||||
|
ADD CONSTRAINT fk_project_flock_populations_created_by
|
||||||
|
FOREIGN KEY (created_by)
|
||||||
|
REFERENCES users(id)
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_populations_chickin_id ON project_flock_populations (project_chickin_id)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_populations_warehouse_id ON project_flock_populations (product_warehouse_id)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_populations_created_by ON project_flock_populations (created_by);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_populations_chickin_deleted ON project_flock_populations (
|
||||||
|
project_chickin_id,
|
||||||
|
deleted_at
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_populations_deleted_at ON project_flock_populations (deleted_at);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_populations_chickin_unique ON project_flock_populations (project_chickin_id)
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user