mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-21 05:45:44 +00:00
Compare commits
1502 Commits
master
..
02b86be4c5
| Author | SHA1 | Date | |
|---|---|---|---|
| 02b86be4c5 | |||
| 99e185a16a | |||
| 995d585f54 | |||
| d05be1aef4 | |||
| 872a71efda | |||
| 6a2d6eec92 | |||
| 18f9da1eaf | |||
| 45bbe2ab1b | |||
| 18bd8ad1d9 | |||
| a40adc22d2 | |||
| 04626560eb | |||
| 945683bdf5 | |||
| 490c7fc9fd | |||
| 4f03b631ef | |||
| eac671fa80 | |||
| 845c14cf95 | |||
| a04ae14271 | |||
| 0caad642be | |||
| a76ab69a84 | |||
| e940c30050 | |||
| aab1c3a2d5 | |||
| f9226a0b41 | |||
| bd8b149f11 | |||
| 68a132f4bb | |||
| e576b73049 | |||
| e138547f3b | |||
| e7038a394b | |||
| e0b9192e91 | |||
| 748375b269 | |||
| 09bc31c602 | |||
| 6474dd57b3 | |||
| ececc5e5e1 | |||
| 83aa23f677 | |||
| 3d2bc11058 | |||
| c328b9a880 | |||
| e29ceffa37 | |||
| 8f7762f769 | |||
| 4c31587771 | |||
| ecac927583 | |||
| 126294d288 | |||
| aa5d4ab818 | |||
| 06070871c7 | |||
| 5511dc78dc | |||
| 29f8b4fbdd | |||
| 4c6942c7b7 | |||
| 699e4448d1 | |||
| 6f02387d69 | |||
| fc5d5d8ad4 | |||
| 0d6ab5e718 | |||
| 547fc309f5 | |||
| 094e8f904b | |||
| 0d928d5856 | |||
| 0357531e73 | |||
| 2fa279c073 | |||
| 90ed035abd | |||
| 81b9e88bb6 | |||
| 7e01d8afb9 | |||
| d5a98b95dc | |||
| 8900937e71 | |||
| cad80e2216 | |||
| 34dad85734 | |||
| b1d2d30773 | |||
| f910d165e4 | |||
| 6f6985ef32 | |||
| d07f074fb1 | |||
| 561481679e | |||
| d86577f007 | |||
| 49ea3f0295 | |||
| 7bab8c66c1 | |||
| f9de4d28f9 | |||
| 45028212e1 | |||
| 0f285dc684 | |||
| d0cd82c703 | |||
| 48351661c5 | |||
| 19d7cd33ca | |||
| 03474dc1fa | |||
| 916fa4205b | |||
| b2be67e052 | |||
| 0ac40adb5a | |||
| cee59c2b99 | |||
| da99bf1429 | |||
| 28fd711ece | |||
| 3768892a17 | |||
| c804c59f05 | |||
| b219bf829f | |||
| f97b1a6484 | |||
| 0d2cdef10f | |||
| 128c8e0d08 | |||
| cf4e723f64 | |||
| a3156a156f | |||
| 16ac54ff39 | |||
| 196bbf4277 | |||
| d3053d6417 | |||
| 27302ea6b5 | |||
| 76ac1d1671 | |||
| 44cbc1b304 | |||
| e4d4bd9483 | |||
| cea79bc569 | |||
| 38cfc6b103 | |||
| 80e4a04875 | |||
| 9513da2a7c | |||
| 874cefda45 | |||
| 795f201a0b | |||
| 49d684ebbe | |||
| 6f6541d4c1 | |||
| 1d6e1fa5be | |||
| aadd87852b | |||
| ac69ae054a | |||
| 7cc5c39092 | |||
| 706303980b | |||
| d7a2a5a2ed | |||
| ae47493109 | |||
| 9726303eeb | |||
| b764d2c3c0 | |||
| eefc9850e1 | |||
| 732ebd423d | |||
| 27d076b817 | |||
| 9a9cc114fe | |||
| af5f3dc7d4 | |||
| e1f8d357c5 | |||
| f6b37926e9 | |||
| 4e7c0e64ad | |||
| e79fde2408 | |||
| 28394ee727 | |||
| 42c088e772 | |||
| df45803f8d | |||
| 6033535894 | |||
| 62723e5ab2 | |||
| f1d7966e2f | |||
| b94b16ab73 | |||
| 9fd9c441d3 | |||
| f5d986bf27 | |||
| 1e9a51b8cd | |||
| 512d616867 | |||
| 50ce780c2c | |||
| bacc7217b3 | |||
| 878b8ccce9 | |||
| 5c39237ac0 | |||
| 0fc6096637 | |||
| 8389bd579b | |||
| cf9e60c64d | |||
| 2dcfd9efde | |||
| adcd4dc8fb | |||
| 9a19bff8d3 | |||
| 596b5ed2fb | |||
| 20479c2ca2 | |||
| a79213882d | |||
| befb351e49 | |||
| 5d38dfac69 | |||
| 26a04d3bdd | |||
| 044649abc9 | |||
| 83eab9de17 | |||
| 940899dad8 | |||
| ffccd6daee | |||
| 7f9604bcb4 | |||
| e5e090aefe | |||
| 552de15c1a | |||
| 8e396d758f | |||
| 84b76a4b2d | |||
| 7c4664844c | |||
| 47d2371b7f | |||
| 5bfd93e300 | |||
| 3fa75e0a31 | |||
| 101a83cd9e | |||
| 13fc537171 | |||
| 46737e4a96 | |||
| 06a23ce1b5 | |||
| fb9915759b | |||
| f0e364f884 | |||
| 65299d7913 | |||
| e6010fe47e | |||
| cdcc268d89 | |||
| 891da70efb | |||
| e45ebca5a4 | |||
| eacc460f67 | |||
| d2ab1c7ea5 | |||
| 151edf578e | |||
| e065e1fb25 | |||
| e24e2ff123 | |||
| 266f683db1 | |||
| c744043321 | |||
| 4673c7ad33 | |||
| 3e99caf3a7 | |||
| a15fd1b174 | |||
| 831d72cb86 | |||
| ff630a1ed0 | |||
| 7d223c81ba | |||
| 91d51bf1b8 | |||
| 2a141a96d1 | |||
| f51fa0a16c | |||
| 9b9f5e257e | |||
| adabd43f38 | |||
| 640b6b382b | |||
| ca6e9ef0d2 | |||
| c8ea370e4b | |||
| fec7bb5825 | |||
| 091f706276 | |||
| 5594c27108 | |||
| e91c45ee50 | |||
| 5b2766676b | |||
| 5e7c51e9c2 | |||
| a98a709766 | |||
| 0d04397bd5 | |||
| ded8be198a | |||
| 1e34a0e7b2 | |||
| c5bb0ef577 | |||
| 5b2f66c0c7 | |||
| 916f1980e9 | |||
| 5355fe0729 | |||
| e679193f18 | |||
| 36a740d330 | |||
| f98c73f569 | |||
| 75d42354e9 | |||
| 30adbb6b8a | |||
| 45bed3b765 | |||
| 04aad18a4c | |||
| 69d6fc165a | |||
| a2ae139fae | |||
| 58fbceea24 | |||
| 187e497f97 | |||
| fcde3b0a36 | |||
| a54c6184a2 | |||
| f808b5cf79 | |||
| 3d87e85d1e | |||
| ef71093b99 | |||
| 1c19fc058c | |||
| 6a012b75aa | |||
| 9b0335cac8 | |||
| 1c70dccc82 | |||
| 94a7191365 | |||
| 1ab16cfe06 | |||
| d9610537de | |||
| cd549de578 | |||
| 7ca7d0841b | |||
| 82794d3d9b | |||
| ca698ff2ae | |||
| fbe0634d46 | |||
| bca02800d6 | |||
| 45e430f01d | |||
| cff5837ff9 | |||
| 5e2187c46b | |||
| d1612e5c65 | |||
| 30a47ffc71 | |||
| b79738dbe1 | |||
| 3eb225cca8 | |||
| a9a84539eb | |||
| 3702d41954 | |||
| ffd96105ce | |||
| 3d75251c96 | |||
| 7c848bc50d | |||
| ddcf13e2ff | |||
| e8c33f818b | |||
| 3daed7e248 | |||
| cde4647b15 | |||
| abc0ac8258 | |||
| aad4f7dc28 | |||
| bfe7b5129f | |||
| a6995f8e18 | |||
| 7638c183f5 | |||
| 450d1e8cee | |||
| b58e9a10b1 | |||
| aa9863646e | |||
| 2a3154042c | |||
| 80f190b69b | |||
| 079ae01b94 | |||
| ee7fa71139 | |||
| f2827d5352 | |||
| 18cf180982 | |||
| fc0b45b433 | |||
| 4d85d6f320 | |||
| e3cfb2648b | |||
| ba8b512293 | |||
| 7bd9ec9ef8 | |||
| 037f9fc71b | |||
| 8fa41e379d | |||
| 7d9c752432 | |||
| 6342a28f09 | |||
| 945b6aba0a | |||
| 23e49a00e4 | |||
| 480c899f6a | |||
| ba4a5324ed | |||
| 4899cee98f | |||
| 2a39342d55 | |||
| f29f09d7b9 | |||
| 4254cbf576 | |||
| 34a3fc44a8 | |||
| fdfc5e069d | |||
| 6880010424 | |||
| 9cc9146641 | |||
| 8be4b54127 | |||
| 88b6e2f294 | |||
| 36b0f97897 | |||
| 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
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# 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
|
||||
.gemini/
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
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"
|
||||
|
||||
HAS_ACCESS_KEY="false"
|
||||
HAS_SECRET_KEY="false"
|
||||
if [ -n "${AWS_ACCESS_KEY_ID:-}" ]; then
|
||||
HAS_ACCESS_KEY="true"
|
||||
fi
|
||||
if [ -n "${AWS_SECRET_ACCESS_KEY:-}" ]; then
|
||||
HAS_SECRET_KEY="true"
|
||||
fi
|
||||
|
||||
if [ "$HAS_ACCESS_KEY" = "true" ] && [ "$HAS_SECRET_KEY" = "true" ]; then
|
||||
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
|
||||
elif [ "$HAS_ACCESS_KEY" = "true" ] || [ "$HAS_SECRET_KEY" = "true" ] || [ -n "${AWS_SESSION_TOKEN:-}" ]; then
|
||||
echo "WARN: Incomplete AWS_* env vars detected; ignoring injected AWS credentials for ECR login."
|
||||
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
|
||||
- [ ] [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:
|
||||
### 1. Clone Project
|
||||
|
||||
```
|
||||
cd existing_repo
|
||||
git remote add origin https://gitlab.com/mbugroup/lti-api.git
|
||||
git branch -M main
|
||||
git push -uf origin main
|
||||
```bash
|
||||
git clone https://gitlab.com/mbugroup/lti-api.git
|
||||
cd lti-api
|
||||
```
|
||||
|
||||
## 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/)
|
||||
- [ ] [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/)
|
||||
Copy .env.example to .env and adjust the variables (e.g. DATABASE_URL, JWT secrets, etc).
|
||||
|
||||
## 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/)
|
||||
- [ ] [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)
|
||||
Run initial docker.
|
||||
|
||||
***
|
||||
```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
|
||||
Choose a self-explaining name for your project.
|
||||
Run project via Docker
|
||||
|
||||
## Description
|
||||
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.
|
||||
### 6. Create New Module
|
||||
|
||||
## Badges
|
||||
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.
|
||||
```bash
|
||||
make gen feat=user
|
||||
```
|
||||
|
||||
## Visuals
|
||||
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.
|
||||
output:
|
||||
|
||||
## Installation
|
||||
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.
|
||||
```bash
|
||||
cmd/
|
||||
├── api/
|
||||
│ └── main.go # Application entrypoint (initialize Fiber, load config, connect DB, register route)
|
||||
|
||||
## Usage
|
||||
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.
|
||||
internal/
|
||||
├── 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
|
||||
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.
|
||||
## ✨ Author
|
||||
|
||||
## Roadmap
|
||||
If you have ideas for releases in the future, it is a good idea to list them in the README.
|
||||
IT Development PT Mitra Berlian Unggas Group
|
||||
|
||||
## Contributing
|
||||
State if you are open to contributions and what your requirements are for accepting them.
|
||||
## 📃 License
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
> This project is private. All rights reserved.
|
||||
# mr test Sat 7 Feb 2026 00:14:58 WIB
|
||||
|
||||
Executable
BIN
Binary file not shown.
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
db := database.Connect(config.DBHost, config.DBName)
|
||||
service := apikeys.NewService(db)
|
||||
ctx := context.Background()
|
||||
|
||||
switch os.Args[1] {
|
||||
case "create":
|
||||
fs := flag.NewFlagSet("create", flag.ExitOnError)
|
||||
name := fs.String("name", "dashboard-read-api", "integration client name")
|
||||
environment := fs.String("env", config.AppEnv, "environment label")
|
||||
permissions := fs.String("permissions", "", "comma separated permission codes")
|
||||
allArea := fs.Bool("all-area", true, "grant all areas")
|
||||
areaIDs := fs.String("area-ids", "", "comma separated area ids")
|
||||
allLocation := fs.Bool("all-location", true, "grant all locations")
|
||||
locationIDs := fs.String("location-ids", "", "comma separated location ids")
|
||||
fs.Parse(os.Args[2:])
|
||||
|
||||
permissionCodes := apikeys.DefaultDashboardPermissions()
|
||||
if strings.TrimSpace(*permissions) != "" {
|
||||
permissionCodes = splitCSV(*permissions)
|
||||
}
|
||||
|
||||
issued, err := service.Create(ctx, apikeys.CreateInput{
|
||||
Name: *name,
|
||||
Environment: *environment,
|
||||
PermissionCodes: permissionCodes,
|
||||
AllArea: *allArea,
|
||||
AreaIDs: parseUintCSV(*areaIDs),
|
||||
AllLocation: *allLocation,
|
||||
LocationIDs: parseUintCSV(*locationIDs),
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("name: %s\n", issued.Record.Name)
|
||||
fmt.Printf("environment: %s\n", issued.Record.Environment)
|
||||
fmt.Printf("prefix: %s\n", issued.Record.KeyPrefix)
|
||||
fmt.Printf("status: %s\n", issued.Record.Status)
|
||||
fmt.Printf("api_key: %s\n", issued.Key)
|
||||
case "list":
|
||||
fs := flag.NewFlagSet("list", flag.ExitOnError)
|
||||
environment := fs.String("env", "", "filter by environment")
|
||||
fs.Parse(os.Args[2:])
|
||||
|
||||
records, err := service.List(ctx, *environment)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
fmt.Printf("%s\t%s\t%s\t%s\tareas=%t\tlocations=%t\n",
|
||||
record.Environment,
|
||||
record.KeyPrefix,
|
||||
record.Status,
|
||||
record.Name,
|
||||
record.AllArea,
|
||||
record.AllLocation,
|
||||
)
|
||||
}
|
||||
case "revoke":
|
||||
fs := flag.NewFlagSet("revoke", flag.ExitOnError)
|
||||
environment := fs.String("env", config.AppEnv, "environment label")
|
||||
prefix := fs.String("prefix", "", "key prefix to revoke")
|
||||
fs.Parse(os.Args[2:])
|
||||
|
||||
if err := service.Revoke(ctx, *environment, *prefix); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("revoked %s/%s\n", *environment, *prefix)
|
||||
default:
|
||||
usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Println("usage:")
|
||||
fmt.Println(" go run ./cmd/api-key create [flags]")
|
||||
fmt.Println(" go run ./cmd/api-key list [flags]")
|
||||
fmt.Println(" go run ./cmd/api-key revoke -env <environment> -prefix <prefix>")
|
||||
}
|
||||
|
||||
func splitCSV(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseUintCSV(raw string) []uint {
|
||||
parts := splitCSV(raw)
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
values := make([]uint, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
var value uint
|
||||
if _, err := fmt.Sscanf(part, "%d", &value); err == nil && value > 0 {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
|
||||
"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/readapi"
|
||||
"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) {
|
||||
middleware.SetAPIKeyAuthenticator(apikeys.NewService(db))
|
||||
|
||||
// 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)
|
||||
})
|
||||
|
||||
readAPIRoutes := app.Group("/api")
|
||||
readapi.RegisterRoutes(readAPIRoutes)
|
||||
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")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,601 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// ── Fake executor ─────────────────────────────────────────────────────────────
|
||||
|
||||
type fakeSystemTransferExecutor struct {
|
||||
createRequests []*transferSvc.SystemTransferRequest
|
||||
createResponses []*entity.StockTransfer
|
||||
createErrors []error
|
||||
deletedTransferIDs []uint
|
||||
deleteErrors map[uint]error
|
||||
}
|
||||
|
||||
func (f *fakeSystemTransferExecutor) CreateSystemTransfer(_ 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(_ context.Context, id uint, _ uint) error {
|
||||
f.deletedTransferIDs = append(f.deletedTransferIDs, id)
|
||||
if f.deleteErrors != nil {
|
||||
return f.deleteErrors[id]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── applyFarmWarehouseOverride ────────────────────────────────────────────────
|
||||
|
||||
func TestApplyOverrideResolvesMultiFarmLocation(t *testing.T) {
|
||||
farmMap := map[uint]farmWarehouseInfo{
|
||||
10: {
|
||||
LocationID: 10,
|
||||
LocationName: "Cijangkar",
|
||||
AllFarm: []farmWarehouseEntry{
|
||||
{ID: 50, Name: "Farm A"},
|
||||
{ID: 51, Name: "Farm B"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := applyFarmWarehouseOverride(farmMap, 51); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
info := farmMap[10]
|
||||
if info.ChosenID != 51 {
|
||||
t.Errorf("expected ChosenID=51, got %d", info.ChosenID)
|
||||
}
|
||||
if info.ChosenName != "Farm B" {
|
||||
t.Errorf("expected ChosenName=Farm B, got %s", info.ChosenName)
|
||||
}
|
||||
if len(info.OtherFarm) != 1 || info.OtherFarm[0].ID != 50 {
|
||||
t.Errorf("expected OtherFarm=[Farm A], got %+v", info.OtherFarm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOverrideErrorsWhenIDNotInAllFarm(t *testing.T) {
|
||||
farmMap := map[uint]farmWarehouseInfo{
|
||||
10: {
|
||||
LocationID: 10,
|
||||
LocationName: "Cijangkar",
|
||||
AllFarm: []farmWarehouseEntry{
|
||||
{ID: 50, Name: "Farm A"},
|
||||
{ID: 51, Name: "Farm B"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := applyFarmWarehouseOverride(farmMap, 99)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown warehouse id, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "99") {
|
||||
t.Errorf("error should mention the invalid id, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Farm A") || !strings.Contains(err.Error(), "Farm B") {
|
||||
t.Errorf("error should list available warehouses, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOverrideIgnoresSingleFarmLocations(t *testing.T) {
|
||||
farmMap := map[uint]farmWarehouseInfo{
|
||||
10: {
|
||||
LocationID: 10,
|
||||
LocationName: "Jamali",
|
||||
AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}},
|
||||
ChosenID: 50,
|
||||
ChosenName: "Farm A",
|
||||
},
|
||||
}
|
||||
|
||||
// Override ID 50 is present, but there is only 1 farm; the function should
|
||||
// not touch this location (no OtherFarm to populate).
|
||||
if err := applyFarmWarehouseOverride(farmMap, 50); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(farmMap[10].OtherFarm) != 0 {
|
||||
t.Errorf("expected no OtherFarm for single-farm location, got %+v", farmMap[10].OtherFarm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOverrideNoopWhenZero(t *testing.T) {
|
||||
farmMap := map[uint]farmWarehouseInfo{
|
||||
10: {
|
||||
LocationID: 10,
|
||||
LocationName: "Cijangkar",
|
||||
AllFarm: []farmWarehouseEntry{
|
||||
{ID: 50, Name: "Farm A"},
|
||||
{ID: 51, Name: "Farm B"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := applyFarmWarehouseOverride(farmMap, 0); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if farmMap[10].ChosenID != 0 {
|
||||
t.Errorf("expected ChosenID unchanged (0), got %d", farmMap[10].ChosenID)
|
||||
}
|
||||
}
|
||||
|
||||
// ── listUnresolvedLocations ───────────────────────────────────────────────────
|
||||
|
||||
func TestListUnresolvedLocationsReturnsOnlyAmbiguous(t *testing.T) {
|
||||
farmMap := map[uint]farmWarehouseInfo{
|
||||
1: {LocationID: 1, LocationName: "Jamali", AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, ChosenID: 50},
|
||||
2: {
|
||||
LocationID: 2,
|
||||
LocationName: "Cijangkar",
|
||||
AllFarm: []farmWarehouseEntry{{ID: 60, Name: "Farm X"}, {ID: 61, Name: "Farm Y"}},
|
||||
// ChosenID = 0: unresolved
|
||||
},
|
||||
3: {LocationID: 3, LocationName: "Tamansari", AllFarm: nil}, // no farm at all, not an error here
|
||||
}
|
||||
|
||||
msgs := listUnresolvedLocations(farmMap)
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 unresolved message, got %d: %v", len(msgs), msgs)
|
||||
}
|
||||
if !strings.Contains(msgs[0], "Cijangkar") {
|
||||
t.Errorf("message should name the ambiguous location, got: %s", msgs[0])
|
||||
}
|
||||
if !strings.Contains(msgs[0], "Farm X") || !strings.Contains(msgs[0], "Farm Y") {
|
||||
t.Errorf("message should list available warehouses, got: %s", msgs[0])
|
||||
}
|
||||
}
|
||||
|
||||
// ── buildTransferPlan — kandang source ───────────────────────────────────────
|
||||
|
||||
func TestBuildPlanKandangEligibleGroupedByWarehousePair(t *testing.T) {
|
||||
opts := &commandOptions{RunID: "test-run"}
|
||||
farmMap := map[uint]farmWarehouseInfo{
|
||||
10: {LocationID: 10, LocationName: "Jamali", AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, ChosenID: 50, ChosenName: "Farm A"},
|
||||
}
|
||||
stocks := []kandangStockRow{
|
||||
{LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, SourceWarehouseName: "K1", ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang},
|
||||
{LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, SourceWarehouseName: "K1", ProductID: 2, ProductName: "OVK B", OnHandQty: 50, AllocatedQty: 10, LeftoverQty: 40, SourceType: sourceTypeKandang},
|
||||
}
|
||||
|
||||
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
|
||||
|
||||
if len(groups) != 1 || len(groups[0].Rows) != 2 {
|
||||
t.Fatalf("expected 1 group with 2 rows, got %d groups", len(groups))
|
||||
}
|
||||
if groups[0].SourceType != sourceTypeKandang {
|
||||
t.Errorf("expected group source type kandang_to_farm, got %s", groups[0].SourceType)
|
||||
}
|
||||
for _, row := range reportRows {
|
||||
if row.Status != "eligible" {
|
||||
t.Errorf("expected eligible, got %s for %s", row.Status, row.ProductName)
|
||||
}
|
||||
}
|
||||
if reportRows[1].Qty != 40 {
|
||||
t.Errorf("expected leftover qty 40 for OVK B, got %.3f", reportRows[1].Qty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSkipsMissingFarmWarehouse(t *testing.T) {
|
||||
opts := &commandOptions{RunID: "test-run"}
|
||||
farmMap := map[uint]farmWarehouseInfo{
|
||||
10: {LocationID: 10, LocationName: "Jamali", AllFarm: nil},
|
||||
}
|
||||
stocks := []kandangStockRow{
|
||||
{LocationID: 10, SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang},
|
||||
}
|
||||
|
||||
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
|
||||
if len(groups) != 0 {
|
||||
t.Fatalf("expected no groups, got %d", len(groups))
|
||||
}
|
||||
if reportRows[0].Status != "skipped" || reportRows[0].Reason != "missing_farm_warehouse" {
|
||||
t.Errorf("unexpected: %s / %s", reportRows[0].Status, reportRows[0].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanErrorForUnresolvedMultiFarmWarehouse(t *testing.T) {
|
||||
opts := &commandOptions{RunID: "test-run"}
|
||||
farmMap := map[uint]farmWarehouseInfo{
|
||||
10: {
|
||||
LocationID: 10,
|
||||
LocationName: "Cijangkar",
|
||||
AllFarm: []farmWarehouseEntry{{ID: 60, Name: "Farm X"}, {ID: 61, Name: "Farm Y"}},
|
||||
// ChosenID = 0: unresolved
|
||||
},
|
||||
}
|
||||
stocks := []kandangStockRow{
|
||||
{LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 21, ProductID: 3, ProductName: "Pakan C", OnHandQty: 200, LeftoverQty: 200, SourceType: sourceTypeKandang},
|
||||
}
|
||||
|
||||
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
|
||||
if len(groups) != 0 {
|
||||
t.Fatalf("expected no groups, got %d", len(groups))
|
||||
}
|
||||
if reportRows[0].Status != "error" {
|
||||
t.Errorf("expected error status, got %s", reportRows[0].Status)
|
||||
}
|
||||
if !strings.Contains(reportRows[0].Reason, "multiple_farm_warehouses") {
|
||||
t.Errorf("reason should mention multiple_farm_warehouses, got: %s", reportRows[0].Reason)
|
||||
}
|
||||
// The error message must list the available warehouses so the operator knows
|
||||
// which --farm-warehouse-id to use.
|
||||
if !strings.Contains(reportRows[0].Reason, "Farm X") || !strings.Contains(reportRows[0].Reason, "Farm Y") {
|
||||
t.Errorf("reason should list available warehouses, got: %s", reportRows[0].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSkipsFullyAllocatedKandangStock(t *testing.T) {
|
||||
opts := &commandOptions{RunID: "test-run"}
|
||||
farmMap := map[uint]farmWarehouseInfo{
|
||||
10: {LocationID: 10, AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, ChosenID: 50, ChosenName: "Farm A"},
|
||||
}
|
||||
stocks := []kandangStockRow{
|
||||
{LocationID: 10, SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, AllocatedQty: 100, LeftoverQty: 0, SourceType: sourceTypeKandang},
|
||||
{LocationID: 10, SourceWarehouseID: 20, ProductID: 2, ProductName: "OVK B", OnHandQty: 80, AllocatedQty: 30, LeftoverQty: 50, SourceType: sourceTypeKandang},
|
||||
}
|
||||
|
||||
_, groups := buildTransferPlan(opts, farmMap, stocks)
|
||||
if len(groups) != 1 || len(groups[0].Rows) != 1 {
|
||||
t.Fatalf("expected 1 group with 1 eligible row, got groups=%d", len(groups))
|
||||
}
|
||||
if groups[0].Rows[0].ProductName != "OVK B" {
|
||||
t.Errorf("expected only OVK B to be eligible, got %s", groups[0].Rows[0].ProductName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSkipAmbiguousDowngradesErrorToSkipped(t *testing.T) {
|
||||
opts := &commandOptions{RunID: "test-run", SkipAmbiguous: true}
|
||||
farmMap := map[uint]farmWarehouseInfo{
|
||||
10: {
|
||||
LocationID: 10,
|
||||
LocationName: "Cijangkar",
|
||||
AllFarm: []farmWarehouseEntry{{ID: 60, Name: "Farm X"}, {ID: 61, Name: "Farm Y"}},
|
||||
// ChosenID = 0: unresolved
|
||||
},
|
||||
11: {
|
||||
LocationID: 11,
|
||||
LocationName: "Jamali",
|
||||
AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}},
|
||||
ChosenID: 50,
|
||||
ChosenName: "Farm A",
|
||||
},
|
||||
}
|
||||
stocks := []kandangStockRow{
|
||||
{LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 21, ProductID: 3, ProductName: "Pakan C", OnHandQty: 200, LeftoverQty: 200, SourceType: sourceTypeKandang},
|
||||
{LocationID: 11, LocationName: "Jamali", SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang},
|
||||
}
|
||||
|
||||
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
|
||||
|
||||
// Ambiguous location must be skipped, not error.
|
||||
ambiguous := reportRows[0]
|
||||
if ambiguous.LocationName != "Cijangkar" {
|
||||
t.Fatalf("expected first row to be Cijangkar, got %s", ambiguous.LocationName)
|
||||
}
|
||||
if ambiguous.Status != "skipped" {
|
||||
t.Errorf("expected skipped with --skip-ambiguous, got %s", ambiguous.Status)
|
||||
}
|
||||
if !strings.Contains(ambiguous.Reason, "multiple_farm_warehouses") {
|
||||
t.Errorf("reason should still explain the cause, got: %s", ambiguous.Reason)
|
||||
}
|
||||
|
||||
// Unambiguous location must still be eligible and grouped.
|
||||
if len(groups) != 1 || groups[0].LocationName != "Jamali" {
|
||||
t.Errorf("expected 1 group for Jamali, got %d groups", len(groups))
|
||||
}
|
||||
}
|
||||
|
||||
// ── applyFlagFilter (unit-level, via buildTransferPlan) ───────────────────────
|
||||
|
||||
// applyFlagFilter is a DB-level filter so we test its effect indirectly: the
|
||||
// flag filter is applied before rows reach buildTransferPlan, so we simulate
|
||||
// by only passing stock rows that the query would have returned.
|
||||
// The real guard is that loadKandangLeftoverStocks receives the filtered set.
|
||||
// Here we verify that buildTransferPlan itself is agnostic to the filter and
|
||||
// simply processes whatever rows it is given.
|
||||
func TestBuildPlanOnlyTransfersRowsPassedToIt(t *testing.T) {
|
||||
opts := &commandOptions{RunID: "test-run", FlagFilter: []string{"PAKAN"}}
|
||||
farmMap := map[uint]farmWarehouseInfo{
|
||||
10: {LocationID: 10, AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, ChosenID: 50, ChosenName: "Farm A"},
|
||||
}
|
||||
// Simulate: only PAKAN products survived the DB filter; OVK was excluded.
|
||||
stocks := []kandangStockRow{
|
||||
{LocationID: 10, SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan Broiler", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang},
|
||||
}
|
||||
|
||||
_, groups := buildTransferPlan(opts, farmMap, stocks)
|
||||
if len(groups) != 1 || len(groups[0].Rows) != 1 {
|
||||
t.Fatalf("expected 1 group with 1 row, got %d groups", len(groups))
|
||||
}
|
||||
if groups[0].Rows[0].ProductName != "Pakan Broiler" {
|
||||
t.Errorf("unexpected product: %s", groups[0].Rows[0].ProductName)
|
||||
}
|
||||
}
|
||||
|
||||
// ── buildTransferPlan — farm_consolidation source ────────────────────────────
|
||||
|
||||
func TestBuildPlanFarmConsolidationCreatesOwnGroup(t *testing.T) {
|
||||
opts := &commandOptions{RunID: "test-run"}
|
||||
// Location 10 has 2 farm warehouses; Farm B (id=51) was chosen, Farm A
|
||||
// (id=50) is OtherFarm whose stocks need consolidating.
|
||||
farmMap := map[uint]farmWarehouseInfo{
|
||||
10: {
|
||||
LocationID: 10,
|
||||
LocationName: "Cijangkar",
|
||||
AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}, {ID: 51, Name: "Farm B"}},
|
||||
ChosenID: 51,
|
||||
ChosenName: "Farm B",
|
||||
OtherFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}},
|
||||
},
|
||||
}
|
||||
// Extra farm stock from Farm A + normal kandang stock.
|
||||
stocks := []kandangStockRow{
|
||||
{LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 20, SourceWarehouseName: "Kandang K1", ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang},
|
||||
{LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 50, SourceWarehouseName: "Farm A", ProductID: 2, ProductName: "OVK B", OnHandQty: 60, LeftoverQty: 60, SourceType: sourceTypeFarmConsol},
|
||||
}
|
||||
|
||||
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
|
||||
|
||||
if len(groups) != 2 {
|
||||
t.Fatalf("expected 2 groups (one per source warehouse), got %d", len(groups))
|
||||
}
|
||||
if len(reportRows) != 2 {
|
||||
t.Fatalf("expected 2 report rows, got %d", len(reportRows))
|
||||
}
|
||||
|
||||
groupsBySource := make(map[uint]*transferGroup, 2)
|
||||
for i := range groups {
|
||||
groupsBySource[groups[i].SourceWarehouseID] = &groups[i]
|
||||
}
|
||||
|
||||
kandangGroup := groupsBySource[20]
|
||||
if kandangGroup == nil {
|
||||
t.Fatal("expected a group with SourceWarehouseID=20")
|
||||
}
|
||||
if kandangGroup.SourceType != sourceTypeKandang {
|
||||
t.Errorf("expected kandang_to_farm group, got %s", kandangGroup.SourceType)
|
||||
}
|
||||
if kandangGroup.FarmWarehouseID != 51 {
|
||||
t.Errorf("expected kandang group to target Farm B (51), got %d", kandangGroup.FarmWarehouseID)
|
||||
}
|
||||
|
||||
consolGroup := groupsBySource[50]
|
||||
if consolGroup == nil {
|
||||
t.Fatal("expected a consolidation group with SourceWarehouseID=50")
|
||||
}
|
||||
if consolGroup.SourceType != sourceTypeFarmConsol {
|
||||
t.Errorf("expected farm_consolidation group, got %s", consolGroup.SourceType)
|
||||
}
|
||||
if consolGroup.FarmWarehouseID != 51 {
|
||||
t.Errorf("expected consolidation group to target Farm B (51), got %d", consolGroup.FarmWarehouseID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanFarmConsolidationSkipsZeroLeftover(t *testing.T) {
|
||||
opts := &commandOptions{RunID: "test-run"}
|
||||
farmMap := map[uint]farmWarehouseInfo{
|
||||
10: {LocationID: 10, AllFarm: []farmWarehouseEntry{{ID: 50}, {ID: 51}}, ChosenID: 51, ChosenName: "Farm B", OtherFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}},
|
||||
}
|
||||
stocks := []kandangStockRow{
|
||||
{LocationID: 10, SourceWarehouseID: 50, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, AllocatedQty: 100, LeftoverQty: 0, SourceType: sourceTypeFarmConsol},
|
||||
}
|
||||
|
||||
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
|
||||
if len(groups) != 0 {
|
||||
t.Fatalf("expected no groups, got %d", len(groups))
|
||||
}
|
||||
if reportRows[0].Status != "skipped" {
|
||||
t.Errorf("expected skipped, got %s", reportRows[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
// ── executeApply ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestExecuteApplyTagsReasonAndNotesWithSourceType(t *testing.T) {
|
||||
date := time.Date(2026, 4, 24, 0, 0, 0, 0, time.UTC)
|
||||
opts := &commandOptions{RunID: "run-apply", TransferDate: date, ActorID: 99}
|
||||
|
||||
groups := []transferGroup{
|
||||
{
|
||||
SourceType: sourceTypeKandang,
|
||||
LocationName: "Jamali",
|
||||
SourceWarehouseID: 20,
|
||||
SourceWarehouseName: "K1",
|
||||
FarmWarehouseID: 50,
|
||||
FarmWarehouseName: "Farm A",
|
||||
Rows: []*transferReportRow{{ProductID: 1, ProductName: "Pakan A", Qty: 100}},
|
||||
},
|
||||
{
|
||||
SourceType: sourceTypeFarmConsol,
|
||||
LocationName: "Cijangkar",
|
||||
SourceWarehouseID: 60,
|
||||
SourceWarehouseName: "Farm X",
|
||||
FarmWarehouseID: 61,
|
||||
FarmWarehouseName: "Farm Y",
|
||||
Rows: []*transferReportRow{{ProductID: 2, ProductName: "OVK B", Qty: 40}},
|
||||
},
|
||||
}
|
||||
|
||||
executor := &fakeSystemTransferExecutor{}
|
||||
summary, err := executeApply(context.Background(), executor, opts, groups)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if summary.GroupsApplied != 2 {
|
||||
t.Errorf("expected 2 groups applied, got %d", summary.GroupsApplied)
|
||||
}
|
||||
if len(executor.createRequests) != 2 {
|
||||
t.Fatalf("expected 2 create requests, got %d", len(executor.createRequests))
|
||||
}
|
||||
|
||||
// Both requests must carry the run_id in the reason for rollback to work.
|
||||
for i, req := range executor.createRequests {
|
||||
if !strings.Contains(req.TransferReason, "run_id=run-apply") {
|
||||
t.Errorf("request %d reason missing run_id: %s", i, req.TransferReason)
|
||||
}
|
||||
}
|
||||
|
||||
// Notes for farm_consolidation should be distinct from kandang_to_farm.
|
||||
if !strings.Contains(executor.createRequests[0].StockLogNotes, "kandang to farm") {
|
||||
t.Errorf("kandang group notes should say 'kandang to farm', got: %s", executor.createRequests[0].StockLogNotes)
|
||||
}
|
||||
if !strings.Contains(executor.createRequests[1].StockLogNotes, "consolidation") {
|
||||
t.Errorf("consolidation group notes should say 'consolidation', got: %s", executor.createRequests[1].StockLogNotes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteApplyCreatesTransferWithCorrectProductsAndRecordsTransferID(t *testing.T) {
|
||||
date := time.Date(2026, 4, 24, 0, 0, 0, 0, time.UTC)
|
||||
opts := &commandOptions{RunID: "run-1", TransferDate: date, ActorID: 1}
|
||||
|
||||
row1 := &transferReportRow{ProductID: 1, ProductName: "Pakan A", Qty: 100}
|
||||
row2 := &transferReportRow{ProductID: 2, ProductName: "OVK B", Qty: 40}
|
||||
groups := []transferGroup{
|
||||
{
|
||||
SourceType: sourceTypeKandang, SourceWarehouseID: 20, FarmWarehouseID: 50,
|
||||
Rows: []*transferReportRow{row1, row2},
|
||||
},
|
||||
}
|
||||
|
||||
executor := &fakeSystemTransferExecutor{
|
||||
createResponses: []*entity.StockTransfer{{Id: 1001, MovementNumber: "PND-LTI-1001"}},
|
||||
}
|
||||
|
||||
_, err := executeApply(context.Background(), executor, opts, groups)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if row1.Status != "applied" || row2.Status != "applied" {
|
||||
t.Errorf("both rows should be applied: %s, %s", row1.Status, row2.Status)
|
||||
}
|
||||
if row1.TransferID == nil || *row1.TransferID != 1001 {
|
||||
t.Errorf("expected transfer id 1001, got %+v", row1.TransferID)
|
||||
}
|
||||
if row1.MovementNumber == nil || *row1.MovementNumber != "PND-LTI-1001" {
|
||||
t.Errorf("expected movement number PND-LTI-1001, got %+v", row1.MovementNumber)
|
||||
}
|
||||
|
||||
// Verify both products were included in the create request.
|
||||
if len(executor.createRequests[0].Products) != 2 {
|
||||
t.Errorf("expected 2 products in request, got %d", len(executor.createRequests[0].Products))
|
||||
}
|
||||
}
|
||||
|
||||
// ── executeRollback ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestExecuteRollbackDeletesDescendingAndMarksStatuses(t *testing.T) {
|
||||
executor := &fakeSystemTransferExecutor{
|
||||
deleteErrors: map[uint]error{200: errors.New("already consumed")},
|
||||
}
|
||||
rows := []rollbackDetailRow{
|
||||
{TransferID: 100, ProductName: "Pakan A"},
|
||||
{TransferID: 200, ProductName: "OVK B"},
|
||||
{TransferID: 100, ProductName: "Pakan C"},
|
||||
}
|
||||
|
||||
err := executeRollback(context.Background(), executor, rows, 99)
|
||||
if err == nil || !strings.Contains(err.Error(), "already consumed") {
|
||||
t.Fatalf("expected error for transfer 200, got: %v", err)
|
||||
}
|
||||
if executor.deletedTransferIDs[0] != 200 || executor.deletedTransferIDs[1] != 100 {
|
||||
t.Fatalf("expected delete order [200 100], got %v", executor.deletedTransferIDs)
|
||||
}
|
||||
if rows[0].Status != "rolled_back" || rows[2].Status != "rolled_back" {
|
||||
t.Errorf("transfer 100 rows must be rolled_back: %+v", rows)
|
||||
}
|
||||
if rows[1].Status != "failed" {
|
||||
t.Errorf("transfer 200 row must be failed: %+v", rows[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRollbackRequiresActorID(t *testing.T) {
|
||||
err := executeRollback(context.Background(), &fakeSystemTransferExecutor{}, []rollbackDetailRow{{TransferID: 1}}, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "actor-id") {
|
||||
t.Fatalf("expected actor-id error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── buildTransferReason / buildRunReasonMatcher ───────────────────────────────
|
||||
|
||||
func TestBuildTransferReasonMatchesRunReasonMatcher(t *testing.T) {
|
||||
runID := "product-farm-transfer-20260424T120000.000000000Z"
|
||||
date := time.Date(2026, 4, 24, 0, 0, 0, 0, time.UTC)
|
||||
reason := buildTransferReason(runID, "Jamali", "Gudang K1", "Gudang Farm Jamali", date)
|
||||
|
||||
needle := strings.TrimSuffix(buildRunReasonMatcher(runID), "%")
|
||||
if !strings.HasPrefix(reason, needle) {
|
||||
t.Errorf("reason %q does not match matcher prefix %q", reason, needle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTransferReasonSanitizesPipes(t *testing.T) {
|
||||
reason := buildTransferReason("run-1", "Lok|asi", "Gudang|K1", "Farm|WH", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
parts := strings.Split(reason, "|")
|
||||
// prefix + 5 key=value segments = 6 parts
|
||||
if len(parts) != 6 {
|
||||
t.Errorf("expected 6 pipe-separated segments, got %d: %v", len(parts), parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStockLogNotesContainsSourceTypeHint(t *testing.T) {
|
||||
date := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
kandangNote := buildStockLogNotes("r", "Loc", "Src", "Dst", date, sourceTypeKandang)
|
||||
consolNote := buildStockLogNotes("r", "Loc", "Src", "Dst", date, sourceTypeFarmConsol)
|
||||
|
||||
if !strings.Contains(kandangNote, "kandang to farm") {
|
||||
t.Errorf("kandang note should mention 'kandang to farm': %s", kandangNote)
|
||||
}
|
||||
if !strings.Contains(consolNote, "consolidation") {
|
||||
t.Errorf("consolidation note should mention 'consolidation': %s", consolNote)
|
||||
}
|
||||
}
|
||||
|
||||
// ── summarizeReport ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestSummarizeReportCountsAllStatuses(t *testing.T) {
|
||||
rows := []transferReportRow{
|
||||
{Status: "eligible"},
|
||||
{Status: "applied"},
|
||||
{Status: "applied"},
|
||||
{Status: "skipped"},
|
||||
{Status: "error"},
|
||||
{Status: "failed"},
|
||||
}
|
||||
groups := []transferGroup{{}, {}}
|
||||
s := summarizeReport(rows, groups, 1)
|
||||
|
||||
if s.RowsPlanned != 4 { // eligible + 2 applied + 1 failed
|
||||
t.Errorf("expected RowsPlanned=4, got %d", s.RowsPlanned)
|
||||
}
|
||||
if s.RowsApplied != 2 {
|
||||
t.Errorf("expected RowsApplied=2, got %d", s.RowsApplied)
|
||||
}
|
||||
if s.RowsSkipped != 1 {
|
||||
t.Errorf("expected RowsSkipped=1, got %d", s.RowsSkipped)
|
||||
}
|
||||
if s.RowsError != 1 {
|
||||
t.Errorf("expected RowsError=1, got %d", s.RowsError)
|
||||
}
|
||||
if s.RowsFailed != 1 {
|
||||
t.Errorf("expected RowsFailed=1, got %d", s.RowsFailed)
|
||||
}
|
||||
if s.GroupsPlanned != 2 || s.GroupsApplied != 1 {
|
||||
t.Errorf("unexpected group counts: %+v", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
outputTable = "table"
|
||||
outputJSON = "json"
|
||||
)
|
||||
|
||||
type options struct {
|
||||
Apply bool
|
||||
Output string
|
||||
DBSSLMode string
|
||||
AreaName string
|
||||
}
|
||||
|
||||
type duplicateGroup struct {
|
||||
WarehouseID uint `json:"warehouse_id"`
|
||||
WarehouseName string `json:"warehouse_name"`
|
||||
ProductID uint `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
AreaName string `json:"area_name"`
|
||||
LocationName string `json:"location_name"`
|
||||
ProjectFlockKandangID *uint `json:"project_flock_kandang_id,omitempty"`
|
||||
|
||||
SurvivorID uint `json:"survivor_id"`
|
||||
SurvivorQty float64 `json:"survivor_qty"`
|
||||
AbsorbedCount int `json:"absorbed_count"`
|
||||
TotalMergedQty float64 `json:"total_merged_qty"`
|
||||
AbsorbedIDs string `json:"absorbed_ids"`
|
||||
}
|
||||
|
||||
type consolidateSummary struct {
|
||||
TotalDuplicateGroups int `json:"total_duplicate_groups"`
|
||||
TotalProductWarehouses int64 `json:"total_product_warehouses"`
|
||||
UpdatedReferences map[string]int64 `json:"updated_references,omitempty"`
|
||||
DeletedProductWarehouses int64 `json:"deleted_product_warehouses,omitempty"`
|
||||
OverallStatus string `json:"overall_status"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
opts, err := parseFlags()
|
||||
if err != nil {
|
||||
log.Fatalf("invalid flags: %v", err)
|
||||
}
|
||||
|
||||
if opts.DBSSLMode != "" {
|
||||
config.DBSSLMode = opts.DBSSLMode
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
db := database.Connect(config.DBHost, config.DBName)
|
||||
|
||||
// Find duplicate groups
|
||||
groups, err := findDuplicateProductWarehouses(ctx, db, opts)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to find duplicates: %v", err)
|
||||
}
|
||||
|
||||
if len(groups) == 0 {
|
||||
fmt.Println("No duplicate product_warehouses found")
|
||||
return
|
||||
}
|
||||
|
||||
summary := summarizeGroups(groups)
|
||||
if !opts.Apply {
|
||||
renderConsolidation(opts.Output, groups, summary)
|
||||
return
|
||||
}
|
||||
|
||||
applied, err := applyConsolidation(ctx, db, groups)
|
||||
if err != nil {
|
||||
log.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
renderConsolidation(opts.Output, groups, applied)
|
||||
}
|
||||
|
||||
func parseFlags() (*options, error) {
|
||||
var opts options
|
||||
flag.BoolVar(&opts.Apply, "apply", false, "Apply consolidation (omit for dry-run)")
|
||||
flag.StringVar(&opts.Output, "output", outputTable, "Output format: table or json")
|
||||
flag.StringVar(&opts.DBSSLMode, "db-sslmode", "", "Database sslmode override")
|
||||
flag.StringVar(&opts.AreaName, "area-name", "", "Optional area filter")
|
||||
flag.Parse()
|
||||
|
||||
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
|
||||
opts.AreaName = strings.TrimSpace(opts.AreaName)
|
||||
opts.DBSSLMode = strings.TrimSpace(opts.DBSSLMode)
|
||||
|
||||
if opts.Output == "" {
|
||||
opts.Output = outputTable
|
||||
}
|
||||
if opts.Output != outputTable && opts.Output != outputJSON {
|
||||
return nil, fmt.Errorf("unsupported --output=%s", opts.Output)
|
||||
}
|
||||
|
||||
return &opts, nil
|
||||
}
|
||||
|
||||
func findDuplicateProductWarehouses(ctx context.Context, db *gorm.DB, opts *options) ([]duplicateGroup, error) {
|
||||
filters := ""
|
||||
args := []any{}
|
||||
if opts.AreaName != "" {
|
||||
filters = "WHERE a.name = ?"
|
||||
args = append(args, opts.AreaName)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
WITH duplicates AS (
|
||||
SELECT
|
||||
pw.warehouse_id,
|
||||
w.name AS warehouse_name,
|
||||
pw.product_id,
|
||||
p.name AS product_name,
|
||||
COALESCE(a.name, 'N/A') AS area_name,
|
||||
COALESCE(l.name, 'N/A') AS location_name,
|
||||
pw.project_flock_kandang_id,
|
||||
pw.id,
|
||||
pw.qty,
|
||||
MIN(pw.id) OVER (PARTITION BY pw.warehouse_id, pw.product_id) AS survivor_id,
|
||||
COUNT(*) OVER (PARTITION BY pw.warehouse_id, pw.product_id) AS duplicate_count,
|
||||
SUM(pw.qty) OVER (PARTITION BY pw.warehouse_id, pw.product_id) AS total_qty
|
||||
FROM product_warehouses pw
|
||||
JOIN warehouses w ON w.id = pw.warehouse_id
|
||||
JOIN products p ON p.id = pw.product_id
|
||||
LEFT JOIN locations l ON l.id = w.location_id
|
||||
LEFT JOIN areas a ON a.id = l.area_id
|
||||
%s
|
||||
)
|
||||
SELECT
|
||||
warehouse_id,
|
||||
warehouse_name,
|
||||
product_id,
|
||||
product_name,
|
||||
area_name,
|
||||
location_name,
|
||||
(SELECT project_flock_kandang_id FROM duplicates d2 WHERE d2.id = survivor_id LIMIT 1) AS project_flock_kandang_id,
|
||||
survivor_id,
|
||||
(SELECT qty FROM duplicates d2 WHERE d2.id = survivor_id LIMIT 1) AS survivor_qty,
|
||||
duplicate_count - 1 AS absorbed_count,
|
||||
total_qty AS total_merged_qty,
|
||||
STRING_AGG(id::text, ', ' ORDER BY id::text) FILTER (WHERE id <> survivor_id) AS absorbed_ids
|
||||
FROM duplicates
|
||||
WHERE duplicate_count > 1
|
||||
GROUP BY warehouse_id, warehouse_name, product_id, product_name, area_name, location_name, survivor_id, total_qty, duplicate_count
|
||||
ORDER BY area_name, location_name, warehouse_name, product_name
|
||||
`, filters)
|
||||
|
||||
rows := make([]duplicateGroup, 0)
|
||||
if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func applyConsolidation(ctx context.Context, db *gorm.DB, groups []duplicateGroup) (consolidateSummary, error) {
|
||||
summary := consolidateSummary{
|
||||
TotalDuplicateGroups: len(groups),
|
||||
UpdatedReferences: make(map[string]int64),
|
||||
OverallStatus: "PASS",
|
||||
}
|
||||
|
||||
fifoSvc := commonSvc.NewFifoStockV2Service(db, nil)
|
||||
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for _, group := range groups {
|
||||
absorbedIDs := []uint{}
|
||||
if group.AbsorbedIDs != "" {
|
||||
parts := strings.Split(group.AbsorbedIDs, ", ")
|
||||
for _, p := range parts {
|
||||
var id uint
|
||||
fmt.Sscanf(p, "%d", &id)
|
||||
absorbedIDs = append(absorbedIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
if len(absorbedIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Update all references to point to survivor
|
||||
refTables := []struct {
|
||||
table string
|
||||
column string
|
||||
}{
|
||||
{"stock_allocations", "product_warehouse_id"},
|
||||
{"stock_logs", "product_warehouse_id"},
|
||||
{"purchase_items", "product_warehouse_id"},
|
||||
{"recording_stocks", "product_warehouse_id"},
|
||||
{"recording_eggs", "product_warehouse_id"},
|
||||
{"recording_depletions", "product_warehouse_id"},
|
||||
{"recording_depletions", "source_product_warehouse_id"},
|
||||
{"marketing_delivery_products", "product_warehouse_id"},
|
||||
{"marketing_products", "product_warehouse_id"},
|
||||
{"stock_transfer_details", "source_product_warehouse_id"},
|
||||
{"stock_transfer_details", "dest_product_warehouse_id"},
|
||||
{"adjustment_stocks", "product_warehouse_id"},
|
||||
{"laying_transfer_sources", "product_warehouse_id"},
|
||||
{"laying_transfer_targets", "product_warehouse_id"},
|
||||
{"laying_transfers", "source_product_warehouse_id"},
|
||||
{"project_chickin_details", "product_warehouse_id"},
|
||||
{"project_chickins", "product_warehouse_id"},
|
||||
{"project_flock_populations", "product_warehouse_id"},
|
||||
{"fifo_stock_v2_operation_log", "product_warehouse_id"},
|
||||
{"fifo_stock_v2_reflow_checkpoints", "product_warehouse_id"},
|
||||
{"fifo_stock_v2_shadow_allocations", "product_warehouse_id"},
|
||||
}
|
||||
|
||||
for _, ref := range refTables {
|
||||
res := tx.WithContext(ctx).
|
||||
Table(ref.table).
|
||||
Where(fmt.Sprintf("%s IN ?", ref.column), absorbedIDs).
|
||||
Update(ref.column, group.SurvivorID)
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("update %s.%s: %w", ref.table, ref.column, res.Error)
|
||||
}
|
||||
if res.RowsAffected > 0 {
|
||||
summary.UpdatedReferences[ref.table+"."+ref.column] += res.RowsAffected
|
||||
}
|
||||
}
|
||||
|
||||
// Update survivor qty to merged total
|
||||
res := tx.WithContext(ctx).
|
||||
Table("product_warehouses").
|
||||
Where("id = ?", group.SurvivorID).
|
||||
Update("qty", group.TotalMergedQty)
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("update survivor qty: %w", res.Error)
|
||||
}
|
||||
|
||||
// Clear project_flock_kandang_id for LOKASI warehouse survivors
|
||||
if err := tx.WithContext(ctx).Exec(`
|
||||
UPDATE product_warehouses pw
|
||||
SET project_flock_kandang_id = NULL
|
||||
FROM warehouses w
|
||||
WHERE pw.warehouse_id = w.id
|
||||
AND pw.id = ?
|
||||
AND UPPER(w.type) = 'LOKASI'
|
||||
AND pw.project_flock_kandang_id IS NOT NULL
|
||||
`, group.SurvivorID).Error; err != nil {
|
||||
return fmt.Errorf("clear project_flock_kandang_id survivor %d: %w", group.SurvivorID, err)
|
||||
}
|
||||
|
||||
// Delete absorbed product_warehouses
|
||||
res = tx.WithContext(ctx).
|
||||
Table("product_warehouses").
|
||||
Where("id IN ?", absorbedIDs).
|
||||
Delete(nil)
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("delete absorbed: %w", res.Error)
|
||||
}
|
||||
summary.DeletedProductWarehouses += res.RowsAffected
|
||||
|
||||
// Recalculate stock_logs for survivor
|
||||
if err := recalculateStockLogs(ctx, tx, []uint{group.SurvivorID}); err != nil {
|
||||
return fmt.Errorf("recalculate stock_logs: %w", err)
|
||||
}
|
||||
|
||||
// Reflow and recalculate FIFO
|
||||
if err := reflowProductWarehouse(ctx, fifoSvc, tx, group.SurvivorID); err != nil {
|
||||
return fmt.Errorf("reflow product_warehouse %d: %w", group.SurvivorID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
summary.OverallStatus = "FAIL"
|
||||
return summary, err
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func recalculateStockLogs(ctx context.Context, tx *gorm.DB, productWarehouseIDs []uint) error {
|
||||
if len(productWarehouseIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
query := `
|
||||
WITH recalculated AS (
|
||||
SELECT
|
||||
id,
|
||||
SUM(COALESCE(increase, 0) - COALESCE(decrease, 0))
|
||||
OVER (PARTITION BY product_warehouse_id ORDER BY created_at ASC, id ASC) AS running_stock
|
||||
FROM stock_logs
|
||||
WHERE product_warehouse_id IN ?
|
||||
)
|
||||
UPDATE stock_logs sl
|
||||
SET stock = recalculated.running_stock
|
||||
FROM recalculated
|
||||
WHERE sl.id = recalculated.id
|
||||
`
|
||||
return tx.WithContext(ctx).Exec(query, productWarehouseIDs).Error
|
||||
}
|
||||
|
||||
func reflowProductWarehouse(ctx context.Context, fifoSvc commonSvc.FifoStockV2Service, tx *gorm.DB, productWarehouseID uint) error {
|
||||
type row struct {
|
||||
FlagGroupCode string `gorm:"column:flag_group_code"`
|
||||
}
|
||||
|
||||
var selected row
|
||||
err := tx.WithContext(ctx).
|
||||
Table("fifo_stock_v2_route_rules rr").
|
||||
Select("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 = ?", "STOCKABLE").
|
||||
Where("rr.function_code = ?", "PURCHASE_IN").
|
||||
Where("rr.source_table = ?", "purchase_items").
|
||||
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
|
||||
)`, productWarehouseID).
|
||||
Order("rr.id ASC").
|
||||
Limit(1).
|
||||
Take(&selected).Error
|
||||
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return err
|
||||
}
|
||||
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil
|
||||
}
|
||||
|
||||
flagGroupCode := strings.TrimSpace(selected.FlagGroupCode)
|
||||
|
||||
if _, err := fifoSvc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
|
||||
FlagGroupCode: flagGroupCode,
|
||||
ProductWarehouseID: productWarehouseID,
|
||||
Tx: tx,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := fifoSvc.Recalculate(ctx, commonSvc.FifoStockV2RecalculateRequest{
|
||||
ProductWarehouseIDs: []uint{productWarehouseID},
|
||||
FlagGroupCodes: []string{flagGroupCode},
|
||||
FixDrift: true,
|
||||
Tx: tx,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func summarizeGroups(groups []duplicateGroup) consolidateSummary {
|
||||
var totalQty int64
|
||||
for _, g := range groups {
|
||||
totalQty += int64(g.AbsorbedCount)
|
||||
}
|
||||
|
||||
return consolidateSummary{
|
||||
TotalDuplicateGroups: len(groups),
|
||||
TotalProductWarehouses: totalQty,
|
||||
OverallStatus: "PASS",
|
||||
}
|
||||
}
|
||||
|
||||
func renderConsolidation(mode string, groups []duplicateGroup, summary consolidateSummary) {
|
||||
if mode == outputJSON {
|
||||
payload := map[string]any{
|
||||
"groups": groups,
|
||||
"summary": summary,
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(payload)
|
||||
return
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "AREA\tLOCATION\tWAREHOUSE\tPRODUCT\tPFK_ID\tSURVIVOR_ID\tSURVIVOR_QTY\tABSORBED_COUNT\tTOTAL_MERGED_QTY\tABSORBED_IDS")
|
||||
for _, g := range groups {
|
||||
pfkID := "-"
|
||||
if g.ProjectFlockKandangID != nil {
|
||||
pfkID = fmt.Sprintf("%d", *g.ProjectFlockKandangID)
|
||||
}
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
"%s\t%s\t%s\t%s\t%s\t%d\t%.3f\t%d\t%.3f\t%s\n",
|
||||
g.AreaName,
|
||||
g.LocationName,
|
||||
g.WarehouseName,
|
||||
g.ProductName,
|
||||
pfkID,
|
||||
g.SurvivorID,
|
||||
g.SurvivorQty,
|
||||
g.AbsorbedCount,
|
||||
g.TotalMergedQty,
|
||||
g.AbsorbedIDs,
|
||||
)
|
||||
}
|
||||
_ = w.Flush()
|
||||
|
||||
fmt.Printf("\n=== SUMMARY ===\n")
|
||||
fmt.Printf("Duplicate groups found: %d\n", summary.TotalDuplicateGroups)
|
||||
fmt.Printf("Product warehouses to delete: %d\n", summary.TotalProductWarehouses)
|
||||
fmt.Printf("Overall status: %s\n", summary.OverallStatus)
|
||||
|
||||
if len(summary.UpdatedReferences) > 0 {
|
||||
fmt.Println("\nUpdated references:")
|
||||
keys := make([]string, 0, len(summary.UpdatedReferences))
|
||||
for k := range summary.UpdatedReferences {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
fmt.Printf(" %s=%d\n", k, summary.UpdatedReferences[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,407 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type adjustmentRow struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
||||
ProductID uint `gorm:"column:product_id"`
|
||||
FunctionCode string `gorm:"column:function_code"`
|
||||
TotalQty float64 `gorm:"column:total_qty"`
|
||||
UsageQty float64 `gorm:"column:usage_qty"`
|
||||
PendingQty float64 `gorm:"column:pending_qty"`
|
||||
StockLogIncrease float64 `gorm:"column:stock_log_increase"`
|
||||
StockLogDecrease float64 `gorm:"column:stock_log_decrease"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
type routeResolution struct {
|
||||
FlagGroupCode string `gorm:"column:flag_group_code"`
|
||||
Lane string `gorm:"column:lane"`
|
||||
FunctionCode string `gorm:"column:function_code"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
idsRaw string
|
||||
apply bool
|
||||
)
|
||||
|
||||
flag.StringVar(&idsRaw, "ids", "", "Comma-separated adjustment IDs (required), example: 1,2")
|
||||
flag.BoolVar(&apply, "apply", false, "Apply delete. If false, run as dry-run")
|
||||
flag.Parse()
|
||||
|
||||
ids, err := parseIDs(idsRaw)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid --ids: %v", err)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
log.Fatal("--ids is required")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
db := database.Connect(config.DBHost, config.DBName)
|
||||
|
||||
fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil)
|
||||
|
||||
adjustments, err := loadAdjustments(ctx, db, ids)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load adjustments: %v", err)
|
||||
}
|
||||
if len(adjustments) == 0 {
|
||||
log.Fatal("no adjustments found for provided IDs")
|
||||
}
|
||||
|
||||
sort.Slice(adjustments, func(i, j int) bool {
|
||||
return adjustments[i].ID < adjustments[j].ID
|
||||
})
|
||||
|
||||
fmt.Printf("Mode: %s\n", modeLabel(apply))
|
||||
fmt.Printf("Adjustments loaded: %d\n\n", len(adjustments))
|
||||
|
||||
success := 0
|
||||
failed := 0
|
||||
skipped := 0
|
||||
|
||||
for _, adj := range adjustments {
|
||||
if strings.TrimSpace(adj.FunctionCode) == "" {
|
||||
fmt.Printf("SKIP adj=%d reason=function_code empty\n", adj.ID)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
route, err := resolveRouteByFunctionCode(ctx, db, adj.ProductID, strings.ToUpper(strings.TrimSpace(adj.FunctionCode)))
|
||||
if err != nil {
|
||||
fmt.Printf("FAIL adj=%d error=resolve route: %v\n", adj.ID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
switch route.Lane {
|
||||
case "USABLE":
|
||||
desiredQty := adj.UsageQty + adj.PendingQty
|
||||
if desiredQty <= 0 && adj.StockLogDecrease > 0 {
|
||||
desiredQty = adj.StockLogDecrease
|
||||
}
|
||||
|
||||
activeAlloc, err := countActiveUsableAllocations(ctx, db, fifo.UsableKeyAdjustmentOut.String(), adj.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("FAIL adj=%d error=count usable allocations: %v\n", adj.ID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"PLAN adj=%d lane=USABLE function=%s usage=%.3f pending=%.3f active_alloc=%d action=reflow_to_zero+delete\n",
|
||||
adj.ID,
|
||||
route.FunctionCode,
|
||||
adj.UsageQty,
|
||||
adj.PendingQty,
|
||||
activeAlloc,
|
||||
)
|
||||
|
||||
if !apply {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
reflowReq := commonSvc.FifoStockV2ReflowRequest{
|
||||
FlagGroupCode: route.FlagGroupCode,
|
||||
ProductWarehouseID: adj.ProductWarehouseID,
|
||||
AsOf: &adj.CreatedAt,
|
||||
IdempotencyKey: fmt.Sprintf("delete-adjustment-usable-%d-%d", adj.ID, time.Now().UnixNano()),
|
||||
Tx: tx,
|
||||
}
|
||||
if _, err := fifoStockV2Svc.Reflow(ctx, reflowReq); err != nil {
|
||||
return fmt.Errorf("reflow usable to zero: %w", err)
|
||||
}
|
||||
|
||||
if err := hardDeleteUsableAllocations(ctx, tx, fifo.UsableKeyAdjustmentOut.String(), adj.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := hardDeleteAdjustmentStockLogs(ctx, tx, adj.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := hardDeleteAdjustment(ctx, tx, adj.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("DONE adj=%d deleted\n", adj.ID)
|
||||
success++
|
||||
|
||||
case "STOCKABLE":
|
||||
removeQty := adj.TotalQty
|
||||
if removeQty <= 0 && adj.StockLogIncrease > 0 {
|
||||
removeQty = adj.StockLogIncrease
|
||||
}
|
||||
|
||||
activeAlloc, err := countActiveStockableAllocations(ctx, db, fifo.StockableKeyAdjustmentIn.String(), adj.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("FAIL adj=%d error=count stockable allocations: %v\n", adj.ID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
if activeAlloc > 0 {
|
||||
fmt.Printf(
|
||||
"FAIL adj=%d reason=stockable still allocated active_alloc=%d action=delete blocked\n",
|
||||
adj.ID,
|
||||
activeAlloc,
|
||||
)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"PLAN adj=%d lane=STOCKABLE function=%s total=%.3f remove_qty=%.3f action=reflow_to_zero+delete\n",
|
||||
adj.ID,
|
||||
route.FunctionCode,
|
||||
adj.TotalQty,
|
||||
removeQty,
|
||||
)
|
||||
|
||||
if !apply {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.WithContext(ctx).
|
||||
Table("adjustment_stocks").
|
||||
Where("id = ?", adj.ID).
|
||||
Updates(map[string]any{
|
||||
"total_qty": 0,
|
||||
"total_used": 0,
|
||||
}).Error; err != nil {
|
||||
return fmt.Errorf("set stockable qty to zero: %w", err)
|
||||
}
|
||||
|
||||
reflowReq := commonSvc.FifoStockV2ReflowRequest{
|
||||
FlagGroupCode: route.FlagGroupCode,
|
||||
ProductWarehouseID: adj.ProductWarehouseID,
|
||||
AsOf: &adj.CreatedAt,
|
||||
IdempotencyKey: fmt.Sprintf("delete-adjustment-stockable-%d-%d", adj.ID, time.Now().UnixNano()),
|
||||
Tx: tx,
|
||||
}
|
||||
if _, err := fifoStockV2Svc.Reflow(ctx, reflowReq); err != nil {
|
||||
return fmt.Errorf("reflow stockable to zero: %w", err)
|
||||
}
|
||||
|
||||
if err := hardDeleteStockableAllocations(ctx, tx, fifo.StockableKeyAdjustmentIn.String(), adj.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := hardDeleteAdjustmentStockLogs(ctx, tx, adj.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := hardDeleteAdjustment(ctx, tx, adj.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("DONE adj=%d deleted\n", adj.ID)
|
||||
success++
|
||||
|
||||
default:
|
||||
fmt.Printf("SKIP adj=%d reason=unsupported lane=%s\n", adj.ID, route.Lane)
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("Summary: success=%d failed=%d skipped=%d\n", success, failed, skipped)
|
||||
if failed > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func modeLabel(apply bool) string {
|
||||
if apply {
|
||||
return "APPLY"
|
||||
}
|
||||
return "DRY-RUN"
|
||||
}
|
||||
|
||||
func parseIDs(raw string) ([]uint, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]uint, 0, len(parts))
|
||||
seen := map[uint]struct{}{}
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
v, err := strconv.ParseUint(part, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid id %q", part)
|
||||
}
|
||||
if v == 0 {
|
||||
return nil, fmt.Errorf("id must be > 0: %q", part)
|
||||
}
|
||||
id := uint(v)
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadAdjustments(ctx context.Context, db *gorm.DB, ids []uint) ([]adjustmentRow, error) {
|
||||
var rows []adjustmentRow
|
||||
err := db.WithContext(ctx).
|
||||
Table("adjustment_stocks a").
|
||||
Select(`
|
||||
a.id,
|
||||
a.product_warehouse_id,
|
||||
pw.product_id,
|
||||
a.function_code,
|
||||
COALESCE(a.total_qty, 0) AS total_qty,
|
||||
COALESCE(a.usage_qty, 0) AS usage_qty,
|
||||
COALESCE(a.pending_qty, 0) AS pending_qty,
|
||||
COALESCE((
|
||||
SELECT sl.increase
|
||||
FROM stock_logs sl
|
||||
WHERE sl.loggable_type = 'ADJUSTMENT'
|
||||
AND sl.loggable_id = a.id
|
||||
ORDER BY sl.id DESC
|
||||
LIMIT 1
|
||||
), 0) AS stock_log_increase,
|
||||
COALESCE((
|
||||
SELECT sl.decrease
|
||||
FROM stock_logs sl
|
||||
WHERE sl.loggable_type = 'ADJUSTMENT'
|
||||
AND sl.loggable_id = a.id
|
||||
ORDER BY sl.id DESC
|
||||
LIMIT 1
|
||||
), 0) AS stock_log_decrease,
|
||||
a.created_at
|
||||
`).
|
||||
Joins("JOIN product_warehouses pw ON pw.id = a.product_warehouse_id").
|
||||
Where("a.id IN ?", ids).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func resolveRouteByFunctionCode(ctx context.Context, db *gorm.DB, productID uint, functionCode string) (*routeResolution, error) {
|
||||
var rows []routeResolution
|
||||
err := db.WithContext(ctx).
|
||||
Table("fifo_stock_v2_route_rules rr").
|
||||
Select("rr.flag_group_code, rr.lane, rr.function_code").
|
||||
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
|
||||
Where("rr.is_active = TRUE").
|
||||
Where("rr.function_code = ?", functionCode).
|
||||
Where(`
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM flags f
|
||||
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
|
||||
WHERE f.flagable_type = ?
|
||||
AND f.flagable_id = ?
|
||||
AND fm.flag_group_code = rr.flag_group_code
|
||||
)
|
||||
`, entity.FlagableTypeProduct, productID).
|
||||
Order("CASE WHEN rr.source_table = 'adjustment_stocks' THEN 0 ELSE 1 END ASC").
|
||||
Order("rr.id ASC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("no route found for product_id=%d function_code=%s", productID, functionCode)
|
||||
}
|
||||
|
||||
selected := rows[0]
|
||||
for _, row := range rows {
|
||||
if row.Lane != selected.Lane {
|
||||
return nil, fmt.Errorf("ambiguous lane for product_id=%d function_code=%s", productID, functionCode)
|
||||
}
|
||||
}
|
||||
selected.FunctionCode = functionCode
|
||||
return &selected, nil
|
||||
}
|
||||
|
||||
func countActiveUsableAllocations(ctx context.Context, db *gorm.DB, usableType string, usableID uint) (int64, error) {
|
||||
var count int64
|
||||
err := db.WithContext(ctx).
|
||||
Table("stock_allocations").
|
||||
Where("usable_type = ? AND usable_id = ?", usableType, usableID).
|
||||
Where("status = ?", entity.StockAllocationStatusActive).
|
||||
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func countActiveStockableAllocations(ctx context.Context, db *gorm.DB, stockableType string, stockableID uint) (int64, error) {
|
||||
var count int64
|
||||
err := db.WithContext(ctx).
|
||||
Table("stock_allocations").
|
||||
Where("stockable_type = ? AND stockable_id = ?", stockableType, stockableID).
|
||||
Where("status = ?", entity.StockAllocationStatusActive).
|
||||
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func hardDeleteUsableAllocations(ctx context.Context, tx *gorm.DB, usableType string, usableID uint) error {
|
||||
return tx.WithContext(ctx).
|
||||
Exec("DELETE FROM stock_allocations WHERE usable_type = ? AND usable_id = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationPurposeConsume).
|
||||
Error
|
||||
}
|
||||
|
||||
func hardDeleteStockableAllocations(ctx context.Context, tx *gorm.DB, stockableType string, stockableID uint) error {
|
||||
return tx.WithContext(ctx).
|
||||
Exec("DELETE FROM stock_allocations WHERE stockable_type = ? AND stockable_id = ? AND allocation_purpose = ?", stockableType, stockableID, entity.StockAllocationPurposeConsume).
|
||||
Error
|
||||
}
|
||||
|
||||
func hardDeleteAdjustmentStockLogs(ctx context.Context, tx *gorm.DB, adjustmentID uint) error {
|
||||
return tx.WithContext(ctx).
|
||||
Exec("DELETE FROM stock_logs WHERE loggable_type = ? AND loggable_id = ?", "ADJUSTMENT", adjustmentID).
|
||||
Error
|
||||
}
|
||||
|
||||
func hardDeleteAdjustment(ctx context.Context, tx *gorm.DB, adjustmentID uint) error {
|
||||
return tx.WithContext(ctx).
|
||||
Exec("DELETE FROM adjustment_stocks WHERE id = ?", adjustmentID).
|
||||
Error
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
outputModeTable = "table"
|
||||
outputModeJSON = "json"
|
||||
|
||||
reportUsage = "usage"
|
||||
reportWarehouses = "warehouses"
|
||||
)
|
||||
|
||||
type options struct {
|
||||
Output string
|
||||
Report string
|
||||
AreaName string
|
||||
KandangLocationName string
|
||||
WrongWarehouseName string
|
||||
CorrectWarehouseName string
|
||||
UsableType string
|
||||
DBSSLMode string
|
||||
}
|
||||
|
||||
type usageRow struct {
|
||||
UsableType string `gorm:"column:usable_type" json:"usable_type"`
|
||||
UsableID uint `gorm:"column:usable_id" json:"usable_id"`
|
||||
AreaName string `gorm:"column:area_name" json:"area_name"`
|
||||
LokasiName string `gorm:"column:lokasi_name" json:"lokasi_name"`
|
||||
KandangName string `gorm:"column:kandang_name" json:"kandang_name"`
|
||||
WrongWarehouseID uint `gorm:"column:wrong_warehouse_id" json:"wrong_warehouse_id"`
|
||||
WrongWarehouseName string `gorm:"column:wrong_warehouse_name" json:"wrong_warehouse_name"`
|
||||
CorrectWarehouseID uint `gorm:"column:correct_warehouse_id" json:"correct_warehouse_id"`
|
||||
CorrectWarehouseName string `gorm:"column:correct_warehouse_name" json:"correct_warehouse_name"`
|
||||
ProductNames string `gorm:"column:product_names" json:"product_names"`
|
||||
SourcePurchaseNumbers string `gorm:"column:source_purchase_numbers" json:"source_purchase_numbers"`
|
||||
SourcePurchaseItemIDs string `gorm:"column:source_purchase_item_ids" json:"source_purchase_item_ids"`
|
||||
QtyFromWrongStock float64 `gorm:"column:qty_from_wrong_stock" json:"qty_from_wrong_stock"`
|
||||
RecordingID *uint `gorm:"column:recording_id" json:"recording_id,omitempty"`
|
||||
RecordingDate *string `gorm:"column:recording_date" json:"recording_date,omitempty"`
|
||||
SoNumber *string `gorm:"column:so_number" json:"so_number,omitempty"`
|
||||
SoDate *string `gorm:"column:so_date" json:"so_date,omitempty"`
|
||||
}
|
||||
|
||||
type warehouseMismatchRow struct {
|
||||
AreaName string `gorm:"column:area_name" json:"area_name"`
|
||||
WrongLocationName string `gorm:"column:wrong_location_name" json:"wrong_location_name"`
|
||||
KandangLocationName string `gorm:"column:kandang_location_name" json:"kandang_location_name"`
|
||||
KandangID uint `gorm:"column:kandang_id" json:"kandang_id"`
|
||||
KandangName string `gorm:"column:kandang_name" json:"kandang_name"`
|
||||
WrongWarehouseID uint `gorm:"column:wrong_warehouse_id" json:"wrong_warehouse_id"`
|
||||
WrongWarehouseName string `gorm:"column:wrong_warehouse_name" json:"wrong_warehouse_name"`
|
||||
WrongWarehouseType string `gorm:"column:wrong_warehouse_type" json:"wrong_warehouse_type"`
|
||||
CorrectWarehouseID uint `gorm:"column:correct_warehouse_id" json:"correct_warehouse_id"`
|
||||
CorrectWarehouseName string `gorm:"column:correct_warehouse_name" json:"correct_warehouse_name"`
|
||||
}
|
||||
|
||||
type summary struct {
|
||||
Rows int `json:"rows"`
|
||||
TotalQty float64 `json:"total_qty,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
opts, err := parseFlags()
|
||||
if err != nil {
|
||||
log.Fatalf("invalid flags: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if opts.DBSSLMode != "" {
|
||||
config.DBSSLMode = opts.DBSSLMode
|
||||
}
|
||||
db := database.Connect(config.DBHost, config.DBName)
|
||||
|
||||
switch opts.Report {
|
||||
case reportUsage:
|
||||
rows, err := loadUsageRows(ctx, db, opts)
|
||||
if err != nil {
|
||||
log.Fatalf("failed loading usage rows: %v", err)
|
||||
}
|
||||
renderUsageReport(opts.Output, rows)
|
||||
case reportWarehouses:
|
||||
rows, err := loadWarehouseMismatchRows(ctx, db, opts)
|
||||
if err != nil {
|
||||
log.Fatalf("failed loading warehouse mismatch rows: %v", err)
|
||||
}
|
||||
renderWarehouseReport(opts.Output, rows)
|
||||
default:
|
||||
log.Fatalf("unsupported --report=%s", opts.Report)
|
||||
}
|
||||
}
|
||||
|
||||
func parseFlags() (*options, error) {
|
||||
var opts options
|
||||
flag.StringVar(&opts.Output, "output", outputModeTable, "Output format: table or json")
|
||||
flag.StringVar(&opts.Report, "report", reportUsage, "Report type: usage or warehouses")
|
||||
flag.StringVar(&opts.AreaName, "area-name", "", "Optional exact area name filter")
|
||||
flag.StringVar(&opts.KandangLocationName, "kandang-location-name", "", "Optional exact canonical kandang location filter")
|
||||
flag.StringVar(&opts.WrongWarehouseName, "wrong-warehouse-name", "", "Optional exact wrong warehouse name filter")
|
||||
flag.StringVar(&opts.CorrectWarehouseName, "correct-warehouse-name", "", "Optional exact correct warehouse name filter")
|
||||
flag.StringVar(&opts.UsableType, "usable-type", "", "Optional usage type filter: RECORDING_STOCK or MARKETING_DELIVERY")
|
||||
flag.StringVar(&opts.DBSSLMode, "db-sslmode", "", "Optional database sslmode override, for example: require")
|
||||
flag.Parse()
|
||||
|
||||
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
|
||||
opts.Report = strings.ToLower(strings.TrimSpace(opts.Report))
|
||||
opts.AreaName = strings.TrimSpace(opts.AreaName)
|
||||
opts.KandangLocationName = strings.TrimSpace(opts.KandangLocationName)
|
||||
opts.WrongWarehouseName = strings.TrimSpace(opts.WrongWarehouseName)
|
||||
opts.CorrectWarehouseName = strings.TrimSpace(opts.CorrectWarehouseName)
|
||||
opts.UsableType = strings.ToUpper(strings.TrimSpace(opts.UsableType))
|
||||
opts.DBSSLMode = strings.TrimSpace(opts.DBSSLMode)
|
||||
|
||||
if opts.Output == "" {
|
||||
opts.Output = outputModeTable
|
||||
}
|
||||
if opts.Output != outputModeTable && opts.Output != outputModeJSON {
|
||||
return nil, fmt.Errorf("unsupported --output=%s", opts.Output)
|
||||
}
|
||||
if opts.Report == "" {
|
||||
opts.Report = reportUsage
|
||||
}
|
||||
if opts.Report != reportUsage && opts.Report != reportWarehouses {
|
||||
return nil, fmt.Errorf("unsupported --report=%s", opts.Report)
|
||||
}
|
||||
if opts.UsableType != "" && opts.UsableType != "RECORDING_STOCK" && opts.UsableType != "MARKETING_DELIVERY" {
|
||||
return nil, fmt.Errorf("unsupported --usable-type=%s", opts.UsableType)
|
||||
}
|
||||
|
||||
return &opts, nil
|
||||
}
|
||||
|
||||
func loadUsageRows(ctx context.Context, db *gorm.DB, opts *options) ([]usageRow, error) {
|
||||
warehouseFilters, warehouseArgs := buildWarehouseFilters(opts)
|
||||
|
||||
usageFilters := make([]string, 0, 1)
|
||||
usageArgs := make([]any, 0, 1)
|
||||
if opts.UsableType != "" {
|
||||
usageFilters = append(usageFilters, "sa.usable_type = ?")
|
||||
usageArgs = append(usageArgs, opts.UsableType)
|
||||
}
|
||||
|
||||
args := append([]any{}, warehouseArgs...)
|
||||
args = append(args, usageArgs...)
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
WITH wrong_warehouses AS (
|
||||
SELECT
|
||||
w.id AS wrong_warehouse_id,
|
||||
w.name AS wrong_warehouse_name,
|
||||
k.id AS kandang_id,
|
||||
k.name AS kandang_name,
|
||||
a.name AS area_name,
|
||||
kl.name AS kandang_location_name,
|
||||
correct_w.id AS correct_warehouse_id,
|
||||
correct_w.name AS correct_warehouse_name
|
||||
FROM warehouses w
|
||||
JOIN kandangs k
|
||||
ON k.id = w.kandang_id
|
||||
AND k.deleted_at IS NULL
|
||||
JOIN locations kl
|
||||
ON kl.id = k.location_id
|
||||
JOIN areas a
|
||||
ON a.id = kl.area_id
|
||||
JOIN LATERAL (
|
||||
SELECT w2.id, w2.name
|
||||
FROM warehouses w2
|
||||
WHERE w2.kandang_id = w.kandang_id
|
||||
AND w2.location_id = k.location_id
|
||||
AND w2.deleted_at IS NULL
|
||||
AND w2.id <> w.id
|
||||
ORDER BY w2.id ASC
|
||||
LIMIT 1
|
||||
) AS correct_w ON TRUE
|
||||
WHERE w.deleted_at IS NULL
|
||||
AND w.kandang_id IS NOT NULL
|
||||
AND w.location_id IS DISTINCT FROM k.location_id
|
||||
%s
|
||||
),
|
||||
wrong_allocs AS (
|
||||
SELECT
|
||||
sa.usable_type,
|
||||
sa.usable_id,
|
||||
sa.qty,
|
||||
pi.id AS purchase_item_id,
|
||||
COALESCE(p.po_number, p.pr_number) AS purchase_number,
|
||||
pr.name AS product_name,
|
||||
ww.area_name,
|
||||
ww.kandang_location_name,
|
||||
ww.kandang_name,
|
||||
ww.wrong_warehouse_id,
|
||||
ww.wrong_warehouse_name,
|
||||
ww.correct_warehouse_id,
|
||||
ww.correct_warehouse_name
|
||||
FROM stock_allocations sa
|
||||
JOIN purchase_items pi
|
||||
ON pi.id = sa.stockable_id
|
||||
JOIN purchases p
|
||||
ON p.id = pi.purchase_id
|
||||
AND p.deleted_at IS NULL
|
||||
JOIN products pr
|
||||
ON pr.id = pi.product_id
|
||||
JOIN wrong_warehouses ww
|
||||
ON ww.wrong_warehouse_id = pi.warehouse_id
|
||||
WHERE sa.stockable_type = 'PURCHASE_ITEMS'
|
||||
AND sa.status = 'ACTIVE'
|
||||
AND sa.allocation_purpose = 'CONSUME'
|
||||
AND sa.deleted_at IS NULL
|
||||
%s
|
||||
)
|
||||
SELECT
|
||||
wa.usable_type,
|
||||
wa.usable_id,
|
||||
wa.area_name,
|
||||
wa.kandang_location_name AS lokasi_name,
|
||||
wa.kandang_name,
|
||||
wa.wrong_warehouse_id,
|
||||
wa.wrong_warehouse_name,
|
||||
wa.correct_warehouse_id,
|
||||
wa.correct_warehouse_name,
|
||||
STRING_AGG(DISTINCT wa.product_name, ' | ') AS product_names,
|
||||
STRING_AGG(DISTINCT wa.purchase_number, ', ') AS source_purchase_numbers,
|
||||
STRING_AGG(DISTINCT wa.purchase_item_id::text, ', ') AS source_purchase_item_ids,
|
||||
SUM(wa.qty) AS qty_from_wrong_stock,
|
||||
rs.recording_id,
|
||||
TO_CHAR(r.record_datetime::date, 'YYYY-MM-DD') AS recording_date,
|
||||
m.so_number,
|
||||
TO_CHAR(m.so_date::date, 'YYYY-MM-DD') AS so_date
|
||||
FROM wrong_allocs wa
|
||||
LEFT JOIN recording_stocks rs
|
||||
ON wa.usable_type = 'RECORDING_STOCK'
|
||||
AND rs.id = wa.usable_id
|
||||
LEFT JOIN recordings r
|
||||
ON r.id = rs.recording_id
|
||||
LEFT JOIN marketing_delivery_products mdp
|
||||
ON wa.usable_type = 'MARKETING_DELIVERY'
|
||||
AND mdp.id = wa.usable_id
|
||||
LEFT JOIN marketing_products mp
|
||||
ON mp.id = mdp.marketing_product_id
|
||||
LEFT JOIN marketings m
|
||||
ON m.id = mp.marketing_id
|
||||
GROUP BY
|
||||
wa.usable_type,
|
||||
wa.usable_id,
|
||||
wa.area_name,
|
||||
wa.kandang_location_name,
|
||||
wa.kandang_name,
|
||||
wa.wrong_warehouse_id,
|
||||
wa.wrong_warehouse_name,
|
||||
wa.correct_warehouse_id,
|
||||
wa.correct_warehouse_name,
|
||||
rs.recording_id,
|
||||
r.record_datetime,
|
||||
m.so_number,
|
||||
m.so_date
|
||||
ORDER BY
|
||||
wa.area_name ASC,
|
||||
wa.kandang_location_name ASC,
|
||||
wa.wrong_warehouse_name ASC,
|
||||
wa.usable_type ASC,
|
||||
wa.usable_id ASC
|
||||
`, andClause(warehouseFilters), andClause(usageFilters))
|
||||
|
||||
rows := make([]usageRow, 0)
|
||||
if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func loadWarehouseMismatchRows(ctx context.Context, db *gorm.DB, opts *options) ([]warehouseMismatchRow, error) {
|
||||
warehouseFilters, args := buildWarehouseFilters(opts)
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
a.name AS area_name,
|
||||
wl.name AS wrong_location_name,
|
||||
kl.name AS kandang_location_name,
|
||||
k.id AS kandang_id,
|
||||
k.name AS kandang_name,
|
||||
w.id AS wrong_warehouse_id,
|
||||
w.name AS wrong_warehouse_name,
|
||||
w.type AS wrong_warehouse_type,
|
||||
correct_w.id AS correct_warehouse_id,
|
||||
correct_w.name AS correct_warehouse_name
|
||||
FROM warehouses w
|
||||
JOIN kandangs k
|
||||
ON k.id = w.kandang_id
|
||||
AND k.deleted_at IS NULL
|
||||
JOIN locations kl
|
||||
ON kl.id = k.location_id
|
||||
JOIN areas a
|
||||
ON a.id = kl.area_id
|
||||
LEFT JOIN locations wl
|
||||
ON wl.id = w.location_id
|
||||
JOIN LATERAL (
|
||||
SELECT w2.id, w2.name
|
||||
FROM warehouses w2
|
||||
WHERE w2.kandang_id = w.kandang_id
|
||||
AND w2.location_id = k.location_id
|
||||
AND w2.deleted_at IS NULL
|
||||
AND w2.id <> w.id
|
||||
ORDER BY w2.id ASC
|
||||
LIMIT 1
|
||||
) AS correct_w ON TRUE
|
||||
WHERE w.deleted_at IS NULL
|
||||
AND w.kandang_id IS NOT NULL
|
||||
AND w.location_id IS DISTINCT FROM k.location_id
|
||||
%s
|
||||
ORDER BY a.name ASC, kl.name ASC, k.name ASC, w.id ASC
|
||||
`, andClause(warehouseFilters))
|
||||
|
||||
rows := make([]warehouseMismatchRow, 0)
|
||||
if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func buildWarehouseFilters(opts *options) ([]string, []any) {
|
||||
filters := make([]string, 0, 4)
|
||||
args := make([]any, 0, 4)
|
||||
|
||||
if opts == nil {
|
||||
return filters, args
|
||||
}
|
||||
if opts.AreaName != "" {
|
||||
filters = append(filters, "a.name = ?")
|
||||
args = append(args, opts.AreaName)
|
||||
}
|
||||
if opts.KandangLocationName != "" {
|
||||
filters = append(filters, "kl.name = ?")
|
||||
args = append(args, opts.KandangLocationName)
|
||||
}
|
||||
if opts.WrongWarehouseName != "" {
|
||||
filters = append(filters, "w.name = ?")
|
||||
args = append(args, opts.WrongWarehouseName)
|
||||
}
|
||||
if opts.CorrectWarehouseName != "" {
|
||||
filters = append(filters, "correct_w.name = ?")
|
||||
args = append(args, opts.CorrectWarehouseName)
|
||||
}
|
||||
|
||||
return filters, args
|
||||
}
|
||||
|
||||
func andClause(filters []string) string {
|
||||
if len(filters) == 0 {
|
||||
return ""
|
||||
}
|
||||
return " AND " + strings.Join(filters, " AND ")
|
||||
}
|
||||
|
||||
func renderUsageReport(mode string, rows []usageRow) {
|
||||
if mode == outputModeJSON {
|
||||
payload := map[string]any{
|
||||
"report": reportUsage,
|
||||
"rows": rows,
|
||||
"summary": summarizeUsage(rows),
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(payload)
|
||||
return
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "USABLE_TYPE\tUSABLE_ID\tAREA\tLOKASI\tKANDANG\tWRONG_WAREHOUSE\tCORRECT_WAREHOUSE\tPRODUCTS\tQTY_FROM_WRONG_STOCK\tRECORDING_ID\tRECORDING_DATE\tSO_NUMBER\tSO_DATE\tSOURCE_PURCHASES\tSOURCE_PURCHASE_ITEM_IDS")
|
||||
for _, row := range rows {
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
"%s\t%d\t%s\t%s\t%s\t%s\t%s\t%s\t%.3f\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
row.UsableType,
|
||||
row.UsableID,
|
||||
row.AreaName,
|
||||
row.LokasiName,
|
||||
row.KandangName,
|
||||
row.WrongWarehouseName,
|
||||
row.CorrectWarehouseName,
|
||||
row.ProductNames,
|
||||
row.QtyFromWrongStock,
|
||||
displayOptionalUint(row.RecordingID),
|
||||
displayOptionalString(row.RecordingDate),
|
||||
displayOptionalString(row.SoNumber),
|
||||
displayOptionalString(row.SoDate),
|
||||
row.SourcePurchaseNumbers,
|
||||
row.SourcePurchaseItemIDs,
|
||||
)
|
||||
}
|
||||
_ = w.Flush()
|
||||
|
||||
s := summarizeUsage(rows)
|
||||
fmt.Printf("\nSummary: rows=%d total_qty=%.3f\n", s.Rows, s.TotalQty)
|
||||
}
|
||||
|
||||
func renderWarehouseReport(mode string, rows []warehouseMismatchRow) {
|
||||
if mode == outputModeJSON {
|
||||
payload := map[string]any{
|
||||
"report": reportWarehouses,
|
||||
"rows": rows,
|
||||
"summary": summary{Rows: len(rows)},
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(payload)
|
||||
return
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "AREA\tKANDANG_LOCATION\tKANDANG_ID\tKANDANG\tWRONG_LOCATION\tWRONG_WAREHOUSE_ID\tWRONG_WAREHOUSE\tWRONG_WAREHOUSE_TYPE\tCORRECT_WAREHOUSE_ID\tCORRECT_WAREHOUSE")
|
||||
for _, row := range rows {
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
"%s\t%s\t%d\t%s\t%s\t%d\t%s\t%s\t%d\t%s\n",
|
||||
row.AreaName,
|
||||
row.KandangLocationName,
|
||||
row.KandangID,
|
||||
row.KandangName,
|
||||
row.WrongLocationName,
|
||||
row.WrongWarehouseID,
|
||||
row.WrongWarehouseName,
|
||||
row.WrongWarehouseType,
|
||||
row.CorrectWarehouseID,
|
||||
row.CorrectWarehouseName,
|
||||
)
|
||||
}
|
||||
_ = w.Flush()
|
||||
|
||||
fmt.Printf("\nSummary: rows=%d\n", len(rows))
|
||||
}
|
||||
|
||||
func summarizeUsage(rows []usageRow) summary {
|
||||
out := summary{Rows: len(rows)}
|
||||
for _, row := range rows {
|
||||
out.TotalQty += row.QtyFromWrongStock
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func displayOptionalUint(value *uint) string {
|
||||
if value == nil || *value == 0 {
|
||||
return "-"
|
||||
}
|
||||
return fmt.Sprintf("%d", *value)
|
||||
}
|
||||
|
||||
func displayOptionalString(value *string) string {
|
||||
if value == nil || strings.TrimSpace(*value) == "" {
|
||||
return "-"
|
||||
}
|
||||
return *value
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
// Command: fix-stock-log-drift
|
||||
//
|
||||
// Tujuan:
|
||||
// Sinkronkan `stock_logs.stock` (running ledger) dengan `product_warehouses.qty`
|
||||
// (FIFO truth) ketika keduanya drift.
|
||||
//
|
||||
// Drift biasanya terjadi karena bug di Recording-Edit (sebelum fix) yang
|
||||
// hanya menulis -decrease tanpa +increase saat in-place update. Akibatnya
|
||||
// running ledger di stock_logs tertinggal dari qty riil di product_warehouses.
|
||||
//
|
||||
// Cara kerja:
|
||||
// 1. Ambil product_warehouses.qty (sebagai truth)
|
||||
// 2. Ambil last_stock_log.stock
|
||||
// 3. Cari recording yang berkontribusi pada drift (untuk notes)
|
||||
// 4. Hitung drift = qty - last_stock_log.stock
|
||||
// 5. Jika drift != 0, insert 1 stock_log corrective:
|
||||
// - drift > 0 → increase = drift
|
||||
// - drift < 0 → decrease = |drift|
|
||||
// stock akhir akan sama dengan qty (truth).
|
||||
// Notes otomatis berisi daftar recording IDs yang berkontribusi pada drift.
|
||||
//
|
||||
// Mode:
|
||||
// --apply=false (default) → dry-run, hanya tampilkan rencana
|
||||
// --apply=true → eksekusi insert
|
||||
//
|
||||
// Contoh:
|
||||
// go run cmd/fix-stock-log-drift/main.go --product-warehouse-id=1261
|
||||
// go run cmd/fix-stock-log-drift/main.go --product-warehouse-id=1261 --apply
|
||||
// go run cmd/fix-stock-log-drift/main.go --product-warehouse-id=1261 --apply \
|
||||
// --actor-id=1 --notes="Koreksi manual drift"
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
qtyEpsilon = 1e-6
|
||||
defaultActorID uint = 1
|
||||
maxSuspectInNotes = 30
|
||||
)
|
||||
|
||||
type driftRow struct {
|
||||
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
||||
ProductID uint `gorm:"column:product_id"`
|
||||
ProductName string `gorm:"column:product_name"`
|
||||
WarehouseName string `gorm:"column:warehouse_name"`
|
||||
CurrentQty float64 `gorm:"column:current_qty"`
|
||||
LastLogStock float64 `gorm:"column:last_log_stock"`
|
||||
LastLogID uint `gorm:"column:last_log_id"`
|
||||
FifoExpected float64 `gorm:"column:fifo_expected"`
|
||||
}
|
||||
|
||||
type suspectRecording struct {
|
||||
RecordingID uint `gorm:"column:recording_id"`
|
||||
FifoUsage float64 `gorm:"column:fifo_usage"`
|
||||
NetLogConsumed float64 `gorm:"column:net_log_consumed"`
|
||||
Phantom float64 `gorm:"column:phantom"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
productWarehouseID uint
|
||||
apply bool
|
||||
actorID uint
|
||||
notes string
|
||||
)
|
||||
|
||||
flag.UintVar(&productWarehouseID, "product-warehouse-id", 0, "Target product_warehouse_id (required)")
|
||||
flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run")
|
||||
flag.UintVar(&actorID, "actor-id", defaultActorID, "User id yang akan dicatat sebagai created_by stock_log corrective")
|
||||
flag.StringVar(¬es, "notes", "", "Custom notes untuk stock_log corrective (opsional, default=auto-generate dari data recording)")
|
||||
flag.Parse()
|
||||
|
||||
notes = strings.TrimSpace(notes)
|
||||
if err := validateFlags(productWarehouseID, actorID); err != nil {
|
||||
log.Fatalf("invalid flags: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
db := database.Connect(config.DBHost, config.DBName)
|
||||
|
||||
row, err := loadDriftRow(ctx, db, productWarehouseID)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load product warehouse: %v", err)
|
||||
}
|
||||
|
||||
suspects, err := loadSuspectRecordings(ctx, db, productWarehouseID)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load suspect recordings: %v", err)
|
||||
}
|
||||
|
||||
drift := row.CurrentQty - row.LastLogStock
|
||||
|
||||
// Print info header
|
||||
fmt.Printf("Mode: %s\n", modeLabel(apply))
|
||||
fmt.Printf("Target product_warehouse_id: %d\n", productWarehouseID)
|
||||
fmt.Printf("Product: %q\n", row.ProductName)
|
||||
fmt.Printf("Warehouse: %q\n", row.WarehouseName)
|
||||
fmt.Printf("Current qty (product_warehouses): %.3f\n", row.CurrentQty)
|
||||
fmt.Printf("FIFO expected (sum total_qty - total_used): %.3f\n", row.FifoExpected)
|
||||
fmt.Printf("Last stock_log: id=%d stock=%.3f\n", row.LastLogID, row.LastLogStock)
|
||||
fmt.Printf("Drift (qty - last_log_stock): %+.3f\n", drift)
|
||||
|
||||
if !nearlyEqual(row.CurrentQty, row.FifoExpected) {
|
||||
fmt.Println()
|
||||
fmt.Println("⚠️ WARNING: product_warehouses.qty TIDAK match dengan FIFO expected.")
|
||||
fmt.Println(" Disarankan jalankan dulu cmd/reflow-quantity-product-warehouse-from-stock-allocation")
|
||||
fmt.Println(" sebelum fix stock_log drift, agar truth source-nya sudah benar.")
|
||||
}
|
||||
|
||||
// Print suspect recordings
|
||||
fmt.Println()
|
||||
if len(suspects) > 0 {
|
||||
totalPhantom := 0.0
|
||||
for _, s := range suspects {
|
||||
totalPhantom += s.Phantom
|
||||
}
|
||||
fmt.Printf("Suspect recordings (drift contributors): %d\n", len(suspects))
|
||||
for _, s := range suspects {
|
||||
fmt.Printf(
|
||||
" #%-6d fifo=%-10.3f net_log=%-10.3f phantom=%+.3f\n",
|
||||
s.RecordingID, s.FifoUsage, s.NetLogConsumed, s.Phantom,
|
||||
)
|
||||
}
|
||||
fmt.Printf("Total suspect phantom: %+.3f\n", totalPhantom)
|
||||
} else {
|
||||
fmt.Println("Suspect recordings: none found (drift origin unknown)")
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if nearlyEqual(drift, 0) {
|
||||
fmt.Println("✓ Tidak ada drift. Stock_log sudah sinkron dengan product_warehouses.qty.")
|
||||
fmt.Println("Summary: planned=0 inserted=0 skipped=1 failed=0")
|
||||
return
|
||||
}
|
||||
|
||||
// Build notes if not provided
|
||||
if notes == "" {
|
||||
notes = buildDefaultNotes(row, drift, suspects)
|
||||
}
|
||||
|
||||
plan := buildCorrectiveLog(row, drift, actorID, notes)
|
||||
|
||||
fmt.Printf(
|
||||
"PLAN insert stock_log:\n pw=%d increase=%.3f decrease=%.3f stock=%.3f\n notes=%q\n",
|
||||
plan.ProductWarehouseId,
|
||||
plan.Increase,
|
||||
plan.Decrease,
|
||||
plan.Stock,
|
||||
plan.Notes,
|
||||
)
|
||||
|
||||
if !apply {
|
||||
fmt.Println()
|
||||
fmt.Println("Summary: planned=1 inserted=0 skipped=0 failed=0 (dry-run)")
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// Re-check di dalam transaction agar aman dari race condition
|
||||
current, err := loadDriftRow(ctx, tx, productWarehouseID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("re-read product_warehouse_id=%d: %w", productWarehouseID, err)
|
||||
}
|
||||
currentDrift := current.CurrentQty - current.LastLogStock
|
||||
if nearlyEqual(currentDrift, 0) {
|
||||
fmt.Println("Drift hilang sebelum insert (kemungkinan ada operasi paralel). Skip.")
|
||||
return nil
|
||||
}
|
||||
fresh := buildCorrectiveLog(current, currentDrift, actorID, notes)
|
||||
if err := tx.Create(&fresh).Error; err != nil {
|
||||
return fmt.Errorf("insert corrective stock_log for pw=%d: %w", productWarehouseID, err)
|
||||
}
|
||||
fmt.Printf(
|
||||
"DONE inserted stock_log id=%d pw=%d increase=%.3f decrease=%.3f stock=%.3f\n",
|
||||
fresh.Id,
|
||||
fresh.ProductWarehouseId,
|
||||
fresh.Increase,
|
||||
fresh.Decrease,
|
||||
fresh.Stock,
|
||||
)
|
||||
return nil
|
||||
}); err != nil {
|
||||
fmt.Println()
|
||||
fmt.Println("Summary: planned=1 inserted=0 skipped=0 failed=1")
|
||||
log.Printf("error: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Summary: planned=1 inserted=1 skipped=0 failed=0")
|
||||
}
|
||||
|
||||
func validateFlags(productWarehouseID uint, actorID uint) error {
|
||||
if productWarehouseID == 0 {
|
||||
return errors.New("--product-warehouse-id is required (must be > 0)")
|
||||
}
|
||||
if actorID == 0 {
|
||||
return errors.New("--actor-id must be > 0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadDriftRow(ctx context.Context, db *gorm.DB, productWarehouseID uint) (driftRow, error) {
|
||||
row := driftRow{}
|
||||
|
||||
lastLogSub := db.WithContext(ctx).
|
||||
Table("stock_logs").
|
||||
Select("id, product_warehouse_id, stock").
|
||||
Where("product_warehouse_id = ?", productWarehouseID).
|
||||
Order("id DESC").
|
||||
Limit(1)
|
||||
|
||||
fifoSub := db.WithContext(ctx).
|
||||
Table("purchase_items").
|
||||
Select(`
|
||||
product_warehouse_id,
|
||||
COALESCE(SUM(COALESCE(total_qty, 0) - COALESCE(total_used, 0)), 0) AS fifo_expected
|
||||
`).
|
||||
Where("product_warehouse_id = ?", productWarehouseID).
|
||||
Group("product_warehouse_id")
|
||||
|
||||
if err := db.WithContext(ctx).
|
||||
Table("product_warehouses pw").
|
||||
Select(`
|
||||
pw.id AS product_warehouse_id,
|
||||
pw.product_id AS product_id,
|
||||
COALESCE(p.name, '') AS product_name,
|
||||
COALESCE(w.name, '') AS warehouse_name,
|
||||
COALESCE(pw.qty, 0) AS current_qty,
|
||||
COALESCE(last_log.stock, 0) AS last_log_stock,
|
||||
COALESCE(last_log.id, 0) AS last_log_id,
|
||||
COALESCE(fifo.fifo_expected, 0) AS fifo_expected
|
||||
`).
|
||||
Joins("LEFT JOIN products p ON p.id = pw.product_id").
|
||||
Joins("LEFT JOIN warehouses w ON w.id = pw.warehouse_id").
|
||||
Joins("LEFT JOIN (?) last_log ON last_log.product_warehouse_id = pw.id", lastLogSub).
|
||||
Joins("LEFT JOIN (?) fifo ON fifo.product_warehouse_id = pw.id", fifoSub).
|
||||
Where("pw.id = ?", productWarehouseID).
|
||||
Scan(&row).Error; err != nil {
|
||||
return row, err
|
||||
}
|
||||
|
||||
if row.ProductWarehouseID == 0 {
|
||||
return row, fmt.Errorf("product_warehouse_id=%d not found", productWarehouseID)
|
||||
}
|
||||
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// loadSuspectRecordings mencari recording yang net stock_log consumed-nya
|
||||
// melebihi FIFO usage_qty — ini adalah recording yang berkontribusi pada drift
|
||||
// akibat bug Recording-Edit yang hanya menulis -decrease tanpa +increase.
|
||||
func loadSuspectRecordings(ctx context.Context, db *gorm.DB, productWarehouseID uint) ([]suspectRecording, error) {
|
||||
rows := make([]suspectRecording, 0)
|
||||
if err := db.WithContext(ctx).
|
||||
Table("recording_stocks rs").
|
||||
Select(`
|
||||
rs.recording_id,
|
||||
COALESCE(rs.usage_qty, 0) AS fifo_usage,
|
||||
(
|
||||
COALESCE(SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END), 0) -
|
||||
COALESCE(SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END), 0)
|
||||
) AS net_log_consumed,
|
||||
(
|
||||
COALESCE(SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END), 0) -
|
||||
COALESCE(SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END), 0) -
|
||||
COALESCE(rs.usage_qty, 0)
|
||||
) AS phantom
|
||||
`).
|
||||
Joins(`
|
||||
JOIN stock_logs sl ON sl.loggable_type = ?
|
||||
AND sl.loggable_id = rs.recording_id
|
||||
AND sl.product_warehouse_id = rs.product_warehouse_id
|
||||
`, string(utils.StockLogTypeRecording)).
|
||||
Where("rs.product_warehouse_id = ?", productWarehouseID).
|
||||
Group("rs.recording_id, rs.usage_qty").
|
||||
Having(`
|
||||
ABS(
|
||||
COALESCE(SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END), 0) -
|
||||
COALESCE(SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END), 0) -
|
||||
COALESCE(rs.usage_qty, 0)
|
||||
) > ?
|
||||
`, qtyEpsilon).
|
||||
Order("rs.recording_id ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// buildDefaultNotes membuat notes otomatis yang berisi penjelasan drift
|
||||
// beserta daftar recording_id yang berkontribusi + phantom amount masing-masing.
|
||||
func buildDefaultNotes(row driftRow, drift float64, suspects []suspectRecording) string {
|
||||
sign := "+"
|
||||
if drift < 0 {
|
||||
sign = ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf(
|
||||
"Koreksi drift stock_log akibat bug Recording-Edit (in-place update menulis -decrease tanpa +increase). PW=%d (%s) drift=%s%.3f.",
|
||||
row.ProductWarehouseID,
|
||||
row.WarehouseName,
|
||||
sign,
|
||||
drift,
|
||||
))
|
||||
|
||||
if len(suspects) == 0 {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
sb.WriteString(" Recordings affected:")
|
||||
|
||||
limit := len(suspects)
|
||||
truncated := 0
|
||||
if limit > maxSuspectInNotes {
|
||||
truncated = limit - maxSuspectInNotes
|
||||
limit = maxSuspectInNotes
|
||||
}
|
||||
|
||||
for i := 0; i < limit; i++ {
|
||||
s := suspects[i]
|
||||
phantomSign := "+"
|
||||
if s.Phantom < 0 {
|
||||
phantomSign = ""
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" #%d(%s%.0f)", s.RecordingID, phantomSign, s.Phantom))
|
||||
if i < limit-1 || truncated > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
}
|
||||
|
||||
if truncated > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" ... (+%d more)", truncated))
|
||||
}
|
||||
|
||||
sb.WriteString(".")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func buildCorrectiveLog(row driftRow, drift float64, actorID uint, notes string) entity.StockLog {
|
||||
corrective := entity.StockLog{
|
||||
ProductWarehouseId: row.ProductWarehouseID,
|
||||
CreatedBy: actorID,
|
||||
LoggableType: string(utils.StockLogTypeAdjustment),
|
||||
LoggableId: 0,
|
||||
Stock: row.CurrentQty,
|
||||
Notes: notes,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if drift > 0 {
|
||||
corrective.Increase = drift
|
||||
corrective.Decrease = 0
|
||||
} else {
|
||||
corrective.Increase = 0
|
||||
corrective.Decrease = -drift
|
||||
}
|
||||
return corrective
|
||||
}
|
||||
|
||||
func modeLabel(apply bool) string {
|
||||
if apply {
|
||||
return "APPLY"
|
||||
}
|
||||
return "DRY-RUN"
|
||||
}
|
||||
|
||||
func nearlyEqual(a, b float64) bool {
|
||||
return math.Abs(a-b) <= qtyEpsilon
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/cache"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/readapi"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/route"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func main() {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
readapi.PrimeBuildConfig()
|
||||
cache.SetRedis(redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"}))
|
||||
app := fiber.New(config.FiberConfig())
|
||||
app.Get("/healthz", func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"status": "ok", "service": "api", "version": config.Version})
|
||||
})
|
||||
app.Get("/readyz", func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"status": "ok", "db": "up", "redis": "up"})
|
||||
})
|
||||
route.Routes(app, nil)
|
||||
|
||||
artifacts, err := readapi.BuildArtifactsFromApp(app)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
files := map[string][]byte{
|
||||
filepath.Join(root, "docs", "openapi", "read-api.json"): artifacts.OpenAPIJSON,
|
||||
filepath.Join(root, "docs", "openapi", "read-api.yaml"): artifacts.OpenAPIYAML,
|
||||
filepath.Join(root, "docs", "postman", "read-api.collection.json"): artifacts.PostmanCollectionJSON,
|
||||
filepath.Join(root, "docs", "postman", "read-api.environment.json"): artifacts.PostmanEnvironmentJSON,
|
||||
}
|
||||
|
||||
for path, body := range files {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.WriteFile(path, body, 0o644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("wrote %s\n", path)
|
||||
}
|
||||
}
|
||||
|
||||
func findRepoRoot() (string, error) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
current := wd
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(current, "go.mod")); err == nil {
|
||||
return current, nil
|
||||
}
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
return "", fmt.Errorf("go.mod not found from %s", wd)
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type importOptions struct {
|
||||
FilePath string
|
||||
Sheet string
|
||||
Apply bool
|
||||
}
|
||||
|
||||
type headerIndexes struct {
|
||||
AdjustmentID int
|
||||
Weight int
|
||||
}
|
||||
|
||||
type adjustmentPriceImportRow struct {
|
||||
RowNumber int
|
||||
AdjustmentID uint
|
||||
Weight float64
|
||||
}
|
||||
|
||||
type validationIssue struct {
|
||||
Row int
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (i validationIssue) Error() string {
|
||||
if i.Row > 0 {
|
||||
return fmt.Sprintf("row=%d field=%s message=%s", i.Row, i.Field, i.Message)
|
||||
}
|
||||
return fmt.Sprintf("field=%s message=%s", i.Field, i.Message)
|
||||
}
|
||||
|
||||
type adjustmentResolver interface {
|
||||
ResolveExistingAdjustmentIDs(ctx context.Context, adjustmentIDs []uint) (map[uint]struct{}, error)
|
||||
}
|
||||
|
||||
type dbAdjustmentResolver struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type adjustmentPriceStore interface {
|
||||
UpdatePrice(ctx context.Context, adjustmentID uint, price float64) (bool, error)
|
||||
}
|
||||
|
||||
type txRunner interface {
|
||||
InTx(ctx context.Context, fn func(store adjustmentPriceStore) error) error
|
||||
}
|
||||
|
||||
type dbTxRunner struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type dbAdjustmentPriceStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type applyRowResult struct {
|
||||
RowNumber int
|
||||
AdjustmentID uint
|
||||
Price float64
|
||||
Changed bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
var opts importOptions
|
||||
flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file (required)")
|
||||
flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)")
|
||||
flag.BoolVar(&opts.Apply, "apply", false, "Apply changes. If false, run as dry-run")
|
||||
flag.Parse()
|
||||
|
||||
opts.FilePath = strings.TrimSpace(opts.FilePath)
|
||||
opts.Sheet = strings.TrimSpace(opts.Sheet)
|
||||
|
||||
if opts.FilePath == "" {
|
||||
log.Fatal("--file is required")
|
||||
}
|
||||
|
||||
sheetName, rows, parseIssues, err := parseAdjustmentPriceFile(opts.FilePath, opts.Sheet)
|
||||
if err != nil {
|
||||
log.Fatalf("failed reading excel: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
db := database.Connect(config.DBHost, config.DBName)
|
||||
resolver := dbAdjustmentResolver{db: db}
|
||||
|
||||
existingAdjustmentIDs, err := resolver.ResolveExistingAdjustmentIDs(ctx, collectAdjustmentIDs(rows))
|
||||
if err != nil {
|
||||
log.Fatalf("failed checking adjustment_id against adjustment_stocks: %v", err)
|
||||
}
|
||||
|
||||
processableRows, skippedRows := splitRowsByExistingIDs(rows, existingAdjustmentIDs)
|
||||
issues := append([]validationIssue{}, parseIssues...)
|
||||
sortValidationIssues(issues)
|
||||
|
||||
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
|
||||
fmt.Printf("File: %s\n", opts.FilePath)
|
||||
fmt.Printf("Sheet: %s\n", sheetName)
|
||||
fmt.Printf("Rows parsed: %d\n", len(rows))
|
||||
fmt.Printf("Rows invalid: %d\n", len(issues))
|
||||
fmt.Printf("Rows processable: %d\n", len(processableRows))
|
||||
fmt.Printf("Rows skipped_missing: %d\n", len(skippedRows))
|
||||
fmt.Println()
|
||||
|
||||
if len(processableRows) > 0 {
|
||||
printPlanRows(processableRows)
|
||||
}
|
||||
if len(skippedRows) > 0 {
|
||||
printSkippedRows(skippedRows)
|
||||
}
|
||||
if len(processableRows) > 0 || len(skippedRows) > 0 {
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if len(issues) > 0 {
|
||||
fmt.Println("Validation errors:")
|
||||
for _, issue := range issues {
|
||||
fmt.Printf("ERROR %s\n", issue.Error())
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Printf(
|
||||
"Summary: planned=%d processable=%d skipped_missing=%d applied=0 failed=%d\n",
|
||||
len(rows),
|
||||
len(processableRows),
|
||||
len(skippedRows),
|
||||
len(issues),
|
||||
)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if !opts.Apply {
|
||||
fmt.Printf(
|
||||
"Summary: planned=%d processable=%d skipped_missing=%d applied=0 failed=0\n",
|
||||
len(rows),
|
||||
len(processableRows),
|
||||
len(skippedRows),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
results, err := applyIfRequested(ctx, true, dbTxRunner{db: db}, processableRows)
|
||||
if err != nil {
|
||||
log.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
fmt.Printf(
|
||||
"DONE row=%d adjustment_id=%d price=%.3f status=%s\n",
|
||||
result.RowNumber,
|
||||
result.AdjustmentID,
|
||||
result.Price,
|
||||
applyStatus(result.Changed),
|
||||
)
|
||||
}
|
||||
|
||||
appliedCount := countChangedRows(results)
|
||||
if len(results) > 0 {
|
||||
fmt.Println()
|
||||
}
|
||||
fmt.Printf(
|
||||
"Summary: planned=%d processable=%d skipped_missing=%d applied=%d failed=0\n",
|
||||
len(rows),
|
||||
len(processableRows),
|
||||
len(skippedRows),
|
||||
appliedCount,
|
||||
)
|
||||
}
|
||||
|
||||
func parseAdjustmentPriceFile(
|
||||
filePath string,
|
||||
requestedSheet string,
|
||||
) (string, []adjustmentPriceImportRow, []validationIssue, error) {
|
||||
workbook, err := excelize.OpenFile(filePath)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = workbook.Close()
|
||||
}()
|
||||
|
||||
sheetName, err := resolveSheetName(workbook, requestedSheet)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
|
||||
allRows, err := workbook.GetRows(sheetName, excelize.Options{RawCellValue: true})
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
if len(allRows) == 0 {
|
||||
return sheetName, nil, []validationIssue{{Field: "header", Message: "sheet is empty"}}, nil
|
||||
}
|
||||
|
||||
indexes, headerIssues := parseHeaderIndexes(allRows[0])
|
||||
if len(headerIssues) > 0 {
|
||||
return sheetName, nil, headerIssues, nil
|
||||
}
|
||||
|
||||
rowsByAdjustmentID := make(map[uint]adjustmentPriceImportRow)
|
||||
issues := make([]validationIssue, 0)
|
||||
|
||||
for idx := 1; idx < len(allRows); idx++ {
|
||||
rowNumber := idx + 1
|
||||
rawRow := allRows[idx]
|
||||
|
||||
if isRowEmpty(rawRow) {
|
||||
continue
|
||||
}
|
||||
|
||||
parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes)
|
||||
if len(rowIssues) > 0 {
|
||||
issues = append(issues, rowIssues...)
|
||||
continue
|
||||
}
|
||||
|
||||
rowsByAdjustmentID[parsed.AdjustmentID] = *parsed
|
||||
}
|
||||
|
||||
rows := make([]adjustmentPriceImportRow, 0, len(rowsByAdjustmentID))
|
||||
for _, row := range rowsByAdjustmentID {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
return rows[i].RowNumber < rows[j].RowNumber
|
||||
})
|
||||
|
||||
if len(rows) == 0 && len(issues) == 0 {
|
||||
issues = append(issues, validationIssue{Field: "rows", Message: "no data rows found"})
|
||||
}
|
||||
|
||||
return sheetName, rows, issues, nil
|
||||
}
|
||||
|
||||
func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) {
|
||||
if workbook == nil {
|
||||
return "", fmt.Errorf("workbook is nil")
|
||||
}
|
||||
|
||||
sheets := workbook.GetSheetList()
|
||||
if len(sheets) == 0 {
|
||||
return "", fmt.Errorf("workbook has no sheets")
|
||||
}
|
||||
|
||||
if requestedSheet == "" {
|
||||
return sheets[0], nil
|
||||
}
|
||||
|
||||
for _, sheet := range sheets {
|
||||
if strings.EqualFold(strings.TrimSpace(sheet), strings.TrimSpace(requestedSheet)) {
|
||||
return sheet, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("sheet %q not found", requestedSheet)
|
||||
}
|
||||
|
||||
func parseHeaderIndexes(headerRow []string) (headerIndexes, []validationIssue) {
|
||||
indexes := headerIndexes{AdjustmentID: -1, Weight: -1}
|
||||
issues := make([]validationIssue, 0)
|
||||
|
||||
for idx, raw := range headerRow {
|
||||
header := normalizeHeader(raw)
|
||||
if header == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch header {
|
||||
case "adjustment_id":
|
||||
if indexes.AdjustmentID >= 0 {
|
||||
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header adjustment_id"})
|
||||
}
|
||||
indexes.AdjustmentID = idx
|
||||
case "weight":
|
||||
if indexes.Weight >= 0 {
|
||||
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header weight"})
|
||||
}
|
||||
indexes.Weight = idx
|
||||
}
|
||||
}
|
||||
|
||||
if indexes.AdjustmentID < 0 {
|
||||
issues = append(issues, validationIssue{Field: "adjustment_id", Message: "required header is missing"})
|
||||
}
|
||||
if indexes.Weight < 0 {
|
||||
issues = append(issues, validationIssue{Field: "weight", Message: "required header is missing"})
|
||||
}
|
||||
|
||||
return indexes, issues
|
||||
}
|
||||
|
||||
func parseDataRow(
|
||||
rawRow []string,
|
||||
rowNumber int,
|
||||
indexes headerIndexes,
|
||||
) (*adjustmentPriceImportRow, []validationIssue) {
|
||||
issues := make([]validationIssue, 0)
|
||||
|
||||
adjustmentIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.AdjustmentID))
|
||||
adjustmentID, err := parsePositiveUint(adjustmentIDRaw)
|
||||
if err != nil {
|
||||
issues = append(issues, validationIssue{Row: rowNumber, Field: "adjustment_id", Message: err.Error()})
|
||||
}
|
||||
|
||||
weightRaw := strings.TrimSpace(cellValue(rawRow, indexes.Weight))
|
||||
weight, err := parseNonNegativeFloat(weightRaw)
|
||||
if err != nil {
|
||||
issues = append(issues, validationIssue{Row: rowNumber, Field: "weight", Message: err.Error()})
|
||||
}
|
||||
|
||||
if len(issues) > 0 {
|
||||
return nil, issues
|
||||
}
|
||||
|
||||
return &adjustmentPriceImportRow{
|
||||
RowNumber: rowNumber,
|
||||
AdjustmentID: adjustmentID,
|
||||
Weight: weight,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parsePositiveUint(raw string) (uint, error) {
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("is required")
|
||||
}
|
||||
|
||||
uintValue, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err == nil {
|
||||
if uintValue == 0 {
|
||||
return 0, fmt.Errorf("must be greater than 0")
|
||||
}
|
||||
return uint(uintValue), nil
|
||||
}
|
||||
|
||||
floatValue, floatErr := strconv.ParseFloat(raw, 64)
|
||||
if floatErr != nil {
|
||||
return 0, fmt.Errorf("must be a positive integer")
|
||||
}
|
||||
if floatValue <= 0 {
|
||||
return 0, fmt.Errorf("must be greater than 0")
|
||||
}
|
||||
if floatValue != float64(uint(floatValue)) {
|
||||
return 0, fmt.Errorf("must be a positive integer")
|
||||
}
|
||||
|
||||
return uint(floatValue), nil
|
||||
}
|
||||
|
||||
func parseNonNegativeFloat(raw string) (float64, error) {
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("is required")
|
||||
}
|
||||
|
||||
value, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("must be numeric")
|
||||
}
|
||||
if value < 0 {
|
||||
return 0, fmt.Errorf("must be greater than or equal to 0")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func isRowEmpty(row []string) bool {
|
||||
for _, cell := range row {
|
||||
if strings.TrimSpace(cell) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeHeader(raw string) string {
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
func cellValue(row []string, index int) string {
|
||||
if index < 0 || index >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return row[index]
|
||||
}
|
||||
|
||||
func collectAdjustmentIDs(rows []adjustmentPriceImportRow) []uint {
|
||||
ids := make([]uint, 0, len(rows))
|
||||
seen := make(map[uint]struct{}, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.AdjustmentID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[row.AdjustmentID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[row.AdjustmentID] = struct{}{}
|
||||
ids = append(ids, row.AdjustmentID)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool {
|
||||
return ids[i] < ids[j]
|
||||
})
|
||||
return ids
|
||||
}
|
||||
|
||||
func (r dbAdjustmentResolver) ResolveExistingAdjustmentIDs(
|
||||
ctx context.Context,
|
||||
adjustmentIDs []uint,
|
||||
) (map[uint]struct{}, error) {
|
||||
result := make(map[uint]struct{})
|
||||
if len(adjustmentIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type adjustmentIDRow struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
}
|
||||
|
||||
rows := make([]adjustmentIDRow, 0, len(adjustmentIDs))
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table("adjustment_stocks").
|
||||
Select("id").
|
||||
Where("id IN ?", adjustmentIDs).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
result[row.ID] = struct{}{}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func splitRowsByExistingIDs(
|
||||
rows []adjustmentPriceImportRow,
|
||||
existing map[uint]struct{},
|
||||
) ([]adjustmentPriceImportRow, []adjustmentPriceImportRow) {
|
||||
processable := make([]adjustmentPriceImportRow, 0, len(rows))
|
||||
skipped := make([]adjustmentPriceImportRow, 0)
|
||||
|
||||
for _, row := range rows {
|
||||
if _, exists := existing[row.AdjustmentID]; exists {
|
||||
processable = append(processable, row)
|
||||
continue
|
||||
}
|
||||
skipped = append(skipped, row)
|
||||
}
|
||||
|
||||
return processable, skipped
|
||||
}
|
||||
|
||||
func printPlanRows(rows []adjustmentPriceImportRow) {
|
||||
for _, row := range rows {
|
||||
fmt.Printf(
|
||||
"PLAN row=%d adjustment_id=%d price=%.3f\n",
|
||||
row.RowNumber,
|
||||
row.AdjustmentID,
|
||||
row.Weight,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func printSkippedRows(rows []adjustmentPriceImportRow) {
|
||||
for _, row := range rows {
|
||||
fmt.Printf(
|
||||
"SKIP row=%d adjustment_id=%d reason=adjustment_id not found\n",
|
||||
row.RowNumber,
|
||||
row.AdjustmentID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func sortValidationIssues(issues []validationIssue) {
|
||||
sort.Slice(issues, func(i, j int) bool {
|
||||
if issues[i].Row == issues[j].Row {
|
||||
if issues[i].Field == issues[j].Field {
|
||||
return issues[i].Message < issues[j].Message
|
||||
}
|
||||
return issues[i].Field < issues[j].Field
|
||||
}
|
||||
return issues[i].Row < issues[j].Row
|
||||
})
|
||||
}
|
||||
|
||||
func applyIfRequested(
|
||||
ctx context.Context,
|
||||
apply bool,
|
||||
runner txRunner,
|
||||
rows []adjustmentPriceImportRow,
|
||||
) ([]applyRowResult, error) {
|
||||
if !apply || len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return applyImportRows(ctx, runner, rows)
|
||||
}
|
||||
|
||||
func applyImportRows(
|
||||
ctx context.Context,
|
||||
runner txRunner,
|
||||
rows []adjustmentPriceImportRow,
|
||||
) ([]applyRowResult, error) {
|
||||
results := make([]applyRowResult, 0, len(rows))
|
||||
|
||||
err := runner.InTx(ctx, func(store adjustmentPriceStore) error {
|
||||
for _, row := range rows {
|
||||
changed, err := store.UpdatePrice(ctx, row.AdjustmentID, row.Weight)
|
||||
if err != nil {
|
||||
return fmt.Errorf("row %d adjustment_id=%d update failed: %w", row.RowNumber, row.AdjustmentID, err)
|
||||
}
|
||||
|
||||
results = append(results, applyRowResult{
|
||||
RowNumber: row.RowNumber,
|
||||
AdjustmentID: row.AdjustmentID,
|
||||
Price: row.Weight,
|
||||
Changed: changed,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (r dbTxRunner) InTx(ctx context.Context, fn func(store adjustmentPriceStore) error) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return fn(dbAdjustmentPriceStore{db: tx})
|
||||
})
|
||||
}
|
||||
|
||||
func (s dbAdjustmentPriceStore) UpdatePrice(
|
||||
ctx context.Context,
|
||||
adjustmentID uint,
|
||||
price float64,
|
||||
) (bool, error) {
|
||||
result := s.db.WithContext(ctx).Exec(`
|
||||
UPDATE adjustment_stocks
|
||||
SET price = ?,
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
AND price IS DISTINCT FROM ?
|
||||
`, price, adjustmentID, price)
|
||||
if result.Error != nil {
|
||||
return false, result.Error
|
||||
}
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
func modeLabel(apply bool) string {
|
||||
if apply {
|
||||
return "APPLY"
|
||||
}
|
||||
return "DRY-RUN"
|
||||
}
|
||||
|
||||
func applyStatus(changed bool) string {
|
||||
if changed {
|
||||
return "UPDATED"
|
||||
}
|
||||
return "UNCHANGED"
|
||||
}
|
||||
|
||||
func countChangedRows(results []applyRowResult) int {
|
||||
count := 0
|
||||
for _, result := range results {
|
||||
if result.Changed {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
func TestParseAdjustmentPriceFile_ValidSingleRow(t *testing.T) {
|
||||
filePath := createWorkbook(
|
||||
t,
|
||||
"adjustment_prices",
|
||||
[]string{"adjustment_id", "weight"},
|
||||
[][]string{{"101", "12.345"}},
|
||||
)
|
||||
|
||||
sheet, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if sheet != "adjustment_prices" {
|
||||
t.Fatalf("expected selected sheet adjustment_prices, got %q", sheet)
|
||||
}
|
||||
if len(issues) != 0 {
|
||||
t.Fatalf("expected no issues, got %+v", issues)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
||||
}
|
||||
if rows[0].AdjustmentID != 101 {
|
||||
t.Fatalf("expected adjustment_id 101, got %d", rows[0].AdjustmentID)
|
||||
}
|
||||
if rows[0].Weight != 12.345 {
|
||||
t.Fatalf("expected weight 12.345, got %v", rows[0].Weight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdjustmentPriceFile_ValidMultiRow(t *testing.T) {
|
||||
filePath := createWorkbook(
|
||||
t,
|
||||
"adjustment_prices",
|
||||
[]string{" Adjustment_ID ", "WEIGHT"},
|
||||
[][]string{{"101", "10"}, {"102", "11.5"}},
|
||||
)
|
||||
|
||||
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "adjustment_prices")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if len(issues) != 0 {
|
||||
t.Fatalf("expected no issues, got %+v", issues)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("expected 2 rows, got %d", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdjustmentPriceFile_MissingRequiredHeader(t *testing.T) {
|
||||
filePath := createWorkbook(
|
||||
t,
|
||||
"adjustment_prices",
|
||||
[]string{"adjustment_id", "price"},
|
||||
[][]string{{"101", "12"}},
|
||||
)
|
||||
|
||||
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("expected 0 parsed rows when header invalid, got %d", len(rows))
|
||||
}
|
||||
if !hasIssue(issues, 0, "weight", "required header is missing") {
|
||||
t.Fatalf("expected missing weight header issue, got %+v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdjustmentPriceFile_InvalidAdjustmentID(t *testing.T) {
|
||||
filePath := createWorkbook(
|
||||
t,
|
||||
"adjustment_prices",
|
||||
[]string{"adjustment_id", "weight"},
|
||||
[][]string{{"abc", "10"}, {"0", "12"}},
|
||||
)
|
||||
|
||||
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("expected no valid rows, got %d", len(rows))
|
||||
}
|
||||
if !hasIssue(issues, 2, "adjustment_id", "must be a positive integer") {
|
||||
t.Fatalf("expected non numeric adjustment_id issue, got %+v", issues)
|
||||
}
|
||||
if !hasIssue(issues, 3, "adjustment_id", "must be greater than 0") {
|
||||
t.Fatalf("expected adjustment_id >0 issue, got %+v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdjustmentPriceFile_InvalidWeight(t *testing.T) {
|
||||
filePath := createWorkbook(
|
||||
t,
|
||||
"adjustment_prices",
|
||||
[]string{"adjustment_id", "weight"},
|
||||
[][]string{{"101", "abc"}, {"102", "-1"}},
|
||||
)
|
||||
|
||||
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("expected no valid rows, got %d", len(rows))
|
||||
}
|
||||
if !hasIssue(issues, 2, "weight", "must be numeric") {
|
||||
t.Fatalf("expected weight numeric issue, got %+v", issues)
|
||||
}
|
||||
if !hasIssue(issues, 3, "weight", "must be greater than or equal to 0") {
|
||||
t.Fatalf("expected weight >=0 issue, got %+v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdjustmentPriceFile_DuplicateAdjustmentID_LastRowWins(t *testing.T) {
|
||||
filePath := createWorkbook(
|
||||
t,
|
||||
"adjustment_prices",
|
||||
[]string{"adjustment_id", "weight"},
|
||||
[][]string{{"101", "10"}, {"102", "20"}, {"101", "30"}},
|
||||
)
|
||||
|
||||
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if len(issues) != 0 {
|
||||
t.Fatalf("expected no issues, got %+v", issues)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("expected 2 deduped rows, got %d", len(rows))
|
||||
}
|
||||
|
||||
row101, ok := findRowByAdjustmentID(rows, 101)
|
||||
if !ok {
|
||||
t.Fatalf("expected adjustment_id 101 to exist, got %+v", rows)
|
||||
}
|
||||
if row101.Weight != 30 {
|
||||
t.Fatalf("expected duplicate adjustment_id to keep last weight 30, got %v", row101.Weight)
|
||||
}
|
||||
if row101.RowNumber != 4 {
|
||||
t.Fatalf("expected duplicate adjustment_id to keep last row number 4, got %d", row101.RowNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitRowsByExistingIDs_SkipMissing(t *testing.T) {
|
||||
rows := []adjustmentPriceImportRow{
|
||||
{RowNumber: 2, AdjustmentID: 101, Weight: 10},
|
||||
{RowNumber: 3, AdjustmentID: 102, Weight: 11},
|
||||
{RowNumber: 4, AdjustmentID: 103, Weight: 12},
|
||||
}
|
||||
existing := map[uint]struct{}{101: {}, 103: {}}
|
||||
|
||||
processable, skipped := splitRowsByExistingIDs(rows, existing)
|
||||
if len(processable) != 2 {
|
||||
t.Fatalf("expected 2 processable rows, got %d", len(processable))
|
||||
}
|
||||
if len(skipped) != 1 {
|
||||
t.Fatalf("expected 1 skipped row, got %d", len(skipped))
|
||||
}
|
||||
if skipped[0].AdjustmentID != 102 {
|
||||
t.Fatalf("expected adjustment_id 102 skipped, got %+v", skipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyIfRequested_DryRunDoesNotWrite(t *testing.T) {
|
||||
runner := &fakeTransactionRunner{}
|
||||
rows := []adjustmentPriceImportRow{{RowNumber: 2, AdjustmentID: 101, Weight: 10}}
|
||||
|
||||
results, err := applyIfRequested(context.Background(), false, runner, rows)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if results != nil {
|
||||
t.Fatalf("expected nil results on dry-run, got %+v", results)
|
||||
}
|
||||
if runner.txCalls != 0 {
|
||||
t.Fatalf("expected no transaction call during dry-run, got %d", runner.txCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyImportRows_Success(t *testing.T) {
|
||||
runner := &fakeTransactionRunner{
|
||||
changedByID: map[uint]bool{101: true, 102: false},
|
||||
}
|
||||
rows := []adjustmentPriceImportRow{
|
||||
{RowNumber: 2, AdjustmentID: 101, Weight: 10},
|
||||
{RowNumber: 3, AdjustmentID: 102, Weight: 11},
|
||||
}
|
||||
|
||||
results, err := applyImportRows(context.Background(), runner, rows)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if runner.txCalls != 1 {
|
||||
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
|
||||
}
|
||||
if len(runner.committedCalls) != 2 {
|
||||
t.Fatalf("expected 2 committed updates, got %d", len(runner.committedCalls))
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 row results, got %d", len(results))
|
||||
}
|
||||
if !results[0].Changed || results[1].Changed {
|
||||
t.Fatalf("unexpected changed flags: %+v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyImportRows_RollbackOnError(t *testing.T) {
|
||||
runner := &fakeTransactionRunner{
|
||||
errByID: map[uint]error{102: errors.New("boom")},
|
||||
}
|
||||
rows := []adjustmentPriceImportRow{
|
||||
{RowNumber: 2, AdjustmentID: 101, Weight: 10},
|
||||
{RowNumber: 3, AdjustmentID: 102, Weight: 11},
|
||||
}
|
||||
|
||||
_, err := applyImportRows(context.Background(), runner, rows)
|
||||
if err == nil {
|
||||
t.Fatal("expected error due to update failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "row 3 adjustment_id=102 update failed") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
if runner.txCalls != 1 {
|
||||
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
|
||||
}
|
||||
if len(runner.committedCalls) != 0 {
|
||||
t.Fatalf("expected no committed updates on rollback, got %d", len(runner.committedCalls))
|
||||
}
|
||||
}
|
||||
|
||||
func createWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string {
|
||||
t.Helper()
|
||||
|
||||
f := excelize.NewFile()
|
||||
defaultSheet := f.GetSheetName(f.GetActiveSheetIndex())
|
||||
if sheetName == "" {
|
||||
sheetName = defaultSheet
|
||||
} else if sheetName != defaultSheet {
|
||||
f.SetSheetName(defaultSheet, sheetName)
|
||||
}
|
||||
|
||||
for idx, header := range headers {
|
||||
cell, err := excelize.CoordinatesToCellName(idx+1, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("failed resolving header cell: %v", err)
|
||||
}
|
||||
if err := f.SetCellValue(sheetName, cell, header); err != nil {
|
||||
t.Fatalf("failed setting header cell: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for rowIdx, row := range rows {
|
||||
for colIdx, value := range row {
|
||||
cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
|
||||
if err != nil {
|
||||
t.Fatalf("failed resolving data cell: %v", err)
|
||||
}
|
||||
if err := f.SetCellValue(sheetName, cell, value); err != nil {
|
||||
t.Fatalf("failed setting data cell: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "adjustment_prices.xlsx")
|
||||
if err := f.SaveAs(path); err != nil {
|
||||
t.Fatalf("failed saving workbook: %v", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatalf("failed closing workbook: %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
func hasIssue(issues []validationIssue, row int, field, messageContains string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Row != row {
|
||||
continue
|
||||
}
|
||||
if issue.Field != field {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(issue.Message, messageContains) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func findRowByAdjustmentID(rows []adjustmentPriceImportRow, adjustmentID uint) (adjustmentPriceImportRow, bool) {
|
||||
for _, row := range rows {
|
||||
if row.AdjustmentID == adjustmentID {
|
||||
return row, true
|
||||
}
|
||||
}
|
||||
return adjustmentPriceImportRow{}, false
|
||||
}
|
||||
|
||||
type updateCall struct {
|
||||
adjustmentID uint
|
||||
price float64
|
||||
}
|
||||
|
||||
type fakeAdjustmentPriceStore struct {
|
||||
changedByID map[uint]bool
|
||||
errByID map[uint]error
|
||||
calls []updateCall
|
||||
}
|
||||
|
||||
func (s *fakeAdjustmentPriceStore) UpdatePrice(_ context.Context, adjustmentID uint, price float64) (bool, error) {
|
||||
s.calls = append(s.calls, updateCall{adjustmentID: adjustmentID, price: price})
|
||||
if err, exists := s.errByID[adjustmentID]; exists {
|
||||
return false, fmt.Errorf("forced update failure for adjustment_id=%d: %w", adjustmentID, err)
|
||||
}
|
||||
if changed, exists := s.changedByID[adjustmentID]; exists {
|
||||
return changed, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
type fakeTransactionRunner struct {
|
||||
txCalls int
|
||||
changedByID map[uint]bool
|
||||
errByID map[uint]error
|
||||
committedCalls []updateCall
|
||||
}
|
||||
|
||||
func (r *fakeTransactionRunner) InTx(ctx context.Context, fn func(store adjustmentPriceStore) error) error {
|
||||
r.txCalls++
|
||||
|
||||
txStore := &fakeAdjustmentPriceStore{
|
||||
changedByID: r.changedByID,
|
||||
errByID: r.errByID,
|
||||
calls: make([]updateCall, 0),
|
||||
}
|
||||
|
||||
if err := fn(txStore); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.committedCalls = append(r.committedCalls, txStore.calls...)
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ txRunner = (*fakeTransactionRunner)(nil)
|
||||
var _ adjustmentPriceStore = (*fakeAdjustmentPriceStore)(nil)
|
||||
@@ -0,0 +1,632 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
"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"
|
||||
repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const dateLayout = "2006-01-02"
|
||||
|
||||
type importOptions struct {
|
||||
FilePath string
|
||||
Sheet string
|
||||
Apply bool
|
||||
}
|
||||
|
||||
type headerIndexes struct {
|
||||
ProjectFlockID int
|
||||
TotalCost int
|
||||
CutoverDate int
|
||||
Note int
|
||||
}
|
||||
|
||||
type manualInputImportRow struct {
|
||||
RowNumber int
|
||||
ProjectFlockID uint
|
||||
TotalCost float64
|
||||
CutoverDate time.Time
|
||||
Note *string
|
||||
}
|
||||
|
||||
type validationIssue struct {
|
||||
Row int
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (i validationIssue) Error() string {
|
||||
if i.Row > 0 {
|
||||
return fmt.Sprintf("row=%d field=%s message=%s", i.Row, i.Field, i.Message)
|
||||
}
|
||||
return fmt.Sprintf("field=%s message=%s", i.Field, i.Message)
|
||||
}
|
||||
|
||||
type farmResolver interface {
|
||||
ResolveActiveLayingFarms(ctx context.Context, projectFlockIDs []uint) (map[uint]string, error)
|
||||
}
|
||||
|
||||
type dbFarmResolver struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type manualInputStore interface {
|
||||
UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error
|
||||
DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error
|
||||
}
|
||||
|
||||
type txRunner interface {
|
||||
InTx(ctx context.Context, fn func(store manualInputStore) error) error
|
||||
}
|
||||
|
||||
type dbTxRunner struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type expenseDepreciationStore struct {
|
||||
repo repportRepo.ExpenseDepreciationRepository
|
||||
}
|
||||
|
||||
type farmIdentityRow struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
FarmName string `gorm:"column:farm_name"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var opts importOptions
|
||||
flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file (required)")
|
||||
flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)")
|
||||
flag.BoolVar(&opts.Apply, "apply", false, "Apply changes. If false, run as dry-run")
|
||||
flag.Parse()
|
||||
|
||||
opts.FilePath = strings.TrimSpace(opts.FilePath)
|
||||
opts.Sheet = strings.TrimSpace(opts.Sheet)
|
||||
|
||||
if opts.FilePath == "" {
|
||||
log.Fatal("--file is required")
|
||||
}
|
||||
|
||||
location, err := time.LoadLocation("Asia/Jakarta")
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load timezone Asia/Jakarta: %v", err)
|
||||
}
|
||||
|
||||
sheetName, rows, parseIssues, err := parseManualInputFile(opts.FilePath, opts.Sheet, location)
|
||||
if err != nil {
|
||||
log.Fatalf("failed reading excel: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
db := database.Connect(config.DBHost, config.DBName)
|
||||
resolver := dbFarmResolver{db: db}
|
||||
|
||||
farmNameByID, err := resolver.ResolveActiveLayingFarms(ctx, collectProjectFlockIDs(rows))
|
||||
if err != nil {
|
||||
log.Fatalf("failed validating project_flock_id against project_flocks: %v", err)
|
||||
}
|
||||
|
||||
issues := append([]validationIssue{}, parseIssues...)
|
||||
issues = append(issues, buildMissingFarmIssues(rows, farmNameByID)...)
|
||||
sortValidationIssues(issues)
|
||||
|
||||
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
|
||||
fmt.Printf("File: %s\n", opts.FilePath)
|
||||
fmt.Printf("Sheet: %s\n", sheetName)
|
||||
fmt.Printf("Rows parsed: %d\n", len(rows))
|
||||
fmt.Printf("Rows invalid: %d\n", len(issues))
|
||||
fmt.Println()
|
||||
|
||||
if len(rows) > 0 {
|
||||
printPlanRows(rows, farmNameByID)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if len(issues) > 0 {
|
||||
fmt.Println("Validation errors:")
|
||||
for _, issue := range issues {
|
||||
fmt.Printf("ERROR %s\n", issue.Error())
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Printf("Summary: planned=%d applied=0 failed=%d\n", len(rows), len(issues))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if !opts.Apply {
|
||||
fmt.Printf("Summary: planned=%d applied=0 failed=0\n", len(rows))
|
||||
return
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
fmt.Println("Summary: planned=0 applied=0 failed=0")
|
||||
return
|
||||
}
|
||||
|
||||
if err := applyIfRequested(ctx, true, dbTxRunner{db: db}, rows); err != nil {
|
||||
log.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
fmt.Printf(
|
||||
"DONE row=%d project_flock_id=%d cutover_date=%s\n",
|
||||
row.RowNumber,
|
||||
row.ProjectFlockID,
|
||||
row.CutoverDate.In(location).Format(dateLayout),
|
||||
)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("Summary: planned=%d applied=%d failed=0\n", len(rows), len(rows))
|
||||
}
|
||||
|
||||
func parseManualInputFile(
|
||||
filePath string,
|
||||
requestedSheet string,
|
||||
location *time.Location,
|
||||
) (string, []manualInputImportRow, []validationIssue, error) {
|
||||
workbook, err := excelize.OpenFile(filePath)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = workbook.Close()
|
||||
}()
|
||||
|
||||
sheetName, err := resolveSheetName(workbook, requestedSheet)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
|
||||
allRows, err := workbook.GetRows(sheetName, excelize.Options{RawCellValue: true})
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
if len(allRows) == 0 {
|
||||
return sheetName, nil, []validationIssue{
|
||||
{Field: "header", Message: "sheet is empty"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
indexes, headerIssues := parseHeaderIndexes(allRows[0])
|
||||
if len(headerIssues) > 0 {
|
||||
return sheetName, nil, headerIssues, nil
|
||||
}
|
||||
|
||||
rows := make([]manualInputImportRow, 0, len(allRows)-1)
|
||||
issues := make([]validationIssue, 0)
|
||||
seenProjectFlockIDs := make(map[uint]int)
|
||||
|
||||
for idx := 1; idx < len(allRows); idx++ {
|
||||
rowNumber := idx + 1
|
||||
rawRow := allRows[idx]
|
||||
|
||||
if isRowEmpty(rawRow) {
|
||||
continue
|
||||
}
|
||||
|
||||
parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes, location, seenProjectFlockIDs)
|
||||
if len(rowIssues) > 0 {
|
||||
issues = append(issues, rowIssues...)
|
||||
continue
|
||||
}
|
||||
|
||||
rows = append(rows, *parsed)
|
||||
}
|
||||
|
||||
if len(rows) == 0 && len(issues) == 0 {
|
||||
issues = append(issues, validationIssue{
|
||||
Field: "rows",
|
||||
Message: "no data rows found",
|
||||
})
|
||||
}
|
||||
|
||||
return sheetName, rows, issues, nil
|
||||
}
|
||||
|
||||
func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) {
|
||||
if workbook == nil {
|
||||
return "", fmt.Errorf("workbook is nil")
|
||||
}
|
||||
|
||||
sheets := workbook.GetSheetList()
|
||||
if len(sheets) == 0 {
|
||||
return "", fmt.Errorf("workbook has no sheets")
|
||||
}
|
||||
|
||||
if requestedSheet == "" {
|
||||
return sheets[0], nil
|
||||
}
|
||||
|
||||
for _, sheet := range sheets {
|
||||
if strings.EqualFold(strings.TrimSpace(sheet), strings.TrimSpace(requestedSheet)) {
|
||||
return sheet, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("sheet %q not found", requestedSheet)
|
||||
}
|
||||
|
||||
func parseHeaderIndexes(headerRow []string) (headerIndexes, []validationIssue) {
|
||||
indexes := headerIndexes{
|
||||
ProjectFlockID: -1,
|
||||
TotalCost: -1,
|
||||
CutoverDate: -1,
|
||||
Note: -1,
|
||||
}
|
||||
issues := make([]validationIssue, 0)
|
||||
|
||||
for idx, raw := range headerRow {
|
||||
header := normalizeHeader(raw)
|
||||
if header == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch header {
|
||||
case "project_flock_id":
|
||||
if indexes.ProjectFlockID >= 0 {
|
||||
issues = append(issues, validationIssue{
|
||||
Field: "header",
|
||||
Message: "duplicate header project_flock_id",
|
||||
})
|
||||
}
|
||||
indexes.ProjectFlockID = idx
|
||||
case "total_cost":
|
||||
if indexes.TotalCost >= 0 {
|
||||
issues = append(issues, validationIssue{
|
||||
Field: "header",
|
||||
Message: "duplicate header total_cost",
|
||||
})
|
||||
}
|
||||
indexes.TotalCost = idx
|
||||
case "cutover_date":
|
||||
if indexes.CutoverDate >= 0 {
|
||||
issues = append(issues, validationIssue{
|
||||
Field: "header",
|
||||
Message: "duplicate header cutover_date",
|
||||
})
|
||||
}
|
||||
indexes.CutoverDate = idx
|
||||
case "note":
|
||||
if indexes.Note >= 0 {
|
||||
issues = append(issues, validationIssue{
|
||||
Field: "header",
|
||||
Message: "duplicate header note",
|
||||
})
|
||||
}
|
||||
indexes.Note = idx
|
||||
}
|
||||
}
|
||||
|
||||
if indexes.ProjectFlockID < 0 {
|
||||
issues = append(issues, validationIssue{
|
||||
Field: "project_flock_id",
|
||||
Message: "required header is missing",
|
||||
})
|
||||
}
|
||||
if indexes.TotalCost < 0 {
|
||||
issues = append(issues, validationIssue{
|
||||
Field: "total_cost",
|
||||
Message: "required header is missing",
|
||||
})
|
||||
}
|
||||
if indexes.CutoverDate < 0 {
|
||||
issues = append(issues, validationIssue{
|
||||
Field: "cutover_date",
|
||||
Message: "required header is missing",
|
||||
})
|
||||
}
|
||||
|
||||
return indexes, issues
|
||||
}
|
||||
|
||||
func parseDataRow(
|
||||
rawRow []string,
|
||||
rowNumber int,
|
||||
indexes headerIndexes,
|
||||
location *time.Location,
|
||||
seenProjectFlockIDs map[uint]int,
|
||||
) (*manualInputImportRow, []validationIssue) {
|
||||
issues := make([]validationIssue, 0)
|
||||
|
||||
projectFlockIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.ProjectFlockID))
|
||||
projectFlockID, err := parsePositiveUint(projectFlockIDRaw)
|
||||
if err != nil {
|
||||
issues = append(issues, validationIssue{
|
||||
Row: rowNumber,
|
||||
Field: "project_flock_id",
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
totalCostRaw := strings.TrimSpace(cellValue(rawRow, indexes.TotalCost))
|
||||
totalCost, err := parseNonNegativeFloat(totalCostRaw)
|
||||
if err != nil {
|
||||
issues = append(issues, validationIssue{
|
||||
Row: rowNumber,
|
||||
Field: "total_cost",
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
cutoverDateRaw := strings.TrimSpace(cellValue(rawRow, indexes.CutoverDate))
|
||||
cutoverDate, err := parseDateOnlyInLocation(cutoverDateRaw, location)
|
||||
if err != nil {
|
||||
issues = append(issues, validationIssue{
|
||||
Row: rowNumber,
|
||||
Field: "cutover_date",
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
var note *string
|
||||
noteRaw := strings.TrimSpace(cellValue(rawRow, indexes.Note))
|
||||
if noteRaw != "" {
|
||||
if len([]rune(noteRaw)) > 1000 {
|
||||
issues = append(issues, validationIssue{
|
||||
Row: rowNumber,
|
||||
Field: "note",
|
||||
Message: "must have at most 1000 characters",
|
||||
})
|
||||
} else {
|
||||
note = ¬eRaw
|
||||
}
|
||||
}
|
||||
|
||||
if projectFlockID > 0 {
|
||||
if previousRow, exists := seenProjectFlockIDs[projectFlockID]; exists {
|
||||
issues = append(issues, validationIssue{
|
||||
Row: rowNumber,
|
||||
Field: "project_flock_id",
|
||||
Message: fmt.Sprintf("duplicate value %d (already used in row %d)", projectFlockID, previousRow),
|
||||
})
|
||||
} else {
|
||||
seenProjectFlockIDs[projectFlockID] = rowNumber
|
||||
}
|
||||
}
|
||||
|
||||
if len(issues) > 0 {
|
||||
return nil, issues
|
||||
}
|
||||
|
||||
return &manualInputImportRow{
|
||||
RowNumber: rowNumber,
|
||||
ProjectFlockID: projectFlockID,
|
||||
TotalCost: totalCost,
|
||||
CutoverDate: cutoverDate,
|
||||
Note: note,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parsePositiveUint(raw string) (uint, error) {
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("is required")
|
||||
}
|
||||
|
||||
uintValue, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err == nil {
|
||||
if uintValue == 0 {
|
||||
return 0, fmt.Errorf("must be greater than 0")
|
||||
}
|
||||
return uint(uintValue), nil
|
||||
}
|
||||
|
||||
floatValue, floatErr := strconv.ParseFloat(raw, 64)
|
||||
if floatErr != nil {
|
||||
return 0, fmt.Errorf("must be a positive integer")
|
||||
}
|
||||
if floatValue <= 0 {
|
||||
return 0, fmt.Errorf("must be greater than 0")
|
||||
}
|
||||
if floatValue != float64(uint(floatValue)) {
|
||||
return 0, fmt.Errorf("must be a positive integer")
|
||||
}
|
||||
|
||||
return uint(floatValue), nil
|
||||
}
|
||||
|
||||
func parseNonNegativeFloat(raw string) (float64, error) {
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("is required")
|
||||
}
|
||||
|
||||
value, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("must be numeric")
|
||||
}
|
||||
if value < 0 {
|
||||
return 0, fmt.Errorf("must be greater than or equal to 0")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseDateOnlyInLocation(raw string, location *time.Location) (time.Time, error) {
|
||||
if raw == "" {
|
||||
return time.Time{}, fmt.Errorf("is required")
|
||||
}
|
||||
value, err := time.ParseInLocation(dateLayout, raw, location)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("must follow format YYYY-MM-DD")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func isRowEmpty(row []string) bool {
|
||||
for _, cell := range row {
|
||||
if strings.TrimSpace(cell) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeHeader(raw string) string {
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
func cellValue(row []string, index int) string {
|
||||
if index < 0 || index >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return row[index]
|
||||
}
|
||||
|
||||
func collectProjectFlockIDs(rows []manualInputImportRow) []uint {
|
||||
ids := make([]uint, 0, len(rows))
|
||||
seen := make(map[uint]struct{}, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.ProjectFlockID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[row.ProjectFlockID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[row.ProjectFlockID] = struct{}{}
|
||||
ids = append(ids, row.ProjectFlockID)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool {
|
||||
return ids[i] < ids[j]
|
||||
})
|
||||
return ids
|
||||
}
|
||||
|
||||
func (r dbFarmResolver) ResolveActiveLayingFarms(
|
||||
ctx context.Context,
|
||||
projectFlockIDs []uint,
|
||||
) (map[uint]string, error) {
|
||||
result := make(map[uint]string)
|
||||
if len(projectFlockIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
rows := make([]farmIdentityRow, 0, len(projectFlockIDs))
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table("project_flocks").
|
||||
Select("id, flock_name AS farm_name").
|
||||
Where("id IN ?", projectFlockIDs).
|
||||
Where("deleted_at IS NULL").
|
||||
Where("category = ?", utils.ProjectFlockCategoryLaying).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
result[row.ID] = row.FarmName
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildMissingFarmIssues(rows []manualInputImportRow, farmNameByID map[uint]string) []validationIssue {
|
||||
issues := make([]validationIssue, 0)
|
||||
for _, row := range rows {
|
||||
if _, exists := farmNameByID[row.ProjectFlockID]; exists {
|
||||
continue
|
||||
}
|
||||
issues = append(issues, validationIssue{
|
||||
Row: row.RowNumber,
|
||||
Field: "project_flock_id",
|
||||
Message: fmt.Sprintf("value %d must reference an active LAYING project_flock", row.ProjectFlockID),
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func printPlanRows(rows []manualInputImportRow, farmNameByID map[uint]string) {
|
||||
for _, row := range rows {
|
||||
farmName := farmNameByID[row.ProjectFlockID]
|
||||
fmt.Printf(
|
||||
"PLAN row=%d project_flock_id=%d farm_name=%q total_cost=%.3f cutover_date=%s note=%q\n",
|
||||
row.RowNumber,
|
||||
row.ProjectFlockID,
|
||||
farmName,
|
||||
row.TotalCost,
|
||||
row.CutoverDate.Format(dateLayout),
|
||||
derefString(row.Note),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func sortValidationIssues(issues []validationIssue) {
|
||||
sort.Slice(issues, func(i, j int) bool {
|
||||
if issues[i].Row == issues[j].Row {
|
||||
if issues[i].Field == issues[j].Field {
|
||||
return issues[i].Message < issues[j].Message
|
||||
}
|
||||
return issues[i].Field < issues[j].Field
|
||||
}
|
||||
return issues[i].Row < issues[j].Row
|
||||
})
|
||||
}
|
||||
|
||||
func applyIfRequested(ctx context.Context, apply bool, runner txRunner, rows []manualInputImportRow) error {
|
||||
if !apply || len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
return applyImportRows(ctx, runner, rows)
|
||||
}
|
||||
|
||||
func applyImportRows(ctx context.Context, runner txRunner, rows []manualInputImportRow) error {
|
||||
return runner.InTx(ctx, func(store manualInputStore) error {
|
||||
for _, row := range rows {
|
||||
payload := entity.FarmDepreciationManualInput{
|
||||
ProjectFlockId: row.ProjectFlockID,
|
||||
TotalCost: row.TotalCost,
|
||||
CutoverDate: row.CutoverDate,
|
||||
Note: row.Note,
|
||||
}
|
||||
|
||||
if err := store.UpsertManualInput(ctx, &payload); err != nil {
|
||||
return fmt.Errorf("row %d project_flock_id=%d upsert failed: %w", row.RowNumber, row.ProjectFlockID, err)
|
||||
}
|
||||
|
||||
if err := store.DeleteSnapshotsFromDate(ctx, row.CutoverDate, []uint{row.ProjectFlockID}); err != nil {
|
||||
return fmt.Errorf("row %d project_flock_id=%d snapshot invalidation failed: %w", row.RowNumber, row.ProjectFlockID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r dbTxRunner) InTx(ctx context.Context, fn func(store manualInputStore) error) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
repo := repportRepo.NewExpenseDepreciationRepository(tx)
|
||||
store := expenseDepreciationStore{repo: repo}
|
||||
return fn(store)
|
||||
})
|
||||
}
|
||||
|
||||
func (s expenseDepreciationStore) UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error {
|
||||
return s.repo.UpsertManualInput(ctx, row)
|
||||
}
|
||||
|
||||
func (s expenseDepreciationStore) DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error {
|
||||
return s.repo.DeleteSnapshotsFromDate(ctx, fromDate, farmIDs)
|
||||
}
|
||||
|
||||
func modeLabel(apply bool) string {
|
||||
if apply {
|
||||
return "APPLY"
|
||||
}
|
||||
return "DRY-RUN"
|
||||
}
|
||||
|
||||
func derefString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
)
|
||||
|
||||
func TestParseManualInputFile_ValidSingleRow(t *testing.T) {
|
||||
filePath := createManualInputWorkbook(
|
||||
t,
|
||||
"manual_inputs",
|
||||
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
|
||||
[][]string{
|
||||
{"101", "12345.678", "2026-06-01", "manual seed"},
|
||||
},
|
||||
)
|
||||
|
||||
location := mustJakartaLocation(t)
|
||||
sheet, rows, issues, err := parseManualInputFile(filePath, "", location)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if sheet != "manual_inputs" {
|
||||
t.Fatalf("expected selected sheet manual_inputs, got %q", sheet)
|
||||
}
|
||||
if len(issues) != 0 {
|
||||
t.Fatalf("expected no issues, got %+v", issues)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
||||
}
|
||||
if rows[0].ProjectFlockID != 101 {
|
||||
t.Fatalf("expected project_flock_id 101, got %d", rows[0].ProjectFlockID)
|
||||
}
|
||||
if rows[0].TotalCost != 12345.678 {
|
||||
t.Fatalf("expected total_cost 12345.678, got %v", rows[0].TotalCost)
|
||||
}
|
||||
if rows[0].CutoverDate.Format(dateLayout) != "2026-06-01" {
|
||||
t.Fatalf("expected cutover_date 2026-06-01, got %s", rows[0].CutoverDate.Format(dateLayout))
|
||||
}
|
||||
if rows[0].Note == nil || *rows[0].Note != "manual seed" {
|
||||
t.Fatalf("expected note manual seed, got %+v", rows[0].Note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManualInputFile_ValidMultiRow(t *testing.T) {
|
||||
filePath := createManualInputWorkbook(
|
||||
t,
|
||||
"manual_inputs",
|
||||
[]string{" Project_Flock_ID ", "TOTAL_COST", "cutover_date", "NOTE"},
|
||||
[][]string{
|
||||
{"101", "1200", "2026-06-01", ""},
|
||||
{"102", "1300.5", "2026-06-02", "second"},
|
||||
},
|
||||
)
|
||||
|
||||
location := mustJakartaLocation(t)
|
||||
_, rows, issues, err := parseManualInputFile(filePath, "manual_inputs", location)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if len(issues) != 0 {
|
||||
t.Fatalf("expected no issues, got %+v", issues)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("expected 2 rows, got %d", len(rows))
|
||||
}
|
||||
if rows[0].Note != nil {
|
||||
t.Fatalf("expected first row note nil, got %+v", rows[0].Note)
|
||||
}
|
||||
if rows[1].Note == nil || *rows[1].Note != "second" {
|
||||
t.Fatalf("expected second row note second, got %+v", rows[1].Note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManualInputFile_MissingRequiredHeader(t *testing.T) {
|
||||
filePath := createManualInputWorkbook(
|
||||
t,
|
||||
"manual_inputs",
|
||||
[]string{"project_flock_id", "totalcost", "cutover_date", "note"},
|
||||
[][]string{
|
||||
{"101", "1200", "2026-06-01", ""},
|
||||
},
|
||||
)
|
||||
|
||||
location := mustJakartaLocation(t)
|
||||
_, rows, issues, err := parseManualInputFile(filePath, "", location)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("expected 0 parsed rows when header invalid, got %d", len(rows))
|
||||
}
|
||||
if !hasIssue(issues, 0, "total_cost", "required header is missing") {
|
||||
t.Fatalf("expected missing total_cost header issue, got %+v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManualInputFile_InvalidProjectFlockID(t *testing.T) {
|
||||
filePath := createManualInputWorkbook(
|
||||
t,
|
||||
"manual_inputs",
|
||||
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
|
||||
[][]string{
|
||||
{"abc", "1200", "2026-06-01", ""},
|
||||
{"0", "1300", "2026-06-02", ""},
|
||||
},
|
||||
)
|
||||
|
||||
location := mustJakartaLocation(t)
|
||||
_, rows, issues, err := parseManualInputFile(filePath, "", location)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("expected no valid rows, got %d", len(rows))
|
||||
}
|
||||
if !hasIssue(issues, 2, "project_flock_id", "must be a positive integer") {
|
||||
t.Fatalf("expected non numeric project_flock_id issue, got %+v", issues)
|
||||
}
|
||||
if !hasIssue(issues, 3, "project_flock_id", "must be greater than 0") {
|
||||
t.Fatalf("expected project_flock_id >0 issue, got %+v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManualInputFile_InvalidTotalCost(t *testing.T) {
|
||||
filePath := createManualInputWorkbook(
|
||||
t,
|
||||
"manual_inputs",
|
||||
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
|
||||
[][]string{
|
||||
{"101", "abc", "2026-06-01", ""},
|
||||
{"102", "-1", "2026-06-02", ""},
|
||||
},
|
||||
)
|
||||
|
||||
location := mustJakartaLocation(t)
|
||||
_, rows, issues, err := parseManualInputFile(filePath, "", location)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("expected no valid rows, got %d", len(rows))
|
||||
}
|
||||
if !hasIssue(issues, 2, "total_cost", "must be numeric") {
|
||||
t.Fatalf("expected total_cost numeric issue, got %+v", issues)
|
||||
}
|
||||
if !hasIssue(issues, 3, "total_cost", "must be greater than or equal to 0") {
|
||||
t.Fatalf("expected total_cost >=0 issue, got %+v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManualInputFile_InvalidCutoverDate(t *testing.T) {
|
||||
filePath := createManualInputWorkbook(
|
||||
t,
|
||||
"manual_inputs",
|
||||
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
|
||||
[][]string{
|
||||
{"101", "1200", "06-01-2026", ""},
|
||||
},
|
||||
)
|
||||
|
||||
location := mustJakartaLocation(t)
|
||||
_, rows, issues, err := parseManualInputFile(filePath, "", location)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("expected no valid rows, got %d", len(rows))
|
||||
}
|
||||
if !hasIssue(issues, 2, "cutover_date", "must follow format YYYY-MM-DD") {
|
||||
t.Fatalf("expected cutover_date format issue, got %+v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManualInputFile_DuplicateProjectFlockID(t *testing.T) {
|
||||
filePath := createManualInputWorkbook(
|
||||
t,
|
||||
"manual_inputs",
|
||||
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
|
||||
[][]string{
|
||||
{"101", "1200", "2026-06-01", ""},
|
||||
{"101", "1300", "2026-06-02", ""},
|
||||
},
|
||||
)
|
||||
|
||||
location := mustJakartaLocation(t)
|
||||
_, rows, issues, err := parseManualInputFile(filePath, "", location)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected first row valid and second row invalid, got %d rows", len(rows))
|
||||
}
|
||||
if !hasIssue(issues, 3, "project_flock_id", "duplicate value 101") {
|
||||
t.Fatalf("expected duplicate project_flock_id issue, got %+v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManualInputFile_NoteValidation(t *testing.T) {
|
||||
longNote := strings.Repeat("a", 1001)
|
||||
filePath := createManualInputWorkbook(
|
||||
t,
|
||||
"manual_inputs",
|
||||
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
|
||||
[][]string{
|
||||
{"101", "1200", "2026-06-01", ""},
|
||||
{"102", "1300", "2026-06-02", longNote},
|
||||
},
|
||||
)
|
||||
|
||||
location := mustJakartaLocation(t)
|
||||
_, rows, issues, err := parseManualInputFile(filePath, "", location)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected only first row valid, got %d", len(rows))
|
||||
}
|
||||
if rows[0].Note != nil {
|
||||
t.Fatalf("expected first row note nil, got %+v", rows[0].Note)
|
||||
}
|
||||
if !hasIssue(issues, 3, "note", "at most 1000 characters") {
|
||||
t.Fatalf("expected note length issue, got %+v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyImportRows_Success(t *testing.T) {
|
||||
location := mustJakartaLocation(t)
|
||||
runner := &fakeTransactionRunner{}
|
||||
rows := []manualInputImportRow{
|
||||
{
|
||||
RowNumber: 2,
|
||||
ProjectFlockID: 101,
|
||||
TotalCost: 1000,
|
||||
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
|
||||
},
|
||||
{
|
||||
RowNumber: 3,
|
||||
ProjectFlockID: 102,
|
||||
TotalCost: 2000,
|
||||
CutoverDate: mustDateInLocation(t, "2026-06-02", location),
|
||||
},
|
||||
}
|
||||
|
||||
err := applyImportRows(context.Background(), runner, rows)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if runner.txCalls != 1 {
|
||||
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
|
||||
}
|
||||
if len(runner.committedUpserts) != 2 {
|
||||
t.Fatalf("expected 2 committed upserts, got %d", len(runner.committedUpserts))
|
||||
}
|
||||
if len(runner.committedInvalidations) != 2 {
|
||||
t.Fatalf("expected 2 committed invalidations, got %d", len(runner.committedInvalidations))
|
||||
}
|
||||
if runner.committedInvalidations[0].farmIDs[0] != 101 || runner.committedInvalidations[1].farmIDs[0] != 102 {
|
||||
t.Fatalf("unexpected invalidation farm IDs: %+v", runner.committedInvalidations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyImportRows_RollbackOnError(t *testing.T) {
|
||||
location := mustJakartaLocation(t)
|
||||
runner := &fakeTransactionRunner{
|
||||
failUpsertOnProjectFlockID: 102,
|
||||
}
|
||||
rows := []manualInputImportRow{
|
||||
{
|
||||
RowNumber: 2,
|
||||
ProjectFlockID: 101,
|
||||
TotalCost: 1000,
|
||||
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
|
||||
},
|
||||
{
|
||||
RowNumber: 3,
|
||||
ProjectFlockID: 102,
|
||||
TotalCost: 2000,
|
||||
CutoverDate: mustDateInLocation(t, "2026-06-02", location),
|
||||
},
|
||||
}
|
||||
|
||||
err := applyImportRows(context.Background(), runner, rows)
|
||||
if err == nil {
|
||||
t.Fatal("expected error due to upsert failure")
|
||||
}
|
||||
if runner.txCalls != 1 {
|
||||
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
|
||||
}
|
||||
if len(runner.committedUpserts) != 0 {
|
||||
t.Fatalf("expected no committed upserts on rollback, got %d", len(runner.committedUpserts))
|
||||
}
|
||||
if len(runner.committedInvalidations) != 0 {
|
||||
t.Fatalf("expected no committed invalidations on rollback, got %d", len(runner.committedInvalidations))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyIfRequested_DryRunDoesNotWrite(t *testing.T) {
|
||||
location := mustJakartaLocation(t)
|
||||
runner := &fakeTransactionRunner{}
|
||||
rows := []manualInputImportRow{
|
||||
{
|
||||
RowNumber: 2,
|
||||
ProjectFlockID: 101,
|
||||
TotalCost: 1000,
|
||||
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
|
||||
},
|
||||
}
|
||||
|
||||
err := applyIfRequested(context.Background(), false, runner, rows)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if runner.txCalls != 0 {
|
||||
t.Fatalf("expected no transaction call during dry-run, got %d", runner.txCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func createManualInputWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string {
|
||||
t.Helper()
|
||||
|
||||
f := excelize.NewFile()
|
||||
defaultSheet := f.GetSheetName(f.GetActiveSheetIndex())
|
||||
if sheetName == "" {
|
||||
sheetName = defaultSheet
|
||||
} else if sheetName != defaultSheet {
|
||||
f.SetSheetName(defaultSheet, sheetName)
|
||||
}
|
||||
|
||||
for idx, header := range headers {
|
||||
cell, err := excelize.CoordinatesToCellName(idx+1, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("failed resolving header cell: %v", err)
|
||||
}
|
||||
if err := f.SetCellValue(sheetName, cell, header); err != nil {
|
||||
t.Fatalf("failed setting header cell: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for rowIdx, row := range rows {
|
||||
for colIdx, value := range row {
|
||||
cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
|
||||
if err != nil {
|
||||
t.Fatalf("failed resolving data cell: %v", err)
|
||||
}
|
||||
if err := f.SetCellValue(sheetName, cell, value); err != nil {
|
||||
t.Fatalf("failed setting data cell: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "manual_inputs.xlsx")
|
||||
if err := f.SaveAs(path); err != nil {
|
||||
t.Fatalf("failed saving workbook: %v", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatalf("failed closing workbook: %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
func mustJakartaLocation(t *testing.T) *time.Location {
|
||||
t.Helper()
|
||||
location, err := time.LoadLocation("Asia/Jakarta")
|
||||
if err != nil {
|
||||
t.Fatalf("failed loading Asia/Jakarta location: %v", err)
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
func mustDateInLocation(t *testing.T, raw string, location *time.Location) time.Time {
|
||||
t.Helper()
|
||||
value, err := time.ParseInLocation(dateLayout, raw, location)
|
||||
if err != nil {
|
||||
t.Fatalf("failed parsing date %q: %v", raw, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func hasIssue(issues []validationIssue, row int, field, messageContains string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Row != row {
|
||||
continue
|
||||
}
|
||||
if issue.Field != field {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(issue.Message, messageContains) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type fakeInvalidation struct {
|
||||
fromDate time.Time
|
||||
farmIDs []uint
|
||||
}
|
||||
|
||||
type fakeManualInputStore struct {
|
||||
failUpsertOnProjectFlockID uint
|
||||
failDeleteOnProjectFlockID uint
|
||||
upserts []entity.FarmDepreciationManualInput
|
||||
invalidations []fakeInvalidation
|
||||
}
|
||||
|
||||
func (s *fakeManualInputStore) UpsertManualInput(_ context.Context, row *entity.FarmDepreciationManualInput) error {
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
if s.failUpsertOnProjectFlockID > 0 && row.ProjectFlockId == s.failUpsertOnProjectFlockID {
|
||||
return fmt.Errorf("forced upsert failure for project_flock_id=%d", row.ProjectFlockId)
|
||||
}
|
||||
cloned := *row
|
||||
s.upserts = append(s.upserts, cloned)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeManualInputStore) DeleteSnapshotsFromDate(_ context.Context, fromDate time.Time, farmIDs []uint) error {
|
||||
if s.failDeleteOnProjectFlockID > 0 {
|
||||
for _, farmID := range farmIDs {
|
||||
if farmID == s.failDeleteOnProjectFlockID {
|
||||
return fmt.Errorf("forced delete failure for project_flock_id=%d", farmID)
|
||||
}
|
||||
}
|
||||
}
|
||||
copiedFarmIDs := append([]uint{}, farmIDs...)
|
||||
s.invalidations = append(s.invalidations, fakeInvalidation{
|
||||
fromDate: fromDate,
|
||||
farmIDs: copiedFarmIDs,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeTransactionRunner struct {
|
||||
txCalls int
|
||||
failUpsertOnProjectFlockID uint
|
||||
failDeleteOnProjectFlockID uint
|
||||
committedUpserts []entity.FarmDepreciationManualInput
|
||||
committedInvalidations []fakeInvalidation
|
||||
}
|
||||
|
||||
func (r *fakeTransactionRunner) InTx(ctx context.Context, fn func(store manualInputStore) error) error {
|
||||
r.txCalls++
|
||||
|
||||
txStore := &fakeManualInputStore{
|
||||
failUpsertOnProjectFlockID: r.failUpsertOnProjectFlockID,
|
||||
failDeleteOnProjectFlockID: r.failDeleteOnProjectFlockID,
|
||||
}
|
||||
|
||||
if err := fn(txStore); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.committedUpserts = append(r.committedUpserts, txStore.upserts...)
|
||||
r.committedInvalidations = append(r.committedInvalidations, txStore.invalidations...)
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ txRunner = (*fakeTransactionRunner)(nil)
|
||||
var _ manualInputStore = (*fakeManualInputStore)(nil)
|
||||
|
||||
func TestBuildMissingFarmIssues(t *testing.T) {
|
||||
location := mustJakartaLocation(t)
|
||||
rows := []manualInputImportRow{
|
||||
{
|
||||
RowNumber: 2,
|
||||
ProjectFlockID: 101,
|
||||
TotalCost: 1000,
|
||||
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
|
||||
},
|
||||
{
|
||||
RowNumber: 3,
|
||||
ProjectFlockID: 102,
|
||||
TotalCost: 1000,
|
||||
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
|
||||
},
|
||||
}
|
||||
|
||||
issues := buildMissingFarmIssues(rows, map[uint]string{
|
||||
101: "Farm A",
|
||||
})
|
||||
if len(issues) != 1 {
|
||||
t.Fatalf("expected 1 issue, got %+v", issues)
|
||||
}
|
||||
if issues[0].Row != 3 || issues[0].Field != "project_flock_id" {
|
||||
t.Fatalf("unexpected issue: %+v", issues[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyImportRows_PropagatesDeleteError(t *testing.T) {
|
||||
location := mustJakartaLocation(t)
|
||||
runner := &fakeTransactionRunner{
|
||||
failDeleteOnProjectFlockID: 101,
|
||||
}
|
||||
rows := []manualInputImportRow{
|
||||
{
|
||||
RowNumber: 2,
|
||||
ProjectFlockID: 101,
|
||||
TotalCost: 1000,
|
||||
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
|
||||
},
|
||||
}
|
||||
|
||||
err := applyImportRows(context.Background(), runner, rows)
|
||||
if err == nil {
|
||||
t.Fatal("expected delete failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "snapshot invalidation failed") {
|
||||
t.Fatalf("expected snapshot invalidation error message, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSheetName_ErrorWhenSheetNotFound(t *testing.T) {
|
||||
workbook := excelize.NewFile()
|
||||
defer func() {
|
||||
_ = workbook.Close()
|
||||
}()
|
||||
|
||||
_, err := resolveSheetName(workbook, "unknown")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when sheet is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyIfRequested_ApplyUsesRunnerError(t *testing.T) {
|
||||
location := mustJakartaLocation(t)
|
||||
rows := []manualInputImportRow{
|
||||
{
|
||||
RowNumber: 2,
|
||||
ProjectFlockID: 101,
|
||||
TotalCost: 1000,
|
||||
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
|
||||
},
|
||||
}
|
||||
runner := &errorTxRunner{err: errors.New("tx failed")}
|
||||
|
||||
err := applyIfRequested(context.Background(), true, runner, rows)
|
||||
if err == nil {
|
||||
t.Fatal("expected transaction error")
|
||||
}
|
||||
if err.Error() != "tx failed" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type errorTxRunner struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *errorTxRunner) InTx(_ context.Context, _ func(store manualInputStore) error) error {
|
||||
return r.err
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type importOptions struct {
|
||||
FilePath string
|
||||
Sheet string
|
||||
Apply bool
|
||||
}
|
||||
|
||||
type headerIndexes struct {
|
||||
KandangID int
|
||||
KandangName int
|
||||
HouseType int
|
||||
}
|
||||
|
||||
type kandangHouseTypeImportRow struct {
|
||||
RowNumber int
|
||||
KandangID uint
|
||||
KandangName string
|
||||
HouseType string
|
||||
}
|
||||
|
||||
type validationIssue struct {
|
||||
Row int
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (i validationIssue) Error() string {
|
||||
if i.Row > 0 {
|
||||
return fmt.Sprintf("row=%d field=%s message=%s", i.Row, i.Field, i.Message)
|
||||
}
|
||||
return fmt.Sprintf("field=%s message=%s", i.Field, i.Message)
|
||||
}
|
||||
|
||||
type kandangResolver interface {
|
||||
ResolveActiveKandangs(ctx context.Context, kandangIDs []uint) (map[uint]string, error)
|
||||
}
|
||||
|
||||
type dbKandangResolver struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type txRunner interface {
|
||||
InTx(ctx context.Context, fn func(store kandangHouseTypeStore) error) error
|
||||
}
|
||||
|
||||
type dbTxRunner struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type kandangHouseTypeStore interface {
|
||||
UpdateKandangHouseType(ctx context.Context, kandangID uint, houseType string) (bool, error)
|
||||
NormalizeNullHouseType(ctx context.Context) (int64, error)
|
||||
}
|
||||
|
||||
type dbKandangHouseTypeStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type kandangIdentityRow struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
|
||||
type applyRowResult struct {
|
||||
RowNumber int
|
||||
KandangID uint
|
||||
HouseType string
|
||||
Changed bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
var opts importOptions
|
||||
flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file (required)")
|
||||
flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)")
|
||||
flag.BoolVar(&opts.Apply, "apply", false, "Apply changes. If false, run as dry-run")
|
||||
flag.Parse()
|
||||
|
||||
opts.FilePath = strings.TrimSpace(opts.FilePath)
|
||||
opts.Sheet = strings.TrimSpace(opts.Sheet)
|
||||
|
||||
if opts.FilePath == "" {
|
||||
log.Fatal("--file is required")
|
||||
}
|
||||
|
||||
sheetName, rows, parseIssues, err := parseKandangHouseTypeFile(opts.FilePath, opts.Sheet)
|
||||
if err != nil {
|
||||
log.Fatalf("failed reading excel: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
db := database.Connect(config.DBHost, config.DBName)
|
||||
resolver := dbKandangResolver{db: db}
|
||||
|
||||
kandangNameByID, err := resolver.ResolveActiveKandangs(ctx, collectKandangIDs(rows))
|
||||
if err != nil {
|
||||
log.Fatalf("failed validating kandang_id against kandangs: %v", err)
|
||||
}
|
||||
|
||||
issues := append([]validationIssue{}, parseIssues...)
|
||||
issues = append(issues, buildMissingKandangIssues(rows, kandangNameByID)...)
|
||||
issues = append(issues, buildNameMismatchIssues(rows, kandangNameByID)...)
|
||||
sortValidationIssues(issues)
|
||||
|
||||
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
|
||||
fmt.Printf("File: %s\n", opts.FilePath)
|
||||
fmt.Printf("Sheet: %s\n", sheetName)
|
||||
fmt.Printf("Rows parsed: %d\n", len(rows))
|
||||
fmt.Printf("Rows invalid: %d\n", len(issues))
|
||||
fmt.Println()
|
||||
|
||||
if len(rows) > 0 {
|
||||
printPlanRows(rows, kandangNameByID)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if len(issues) > 0 {
|
||||
fmt.Println("Validation errors:")
|
||||
for _, issue := range issues {
|
||||
fmt.Printf("ERROR %s\n", issue.Error())
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Printf("Summary: planned=%d applied=0 normalized_null_to_open_house=0 failed=%d\n", len(rows), len(issues))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if !opts.Apply {
|
||||
fmt.Printf("Summary: planned=%d applied=0 normalized_null_to_open_house=0 failed=0\n", len(rows))
|
||||
return
|
||||
}
|
||||
|
||||
rowResults, normalizedCount, err := applyImportRows(ctx, dbTxRunner{db: db}, rows)
|
||||
if err != nil {
|
||||
log.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
|
||||
for _, result := range rowResults {
|
||||
fmt.Printf(
|
||||
"DONE row=%d kandang_id=%d house_type=%s status=%s\n",
|
||||
result.RowNumber,
|
||||
result.KandangID,
|
||||
result.HouseType,
|
||||
applyStatus(result.Changed),
|
||||
)
|
||||
}
|
||||
|
||||
appliedCount := countChangedRows(rowResults)
|
||||
fmt.Println()
|
||||
fmt.Printf(
|
||||
"Summary: planned=%d applied=%d normalized_null_to_open_house=%d failed=0\n",
|
||||
len(rows),
|
||||
appliedCount,
|
||||
normalizedCount,
|
||||
)
|
||||
}
|
||||
|
||||
func parseKandangHouseTypeFile(
|
||||
filePath string,
|
||||
requestedSheet string,
|
||||
) (string, []kandangHouseTypeImportRow, []validationIssue, error) {
|
||||
workbook, err := excelize.OpenFile(filePath)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = workbook.Close()
|
||||
}()
|
||||
|
||||
sheetName, err := resolveSheetName(workbook, requestedSheet)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
|
||||
allRows, err := workbook.GetRows(sheetName, excelize.Options{RawCellValue: true})
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
if len(allRows) == 0 {
|
||||
return sheetName, nil, []validationIssue{{Field: "header", Message: "sheet is empty"}}, nil
|
||||
}
|
||||
|
||||
indexes, headerIssues := parseHeaderIndexes(allRows[0])
|
||||
if len(headerIssues) > 0 {
|
||||
return sheetName, nil, headerIssues, nil
|
||||
}
|
||||
|
||||
rows := make([]kandangHouseTypeImportRow, 0, len(allRows)-1)
|
||||
issues := make([]validationIssue, 0)
|
||||
seenKandangIDs := make(map[uint]int)
|
||||
|
||||
for idx := 1; idx < len(allRows); idx++ {
|
||||
rowNumber := idx + 1
|
||||
rawRow := allRows[idx]
|
||||
|
||||
if isRowEmpty(rawRow) {
|
||||
continue
|
||||
}
|
||||
|
||||
parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes, seenKandangIDs)
|
||||
if len(rowIssues) > 0 {
|
||||
issues = append(issues, rowIssues...)
|
||||
continue
|
||||
}
|
||||
|
||||
rows = append(rows, *parsed)
|
||||
}
|
||||
|
||||
if len(rows) == 0 && len(issues) == 0 {
|
||||
issues = append(issues, validationIssue{Field: "rows", Message: "no data rows found"})
|
||||
}
|
||||
|
||||
return sheetName, rows, issues, nil
|
||||
}
|
||||
|
||||
func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) {
|
||||
if workbook == nil {
|
||||
return "", fmt.Errorf("workbook is nil")
|
||||
}
|
||||
|
||||
sheets := workbook.GetSheetList()
|
||||
if len(sheets) == 0 {
|
||||
return "", fmt.Errorf("workbook has no sheets")
|
||||
}
|
||||
|
||||
if requestedSheet == "" {
|
||||
return sheets[0], nil
|
||||
}
|
||||
|
||||
for _, sheet := range sheets {
|
||||
if strings.EqualFold(strings.TrimSpace(sheet), strings.TrimSpace(requestedSheet)) {
|
||||
return sheet, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("sheet %q not found", requestedSheet)
|
||||
}
|
||||
|
||||
func parseHeaderIndexes(headerRow []string) (headerIndexes, []validationIssue) {
|
||||
indexes := headerIndexes{KandangID: -1, KandangName: -1, HouseType: -1}
|
||||
issues := make([]validationIssue, 0)
|
||||
|
||||
for idx, raw := range headerRow {
|
||||
header := normalizeHeader(raw)
|
||||
if header == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch header {
|
||||
case "kandang_id":
|
||||
if indexes.KandangID >= 0 {
|
||||
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header kandang_id"})
|
||||
}
|
||||
indexes.KandangID = idx
|
||||
case "kandang_name":
|
||||
if indexes.KandangName >= 0 {
|
||||
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header kandang_name"})
|
||||
}
|
||||
indexes.KandangName = idx
|
||||
case "house_type", "type_house":
|
||||
if indexes.HouseType >= 0 {
|
||||
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header house_type"})
|
||||
}
|
||||
indexes.HouseType = idx
|
||||
}
|
||||
}
|
||||
|
||||
if indexes.KandangID < 0 {
|
||||
issues = append(issues, validationIssue{Field: "kandang_id", Message: "required header is missing"})
|
||||
}
|
||||
if indexes.KandangName < 0 {
|
||||
issues = append(issues, validationIssue{Field: "kandang_name", Message: "required header is missing"})
|
||||
}
|
||||
if indexes.HouseType < 0 {
|
||||
issues = append(issues, validationIssue{Field: "house_type", Message: "required header is missing"})
|
||||
}
|
||||
|
||||
return indexes, issues
|
||||
}
|
||||
|
||||
func parseDataRow(
|
||||
rawRow []string,
|
||||
rowNumber int,
|
||||
indexes headerIndexes,
|
||||
seenKandangIDs map[uint]int,
|
||||
) (*kandangHouseTypeImportRow, []validationIssue) {
|
||||
issues := make([]validationIssue, 0)
|
||||
|
||||
kandangIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.KandangID))
|
||||
kandangID, err := parsePositiveUint(kandangIDRaw)
|
||||
if err != nil {
|
||||
issues = append(issues, validationIssue{Row: rowNumber, Field: "kandang_id", Message: err.Error()})
|
||||
}
|
||||
|
||||
kandangNameRaw := strings.TrimSpace(cellValue(rawRow, indexes.KandangName))
|
||||
if kandangNameRaw == "" {
|
||||
issues = append(issues, validationIssue{Row: rowNumber, Field: "kandang_name", Message: "is required"})
|
||||
}
|
||||
|
||||
houseTypeRaw := strings.TrimSpace(cellValue(rawRow, indexes.HouseType))
|
||||
houseType, err := normalizeHouseType(houseTypeRaw)
|
||||
if err != nil {
|
||||
issues = append(issues, validationIssue{Row: rowNumber, Field: "house_type", Message: err.Error()})
|
||||
}
|
||||
|
||||
if kandangID > 0 {
|
||||
if previousRow, exists := seenKandangIDs[kandangID]; exists {
|
||||
issues = append(issues, validationIssue{
|
||||
Row: rowNumber,
|
||||
Field: "kandang_id",
|
||||
Message: fmt.Sprintf("duplicate value %d (already used in row %d)", kandangID, previousRow),
|
||||
})
|
||||
} else {
|
||||
seenKandangIDs[kandangID] = rowNumber
|
||||
}
|
||||
}
|
||||
|
||||
if len(issues) > 0 {
|
||||
return nil, issues
|
||||
}
|
||||
|
||||
return &kandangHouseTypeImportRow{
|
||||
RowNumber: rowNumber,
|
||||
KandangID: kandangID,
|
||||
KandangName: kandangNameRaw,
|
||||
HouseType: houseType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeHouseType(raw string) (string, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(raw))
|
||||
if normalized == "" {
|
||||
return string(utils.HouseTypeOpenHouse), nil
|
||||
}
|
||||
|
||||
switch normalized {
|
||||
case string(utils.HouseTypeOpenHouse), string(utils.HouseTypeCloseHouse):
|
||||
return normalized, nil
|
||||
default:
|
||||
return "", fmt.Errorf("must be one of: open_house, close_house (or empty for default open_house)")
|
||||
}
|
||||
}
|
||||
|
||||
func parsePositiveUint(raw string) (uint, error) {
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("is required")
|
||||
}
|
||||
|
||||
uintValue, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err == nil {
|
||||
if uintValue == 0 {
|
||||
return 0, fmt.Errorf("must be greater than 0")
|
||||
}
|
||||
return uint(uintValue), nil
|
||||
}
|
||||
|
||||
floatValue, floatErr := strconv.ParseFloat(raw, 64)
|
||||
if floatErr != nil {
|
||||
return 0, fmt.Errorf("must be a positive integer")
|
||||
}
|
||||
if floatValue <= 0 {
|
||||
return 0, fmt.Errorf("must be greater than 0")
|
||||
}
|
||||
if floatValue != float64(uint(floatValue)) {
|
||||
return 0, fmt.Errorf("must be a positive integer")
|
||||
}
|
||||
|
||||
return uint(floatValue), nil
|
||||
}
|
||||
|
||||
func isRowEmpty(row []string) bool {
|
||||
for _, cell := range row {
|
||||
if strings.TrimSpace(cell) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeHeader(raw string) string {
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
func cellValue(row []string, index int) string {
|
||||
if index < 0 || index >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return row[index]
|
||||
}
|
||||
|
||||
func collectKandangIDs(rows []kandangHouseTypeImportRow) []uint {
|
||||
ids := make([]uint, 0, len(rows))
|
||||
seen := make(map[uint]struct{}, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.KandangID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[row.KandangID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[row.KandangID] = struct{}{}
|
||||
ids = append(ids, row.KandangID)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
return ids
|
||||
}
|
||||
|
||||
func (r dbKandangResolver) ResolveActiveKandangs(ctx context.Context, kandangIDs []uint) (map[uint]string, error) {
|
||||
result := make(map[uint]string)
|
||||
if len(kandangIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
rows := make([]kandangIdentityRow, 0, len(kandangIDs))
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table("kandangs").
|
||||
Select("id, name").
|
||||
Where("id IN ?", kandangIDs).
|
||||
Where("deleted_at IS NULL").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
result[row.ID] = row.Name
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildMissingKandangIssues(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) []validationIssue {
|
||||
issues := make([]validationIssue, 0)
|
||||
for _, row := range rows {
|
||||
if _, exists := kandangNameByID[row.KandangID]; exists {
|
||||
continue
|
||||
}
|
||||
issues = append(issues, validationIssue{
|
||||
Row: row.RowNumber,
|
||||
Field: "kandang_id",
|
||||
Message: fmt.Sprintf("value %d must reference an active kandang", row.KandangID),
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func buildNameMismatchIssues(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) []validationIssue {
|
||||
issues := make([]validationIssue, 0)
|
||||
for _, row := range rows {
|
||||
dbName, exists := kandangNameByID[row.KandangID]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(row.KandangName), strings.TrimSpace(dbName)) {
|
||||
continue
|
||||
}
|
||||
|
||||
issues = append(issues, validationIssue{
|
||||
Row: row.RowNumber,
|
||||
Field: "kandang_name",
|
||||
Message: fmt.Sprintf("value %q does not match kandang_id %d name %q", row.KandangName, row.KandangID, dbName),
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func printPlanRows(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) {
|
||||
for _, row := range rows {
|
||||
fmt.Printf(
|
||||
"PLAN row=%d kandang_id=%d kandang_name_file=%q kandang_name_db=%q house_type=%q\n",
|
||||
row.RowNumber,
|
||||
row.KandangID,
|
||||
row.KandangName,
|
||||
kandangNameByID[row.KandangID],
|
||||
row.HouseType,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func sortValidationIssues(issues []validationIssue) {
|
||||
sort.Slice(issues, func(i, j int) bool {
|
||||
if issues[i].Row == issues[j].Row {
|
||||
if issues[i].Field == issues[j].Field {
|
||||
return issues[i].Message < issues[j].Message
|
||||
}
|
||||
return issues[i].Field < issues[j].Field
|
||||
}
|
||||
return issues[i].Row < issues[j].Row
|
||||
})
|
||||
}
|
||||
|
||||
func applyImportRows(
|
||||
ctx context.Context,
|
||||
runner txRunner,
|
||||
rows []kandangHouseTypeImportRow,
|
||||
) ([]applyRowResult, int64, error) {
|
||||
results := make([]applyRowResult, 0, len(rows))
|
||||
normalizedNullCount := int64(0)
|
||||
|
||||
err := runner.InTx(ctx, func(store kandangHouseTypeStore) error {
|
||||
for _, row := range rows {
|
||||
changed, err := store.UpdateKandangHouseType(ctx, row.KandangID, row.HouseType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("row %d kandang_id=%d update failed: %w", row.RowNumber, row.KandangID, err)
|
||||
}
|
||||
|
||||
results = append(results, applyRowResult{
|
||||
RowNumber: row.RowNumber,
|
||||
KandangID: row.KandangID,
|
||||
HouseType: row.HouseType,
|
||||
Changed: changed,
|
||||
})
|
||||
}
|
||||
|
||||
normalized, err := store.NormalizeNullHouseType(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("normalize null house_type to open_house failed: %w", err)
|
||||
}
|
||||
normalizedNullCount = normalized
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return results, normalizedNullCount, nil
|
||||
}
|
||||
|
||||
func (r dbTxRunner) InTx(ctx context.Context, fn func(store kandangHouseTypeStore) error) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return fn(dbKandangHouseTypeStore{db: tx})
|
||||
})
|
||||
}
|
||||
|
||||
func (s dbKandangHouseTypeStore) UpdateKandangHouseType(ctx context.Context, kandangID uint, houseType string) (bool, error) {
|
||||
result := s.db.WithContext(ctx).Exec(`
|
||||
UPDATE kandangs
|
||||
SET house_type = ?::house_type_enum,
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
AND deleted_at IS NULL
|
||||
AND house_type IS DISTINCT FROM ?::house_type_enum
|
||||
`, houseType, kandangID, houseType)
|
||||
if result.Error != nil {
|
||||
return false, result.Error
|
||||
}
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
func (s dbKandangHouseTypeStore) NormalizeNullHouseType(ctx context.Context) (int64, error) {
|
||||
result := s.db.WithContext(ctx).Exec(`
|
||||
UPDATE kandangs
|
||||
SET house_type = 'open_house'::house_type_enum,
|
||||
updated_at = NOW()
|
||||
WHERE deleted_at IS NULL
|
||||
AND house_type IS NULL
|
||||
`)
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
}
|
||||
return result.RowsAffected, nil
|
||||
}
|
||||
|
||||
func modeLabel(apply bool) string {
|
||||
if apply {
|
||||
return "APPLY"
|
||||
}
|
||||
return "DRY-RUN"
|
||||
}
|
||||
|
||||
func applyStatus(changed bool) string {
|
||||
if changed {
|
||||
return "UPDATED"
|
||||
}
|
||||
return "UNCHANGED"
|
||||
}
|
||||
|
||||
func countChangedRows(results []applyRowResult) int {
|
||||
count := 0
|
||||
for _, item := range results {
|
||||
if item.Changed {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
func TestParseKandangHouseTypeFile_ValidSingleRowAndDefaultHouseType(t *testing.T) {
|
||||
filePath := createWorkbook(
|
||||
t,
|
||||
"kandang_house_type",
|
||||
[]string{"kandang_id", "kandang_name", "house_type"},
|
||||
[][]string{{"101", "Kandang A1", ""}},
|
||||
)
|
||||
|
||||
sheet, rows, issues, err := parseKandangHouseTypeFile(filePath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if sheet != "kandang_house_type" {
|
||||
t.Fatalf("expected sheet kandang_house_type, got %q", sheet)
|
||||
}
|
||||
if len(issues) != 0 {
|
||||
t.Fatalf("expected no issues, got %+v", issues)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
||||
}
|
||||
if rows[0].KandangID != 101 {
|
||||
t.Fatalf("expected kandang_id 101, got %d", rows[0].KandangID)
|
||||
}
|
||||
if rows[0].KandangName != "Kandang A1" {
|
||||
t.Fatalf("expected kandang_name Kandang A1, got %q", rows[0].KandangName)
|
||||
}
|
||||
if rows[0].HouseType != "open_house" {
|
||||
t.Fatalf("expected default house_type open_house, got %q", rows[0].HouseType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseKandangHouseTypeFile_TypeHouseHeaderAlias(t *testing.T) {
|
||||
filePath := createWorkbook(
|
||||
t,
|
||||
"kandang_house_type",
|
||||
[]string{"kandang_id", "kandang_name", "type_house"},
|
||||
[][]string{{"101", "Kandang A1", "close_house"}},
|
||||
)
|
||||
|
||||
_, rows, issues, err := parseKandangHouseTypeFile(filePath, "kandang_house_type")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if len(issues) != 0 {
|
||||
t.Fatalf("expected no issues, got %+v", issues)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].HouseType != "close_house" {
|
||||
t.Fatalf("expected parsed close_house row, got %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseKandangHouseTypeFile_InvalidHouseType(t *testing.T) {
|
||||
filePath := createWorkbook(
|
||||
t,
|
||||
"kandang_house_type",
|
||||
[]string{"kandang_id", "kandang_name", "house_type"},
|
||||
[][]string{{"101", "Kandang A1", "semi_house"}},
|
||||
)
|
||||
|
||||
_, rows, issues, err := parseKandangHouseTypeFile(filePath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("expected no valid rows, got %d", len(rows))
|
||||
}
|
||||
if !hasIssue(issues, 2, "house_type", "must be one of") {
|
||||
t.Fatalf("expected invalid house_type issue, got %+v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseKandangHouseTypeFile_DuplicateKandangID(t *testing.T) {
|
||||
filePath := createWorkbook(
|
||||
t,
|
||||
"kandang_house_type",
|
||||
[]string{"kandang_id", "kandang_name", "house_type"},
|
||||
[][]string{
|
||||
{"101", "Kandang A1", "open_house"},
|
||||
{"101", "Kandang A2", "close_house"},
|
||||
},
|
||||
)
|
||||
|
||||
_, rows, issues, err := parseKandangHouseTypeFile(filePath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected first row valid and second invalid, got %d", len(rows))
|
||||
}
|
||||
if !hasIssue(issues, 3, "kandang_id", "duplicate value 101") {
|
||||
t.Fatalf("expected duplicate kandang_id issue, got %+v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNameMismatchIssues(t *testing.T) {
|
||||
rows := []kandangHouseTypeImportRow{{
|
||||
RowNumber: 2,
|
||||
KandangID: 10,
|
||||
KandangName: "Kandang Salah",
|
||||
HouseType: "open_house",
|
||||
}}
|
||||
|
||||
issues := buildNameMismatchIssues(rows, map[uint]string{10: "Kandang Benar"})
|
||||
if !hasIssue(issues, 2, "kandang_name", "does not match") {
|
||||
t.Fatalf("expected name mismatch issue, got %+v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyImportRows_Success(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
changedByID: map[uint]bool{101: true, 102: false},
|
||||
normalizeResult: 3,
|
||||
}
|
||||
runner := &fakeTransactionRunner{store: store}
|
||||
|
||||
rows := []kandangHouseTypeImportRow{
|
||||
{RowNumber: 2, KandangID: 101, HouseType: "open_house"},
|
||||
{RowNumber: 3, KandangID: 102, HouseType: "close_house"},
|
||||
}
|
||||
|
||||
results, normalized, err := applyImportRows(context.Background(), runner, rows)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if runner.txCalls != 1 {
|
||||
t.Fatalf("expected 1 tx call, got %d", runner.txCalls)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 row results, got %d", len(results))
|
||||
}
|
||||
if normalized != 3 {
|
||||
t.Fatalf("expected normalized count 3, got %d", normalized)
|
||||
}
|
||||
if !results[0].Changed || results[1].Changed {
|
||||
t.Fatalf("unexpected changed flags: %+v", results)
|
||||
}
|
||||
if len(store.updateCalls) != 2 {
|
||||
t.Fatalf("expected 2 update calls, got %d", len(store.updateCalls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyImportRows_FailOnUpdate(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
updateErrByID: map[uint]error{101: errors.New("boom")},
|
||||
}
|
||||
runner := &fakeTransactionRunner{store: store}
|
||||
|
||||
rows := []kandangHouseTypeImportRow{{RowNumber: 2, KandangID: 101, HouseType: "open_house"}}
|
||||
|
||||
_, _, err := applyImportRows(context.Background(), runner, rows)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "update failed") {
|
||||
t.Fatalf("expected update failed error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountChangedRows(t *testing.T) {
|
||||
count := countChangedRows([]applyRowResult{{Changed: true}, {Changed: false}, {Changed: true}})
|
||||
if count != 2 {
|
||||
t.Fatalf("expected 2 changed rows, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeTransactionRunner struct {
|
||||
store *fakeStore
|
||||
txCalls int
|
||||
}
|
||||
|
||||
func (f *fakeTransactionRunner) InTx(_ context.Context, fn func(store kandangHouseTypeStore) error) error {
|
||||
f.txCalls++
|
||||
return fn(f.store)
|
||||
}
|
||||
|
||||
type updateCall struct {
|
||||
kandangID uint
|
||||
houseType string
|
||||
}
|
||||
|
||||
type fakeStore struct {
|
||||
updateCalls []updateCall
|
||||
changedByID map[uint]bool
|
||||
updateErrByID map[uint]error
|
||||
normalizeResult int64
|
||||
normalizeErr error
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpdateKandangHouseType(_ context.Context, kandangID uint, houseType string) (bool, error) {
|
||||
f.updateCalls = append(f.updateCalls, updateCall{kandangID: kandangID, houseType: houseType})
|
||||
if err, exists := f.updateErrByID[kandangID]; exists {
|
||||
return false, err
|
||||
}
|
||||
if changed, exists := f.changedByID[kandangID]; exists {
|
||||
return changed, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) NormalizeNullHouseType(_ context.Context) (int64, error) {
|
||||
if f.normalizeErr != nil {
|
||||
return 0, f.normalizeErr
|
||||
}
|
||||
return f.normalizeResult, nil
|
||||
}
|
||||
|
||||
func createWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string {
|
||||
t.Helper()
|
||||
|
||||
f := excelize.NewFile()
|
||||
if sheetName == "" {
|
||||
sheetName = "Sheet1"
|
||||
}
|
||||
defaultSheet := f.GetSheetName(0)
|
||||
if defaultSheet != sheetName {
|
||||
idx, err := f.NewSheet(sheetName)
|
||||
if err != nil {
|
||||
t.Fatalf("failed creating sheet: %v", err)
|
||||
}
|
||||
f.SetActiveSheet(idx)
|
||||
_ = f.DeleteSheet(defaultSheet)
|
||||
}
|
||||
|
||||
for idx, header := range headers {
|
||||
cell, err := excelize.CoordinatesToCellName(idx+1, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("failed computing header cell: %v", err)
|
||||
}
|
||||
if err := f.SetCellValue(sheetName, cell, header); err != nil {
|
||||
t.Fatalf("failed setting header cell: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for rowIdx, row := range rows {
|
||||
for colIdx, value := range row {
|
||||
cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
|
||||
if err != nil {
|
||||
t.Fatalf("failed computing row cell: %v", err)
|
||||
}
|
||||
if err := f.SetCellValue(sheetName, cell, value); err != nil {
|
||||
t.Fatalf("failed setting row cell: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "kandang_house_type.xlsx")
|
||||
if err := f.SaveAs(path); err != nil {
|
||||
t.Fatalf("failed saving workbook: %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
func hasIssue(issues []validationIssue, row int, field string, contains string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Row != row {
|
||||
continue
|
||||
}
|
||||
if issue.Field != field {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(issue.Message, contains) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type options struct {
|
||||
FilePath string
|
||||
Apply bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
var opts options
|
||||
flag.StringVar(&opts.FilePath, "file", "", "Path to .sql file (required)")
|
||||
flag.BoolVar(&opts.Apply, "apply", false, "Apply SQL to database. If false, run as dry-run")
|
||||
flag.Parse()
|
||||
|
||||
opts.FilePath = strings.TrimSpace(opts.FilePath)
|
||||
if opts.FilePath == "" {
|
||||
log.Fatal("--file is required")
|
||||
}
|
||||
|
||||
sqlContent, err := readSQLFile(opts.FilePath)
|
||||
if err != nil {
|
||||
log.Fatalf("failed reading sql file: %v", err)
|
||||
}
|
||||
|
||||
mode := "dry-run"
|
||||
if opts.Apply {
|
||||
mode = "apply"
|
||||
}
|
||||
fmt.Printf("Mode: %s\n", mode)
|
||||
fmt.Printf("File: %s\n", opts.FilePath)
|
||||
fmt.Printf("SQL bytes: %d\n", len(sqlContent))
|
||||
|
||||
if !opts.Apply {
|
||||
fmt.Println("Dry-run only. Add --apply to execute the SQL file.")
|
||||
return
|
||||
}
|
||||
|
||||
db := database.Connect(config.DBHost, config.DBName)
|
||||
if err := executeSQL(db, sqlContent); err != nil {
|
||||
log.Fatalf("failed executing sql file: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("DONE: SQL executed successfully")
|
||||
}
|
||||
|
||||
func readSQLFile(path string) (string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
sql := strings.TrimSpace(strings.TrimPrefix(string(raw), "\ufeff"))
|
||||
if sql == "" {
|
||||
return "", fmt.Errorf("sql file is empty")
|
||||
}
|
||||
|
||||
return sql, nil
|
||||
}
|
||||
|
||||
func executeSQL(db *gorm.DB, sql string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
return tx.Exec(sql).Error
|
||||
})
|
||||
}
|
||||
@@ -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,524 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
outputTable = "table"
|
||||
outputJSON = "json"
|
||||
|
||||
caseA = "A"
|
||||
caseB = "B"
|
||||
caseAll = "ALL"
|
||||
)
|
||||
|
||||
type options struct {
|
||||
Output string
|
||||
AreaName string
|
||||
KandangLocationName string
|
||||
DBSSLMode string
|
||||
VerifyCase string
|
||||
}
|
||||
|
||||
type sourceWarehouseCheck struct {
|
||||
AreaName string `json:"area_name"`
|
||||
KandangLocationName string `json:"kandang_location_name"`
|
||||
KandangID uint `json:"kandang_id"`
|
||||
KandangName string `json:"kandang_name"`
|
||||
SourceWarehouseID uint `json:"source_warehouse_id"`
|
||||
SourceWarehouseName string `json:"source_warehouse_name"`
|
||||
Case string `json:"case" gorm:"column:case_type"`
|
||||
DeletedAt *string `json:"deleted_at"`
|
||||
StockInProductWH float64 `json:"stock_in_product_wh"`
|
||||
ActivePurchaseItems int64 `json:"active_purchase_items"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type destinationWarehouseCheck struct {
|
||||
AreaName string `json:"area_name"`
|
||||
KandangLocationName string `json:"kandang_location_name"`
|
||||
FarmWarehouseID uint `json:"farm_warehouse_id"`
|
||||
FarmWarehouseName string `json:"farm_warehouse_name"`
|
||||
ProductID uint `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
CurrentQty float64 `json:"current_qty"`
|
||||
StockLogsTotal float64 `json:"stock_logs_total"`
|
||||
StockLogsCount int64 `json:"stock_logs_count"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type orphanedReferenceCheck struct {
|
||||
Table string `json:"table"`
|
||||
Column string `json:"column"`
|
||||
ReferenceCount int64 `json:"reference_count"`
|
||||
DeletedWarehouseIDs string `json:"deleted_warehouse_ids"`
|
||||
}
|
||||
|
||||
type verificationSummary struct {
|
||||
TotalSourceWarehouses int `json:"total_source_warehouses"`
|
||||
CleanSourceWarehouses int `json:"clean_source_warehouses"`
|
||||
DirtySourceWarehouses int `json:"dirty_source_warehouses"`
|
||||
TotalDestinationWarehouses int `json:"total_destination_warehouses"`
|
||||
MatchingDestinations int `json:"matching_destinations"`
|
||||
DiscrepancyDestinations int `json:"discrepancy_destinations"`
|
||||
TotalOrphanedReferences int64 `json:"total_orphaned_references"`
|
||||
OverallStatus string `json:"overall_status"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
opts, err := parseFlags()
|
||||
if err != nil {
|
||||
log.Fatalf("invalid flags: %v", err)
|
||||
}
|
||||
|
||||
if opts.DBSSLMode != "" {
|
||||
config.DBSSLMode = opts.DBSSLMode
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
db := database.Connect(config.DBHost, config.DBName)
|
||||
|
||||
// Verify source warehouses
|
||||
sourceChecks, err := verifySourceWarehouses(ctx, db, opts)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to verify source warehouses: %v", err)
|
||||
}
|
||||
|
||||
// Verify destination warehouses
|
||||
destChecks, err := verifyDestinationWarehouses(ctx, db, opts, sourceChecks)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to verify destination warehouses: %v", err)
|
||||
}
|
||||
|
||||
// Verify no orphaned references
|
||||
orphanedRefs, err := verifyOrphanedReferences(ctx, db, sourceChecks)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to verify orphaned references: %v", err)
|
||||
}
|
||||
|
||||
// Render results
|
||||
summary := buildSummary(sourceChecks, destChecks, orphanedRefs)
|
||||
renderVerification(opts.Output, sourceChecks, destChecks, orphanedRefs, summary)
|
||||
}
|
||||
|
||||
func parseFlags() (*options, error) {
|
||||
var opts options
|
||||
flag.StringVar(&opts.Output, "output", outputTable, "Output format: table or json")
|
||||
flag.StringVar(&opts.AreaName, "area-name", "", "Optional exact area name filter")
|
||||
flag.StringVar(&opts.KandangLocationName, "kandang-location-name", "", "Optional exact canonical kandang location filter")
|
||||
flag.StringVar(&opts.DBSSLMode, "db-sslmode", "", "Optional database sslmode override, for example: require")
|
||||
flag.StringVar(&opts.VerifyCase, "verify-case", caseAll, "Verify specific case: A, B, or all")
|
||||
flag.Parse()
|
||||
|
||||
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
|
||||
opts.AreaName = strings.TrimSpace(opts.AreaName)
|
||||
opts.KandangLocationName = strings.TrimSpace(opts.KandangLocationName)
|
||||
opts.DBSSLMode = strings.TrimSpace(opts.DBSSLMode)
|
||||
opts.VerifyCase = strings.ToUpper(strings.TrimSpace(opts.VerifyCase))
|
||||
|
||||
if opts.Output == "" {
|
||||
opts.Output = outputTable
|
||||
}
|
||||
if opts.Output != outputTable && opts.Output != outputJSON {
|
||||
return nil, fmt.Errorf("unsupported --output=%s", opts.Output)
|
||||
}
|
||||
if opts.VerifyCase == "" {
|
||||
opts.VerifyCase = caseAll
|
||||
}
|
||||
if opts.VerifyCase != caseA && opts.VerifyCase != caseB && opts.VerifyCase != caseAll {
|
||||
return nil, fmt.Errorf("unsupported --verify-case=%s", opts.VerifyCase)
|
||||
}
|
||||
|
||||
return &opts, nil
|
||||
}
|
||||
|
||||
func verifySourceWarehouses(ctx context.Context, db *gorm.DB, opts *options) ([]sourceWarehouseCheck, error) {
|
||||
filters, args := buildFilters(opts)
|
||||
query := fmt.Sprintf(`
|
||||
WITH case_a_warehouses AS (
|
||||
-- Case A: Kandang-level warehouses (type != 'LOKASI' or NULL)
|
||||
SELECT
|
||||
w.id,
|
||||
a.name AS area_name,
|
||||
kl.name AS kandang_location_name,
|
||||
k.id,
|
||||
k.name,
|
||||
'A'::text AS case_type
|
||||
FROM warehouses w
|
||||
JOIN kandangs k ON k.id = w.kandang_id AND k.deleted_at IS NULL
|
||||
JOIN locations kl ON kl.id = k.location_id
|
||||
JOIN areas a ON a.id = kl.area_id
|
||||
WHERE w.deleted_at IS NOT NULL
|
||||
AND w.kandang_id IS NOT NULL
|
||||
AND UPPER(COALESCE(w.type, '')) <> 'LOKASI'
|
||||
),
|
||||
case_b_warehouses AS (
|
||||
-- Case B: Wrong-location warehouses (location_id != kandang.location_id)
|
||||
SELECT
|
||||
w.id,
|
||||
a.name AS area_name,
|
||||
kl.name AS kandang_location_name,
|
||||
k.id,
|
||||
k.name,
|
||||
'B'::text AS case_type
|
||||
FROM warehouses w
|
||||
JOIN kandangs k ON k.id = w.kandang_id AND k.deleted_at IS NULL
|
||||
JOIN locations kl ON kl.id = k.location_id
|
||||
JOIN areas a ON a.id = kl.area_id
|
||||
WHERE w.deleted_at IS NOT NULL
|
||||
AND w.kandang_id IS NOT NULL
|
||||
AND w.location_id IS DISTINCT FROM k.location_id
|
||||
),
|
||||
all_source_warehouses AS (
|
||||
SELECT w_id, area_name, kandang_location_name, k_id AS kandang_id, name, case_type FROM (
|
||||
SELECT w.id as w_id, a.name AS area_name, kl.name AS kandang_location_name, k.id as k_id, k.name, 'A'::text AS case_type
|
||||
FROM warehouses w
|
||||
JOIN kandangs k ON k.id = w.kandang_id AND k.deleted_at IS NULL
|
||||
JOIN locations kl ON kl.id = k.location_id
|
||||
JOIN areas a ON a.id = kl.area_id
|
||||
WHERE w.deleted_at IS NOT NULL
|
||||
AND w.kandang_id IS NOT NULL
|
||||
AND UPPER(COALESCE(w.type, '')) <> 'LOKASI'
|
||||
) case_a_warehouses
|
||||
UNION ALL
|
||||
SELECT w_id, area_name, kandang_location_name, k_id AS kandang_id, name, case_type FROM (
|
||||
SELECT w.id as w_id, a.name AS area_name, kl.name AS kandang_location_name, k.id as k_id, k.name, 'B'::text AS case_type
|
||||
FROM warehouses w
|
||||
JOIN kandangs k ON k.id = w.kandang_id AND k.deleted_at IS NULL
|
||||
JOIN locations kl ON kl.id = k.location_id
|
||||
JOIN areas a ON a.id = kl.area_id
|
||||
WHERE w.deleted_at IS NOT NULL
|
||||
AND w.kandang_id IS NOT NULL
|
||||
AND w.location_id IS DISTINCT FROM k.location_id
|
||||
) case_b_warehouses
|
||||
)
|
||||
SELECT
|
||||
asw.area_name,
|
||||
asw.kandang_location_name,
|
||||
asw.kandang_id,
|
||||
asw.name AS kandang_name,
|
||||
w.id AS source_warehouse_id,
|
||||
w.name AS source_warehouse_name,
|
||||
asw.case_type,
|
||||
TO_CHAR(w.deleted_at, 'YYYY-MM-DD') AS deleted_at,
|
||||
COALESCE(SUM(pw.qty), 0) AS stock_in_product_wh,
|
||||
COUNT(DISTINCT pi.id) AS active_purchase_items
|
||||
FROM all_source_warehouses asw
|
||||
JOIN warehouses w ON w.id = asw.w_id
|
||||
LEFT JOIN product_warehouses pw ON pw.warehouse_id = w.id
|
||||
LEFT JOIN purchase_items pi ON pi.warehouse_id = w.id
|
||||
WHERE true
|
||||
%s
|
||||
GROUP BY
|
||||
asw.area_name,
|
||||
asw.kandang_location_name,
|
||||
asw.kandang_id,
|
||||
asw.name,
|
||||
w.id,
|
||||
w.name,
|
||||
asw.case_type,
|
||||
w.deleted_at
|
||||
ORDER BY asw.area_name ASC, asw.kandang_location_name ASC, w.name ASC
|
||||
`, andClause(filters))
|
||||
|
||||
rows := make([]sourceWarehouseCheck, 0)
|
||||
if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Determine status for each row
|
||||
for i := range rows {
|
||||
if rows[i].StockInProductWH == 0 && rows[i].ActivePurchaseItems == 0 {
|
||||
rows[i].Status = "CLEAN"
|
||||
} else {
|
||||
rows[i].Status = "DIRTY"
|
||||
}
|
||||
|
||||
// Filter by case if requested
|
||||
if opts.VerifyCase != caseAll && rows[i].Case != opts.VerifyCase {
|
||||
rows = append(rows[:i], rows[i+1:]...)
|
||||
i--
|
||||
}
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func verifyDestinationWarehouses(ctx context.Context, db *gorm.DB, opts *options, sourceChecks []sourceWarehouseCheck) ([]destinationWarehouseCheck, error) {
|
||||
filters, args := buildFilters(opts)
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
a.name AS area_name,
|
||||
kl.name AS kandang_location_name,
|
||||
fw.id AS farm_warehouse_id,
|
||||
fw.name AS farm_warehouse_name,
|
||||
p.id AS product_id,
|
||||
p.name AS product_name,
|
||||
COALESCE(pw.qty, 0) AS current_qty,
|
||||
COALESCE(SUM(sl.stock), 0) AS stock_logs_total,
|
||||
COUNT(DISTINCT sl.id) AS stock_logs_count
|
||||
FROM warehouses fw
|
||||
JOIN locations kl ON kl.id = fw.location_id
|
||||
JOIN areas a ON a.id = kl.area_id
|
||||
JOIN product_warehouses pw ON pw.warehouse_id = fw.id
|
||||
JOIN products p ON p.id = pw.product_id
|
||||
JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = 'products' AND UPPER(f.name) IN ('PAKAN', 'OVK')
|
||||
LEFT JOIN stock_logs sl ON sl.product_warehouse_id = pw.id
|
||||
WHERE fw.deleted_at IS NULL
|
||||
AND UPPER(COALESCE(fw.type, '')) = 'LOKASI'
|
||||
%s
|
||||
GROUP BY
|
||||
a.name,
|
||||
kl.name,
|
||||
fw.id,
|
||||
fw.name,
|
||||
p.id,
|
||||
p.name,
|
||||
pw.qty
|
||||
ORDER BY a.name ASC, kl.name ASC, fw.name ASC, p.name ASC
|
||||
`, andClause(filters))
|
||||
|
||||
rows := make([]destinationWarehouseCheck, 0)
|
||||
if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Determine status: check if current_qty matches stock_logs
|
||||
for i := range rows {
|
||||
if rows[i].CurrentQty > 0 {
|
||||
// Allow small floating point discrepancies
|
||||
if abs(rows[i].CurrentQty-rows[i].StockLogsTotal) < 0.001 {
|
||||
rows[i].Status = "MATCHED"
|
||||
} else {
|
||||
rows[i].Status = "DISCREPANCY"
|
||||
}
|
||||
} else {
|
||||
rows[i].Status = "EMPTY"
|
||||
}
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func verifyOrphanedReferences(ctx context.Context, db *gorm.DB, sourceChecks []sourceWarehouseCheck) ([]orphanedReferenceCheck, error) {
|
||||
if len(sourceChecks) == 0 {
|
||||
return []orphanedReferenceCheck{}, nil
|
||||
}
|
||||
|
||||
// Get unique warehouse IDs from source checks
|
||||
warehouseIDs := make([]uint, 0)
|
||||
for _, check := range sourceChecks {
|
||||
warehouseIDs = append(warehouseIDs, check.SourceWarehouseID)
|
||||
}
|
||||
|
||||
// Check common references
|
||||
var results []orphanedReferenceCheck
|
||||
|
||||
refChecks := []struct {
|
||||
table string
|
||||
column string
|
||||
}{
|
||||
{"purchase_items", "warehouse_id"},
|
||||
{"stock_transfers", "from_warehouse_id"},
|
||||
{"stock_transfers", "to_warehouse_id"},
|
||||
}
|
||||
|
||||
for _, ref := range refChecks {
|
||||
var count int64
|
||||
if err := db.Table(ref.table).
|
||||
Where(fmt.Sprintf("%s IN ?", ref.column), warehouseIDs).
|
||||
Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
// Get the specific warehouse IDs using raw SQL
|
||||
var ids []uint
|
||||
query := fmt.Sprintf("SELECT DISTINCT %s FROM %s WHERE %s IN ?",
|
||||
ref.column, ref.table, ref.column)
|
||||
if err := db.Raw(query, warehouseIDs).Scan(&ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
idStrs := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
idStrs = append(idStrs, fmt.Sprintf("%d", id))
|
||||
}
|
||||
|
||||
results = append(results, orphanedReferenceCheck{
|
||||
Table: ref.table,
|
||||
Column: ref.column,
|
||||
ReferenceCount: count,
|
||||
DeletedWarehouseIDs: strings.Join(idStrs, ", "),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func buildFilters(opts *options) ([]string, []any) {
|
||||
filters := make([]string, 0, 2)
|
||||
args := make([]any, 0, 2)
|
||||
if opts.AreaName != "" {
|
||||
filters = append(filters, "a.name = ?")
|
||||
args = append(args, opts.AreaName)
|
||||
}
|
||||
if opts.KandangLocationName != "" {
|
||||
filters = append(filters, "kl.name = ?")
|
||||
args = append(args, opts.KandangLocationName)
|
||||
}
|
||||
return filters, args
|
||||
}
|
||||
|
||||
func andClause(filters []string) string {
|
||||
if len(filters) == 0 {
|
||||
return ""
|
||||
}
|
||||
return " AND " + strings.Join(filters, " AND ")
|
||||
}
|
||||
|
||||
func buildSummary(sourceChecks []sourceWarehouseCheck, destChecks []destinationWarehouseCheck, orphanedRefs []orphanedReferenceCheck) verificationSummary {
|
||||
summary := verificationSummary{
|
||||
TotalSourceWarehouses: len(sourceChecks),
|
||||
OverallStatus: "PASS",
|
||||
}
|
||||
|
||||
for _, check := range sourceChecks {
|
||||
if check.Status == "CLEAN" {
|
||||
summary.CleanSourceWarehouses++
|
||||
} else {
|
||||
summary.DirtySourceWarehouses++
|
||||
summary.OverallStatus = "FAIL"
|
||||
}
|
||||
}
|
||||
|
||||
summary.TotalDestinationWarehouses = len(destChecks)
|
||||
for _, check := range destChecks {
|
||||
if check.Status == "MATCHED" || check.Status == "EMPTY" {
|
||||
summary.MatchingDestinations++
|
||||
} else if check.Status == "DISCREPANCY" {
|
||||
summary.DiscrepancyDestinations++
|
||||
summary.OverallStatus = "FAIL"
|
||||
}
|
||||
}
|
||||
|
||||
for _, ref := range orphanedRefs {
|
||||
summary.TotalOrphanedReferences += ref.ReferenceCount
|
||||
summary.OverallStatus = "FAIL"
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
func renderVerification(mode string, sourceChecks []sourceWarehouseCheck, destChecks []destinationWarehouseCheck, orphanedRefs []orphanedReferenceCheck, summary verificationSummary) {
|
||||
if mode == outputJSON {
|
||||
payload := map[string]any{
|
||||
"source_warehouses": sourceChecks,
|
||||
"destination_warehouses": destChecks,
|
||||
"orphaned_references": orphanedRefs,
|
||||
"summary": summary,
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(payload)
|
||||
return
|
||||
}
|
||||
|
||||
// Table mode
|
||||
fmt.Println("\n=== SOURCE WAREHOUSES VERIFICATION ===")
|
||||
if len(sourceChecks) == 0 {
|
||||
fmt.Println("No deleted warehouses found")
|
||||
} else {
|
||||
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "AREA\tLOKASI\tKANDANG\tWAREHOUSE\tCASE\tDELETED_AT\tSTOCK_IN_PW\tPURCHASE_ITEMS\tSTATUS")
|
||||
for _, check := range sourceChecks {
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
"%s\t%s\t%s\t%s\t%s\t%s\t%.3f\t%d\t%s\n",
|
||||
check.AreaName,
|
||||
check.KandangLocationName,
|
||||
check.KandangName,
|
||||
check.SourceWarehouseName,
|
||||
check.Case,
|
||||
displayOptionalString(check.DeletedAt),
|
||||
check.StockInProductWH,
|
||||
check.ActivePurchaseItems,
|
||||
check.Status,
|
||||
)
|
||||
}
|
||||
_ = w.Flush()
|
||||
}
|
||||
|
||||
fmt.Println("\n=== DESTINATION WAREHOUSES VERIFICATION ===")
|
||||
if len(destChecks) == 0 {
|
||||
fmt.Println("No destination warehouses found")
|
||||
} else {
|
||||
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "AREA\tLOKASI\tFARM_WAREHOUSE\tPRODUCT\tCURRENT_QTY\tSTOCK_LOGS_TOTAL\tLOGS_COUNT\tSTATUS")
|
||||
for _, check := range destChecks {
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
"%s\t%s\t%s\t%s\t%.3f\t%.3f\t%d\t%s\n",
|
||||
check.AreaName,
|
||||
check.KandangLocationName,
|
||||
check.FarmWarehouseName,
|
||||
check.ProductName,
|
||||
check.CurrentQty,
|
||||
check.StockLogsTotal,
|
||||
check.StockLogsCount,
|
||||
check.Status,
|
||||
)
|
||||
}
|
||||
_ = w.Flush()
|
||||
}
|
||||
|
||||
if len(orphanedRefs) > 0 {
|
||||
fmt.Println("\n=== ORPHANED REFERENCES (ERRORS) ===")
|
||||
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "TABLE\tCOLUMN\tCOUNT\tWAREHOUSE_IDS")
|
||||
for _, ref := range orphanedRefs {
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
"%s\t%s\t%d\t%s\n",
|
||||
ref.Table,
|
||||
ref.Column,
|
||||
ref.ReferenceCount,
|
||||
ref.DeletedWarehouseIDs,
|
||||
)
|
||||
}
|
||||
_ = w.Flush()
|
||||
}
|
||||
|
||||
fmt.Printf("\n=== SUMMARY ===\n")
|
||||
fmt.Printf("Source Warehouses: %d total, %d clean, %d dirty\n", summary.TotalSourceWarehouses, summary.CleanSourceWarehouses, summary.DirtySourceWarehouses)
|
||||
fmt.Printf("Destination Warehouses: %d total, %d matching, %d discrepancies\n", summary.TotalDestinationWarehouses, summary.MatchingDestinations, summary.DiscrepancyDestinations)
|
||||
fmt.Printf("Orphaned References: %d\n", summary.TotalOrphanedReferences)
|
||||
fmt.Printf("Overall Status: %s\n", summary.OverallStatus)
|
||||
}
|
||||
|
||||
func displayOptionalString(value *string) string {
|
||||
if value == nil || strings.TrimSpace(*value) == "" {
|
||||
return "-"
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func abs(x float64) float64 {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -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,460 @@
|
||||
# Stock Consolidation Operations Guide
|
||||
|
||||
This guide explains how to use the warehouse consolidation commands to fix misplaced PAKAN/OVK stocks and migrate them to the correct farm-level warehouses.
|
||||
|
||||
## Overview
|
||||
|
||||
The stock consolidation system handles two main scenarios:
|
||||
|
||||
| Case | Scenario | Root Cause | Solution |
|
||||
|------|----------|-----------|----------|
|
||||
| **Case B** | Invalid kandang references | Purchases pointed to warehouses with location mismatch | Move unused stocks to correct farm-level warehouse |
|
||||
| **Case A** | General kandang cleanup | Any kandang-level warehouse with unused PAKAN/OVK stocks | Consolidate to farm-level warehouse |
|
||||
|
||||
## Recommended Execution Order
|
||||
|
||||
For a complete stock consolidation operation, follow this sequence:
|
||||
|
||||
```
|
||||
1. find-wrong-warehouse-records ← Diagnose issues
|
||||
2. repoint-wrong-warehouse-relations ← Fix Case B (invalid references)
|
||||
3. consolidate-kandang-to-farm-stocks ← Fix Case A (general cleanup)
|
||||
4. verify-stock-consolidation ← Audit and verify results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Command Reference
|
||||
|
||||
### 1. `find-wrong-warehouse-records` — Diagnostic Tool
|
||||
|
||||
**Purpose:** Identify problematic warehouses and their associated stocks before making any changes.
|
||||
|
||||
**Applies to:** Both Case A and Case B scenarios
|
||||
|
||||
**What it does:**
|
||||
- Lists warehouses with location mismatches (Case B)
|
||||
- Shows stock allocations that reference wrong warehouses
|
||||
- Helps identify scope of work needed
|
||||
|
||||
#### Usage:
|
||||
|
||||
```bash
|
||||
# Report 1: Find warehouses with location mismatches (Case B issues)
|
||||
./find-wrong-warehouse-records --report=warehouses
|
||||
|
||||
# Report 2: Find stock allocations in wrong warehouses (Case B impact)
|
||||
./find-wrong-warehouse-records --report=usage
|
||||
|
||||
# Filter by area
|
||||
./find-wrong-warehouse-records --report=warehouses --area-name "East Region"
|
||||
|
||||
# Filter by kandang location
|
||||
./find-wrong-warehouse-records --report=usage --kandang-location-name "Location 1"
|
||||
|
||||
# Filter by product type
|
||||
./find-wrong-warehouse-records --report=usage --usable-type=RECORDING_STOCK
|
||||
|
||||
# JSON output for analysis
|
||||
./find-wrong-warehouse-records --report=usage --output=json > analysis.json
|
||||
```
|
||||
|
||||
#### Output Columns (Warehouses Report):
|
||||
- **AREA**: Geographic area
|
||||
- **KANDANG_LOCATION**: Kandang's intended location
|
||||
- **KANDANG**: Kandang name
|
||||
- **WRONG_LOCATION**: Where the warehouse actually is
|
||||
- **WRONG_WAREHOUSE**: Problematic warehouse name
|
||||
- **CORRECT_WAREHOUSE**: Where stocks should be
|
||||
|
||||
#### Output Columns (Usage Report):
|
||||
- **USABLE_TYPE**: RECORDING_STOCK or MARKETING_DELIVERY
|
||||
- **PRODUCTS**: Which products are affected
|
||||
- **QTY_FROM_WRONG_STOCK**: How much stock is misplaced
|
||||
- **SOURCE_PURCHASES**: Which purchase orders are affected
|
||||
|
||||
**When to use:**
|
||||
- Before starting any consolidation
|
||||
- To understand the scope of issues
|
||||
- To get metrics on how much stock needs moving
|
||||
- To identify which areas are most affected
|
||||
|
||||
---
|
||||
|
||||
### 2. `repoint-wrong-warehouse-relations` — Fix Case B (Invalid References)
|
||||
|
||||
**Purpose:** Fix purchases pointed to invalid kandang warehouses (location mismatch).
|
||||
|
||||
**Applies to:** Case B only
|
||||
|
||||
**Cases it handles:**
|
||||
- ✅ Warehouses with `location_id ≠ kandang.location_id` (location mismatch)
|
||||
- ✅ Only PAKAN/OVK products
|
||||
- ✅ Only unused/leftover stocks (no active allocations)
|
||||
- ✅ Moves to farm-level warehouse at correct location
|
||||
|
||||
**What it does:**
|
||||
1. Finds product_warehouses in wrong locations
|
||||
2. Consolidates duplicates into survivor warehouses
|
||||
3. Updates all references across the system
|
||||
4. Recalculates FIFO stocks if needed
|
||||
5. Optionally soft-deletes the wrong warehouse
|
||||
|
||||
#### Usage:
|
||||
|
||||
```bash
|
||||
# Dry-run: See what would be moved (always run first!)
|
||||
./repoint-wrong-warehouse-relations
|
||||
|
||||
# Dry-run with specific filters
|
||||
./repoint-wrong-warehouse-relations --area-name "East Region"
|
||||
./repoint-wrong-warehouse-relations --kandang-location-name "Location 1"
|
||||
|
||||
# Actually apply the migration
|
||||
./repoint-wrong-warehouse-relations --apply
|
||||
|
||||
# Apply but keep the wrong warehouses (for audit trail)
|
||||
./repoint-wrong-warehouse-relations --apply --delete-wrong-warehouses=false
|
||||
|
||||
# JSON output for automation/logging
|
||||
./repoint-wrong-warehouse-relations --apply --output=json > migration.json
|
||||
```
|
||||
|
||||
#### Flags:
|
||||
- `--apply`: Apply changes (omit for dry-run)
|
||||
- `--output`: `table` (default) or `json`
|
||||
- `--area-name`: Filter by exact area name
|
||||
- `--kandang-location-name`: Filter by exact location name
|
||||
- `--delete-wrong-warehouses`: Soft-delete wrong warehouses (default: true)
|
||||
- `--db-sslmode`: PostgreSQL SSL mode override (e.g., `require`)
|
||||
|
||||
#### Output:
|
||||
|
||||
**Table mode shows:**
|
||||
- AREA, LOCATION, KANDANG: Where the issue is
|
||||
- WRONG_WAREHOUSE: Source (will be deleted)
|
||||
- TARGET_WAREHOUSE: Destination (farm-level)
|
||||
- PRODUCT: What's being moved
|
||||
- SURVIVOR_PW / ABSORBED_PW: Consolidation details
|
||||
- NEEDS_REFLOW: Whether FIFO recalculation is needed
|
||||
|
||||
**Summary shows:**
|
||||
```
|
||||
Summary: plan_rows=15 wrong_warehouses=3 survivor_pws=12 absorbed_pws=5
|
||||
needs_reflow_pws=3 deleted_product_warehouses=5 soft_deleted_warehouses=3
|
||||
|
||||
Updated product_warehouse refs:
|
||||
fifo_stock_v2_operation_log.product_warehouse_id=8
|
||||
fifo_stock_v2_reflow_checkpoints.product_warehouse_id=3
|
||||
purchase_items.warehouse_id=12
|
||||
|
||||
Updated warehouse refs:
|
||||
purchase_items.warehouse_id=12
|
||||
```
|
||||
|
||||
#### Safety Features:
|
||||
- **Dry-run first**: Always preview before applying
|
||||
- **Prechecks**: Verifies no blocked references or FIFO conflicts
|
||||
- **Atomic transactions**: All-or-nothing database updates
|
||||
- **Reference verification**: Confirms all references were updated
|
||||
- **Stock log recalculation**: Ensures FIFO accuracy after moves
|
||||
|
||||
---
|
||||
|
||||
### 3. `consolidate-kandang-to-farm-stocks` — Fix Case A (General Cleanup)
|
||||
|
||||
**Purpose:** Consolidate ALL kandang-level PAKAN/OVK stocks to farm-level warehouse.
|
||||
|
||||
**Applies to:** Case A only
|
||||
|
||||
**Cases it handles:**
|
||||
- ✅ ALL kandang-level warehouses (type ≠ 'LOKASI')
|
||||
- ✅ Only PAKAN/OVK products
|
||||
- ✅ Only unused/leftover stocks (no active allocations)
|
||||
- ✅ Moves to farm-level warehouse regardless of warehouse validity
|
||||
- ✅ No location validation (processes all kandang warehouses)
|
||||
|
||||
**What it does:**
|
||||
1. Finds all kandang-level warehouses with unused stocks
|
||||
2. Consolidates duplicates into survivor warehouses
|
||||
3. Updates all references across the system
|
||||
4. Recalculates FIFO stocks if needed
|
||||
5. Optionally soft-deletes the kandang warehouse
|
||||
|
||||
#### Usage:
|
||||
|
||||
```bash
|
||||
# Dry-run: See what would be consolidated
|
||||
./consolidate-kandang-to-farm-stocks
|
||||
|
||||
# Dry-run with filters
|
||||
./consolidate-kandang-to-farm-stocks --area-name "East Region"
|
||||
./consolidate-kandang-to-farm-stocks --kandang-location-name "Location 1"
|
||||
|
||||
# Actually apply the consolidation
|
||||
./consolidate-kandang-to-farm-stocks --apply
|
||||
|
||||
# Apply but keep kandang warehouses
|
||||
./consolidate-kandang-to-farm-stocks --apply --delete-kandang-warehouses=false
|
||||
|
||||
# JSON output for logging
|
||||
./consolidate-kandang-to-farm-stocks --apply --output=json > consolidation.json
|
||||
```
|
||||
|
||||
#### Flags:
|
||||
- `--apply`: Apply changes (omit for dry-run)
|
||||
- `--output`: `table` (default) or `json`
|
||||
- `--area-name`: Filter by exact area name
|
||||
- `--kandang-location-name`: Filter by exact location name
|
||||
- `--delete-kandang-warehouses`: Soft-delete kandang warehouses (default: true)
|
||||
- `--db-sslmode`: PostgreSQL SSL mode override
|
||||
|
||||
#### Output Format:
|
||||
Similar to Case B, shows:
|
||||
- Source kandang warehouse → Destination farm warehouse
|
||||
- Product and quantity details
|
||||
- Consolidation and FIFO reflow information
|
||||
|
||||
#### Key Differences from Case B:
|
||||
| Aspect | Case B | Case A |
|
||||
|--------|--------|--------|
|
||||
| Scope | Wrong-location warehouses only | ALL kandang-level warehouses |
|
||||
| Validation | Checks location mismatch | No validation checks |
|
||||
| When to use | After finding mismatches | General cleanup/consolidation |
|
||||
| Risk level | Lower (targeted fix) | Higher (broader scope) |
|
||||
|
||||
---
|
||||
|
||||
### 4. `verify-stock-consolidation` — Audit and Verify
|
||||
|
||||
**Purpose:** Verify that stock consolidations were successful and no stocks were lost.
|
||||
|
||||
**Applies to:** Both Case A and Case B (post-migration verification)
|
||||
|
||||
**What it checks:**
|
||||
|
||||
#### ✅ Source Warehouse Verification
|
||||
Ensures deleted warehouses are clean:
|
||||
- **CLEAN**: No remaining stock or purchase references
|
||||
- **DIRTY**: Still has orphaned data (migration incomplete)
|
||||
|
||||
#### ✅ Destination Warehouse Verification
|
||||
Ensures farm-level warehouses received stocks correctly:
|
||||
- **MATCHED**: Quantity in product_warehouse matches stock_logs
|
||||
- **DISCREPANCY**: Quantity mismatch (data integrity issue!)
|
||||
- **EMPTY**: No stocks (correct if nothing was supposed to move)
|
||||
|
||||
#### ✅ Orphaned Reference Detection
|
||||
Finds any remaining references to deleted warehouses in:
|
||||
- `purchase_items.warehouse_id`
|
||||
- `stock_transfers.from/to_warehouse_id`
|
||||
- `fifo_stock_v2_operation_log.warehouse_id`
|
||||
|
||||
#### Usage:
|
||||
|
||||
```bash
|
||||
# Verify all consolidations (Case A + B together)
|
||||
./verify-stock-consolidation
|
||||
|
||||
# Verify only Case B results
|
||||
./verify-stock-consolidation --verify-case=B
|
||||
|
||||
# Verify only Case A results
|
||||
./verify-stock-consolidation --verify-case=A
|
||||
|
||||
# Filter by area
|
||||
./verify-stock-consolidation --area-name "East Region"
|
||||
|
||||
# Filter by location
|
||||
./verify-stock-consolidation --kandang-location-name "Location 1"
|
||||
|
||||
# JSON output for reporting
|
||||
./verify-stock-consolidation --output=json > verification_report.json
|
||||
```
|
||||
|
||||
#### Flags:
|
||||
- `--verify-case`: `A`, `B`, or `all` (default)
|
||||
- `--output`: `table` (default) or `json`
|
||||
- `--area-name`: Filter by exact area name
|
||||
- `--kandang-location-name`: Filter by exact location name
|
||||
- `--db-sslmode`: PostgreSQL SSL mode override
|
||||
|
||||
#### Output Sections:
|
||||
|
||||
**1. Source Warehouses**
|
||||
```
|
||||
AREA LOKASI KANDANG WAREHOUSE CASE DELETED_AT STOCK PURCHASES STATUS
|
||||
Area A Location 1 Kandang A KWH-A-01 A 2026-04-23 0.000 0 CLEAN
|
||||
Area A Location 1 Kandang B WH-WRONG-001 B 2026-04-23 2.500 1 DIRTY ❌
|
||||
```
|
||||
|
||||
**2. Destination Warehouses**
|
||||
```
|
||||
AREA LOKASI FARM_WAREHOUSE PRODUCT QTY LOGS_TOTAL LOGS STATUS
|
||||
Area A Location 1 FWH-LOC-001 PAKAN A 2.500 2.500 3 MATCHED ✅
|
||||
Area A Location 1 FWH-LOC-001 OVK B 5.000 4.999 5 DISCREPANCY ❌
|
||||
```
|
||||
|
||||
**3. Orphaned References** (if any)
|
||||
```
|
||||
TABLE COLUMN COUNT WAREHOUSE_IDS
|
||||
purchase_items warehouse_id 3 1001, 1002, 1003
|
||||
stock_transfers from_warehouse_id 1 1001
|
||||
```
|
||||
|
||||
**4. Summary**
|
||||
```
|
||||
Source Warehouses: 10 total, 8 clean, 2 dirty
|
||||
Destination Warehouses: 15 total, 14 matching, 1 discrepancy
|
||||
Orphaned References: 4
|
||||
Overall Status: FAIL ❌
|
||||
```
|
||||
|
||||
#### Interpreting Results:
|
||||
|
||||
| Scenario | Meaning | Action |
|
||||
|----------|---------|--------|
|
||||
| ✅ Overall Status: PASS | All migrations successful | No action needed |
|
||||
| ❌ Dirty Source Warehouses | Stocks not fully moved | Re-run repoint/consolidate |
|
||||
| ❌ Discrepancy Destinations | Quantity mismatch | Investigate data integrity |
|
||||
| ❌ Orphaned References | Broken references remain | Manual cleanup needed |
|
||||
|
||||
---
|
||||
|
||||
## Complete Workflow Example
|
||||
|
||||
### Scenario: Consolidate East Region stocks
|
||||
|
||||
```bash
|
||||
# Step 1: Understand the scope (Case B issues)
|
||||
./find-wrong-warehouse-records --report=warehouses --area-name "East Region"
|
||||
./find-wrong-warehouse-records --report=usage --area-name "East Region"
|
||||
|
||||
# Review the output to understand:
|
||||
# - How many wrong warehouses
|
||||
# - How much stock needs moving
|
||||
# - Which products are affected
|
||||
|
||||
# Step 2: Fix Case B (invalid kandang references)
|
||||
./repoint-wrong-warehouse-relations --area-name "East Region"
|
||||
# Review dry-run output
|
||||
|
||||
./repoint-wrong-warehouse-relations --apply --area-name "East Region"
|
||||
# Watch for summary - should show successful updates
|
||||
|
||||
# Step 3: Fix Case A (general kandang cleanup)
|
||||
./consolidate-kandang-to-farm-stocks --area-name "East Region"
|
||||
# Review dry-run output
|
||||
|
||||
./consolidate-kandang-to-farm-stocks --apply --area-name "East Region"
|
||||
# Watch for summary - should show consolidation complete
|
||||
|
||||
# Step 4: Verify everything worked
|
||||
./verify-stock-consolidation --area-name "East Region"
|
||||
# Should show:
|
||||
# - All source warehouses: CLEAN
|
||||
# - All destination warehouses: MATCHED
|
||||
# - Orphaned references: 0
|
||||
# - Overall Status: PASS ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flags Reference
|
||||
|
||||
### Common Flags (All Commands)
|
||||
|
||||
| Flag | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| `--output` | Output format | `--output=json` |
|
||||
| `--area-name` | Filter by area | `--area-name "East Region"` |
|
||||
| `--kandang-location-name` | Filter by location | `--kandang-location-name "Location 1"` |
|
||||
| `--db-sslmode` | PostgreSQL SSL mode | `--db-sslmode=require` |
|
||||
|
||||
### Migration-Specific Flags
|
||||
|
||||
| Command | Flag | Description |
|
||||
|---------|------|-------------|
|
||||
| `repoint-wrong-warehouse-relations` | `--apply` | Apply changes |
|
||||
| `repoint-wrong-warehouse-relations` | `--delete-wrong-warehouses` | Delete wrong warehouses (default: true) |
|
||||
| `consolidate-kandang-to-farm-stocks` | `--apply` | Apply changes |
|
||||
| `consolidate-kandang-to-farm-stocks` | `--delete-kandang-warehouses` | Delete kandang warehouses (default: true) |
|
||||
| `verify-stock-consolidation` | `--verify-case` | Verify specific case (A, B, or all) |
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Before Running Any Command
|
||||
|
||||
1. **Back up the database** — These operations modify stock data
|
||||
2. **Run in dry-run mode first** — Always preview changes before applying
|
||||
3. **Check during low-traffic periods** — Avoid peak hours
|
||||
4. **Have a rollback plan** — Know how to restore from backup if needed
|
||||
|
||||
### When Running Migrations
|
||||
|
||||
1. **Start small** — Use `--area-name` to test on one area first
|
||||
2. **Check the summary** — Verify numbers make sense
|
||||
3. **Watch for errors** — Stop if you see unexpected error messages
|
||||
4. **Run verification immediately after** — Don't wait to verify
|
||||
|
||||
### Red Flags (Stop and Investigate)
|
||||
|
||||
- ❌ More rows affected than expected
|
||||
- ❌ Negative quantities or zero counts where expecting data
|
||||
- ❌ Errors about blocked references
|
||||
- ❌ FIFO conflicts or in-flight artifacts
|
||||
- ❌ Very large numbers in NEEDS_REFLOW
|
||||
|
||||
### JSON Output for Automation
|
||||
|
||||
All commands support `--output=json` for:
|
||||
- Piping to other tools
|
||||
- Parsing in scripts
|
||||
- Generating reports
|
||||
- Integration with monitoring systems
|
||||
|
||||
```bash
|
||||
# Example: Extract all affected warehouses to CSV
|
||||
./find-wrong-warehouse-records --report=warehouses --output=json \
|
||||
| jq -r '.rows[] | [.area_name, .kandang_name, .wrong_warehouse_name] | @csv' \
|
||||
> affected_warehouses.csv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: "No wrong warehouse relations found"
|
||||
- **Cause**: No matching Case B issues in the filter scope
|
||||
- **Solution**: Remove filters or use different criteria
|
||||
|
||||
### Issue: "found X rows still point to wrong warehouses"
|
||||
- **Cause**: References not fully migrated
|
||||
- **Solution**: Check for blocked references, re-run command
|
||||
|
||||
### Issue: "discrepancy_destinations > 0" in verification
|
||||
- **Cause**: Quantity mismatch in farm warehouse
|
||||
- **Solution**: Investigate manually or rollback and retry
|
||||
|
||||
### Issue: "DIRTY source warehouses" in verification
|
||||
- **Cause**: Deleted warehouses still have stock/references
|
||||
- **Solution**: May need manual cleanup or re-run migrations
|
||||
|
||||
---
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- Commands use efficient SQL queries with proper filtering
|
||||
- Large operations (100K+ rows) may take a few minutes
|
||||
- Use area/location filters to reduce scope for testing
|
||||
- Dry-runs don't modify database and complete quickly
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
1. Review the relevant section of this guide
|
||||
2. Check the command output for specific error messages
|
||||
3. Run verification to diagnose state issues
|
||||
4. Contact the development team with JSON outputs from failed operations
|
||||
@@ -0,0 +1,76 @@
|
||||
# Farm Depreciation Manual Inputs Import
|
||||
|
||||
Command ini dipakai untuk bulk import data ke tabel `farm_depreciation_manual_inputs` dari file Excel (`.xlsx`).
|
||||
|
||||
## Command
|
||||
|
||||
```bash
|
||||
go run ./cmd/import-farm-depreciation-manual-inputs --file <path.xlsx> [--sheet <name>] [--apply]
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
- `--file` (required): path file `.xlsx`.
|
||||
- `--sheet` (optional): nama sheet. Jika tidak diisi, command pakai sheet pertama.
|
||||
- `--apply` (optional): default `false` (dry-run). Jika `true`, command menulis ke database.
|
||||
|
||||
## Mode
|
||||
|
||||
- Dry-run (default):
|
||||
- parsing dan validasi semua baris.
|
||||
- validasi `project_flock_id` terhadap farm aktif kategori `LAYING`.
|
||||
- menampilkan `PLAN` + daftar error.
|
||||
- tidak menulis data.
|
||||
|
||||
- Apply (`--apply`):
|
||||
- semua validasi tetap dijalankan dulu.
|
||||
- jika ada 1 error, proses dihentikan.
|
||||
- jika valid, upsert dijalankan dalam 1 transaksi (fail-fast).
|
||||
- setelah upsert, snapshot di `farm_depreciation_snapshots` dihapus mulai `cutover_date` untuk `project_flock_id` terkait.
|
||||
|
||||
## Format Excel
|
||||
|
||||
Template tersedia di:
|
||||
|
||||
- `docs/templates/farm_depreciation_manual_inputs.xlsx`
|
||||
|
||||
Header wajib ada di baris 1 (case-insensitive, trim-spaces):
|
||||
|
||||
- `project_flock_id` (required, integer > 0)
|
||||
- `total_cost` (required, numeric >= 0)
|
||||
- `cutover_date` (required, format `YYYY-MM-DD`)
|
||||
- `note` (optional, max 1000 karakter)
|
||||
|
||||
Catatan:
|
||||
|
||||
- Dalam 1 file tidak boleh ada duplikat `project_flock_id`.
|
||||
- `project_flock_id` harus mengarah ke `project_flocks` yang `deleted_at IS NULL` dan `category = LAYING`.
|
||||
|
||||
## Contoh
|
||||
|
||||
Dry-run:
|
||||
|
||||
```bash
|
||||
env DB_HOST=127.0.0.1 DB_PORT=5432 DB_NAME=lti DB_USER=postgres DB_PASSWORD=postgres \
|
||||
go run ./cmd/import-farm-depreciation-manual-inputs \
|
||||
--file docs/templates/farm_depreciation_manual_inputs.xlsx
|
||||
```
|
||||
|
||||
Apply:
|
||||
|
||||
```bash
|
||||
env DB_HOST=127.0.0.1 DB_PORT=5432 DB_NAME=lti DB_USER=postgres DB_PASSWORD=postgres \
|
||||
go run ./cmd/import-farm-depreciation-manual-inputs \
|
||||
--file /path/to/farm_depreciation_manual_inputs.xlsx \
|
||||
--sheet manual_inputs \
|
||||
--apply
|
||||
```
|
||||
|
||||
## Error Umum
|
||||
|
||||
- `required header is missing`: header wajib tidak ditemukan.
|
||||
- `must be a positive integer`: `project_flock_id` bukan integer valid.
|
||||
- `must be greater than or equal to 0`: `total_cost` negatif.
|
||||
- `must follow format YYYY-MM-DD`: `cutover_date` tidak sesuai format.
|
||||
- `duplicate value ...`: `project_flock_id` duplikat dalam file yang sama.
|
||||
- `must reference an active LAYING project_flock`: farm tidak valid untuk import ini.
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"_postman_exported_at": "2026-04-14T00:00:00Z",
|
||||
"_postman_exported_using": "Codex",
|
||||
"_postman_variable_scope": "environment",
|
||||
"id": "lti-read-api-local",
|
||||
"name": "LTI ERP Read API.local",
|
||||
"values": [
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "adjustment_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "api_key",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "area_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "bank_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "base_url",
|
||||
"value": "http://localhost:8081"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "bearer_token",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "chickin_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "customer_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "employee_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "expense_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "flock_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "idDailyChecklist",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "idProjectFlockKandang",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "initial_balance_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "injection_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "location_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "nonstock_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "payment_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "product_category_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "product_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "projectFlockId",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "project_flock_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "project_flock_kandang_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "purchase_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "recording_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "supplier_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "transaction_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "transfer_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "uniformity_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "uom_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "user_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "warehouse_id",
|
||||
"value": "1"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,113 @@
|
||||
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-pdf/fpdf v0.9.0 // 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,384 @@
|
||||
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-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
|
||||
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
|
||||
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.
@@ -0,0 +1,94 @@
|
||||
package apikeys
|
||||
|
||||
func DefaultDashboardPermissions() []string {
|
||||
return []string{
|
||||
"lti.approval.list",
|
||||
"lti.closing.list",
|
||||
"lti.closing.detail",
|
||||
"lti.daily_checklist.create",
|
||||
"lti.daily_checklist.dashboard.list",
|
||||
"lti.daily_checklist.detail",
|
||||
"lti.daily_checklist.list",
|
||||
"lti.daily_checklist.master_data.activity",
|
||||
"lti.daily_checklist.master_data.configuration",
|
||||
"lti.daily_checklist.master_data.employee",
|
||||
"lti.daily_checklist.reports",
|
||||
"lti.dashboard.list",
|
||||
"lti.expense.detail",
|
||||
"lti.expense.list",
|
||||
"lti.finance.initial_balances.detail",
|
||||
"lti.finance.injections.detail",
|
||||
"lti.finance.payments.detail",
|
||||
"lti.finance.transactions.detail",
|
||||
"lti.finance.transactions.list",
|
||||
"lti.inventory.detail",
|
||||
"lti.inventory.list",
|
||||
"lti.inventory.product_stock.detail",
|
||||
"lti.inventory.product_stock.list",
|
||||
"lti.inventory.product_warehouses.detail",
|
||||
"lti.inventory.product_warehouses.list",
|
||||
"lti.inventory.transfer.detail",
|
||||
"lti.inventory.transfer.list",
|
||||
"lti.marketing.delivery_order.detail",
|
||||
"lti.marketing.delivery_order.list",
|
||||
"lti.master.area.detail",
|
||||
"lti.master.area.list",
|
||||
"lti.master.banks.detail",
|
||||
"lti.master.banks.list",
|
||||
"lti.master.customer.detail",
|
||||
"lti.master.customer.list",
|
||||
"lti.master.fcr.detail",
|
||||
"lti.master.fcr.list",
|
||||
"lti.master.flocks.detail",
|
||||
"lti.master.flocks.list",
|
||||
"lti.master.kandangs.detail",
|
||||
"lti.master.kandangs.list",
|
||||
"lti.master.locations.detail",
|
||||
"lti.master.locations.list",
|
||||
"lti.master.nonstocks.detail",
|
||||
"lti.master.nonstocks.list",
|
||||
"lti.master.product_categories.detail",
|
||||
"lti.master.product_categories.list",
|
||||
"lti.master.products.detail",
|
||||
"lti.master.products.list",
|
||||
"lti.master.production_standards.detail",
|
||||
"lti.master.production_standards.list",
|
||||
"lti.master.suppliers.detail",
|
||||
"lti.master.suppliers.list",
|
||||
"lti.master.uoms.detail",
|
||||
"lti.master.uoms.list",
|
||||
"lti.master.warehouses.detail",
|
||||
"lti.master.warehouses.list",
|
||||
"lti.production.chickins.detail",
|
||||
"lti.production.project_flock_kandangs.closing.detail",
|
||||
"lti.production.project_flock_kandangs.detail",
|
||||
"lti.production.project_flock_kandangs.list",
|
||||
"lti.production.project_flocks.detail",
|
||||
"lti.production.project_flocks.list",
|
||||
"lti.production.project_flocks.lookup",
|
||||
"lti.production.project_flocks.next_period",
|
||||
"lti.production.recording.detail",
|
||||
"lti.production.recording.list",
|
||||
"lti.production.recording.next_day",
|
||||
"lti.production.transfer_to_laying.create",
|
||||
"lti.production.transfer_to_laying.detail",
|
||||
"lti.production.transfer_to_laying.getavailableqty",
|
||||
"lti.production.transfer_to_laying.list",
|
||||
"lti.production.uniformity.detail",
|
||||
"lti.production.uniformity.list",
|
||||
"lti.purchase.detail",
|
||||
"lti.purchase.list",
|
||||
"lti.repport.customerpayment.list",
|
||||
"lti.repport.debtsupplier.list",
|
||||
"lti.repport.delivery.list",
|
||||
"lti.repport.expense.list",
|
||||
"lti.repport.expense.depreciation.manage",
|
||||
"lti.repport.gethppperkandang.list",
|
||||
"lti.repport.production_result.list",
|
||||
"lti.repport.purchasesupplier.list",
|
||||
"lti.users.detail",
|
||||
"lti.users.list",
|
||||
"lti.daily_checklist.master_data.kandang",
|
||||
"lti.production.chickins.list",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package apikeys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, record *entity.IntegrationAPIKey) error
|
||||
GetByEnvironmentAndPrefix(ctx context.Context, environment, prefix string) (*entity.IntegrationAPIKey, error)
|
||||
List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error)
|
||||
Revoke(ctx context.Context, environment, prefix string, revokedAt time.Time) error
|
||||
TouchLastUsed(ctx context.Context, id uint, usedAt time.Time, usedFrom string) error
|
||||
}
|
||||
|
||||
type repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) Repository {
|
||||
return &repository{db: db}
|
||||
}
|
||||
|
||||
func (r *repository) Create(ctx context.Context, record *entity.IntegrationAPIKey) error {
|
||||
if r.db == nil {
|
||||
return errors.New("database not configured")
|
||||
}
|
||||
return r.db.WithContext(ctx).Create(record).Error
|
||||
}
|
||||
|
||||
func (r *repository) GetByEnvironmentAndPrefix(ctx context.Context, environment, prefix string) (*entity.IntegrationAPIKey, error) {
|
||||
if r.db == nil {
|
||||
return nil, errors.New("database not configured")
|
||||
}
|
||||
|
||||
var record entity.IntegrationAPIKey
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("environment = ?", environment).
|
||||
Where("key_prefix = ?", prefix).
|
||||
First(&record).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func (r *repository) List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error) {
|
||||
if r.db == nil {
|
||||
return nil, errors.New("database not configured")
|
||||
}
|
||||
|
||||
query := r.db.WithContext(ctx).Model(&entity.IntegrationAPIKey{})
|
||||
if environment != "" {
|
||||
query = query.Where("environment = ?", environment)
|
||||
}
|
||||
|
||||
var records []entity.IntegrationAPIKey
|
||||
if err := query.Order("environment ASC").Order("name ASC").Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (r *repository) Revoke(ctx context.Context, environment, prefix string, revokedAt time.Time) error {
|
||||
if r.db == nil {
|
||||
return errors.New("database not configured")
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"status": entity.IntegrationAPIKeyStatusRevoked,
|
||||
"revoked_at": revokedAt,
|
||||
"updated_at": revokedAt,
|
||||
}
|
||||
|
||||
result := r.db.WithContext(ctx).
|
||||
Model(&entity.IntegrationAPIKey{}).
|
||||
Where("environment = ?", environment).
|
||||
Where("key_prefix = ?", prefix).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repository) TouchLastUsed(ctx context.Context, id uint, usedAt time.Time, usedFrom string) error {
|
||||
if r.db == nil {
|
||||
return errors.New("database not configured")
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&entity.IntegrationAPIKey{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"last_used_at": usedAt,
|
||||
"last_used_from": usedFrom,
|
||||
"updated_at": usedAt,
|
||||
}).Error
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package apikeys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"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/secure"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidAPIKey = errors.New("invalid api key")
|
||||
ErrInactiveKey = errors.New("inactive api key")
|
||||
)
|
||||
|
||||
type Principal struct {
|
||||
ID uint
|
||||
Name string
|
||||
Environment string
|
||||
Permissions []string
|
||||
AllArea bool
|
||||
AreaIDs []uint
|
||||
AllLocation bool
|
||||
LocationIDs []uint
|
||||
}
|
||||
|
||||
type Authenticator interface {
|
||||
Authenticate(ctx context.Context, rawKey, source string) (*Principal, error)
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
Authenticator
|
||||
Create(ctx context.Context, input CreateInput) (*IssuedKey, error)
|
||||
List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error)
|
||||
Revoke(ctx context.Context, environment, prefix string) error
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
Name string
|
||||
Environment string
|
||||
PermissionCodes []string
|
||||
AllArea bool
|
||||
AreaIDs []uint
|
||||
AllLocation bool
|
||||
LocationIDs []uint
|
||||
}
|
||||
|
||||
type IssuedKey struct {
|
||||
Key string
|
||||
Record *entity.IntegrationAPIKey
|
||||
}
|
||||
|
||||
type service struct {
|
||||
repo Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) Service {
|
||||
return &service{
|
||||
repo: NewRepository(db),
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) Authenticate(ctx context.Context, rawKey, source string) (*Principal, error) {
|
||||
environment, prefix, secret, err := parseRawKey(rawKey)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidAPIKey
|
||||
}
|
||||
|
||||
record, err := s.repo.GetByEnvironmentAndPrefix(ctx, environment, prefix)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrInvalidAPIKey
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !strings.EqualFold(record.Status, entity.IntegrationAPIKeyStatusActive) || record.RevokedAt != nil {
|
||||
return nil, ErrInactiveKey
|
||||
}
|
||||
if !secure.Verify(record.KeyHash, secret) {
|
||||
return nil, ErrInvalidAPIKey
|
||||
}
|
||||
|
||||
usedAt := s.now().UTC()
|
||||
if err := s.repo.TouchLastUsed(ctx, record.ID, usedAt, strings.TrimSpace(source)); err != nil {
|
||||
utils.Log.WithError(err).Warn("api key: failed to update last_used fields")
|
||||
}
|
||||
|
||||
return &Principal{
|
||||
ID: record.ID,
|
||||
Name: record.Name,
|
||||
Environment: record.Environment,
|
||||
Permissions: canonicalPermissions(record.PermissionCodes),
|
||||
AllArea: record.AllArea,
|
||||
AreaIDs: uniqueUint(record.AreaIDs),
|
||||
AllLocation: record.AllLocation,
|
||||
LocationIDs: uniqueUint(record.LocationIDs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *service) Create(ctx context.Context, input CreateInput) (*IssuedKey, error) {
|
||||
name := strings.TrimSpace(input.Name)
|
||||
environment := strings.ToLower(strings.TrimSpace(input.Environment))
|
||||
if name == "" || environment == "" {
|
||||
return nil, fmt.Errorf("name and environment are required")
|
||||
}
|
||||
|
||||
prefix, err := randomToken(10)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secret, err := randomToken(24)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hash, err := secure.Hash(secret, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record := &entity.IntegrationAPIKey{
|
||||
Name: name,
|
||||
Environment: environment,
|
||||
Status: entity.IntegrationAPIKeyStatusActive,
|
||||
KeyPrefix: prefix,
|
||||
KeyHash: hash,
|
||||
PermissionCodes: canonicalPermissions(input.PermissionCodes),
|
||||
AllArea: input.AllArea,
|
||||
AreaIDs: uniqueUint(input.AreaIDs),
|
||||
AllLocation: input.AllLocation,
|
||||
LocationIDs: uniqueUint(input.LocationIDs),
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ctx, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &IssuedKey{
|
||||
Key: fmt.Sprintf("lti_%s_%s_%s", environment, prefix, secret),
|
||||
Record: record,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *service) List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error) {
|
||||
return s.repo.List(ctx, strings.ToLower(strings.TrimSpace(environment)))
|
||||
}
|
||||
|
||||
func (s *service) Revoke(ctx context.Context, environment, prefix string) error {
|
||||
environment = strings.ToLower(strings.TrimSpace(environment))
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if environment == "" || prefix == "" {
|
||||
return fmt.Errorf("environment and prefix are required")
|
||||
}
|
||||
return s.repo.Revoke(ctx, environment, prefix, s.now().UTC())
|
||||
}
|
||||
|
||||
func parseRawKey(rawKey string) (environment string, prefix string, secret string, err error) {
|
||||
rawKey = strings.TrimSpace(rawKey)
|
||||
parts := strings.Split(rawKey, "_")
|
||||
if len(parts) != 4 || parts[0] != "lti" {
|
||||
return "", "", "", ErrInvalidAPIKey
|
||||
}
|
||||
|
||||
environment = strings.ToLower(strings.TrimSpace(parts[1]))
|
||||
prefix = strings.TrimSpace(parts[2])
|
||||
secret = strings.TrimSpace(parts[3])
|
||||
if environment == "" || prefix == "" || secret == "" {
|
||||
return "", "", "", ErrInvalidAPIKey
|
||||
}
|
||||
|
||||
return environment, prefix, secret, nil
|
||||
}
|
||||
|
||||
func randomToken(size int) (string, error) {
|
||||
buf := make([]byte, size)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
encoder := base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
return strings.ToLower(encoder.EncodeToString(buf)), nil
|
||||
}
|
||||
|
||||
func canonicalPermissions(perms []string) []string {
|
||||
if len(perms) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(perms))
|
||||
result := make([]string, 0, len(perms))
|
||||
for _, perm := range perms {
|
||||
perm = strings.ToLower(strings.TrimSpace(perm))
|
||||
if perm == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[perm]; ok {
|
||||
continue
|
||||
}
|
||||
seen[perm] = struct{}{}
|
||||
result = append(result, perm)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func uniqueUint(values []uint) []uint {
|
||||
if len(values) == 0 {
|
||||
return []uint{}
|
||||
}
|
||||
|
||||
seen := make(map[uint]struct{}, len(values))
|
||||
result := make([]uint, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
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,604 @@
|
||||
package exportprogress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
UnassignedKandangName = "Farm-level / Unassigned"
|
||||
jakartaTZ = "Asia/Jakarta"
|
||||
)
|
||||
|
||||
type Query struct {
|
||||
StartDate time.Time
|
||||
EndDate time.Time
|
||||
StartDateRaw string
|
||||
EndDateRaw string
|
||||
}
|
||||
|
||||
type Row struct {
|
||||
Module string
|
||||
FarmName string
|
||||
KandangName string
|
||||
ActivityDate time.Time
|
||||
Count int
|
||||
}
|
||||
|
||||
type monthBlock struct {
|
||||
Start time.Time
|
||||
Weeks int
|
||||
}
|
||||
|
||||
func IsProgressExportRequest(c *fiber.Ctx) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") &&
|
||||
strings.EqualFold(strings.TrimSpace(c.Query("type")), "progress")
|
||||
}
|
||||
|
||||
func ParseQuery(c *fiber.Ctx) (*Query, error) {
|
||||
location, err := time.LoadLocation(jakartaTZ)
|
||||
if err != nil {
|
||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration")
|
||||
}
|
||||
|
||||
startRaw := strings.TrimSpace(c.Query("start_date"))
|
||||
endRaw := strings.TrimSpace(c.Query("end_date"))
|
||||
if startRaw == "" || endRaw == "" {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "start_date and end_date are required")
|
||||
}
|
||||
|
||||
startDate, err := time.ParseInLocation("2006-01-02", startRaw, location)
|
||||
if err != nil {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "start_date must use format YYYY-MM-DD")
|
||||
}
|
||||
endDate, err := time.ParseInLocation("2006-01-02", endRaw, location)
|
||||
if err != nil {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "end_date must use format YYYY-MM-DD")
|
||||
}
|
||||
if endDate.Before(startDate) {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "end_date must be greater than or equal to start_date")
|
||||
}
|
||||
|
||||
return &Query{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
StartDateRaw: startRaw,
|
||||
EndDateRaw: endRaw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func BuildWorkbook(moduleTitle string, query *Query, rows []Row) ([]byte, error) {
|
||||
file := excelize.NewFile()
|
||||
defer file.Close()
|
||||
|
||||
sheetName := moduleTitle
|
||||
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
|
||||
if defaultSheet != sheetName {
|
||||
if err := file.SetSheetName(defaultSheet, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
location, err := time.LoadLocation(jakartaTZ)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
titleStyle, metaStyle, monthStyle, weekStyle, dayHeaderStyle, farmStyle, textStyle, numberStyle, subtotalStyle, err := buildStyles(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
months := monthBlocksBetween(query.StartDate, query.EndDate)
|
||||
maxWeeks := 4
|
||||
for _, block := range months {
|
||||
if block.Weeks > maxWeeks {
|
||||
maxWeeks = block.Weeks
|
||||
}
|
||||
}
|
||||
lastColName, err := excelize.ColumnNumberToName(1 + (maxWeeks * 7) + 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := file.MergeCell(sheetName, "A1", lastColName+"1"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := file.SetCellValue(sheetName, "A1", moduleTitle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := file.SetCellStyle(sheetName, "A1", lastColName+"1", titleStyle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metaValue := fmt.Sprintf(
|
||||
"Range: %s to %s | Generated at: %s",
|
||||
query.StartDateRaw,
|
||||
query.EndDateRaw,
|
||||
time.Now().In(location).Format("2006-01-02 15:04:05 MST"),
|
||||
)
|
||||
if err := file.MergeCell(sheetName, "A2", lastColName+"2"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := file.SetCellValue(sheetName, "A2", metaValue); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := file.SetCellStyle(sheetName, "A2", lastColName+"2", metaStyle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := applyColumnWidths(file, sheetName, maxWeeks); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
grouped := groupRows(rows)
|
||||
currentRow := 4
|
||||
for _, month := range months {
|
||||
lastColIndex := 1 + (month.Weeks * 7) + 1
|
||||
monthLastCol, err := excelize.ColumnNumberToName(lastColIndex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := renderMonthHeader(file, sheetName, currentRow, month, monthLastCol, monthStyle, weekStyle, dayHeaderStyle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentRow += 4
|
||||
|
||||
monthData := grouped[month.Start.Format("2006-01")]
|
||||
if len(monthData) == 0 {
|
||||
if err := file.MergeCell(sheetName, "A"+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := file.SetCellValue(sheetName, "A"+fmt.Sprint(currentRow), "No progress data"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := file.SetCellStyle(sheetName, "A"+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow), textStyle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentRow += 2
|
||||
continue
|
||||
}
|
||||
|
||||
farms := sortedKeys(monthData)
|
||||
for _, farm := range farms {
|
||||
if err := file.MergeCell(sheetName, "A"+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := file.SetCellValue(sheetName, "A"+fmt.Sprint(currentRow), farm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := file.SetCellStyle(sheetName, "A"+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow), farmStyle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentRow++
|
||||
|
||||
kandangs := sortedKeys(monthData[farm])
|
||||
farmTotals := make(map[string]int)
|
||||
farmGrandTotal := 0
|
||||
for _, kandang := range kandangs {
|
||||
rowCounts := monthData[farm][kandang]
|
||||
rowTotal := 0
|
||||
|
||||
if err := file.SetCellValue(sheetName, "A"+fmt.Sprint(currentRow), kandang); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := file.SetCellStyle(sheetName, "A"+fmt.Sprint(currentRow), "A"+fmt.Sprint(currentRow), textStyle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for dayKey, count := range rowCounts {
|
||||
activityDate, err := time.ParseInLocation("2006-01-02", dayKey, location)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
colIndex := dayColumnIndex(month, activityDate)
|
||||
colName, err := excelize.ColumnNumberToName(colIndex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := file.SetCellValue(sheetName, colName+fmt.Sprint(currentRow), count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := file.SetCellStyle(sheetName, colName+fmt.Sprint(currentRow), colName+fmt.Sprint(currentRow), numberStyle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rowTotal += count
|
||||
farmTotals[dayKey] += count
|
||||
farmGrandTotal += count
|
||||
}
|
||||
|
||||
if err := file.SetCellValue(sheetName, monthLastCol+fmt.Sprint(currentRow), rowTotal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := file.SetCellStyle(sheetName, monthLastCol+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow), subtotalStyle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := file.SetCellStyle(sheetName, "B"+fmt.Sprint(currentRow), prevColumn(monthLastCol)+fmt.Sprint(currentRow), numberStyle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
currentRow++
|
||||
}
|
||||
|
||||
if err := file.SetCellValue(sheetName, "A"+fmt.Sprint(currentRow), "Subtotal"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := file.SetCellStyle(sheetName, "A"+fmt.Sprint(currentRow), "A"+fmt.Sprint(currentRow), subtotalStyle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for dayKey, count := range farmTotals {
|
||||
activityDate, err := time.ParseInLocation("2006-01-02", dayKey, location)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
colIndex := dayColumnIndex(month, activityDate)
|
||||
colName, err := excelize.ColumnNumberToName(colIndex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := file.SetCellValue(sheetName, colName+fmt.Sprint(currentRow), count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := file.SetCellValue(sheetName, monthLastCol+fmt.Sprint(currentRow), farmGrandTotal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := file.SetCellStyle(sheetName, "A"+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow), subtotalStyle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentRow += 2
|
||||
}
|
||||
}
|
||||
|
||||
if err := file.SetPanes(sheetName, &excelize.Panes{
|
||||
Freeze: true,
|
||||
YSplit: 2,
|
||||
TopLeftCell: "A3",
|
||||
ActivePane: "bottomLeft",
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
buffer, err := file.WriteToBuffer()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
func ParseActivityDate(value string) (time.Time, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return time.Time{}, fmt.Errorf("empty activity date")
|
||||
}
|
||||
|
||||
layouts := []string{
|
||||
"2006-01-02",
|
||||
time.RFC3339,
|
||||
time.RFC3339Nano,
|
||||
"2006-01-02 15:04:05Z07:00",
|
||||
"2006-01-02 15:04:05.999999999Z07:00",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if parsed, err := time.Parse(layout, trimmed); err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(trimmed) >= len("2006-01-02") {
|
||||
if parsed, err := time.Parse("2006-01-02", trimmed[:10]); err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}, fmt.Errorf("unsupported activity date format: %s", value)
|
||||
}
|
||||
|
||||
func buildStyles(file *excelize.File) (int, int, int, int, int, int, int, int, int, error) {
|
||||
titleStyle, err := file.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Bold: true, Size: 18, Color: "1F2937"},
|
||||
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"DCEBFA"}},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
|
||||
}
|
||||
metaStyle, err := file.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Italic: true, Color: "4B5563"},
|
||||
Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center"},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
|
||||
}
|
||||
monthStyle, err := file.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Bold: true, Color: "FFFFFF"},
|
||||
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"1D4ED8"}},
|
||||
Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center"},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
|
||||
}
|
||||
weekStyle, err := file.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Bold: true, Color: "1F2937"},
|
||||
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"DBEAFE"}},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
||||
Border: []excelize.Border{{Type: "bottom", Color: "93C5FD", Style: 1}},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
|
||||
}
|
||||
dayHeaderStyle, err := file.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Bold: true, Color: "374151"},
|
||||
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"EFF6FF"}},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
||||
Border: []excelize.Border{
|
||||
{Type: "left", Color: "BFDBFE", Style: 1},
|
||||
{Type: "top", Color: "BFDBFE", Style: 1},
|
||||
{Type: "bottom", Color: "BFDBFE", Style: 1},
|
||||
{Type: "right", Color: "BFDBFE", Style: 1},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
|
||||
}
|
||||
farmStyle, err := file.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Bold: true, Color: "111827"},
|
||||
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E5E7EB"}},
|
||||
Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center"},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
|
||||
}
|
||||
textStyle, err := file.NewStyle(&excelize.Style{
|
||||
Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center"},
|
||||
Border: []excelize.Border{
|
||||
{Type: "left", Color: "D1D5DB", Style: 1},
|
||||
{Type: "top", Color: "D1D5DB", Style: 1},
|
||||
{Type: "bottom", Color: "D1D5DB", Style: 1},
|
||||
{Type: "right", Color: "D1D5DB", Style: 1},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
|
||||
}
|
||||
numberStyle, err := file.NewStyle(&excelize.Style{
|
||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
||||
Border: []excelize.Border{
|
||||
{Type: "left", Color: "D1D5DB", Style: 1},
|
||||
{Type: "top", Color: "D1D5DB", Style: 1},
|
||||
{Type: "bottom", Color: "D1D5DB", Style: 1},
|
||||
{Type: "right", Color: "D1D5DB", Style: 1},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
|
||||
}
|
||||
subtotalStyle, err := file.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Bold: true, Color: "1F2937"},
|
||||
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"F3F4F6"}},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
||||
Border: []excelize.Border{
|
||||
{Type: "left", Color: "9CA3AF", Style: 1},
|
||||
{Type: "top", Color: "9CA3AF", Style: 1},
|
||||
{Type: "bottom", Color: "9CA3AF", Style: 1},
|
||||
{Type: "right", Color: "9CA3AF", Style: 1},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
|
||||
}
|
||||
|
||||
return titleStyle, metaStyle, monthStyle, weekStyle, dayHeaderStyle, farmStyle, textStyle, numberStyle, subtotalStyle, nil
|
||||
}
|
||||
|
||||
func applyColumnWidths(file *excelize.File, sheet string, maxWeeks int) error {
|
||||
if err := file.SetColWidth(sheet, "A", "A", 28); err != nil {
|
||||
return err
|
||||
}
|
||||
for col := 2; col <= 1+(maxWeeks*7); col++ {
|
||||
colName, err := excelize.ColumnNumberToName(col)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetColWidth(sheet, colName, colName, 6); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
totalCol, err := excelize.ColumnNumberToName(1 + (maxWeeks * 7) + 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return file.SetColWidth(sheet, totalCol, totalCol, 10)
|
||||
}
|
||||
|
||||
func renderMonthHeader(file *excelize.File, sheet string, startRow int, block monthBlock, monthLastCol string, monthStyle, weekStyle, dayHeaderStyle int) error {
|
||||
if err := file.MergeCell(sheet, "A"+fmt.Sprint(startRow), monthLastCol+fmt.Sprint(startRow)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "A"+fmt.Sprint(startRow), block.Start.Format("January 2006")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellStyle(sheet, "A"+fmt.Sprint(startRow), monthLastCol+fmt.Sprint(startRow), monthStyle); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := file.MergeCell(sheet, "A"+fmt.Sprint(startRow+1), "A"+fmt.Sprint(startRow+3)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "A"+fmt.Sprint(startRow+1), "Kandang"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellStyle(sheet, "A"+fmt.Sprint(startRow+1), "A"+fmt.Sprint(startRow+3), dayHeaderStyle); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
totalColIndex := 1 + (block.Weeks * 7) + 1
|
||||
totalColName, err := excelize.ColumnNumberToName(totalColIndex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.MergeCell(sheet, totalColName+fmt.Sprint(startRow+1), totalColName+fmt.Sprint(startRow+3)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, totalColName+fmt.Sprint(startRow+1), "Total"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellStyle(sheet, totalColName+fmt.Sprint(startRow+1), totalColName+fmt.Sprint(startRow+3), dayHeaderStyle); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
weekdayNames := []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
|
||||
for week := 0; week < block.Weeks; week++ {
|
||||
startCol := 2 + (week * 7)
|
||||
endCol := startCol + 6
|
||||
startColName, err := excelize.ColumnNumberToName(startCol)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endColName, err := excelize.ColumnNumberToName(endCol)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.MergeCell(sheet, startColName+fmt.Sprint(startRow+1), endColName+fmt.Sprint(startRow+1)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, startColName+fmt.Sprint(startRow+1), fmt.Sprintf("Week %d", week+1)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellStyle(sheet, startColName+fmt.Sprint(startRow+1), endColName+fmt.Sprint(startRow+1), weekStyle); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for weekday := 0; weekday < 7; weekday++ {
|
||||
colIndex := startCol + weekday
|
||||
colName, err := excelize.ColumnNumberToName(colIndex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, colName+fmt.Sprint(startRow+2), weekdayNames[weekday]); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellStyle(sheet, colName+fmt.Sprint(startRow+2), colName+fmt.Sprint(startRow+2), dayHeaderStyle); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
daysInMonth := time.Date(block.Start.Year(), block.Start.Month()+1, 0, 0, 0, 0, 0, block.Start.Location()).Day()
|
||||
for day := 1; day <= daysInMonth; day++ {
|
||||
date := time.Date(block.Start.Year(), block.Start.Month(), day, 0, 0, 0, 0, block.Start.Location())
|
||||
colIndex := dayColumnIndex(block, date)
|
||||
colName, err := excelize.ColumnNumberToName(colIndex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, colName+fmt.Sprint(startRow+3), day); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellStyle(sheet, colName+fmt.Sprint(startRow+3), colName+fmt.Sprint(startRow+3), dayHeaderStyle); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func groupRows(rows []Row) map[string]map[string]map[string]map[string]int {
|
||||
grouped := make(map[string]map[string]map[string]map[string]int)
|
||||
for _, row := range rows {
|
||||
monthKey := row.ActivityDate.Format("2006-01")
|
||||
if _, exists := grouped[monthKey]; !exists {
|
||||
grouped[monthKey] = make(map[string]map[string]map[string]int)
|
||||
}
|
||||
farmName := strings.TrimSpace(row.FarmName)
|
||||
if farmName == "" {
|
||||
farmName = "Unknown Farm"
|
||||
}
|
||||
if _, exists := grouped[monthKey][farmName]; !exists {
|
||||
grouped[monthKey][farmName] = make(map[string]map[string]int)
|
||||
}
|
||||
kandangName := strings.TrimSpace(row.KandangName)
|
||||
if kandangName == "" {
|
||||
kandangName = UnassignedKandangName
|
||||
}
|
||||
if _, exists := grouped[monthKey][farmName][kandangName]; !exists {
|
||||
grouped[monthKey][farmName][kandangName] = make(map[string]int)
|
||||
}
|
||||
dayKey := row.ActivityDate.Format("2006-01-02")
|
||||
grouped[monthKey][farmName][kandangName][dayKey] += row.Count
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
func monthBlocksBetween(startDate, endDate time.Time) []monthBlock {
|
||||
location := startDate.Location()
|
||||
current := time.Date(startDate.Year(), startDate.Month(), 1, 0, 0, 0, 0, location)
|
||||
last := time.Date(endDate.Year(), endDate.Month(), 1, 0, 0, 0, 0, location)
|
||||
|
||||
blocks := make([]monthBlock, 0)
|
||||
for !current.After(last) {
|
||||
blocks = append(blocks, monthBlock{
|
||||
Start: current,
|
||||
Weeks: monthWeeks(current),
|
||||
})
|
||||
current = current.AddDate(0, 1, 0)
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
func monthWeeks(monthStart time.Time) int {
|
||||
daysInMonth := time.Date(monthStart.Year(), monthStart.Month()+1, 0, 0, 0, 0, 0, monthStart.Location()).Day()
|
||||
offset := mondayIndex(monthStart.Weekday())
|
||||
totalSlots := offset + daysInMonth
|
||||
weeks := totalSlots / 7
|
||||
if totalSlots%7 != 0 {
|
||||
weeks++
|
||||
}
|
||||
if weeks < 4 {
|
||||
return 4
|
||||
}
|
||||
return weeks
|
||||
}
|
||||
|
||||
func dayColumnIndex(block monthBlock, date time.Time) int {
|
||||
day := date.Day()
|
||||
offset := mondayIndex(block.Start.Weekday())
|
||||
position := offset + (day - 1)
|
||||
return 2 + position
|
||||
}
|
||||
|
||||
func mondayIndex(weekday time.Weekday) int {
|
||||
switch weekday {
|
||||
case time.Sunday:
|
||||
return 6
|
||||
default:
|
||||
return int(weekday) - 1
|
||||
}
|
||||
}
|
||||
|
||||
func sortedKeys[V any](input map[string]V) []string {
|
||||
keys := make([]string, 0, len(input))
|
||||
for key := range input {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func prevColumn(col string) string {
|
||||
index, err := excelize.ColumnNameToNumber(col)
|
||||
if err != nil || index <= 1 {
|
||||
return col
|
||||
}
|
||||
result, err := excelize.ColumnNumberToName(index - 1)
|
||||
if err != nil {
|
||||
return col
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package exportprogress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
func TestParseQuery(t *testing.T) {
|
||||
app := fiber.New()
|
||||
app.Get("/", func(c *fiber.Ctx) error {
|
||||
query, err := ParseQuery(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"start": query.StartDateRaw,
|
||||
"end": query.EndDateRaw,
|
||||
})
|
||||
})
|
||||
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/?export=excel&type=progress&start_date=2026-06-01&end_date=2026-07-15", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("failed decoding payload: %v", err)
|
||||
}
|
||||
if payload["start"] != "2026-06-01" || payload["end"] != "2026-07-15" {
|
||||
t.Fatalf("unexpected payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQueryInvalid(t *testing.T) {
|
||||
app := fiber.New()
|
||||
app.Get("/", func(c *fiber.Ctx) error {
|
||||
_, err := ParseQuery(c)
|
||||
return err
|
||||
})
|
||||
|
||||
cases := []string{
|
||||
"/?export=excel&type=progress",
|
||||
"/?export=excel&type=progress&start_date=2026-06-01&end_date=bad",
|
||||
"/?export=excel&type=progress&start_date=2026-07-01&end_date=2026-06-01",
|
||||
}
|
||||
for _, target := range cases {
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, target, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed for %s: %v", target, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for %s, got %d", target, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWorkbook(t *testing.T) {
|
||||
location, err := time.LoadLocation(jakartaTZ)
|
||||
if err != nil {
|
||||
t.Fatalf("failed loading location: %v", err)
|
||||
}
|
||||
|
||||
query := &Query{
|
||||
StartDate: time.Date(2026, 6, 1, 0, 0, 0, 0, location),
|
||||
EndDate: time.Date(2026, 7, 31, 0, 0, 0, 0, location),
|
||||
StartDateRaw: "2026-06-01",
|
||||
EndDateRaw: "2026-07-31",
|
||||
}
|
||||
rows := []Row{
|
||||
{Module: "Expenses", FarmName: "Farm A", KandangName: "Kandang 1", ActivityDate: time.Date(2026, 6, 1, 0, 0, 0, 0, location), Count: 3},
|
||||
{Module: "Expenses", FarmName: "Farm A", KandangName: "Kandang 1", ActivityDate: time.Date(2026, 7, 15, 0, 0, 0, 0, location), Count: 2},
|
||||
}
|
||||
|
||||
content, err := BuildWorkbook("Expenses", query, rows)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWorkbook failed: %v", err)
|
||||
}
|
||||
|
||||
file, err := excelize.OpenReader(bytes.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("failed opening workbook: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if got := file.GetSheetName(file.GetActiveSheetIndex()); got != "Expenses" {
|
||||
t.Fatalf("unexpected sheet name: %s", got)
|
||||
}
|
||||
|
||||
title, err := file.GetCellValue("Expenses", "A1")
|
||||
if err != nil {
|
||||
t.Fatalf("failed reading title: %v", err)
|
||||
}
|
||||
if title != "Expenses" {
|
||||
t.Fatalf("unexpected title: %s", title)
|
||||
}
|
||||
|
||||
monthTitle, err := file.GetCellValue("Expenses", "A4")
|
||||
if err != nil {
|
||||
t.Fatalf("failed reading first month title: %v", err)
|
||||
}
|
||||
if monthTitle != "June 2026" {
|
||||
t.Fatalf("unexpected first month title: %s", monthTitle)
|
||||
}
|
||||
|
||||
firstCount, err := file.GetCellValue("Expenses", "B9")
|
||||
if err != nil {
|
||||
t.Fatalf("failed reading representative count cell: %v", err)
|
||||
}
|
||||
if firstCount != "3" {
|
||||
t.Fatalf("unexpected representative count: %s", firstCount)
|
||||
}
|
||||
}
|
||||
@@ -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,429 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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)
|
||||
GetManualDepreciationCostByProjectFlockID(ctx context.Context, projectFlockId 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) {
|
||||
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
|
||||
stockableAdjustment := fifo.StockableKeyAdjustmentIn.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(ast.price, 0)
|
||||
ELSE 0
|
||||
END), 0)`,
|
||||
stockablePurchase,
|
||||
stockableAdjustment,
|
||||
).
|
||||
Joins(
|
||||
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?",
|
||||
usableProjectChickin,
|
||||
stockablePurchase,
|
||||
stockableAdjustment,
|
||||
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 adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
|
||||
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
|
||||
}
|
||||
|
||||
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
|
||||
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
|
||||
usableRecordingStock := fifo.UsableKeyRecordingStock.String()
|
||||
|
||||
var total float64
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("recordings AS r").
|
||||
Select(`
|
||||
COALESCE(SUM(sa.qty * CASE
|
||||
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
|
||||
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
|
||||
ELSE 0
|
||||
END), 0)`,
|
||||
stockablePurchase,
|
||||
stockableAdjustment,
|
||||
).
|
||||
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 = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?",
|
||||
usableRecordingStock,
|
||||
stockablePurchase,
|
||||
stockableAdjustment,
|
||||
entity.StockAllocationStatusActive,
|
||||
entity.StockAllocationPurposeConsume,
|
||||
).
|
||||
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
|
||||
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
|
||||
Where("rs.project_flock_kandang_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,
|
||||
}
|
||||
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
|
||||
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
|
||||
usableRecordingStock := fifo.UsableKeyRecordingStock.String()
|
||||
|
||||
var total float64
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("recordings AS r").
|
||||
Select(`
|
||||
COALESCE(SUM(sa.qty * CASE
|
||||
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
|
||||
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
|
||||
ELSE 0
|
||||
END), 0)`,
|
||||
stockablePurchase,
|
||||
stockableAdjustment,
|
||||
).
|
||||
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 = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?",
|
||||
usableRecordingStock,
|
||||
stockablePurchase,
|
||||
stockableAdjustment,
|
||||
entity.StockAllocationStatusActive,
|
||||
entity.StockAllocationPurposeConsume,
|
||||
).
|
||||
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
|
||||
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
|
||||
Where("rs.project_flock_kandang_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()
|
||||
stockableAdjustment := fifo.StockableKeyAdjustmentIn.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)
|
||||
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
|
||||
ELSE 0
|
||||
END), 0)`,
|
||||
stockablePurchase,
|
||||
stockableTransferIn,
|
||||
stockableAdjustment,
|
||||
).
|
||||
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").
|
||||
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
|
||||
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
|
||||
}
|
||||
|
||||
var adjustmentTotalWeight float64
|
||||
adjustmentSubQuery := r.db.WithContext(ctx).
|
||||
Table("recordings AS r").
|
||||
Select("DISTINCT ast.id AS adjustment_id, ast.price AS price").
|
||||
Joins("JOIN recording_eggs AS re ON re.recording_id = r.id").
|
||||
Joins("JOIN stock_transfer_details AS std ON std.dest_product_warehouse_id = re.product_warehouse_id").
|
||||
Joins(
|
||||
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = std.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?",
|
||||
fifo.UsableKeyStockTransferOut.String(),
|
||||
fifo.StockableKeyAdjustmentIn.String(),
|
||||
entity.StockAllocationStatusActive,
|
||||
entity.StockAllocationPurposeConsume,
|
||||
).
|
||||
Joins("JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND ast.product_warehouse_id = std.source_product_warehouse_id").
|
||||
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
||||
Where("r.record_datetime <= ?", *date)
|
||||
|
||||
err = r.db.WithContext(ctx).
|
||||
Table("(?) AS adjustment_sources", adjustmentSubQuery).
|
||||
Select("COALESCE(SUM(adjustment_sources.price), 0)").
|
||||
Scan(&adjustmentTotalWeight).Error
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
totals.TotalWeightKg += adjustmentTotalWeight
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (r *HppRepositoryImpl) GetManualDepreciationCostByProjectFlockID(ctx context.Context, projectFlockId uint) (float64, error) {
|
||||
type row struct {
|
||||
TotalCost float64
|
||||
}
|
||||
|
||||
var selected row
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("farm_depreciation_manual_inputs").
|
||||
Select("total_cost").
|
||||
Where("project_flock_id = ?", projectFlockId).
|
||||
Limit(1).
|
||||
Take(&selected).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return selected.TotalCost, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,390 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
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"
|
||||
)
|
||||
|
||||
func TestHppV2RepositoryGetEggProduksiIncludesTransferredAdjustmentStock(t *testing.T) {
|
||||
db := setupHppV2RepositoryTestDB(t)
|
||||
|
||||
mustExecHppV2(t, db,
|
||||
`INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime) VALUES (1, 101, '2026-04-19 10:00:00')`,
|
||||
`INSERT INTO recording_eggs (id, recording_id, product_warehouse_id, qty, weight, project_flock_kandang_id) VALUES (1, 1, 401, 80, 8, 101)`,
|
||||
`INSERT INTO stock_transfers (id, transfer_date) VALUES (1, '2026-04-18 08:00:00')`,
|
||||
`INSERT INTO stock_transfer_details (id, stock_transfer_id, source_product_warehouse_id, dest_product_warehouse_id) VALUES (1, 1, 301, 401)`,
|
||||
`INSERT INTO stock_allocations (id, usable_type, usable_id, stockable_type, stockable_id, status, allocation_purpose) VALUES (1, 'STOCK_TRANSFER_OUT', 1, 'ADJUSTMENT_IN', 501, 'ACTIVE', 'CONSUME')`,
|
||||
`INSERT INTO adjustment_stocks (id, product_warehouse_id, total_qty, price, created_at) VALUES (501, 301, 20, 2.5, '2026-04-18 07:30:00')`,
|
||||
)
|
||||
|
||||
repo := &HppV2RepositoryImpl{db: db}
|
||||
endDate := mustJakartaTime(t, "2026-04-20 00:00:00")
|
||||
|
||||
totalPieces, totalWeightKg, err := repo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &endDate)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
assertFloatEquals(t, totalPieces, 100)
|
||||
assertFloatEquals(t, totalWeightKg, 10.5)
|
||||
}
|
||||
|
||||
func TestHppV2RepositoryGetEggTerjualUsesEndDateForSameDayFarmSales(t *testing.T) {
|
||||
db := setupHppV2RepositoryTestDB(t)
|
||||
|
||||
mustExecHppV2(t, db,
|
||||
`INSERT INTO kandangs (id, location_id) VALUES (1, 10), (2, 10)`,
|
||||
`INSERT INTO project_flock_kandangs (id, kandang_id) VALUES (101, 1), (102, 2)`,
|
||||
`INSERT INTO warehouses (id, type, location_id) VALUES (201, 'LOKASI', 10)`,
|
||||
`INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES (301, 201, 900, NULL)`,
|
||||
`INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES (1, 'products', 900, 'TELUR')`,
|
||||
`INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime, deleted_at) VALUES (1, 101, '2026-04-19 08:00:00', NULL), (2, 102, '2026-04-19 09:00:00', NULL)`,
|
||||
`INSERT INTO recording_eggs (id, recording_id, product_warehouse_id, qty, weight, project_flock_kandang_id) VALUES (1, 1, 301, 60, 6, 101), (2, 2, 301, 40, 4, 102)`,
|
||||
`INSERT INTO marketing_products (id, product_warehouse_id) VALUES (401, 301)`,
|
||||
`INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty, total_weight, delivery_date) VALUES (501, 401, 50, 5, '2026-04-19 12:00:00')`,
|
||||
)
|
||||
|
||||
repo := &HppV2RepositoryImpl{db: db}
|
||||
startDate := mustJakartaTime(t, "2026-04-19 00:00:00")
|
||||
endDate := mustJakartaTime(t, "2026-04-20 00:00:00")
|
||||
|
||||
totalPieces, totalWeightKg, err := repo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &startDate, &endDate)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
assertFloatEquals(t, totalPieces, 30)
|
||||
assertFloatEquals(t, totalWeightKg, 3)
|
||||
}
|
||||
|
||||
func TestHppV2RepositoryGetEggTerjualProratesHistoricalFarmSalesFromAdjustments(t *testing.T) {
|
||||
db := setupHppV2RepositoryTestDB(t)
|
||||
|
||||
mustExecHppV2(t, db,
|
||||
`INSERT INTO kandangs (id, location_id) VALUES (1, 10), (2, 10)`,
|
||||
`INSERT INTO project_flock_kandangs (id, kandang_id) VALUES (101, 1), (102, 2)`,
|
||||
`INSERT INTO warehouses (id, type, location_id) VALUES (201, 'LOKASI', 10), (211, 'KANDANG', 10), (212, 'KANDANG', 10)`,
|
||||
`INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES (301, 201, 900, NULL), (311, 211, 900, 101), (312, 212, 900, 102)`,
|
||||
`INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES (1, 'products', 900, 'TELUR')`,
|
||||
`INSERT INTO stock_transfers (id, transfer_date) VALUES (1, '2026-04-18 08:00:00'), (2, '2026-04-18 08:15:00')`,
|
||||
`INSERT INTO stock_transfer_details (id, stock_transfer_id, source_product_warehouse_id, dest_product_warehouse_id) VALUES (1, 1, 311, 301), (2, 2, 312, 301)`,
|
||||
`INSERT INTO adjustment_stocks (id, product_warehouse_id, usage_qty, price, function_code, transaction_type, created_at) VALUES
|
||||
(801, 311, 70, 7, 'RECORDING_EGG_IN', 'RECORDING', '2026-04-18 07:00:00'),
|
||||
(802, 312, 30, 3, 'RECORDING_EGG_IN', 'RECORDING', '2026-04-18 07:30:00')`,
|
||||
`INSERT INTO marketing_products (id, product_warehouse_id) VALUES (401, 301)`,
|
||||
`INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty, total_weight, delivery_date) VALUES (501, 401, 20, 2, '2026-04-19 12:00:00')`,
|
||||
)
|
||||
|
||||
repo := &HppV2RepositoryImpl{db: db}
|
||||
startDate := mustJakartaTime(t, "2026-04-19 00:00:00")
|
||||
endDate := mustJakartaTime(t, "2026-04-20 00:00:00")
|
||||
|
||||
totalPieces, totalWeightKg, err := repo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &startDate, &endDate)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
assertFloatEquals(t, totalPieces, 14)
|
||||
assertFloatEquals(t, totalWeightKg, 1.4)
|
||||
}
|
||||
|
||||
func TestHppV2RepositoryGetRecordingStockRoutingAdjustmentCostByProjectFlockID(t *testing.T) {
|
||||
db := setupHppV2RepositoryTestDB(t)
|
||||
approvalType := utils.ApprovalWorkflowTransferToLaying.String()
|
||||
|
||||
mustExecHppV2(t, db,
|
||||
`INSERT INTO project_flock_kandangs (id, kandang_id, project_flock_id) VALUES
|
||||
(101, 1, 1),
|
||||
(102, 2, 1),
|
||||
(103, 3, 1),
|
||||
(104, 4, 1),
|
||||
(105, 5, 1),
|
||||
(201, 6, 2)`,
|
||||
`INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime, deleted_at) VALUES
|
||||
(1, 101, '2026-04-10 08:00:00', NULL),
|
||||
(2, 101, '2026-04-10 08:05:00', NULL),
|
||||
(3, 101, '2026-04-10 08:10:00', NULL),
|
||||
(4, 102, '2026-04-10 08:15:00', NULL),
|
||||
(5, 102, '2026-04-10 08:20:00', NULL),
|
||||
(6, 103, '2026-04-12 08:00:00', NULL),
|
||||
(7, 103, '2026-04-12 08:05:00', NULL),
|
||||
(8, 104, '2026-04-12 08:10:00', NULL),
|
||||
(9, 104, '2026-04-12 08:15:00', NULL),
|
||||
(10, 105, '2026-04-12 08:20:00', NULL),
|
||||
(11, 105, '2026-04-12 08:25:00', NULL)`,
|
||||
`INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES
|
||||
(501, 201, 10, NULL),
|
||||
(502, 201, 10, NULL),
|
||||
(503, 201, 10, NULL),
|
||||
(504, 201, 10, NULL),
|
||||
(505, 201, 10, NULL),
|
||||
(506, 201, 10, NULL),
|
||||
(507, 201, 10, NULL),
|
||||
(508, 201, 10, NULL),
|
||||
(509, 201, 10, NULL),
|
||||
(510, 201, 10, NULL),
|
||||
(511, 201, 10, NULL)`,
|
||||
`INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES
|
||||
(10, 'products', 10, 'PAKAN')`,
|
||||
`INSERT INTO recording_stocks (id, recording_id, product_warehouse_id, project_flock_kandang_id) VALUES
|
||||
(101, 1, 501, NULL),
|
||||
(102, 2, 502, 201),
|
||||
(103, 3, 503, 101),
|
||||
(104, 4, 504, NULL),
|
||||
(105, 5, 505, 201),
|
||||
(106, 6, 506, NULL),
|
||||
(107, 7, 507, 201),
|
||||
(108, 8, 508, NULL),
|
||||
(109, 9, 509, 201),
|
||||
(110, 10, 510, NULL),
|
||||
(111, 11, 511, 201)`,
|
||||
`INSERT INTO purchase_items (id, product_id, price) VALUES
|
||||
(601, 10, 100),
|
||||
(602, 10, 110),
|
||||
(603, 10, 120),
|
||||
(604, 10, 130),
|
||||
(605, 10, 140),
|
||||
(606, 10, 150),
|
||||
(607, 10, 160),
|
||||
(608, 10, 170),
|
||||
(609, 10, 180),
|
||||
(610, 10, 190),
|
||||
(611, 10, 200)`,
|
||||
`INSERT INTO stock_allocations (id, usable_type, usable_id, stockable_type, stockable_id, status, allocation_purpose, qty) VALUES
|
||||
(9001, 'RECORDING_STOCK', 101, 'PURCHASE_ITEMS', 601, 'ACTIVE', 'CONSUME', 2),
|
||||
(9002, 'RECORDING_STOCK', 102, 'PURCHASE_ITEMS', 602, 'ACTIVE', 'CONSUME', 1),
|
||||
(9003, 'RECORDING_STOCK', 103, 'PURCHASE_ITEMS', 603, 'ACTIVE', 'CONSUME', 1),
|
||||
(9004, 'RECORDING_STOCK', 104, 'PURCHASE_ITEMS', 604, 'ACTIVE', 'CONSUME', 1),
|
||||
(9005, 'RECORDING_STOCK', 105, 'PURCHASE_ITEMS', 605, 'ACTIVE', 'CONSUME', 1),
|
||||
(9006, 'RECORDING_STOCK', 106, 'PURCHASE_ITEMS', 606, 'ACTIVE', 'CONSUME', 1),
|
||||
(9007, 'RECORDING_STOCK', 107, 'PURCHASE_ITEMS', 607, 'ACTIVE', 'CONSUME', 1),
|
||||
(9008, 'RECORDING_STOCK', 108, 'PURCHASE_ITEMS', 608, 'ACTIVE', 'CONSUME', 1),
|
||||
(9009, 'RECORDING_STOCK', 109, 'PURCHASE_ITEMS', 609, 'ACTIVE', 'CONSUME', 1),
|
||||
(9010, 'RECORDING_STOCK', 110, 'PURCHASE_ITEMS', 610, 'ACTIVE', 'CONSUME', 1),
|
||||
(9011, 'RECORDING_STOCK', 111, 'PURCHASE_ITEMS', 611, 'ACTIVE', 'CONSUME', 1)`,
|
||||
`INSERT INTO laying_transfers (id, transfer_date, effective_move_date, economic_cutoff_date, executed_at, deleted_at) VALUES
|
||||
(1001, '2026-04-04', '2026-04-05', NULL, '2026-04-05 00:00:00', NULL),
|
||||
(1002, '2026-05-01', '2026-05-01', NULL, '2026-05-01 00:00:00', NULL),
|
||||
(1003, '2026-04-03', '2026-04-05', NULL, '2026-04-05 00:00:00', NULL),
|
||||
(1004, '2026-04-03', '2026-04-05', NULL, NULL, NULL)`,
|
||||
`INSERT INTO laying_transfer_targets (id, laying_transfer_id, target_project_flock_kandang_id, deleted_at) VALUES
|
||||
(2001, 1001, 101, NULL),
|
||||
(2002, 1002, 103, NULL),
|
||||
(2003, 1003, 104, NULL),
|
||||
(2004, 1004, 105, NULL)`,
|
||||
fmt.Sprintf(`INSERT INTO approvals (id, approvable_type, approvable_id, action) VALUES
|
||||
(3001, '%s', 1001, 'APPROVED'),
|
||||
(3002, '%s', 1002, 'APPROVED'),
|
||||
(3003, '%s', 1003, 'APPROVED'),
|
||||
(3004, '%s', 1003, 'REJECTED'),
|
||||
(3005, '%s', 1004, 'APPROVED')`,
|
||||
approvalType, approvalType, approvalType, approvalType, approvalType),
|
||||
)
|
||||
|
||||
repo := &HppV2RepositoryImpl{db: db}
|
||||
|
||||
periodDate := mustJakartaTime(t, "2026-04-30 00:00:00")
|
||||
total, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, periodDate)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
assertFloatEquals(t, total, 750)
|
||||
|
||||
earlyPeriod := mustJakartaTime(t, "2026-04-10 23:59:59")
|
||||
earlyTotal, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, earlyPeriod)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
assertFloatEquals(t, earlyTotal, 240)
|
||||
}
|
||||
|
||||
func setupHppV2RepositoryTestDB(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)
|
||||
}
|
||||
|
||||
mustExecHppV2(t, db,
|
||||
`CREATE TABLE recordings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_flock_kandangs_id INTEGER NULL,
|
||||
record_datetime DATETIME NULL,
|
||||
deleted_at DATETIME NULL
|
||||
)`,
|
||||
`CREATE TABLE recording_stocks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
recording_id INTEGER NULL,
|
||||
product_warehouse_id INTEGER NULL,
|
||||
project_flock_kandang_id INTEGER NULL
|
||||
)`,
|
||||
`CREATE TABLE recording_eggs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
recording_id INTEGER NULL,
|
||||
product_warehouse_id INTEGER NULL,
|
||||
qty NUMERIC(15,3) NULL,
|
||||
weight NUMERIC(15,3) NULL,
|
||||
project_flock_kandang_id INTEGER NULL
|
||||
)`,
|
||||
`CREATE TABLE stock_transfers (
|
||||
id INTEGER PRIMARY KEY,
|
||||
transfer_date DATETIME NULL
|
||||
)`,
|
||||
`CREATE TABLE stock_transfer_details (
|
||||
id INTEGER PRIMARY KEY,
|
||||
stock_transfer_id INTEGER NULL,
|
||||
source_product_warehouse_id INTEGER NULL,
|
||||
dest_product_warehouse_id INTEGER NULL
|
||||
)`,
|
||||
`CREATE TABLE stock_allocations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
usable_type TEXT NULL,
|
||||
usable_id INTEGER NULL,
|
||||
stockable_type TEXT NULL,
|
||||
stockable_id INTEGER NULL,
|
||||
status TEXT NULL,
|
||||
allocation_purpose TEXT NULL,
|
||||
qty NUMERIC(15,3) NULL
|
||||
)`,
|
||||
`CREATE TABLE adjustment_stocks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
product_warehouse_id INTEGER NULL,
|
||||
total_qty NUMERIC(15,3) NULL,
|
||||
usage_qty NUMERIC(15,3) NULL,
|
||||
price NUMERIC(15,3) NULL,
|
||||
grand_total NUMERIC(15,3) NULL,
|
||||
function_code TEXT NULL,
|
||||
transaction_type TEXT NULL,
|
||||
created_at DATETIME NULL
|
||||
)`,
|
||||
`CREATE TABLE kandangs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
location_id INTEGER NULL
|
||||
)`,
|
||||
`CREATE TABLE project_flock_kandangs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
kandang_id INTEGER NULL,
|
||||
project_flock_id INTEGER NULL
|
||||
)`,
|
||||
`CREATE TABLE warehouses (
|
||||
id INTEGER PRIMARY KEY,
|
||||
type TEXT NULL,
|
||||
location_id INTEGER NULL
|
||||
)`,
|
||||
`CREATE TABLE product_warehouses (
|
||||
id INTEGER PRIMARY KEY,
|
||||
warehouse_id INTEGER NULL,
|
||||
product_id INTEGER NULL,
|
||||
project_flock_kandang_id INTEGER NULL
|
||||
)`,
|
||||
`CREATE TABLE marketing_products (
|
||||
id INTEGER PRIMARY KEY,
|
||||
product_warehouse_id INTEGER NULL
|
||||
)`,
|
||||
`CREATE TABLE purchase_items (
|
||||
id INTEGER PRIMARY KEY,
|
||||
product_id INTEGER NULL,
|
||||
price NUMERIC(15,3) NULL
|
||||
)`,
|
||||
`CREATE TABLE marketing_delivery_products (
|
||||
id INTEGER PRIMARY KEY,
|
||||
marketing_product_id INTEGER NULL,
|
||||
usage_qty NUMERIC(15,3) NULL,
|
||||
total_weight NUMERIC(15,3) NULL,
|
||||
delivery_date DATETIME NULL
|
||||
)`,
|
||||
`CREATE TABLE flags (
|
||||
id INTEGER PRIMARY KEY,
|
||||
flagable_type TEXT NULL,
|
||||
flagable_id INTEGER NULL,
|
||||
name TEXT NULL
|
||||
)`,
|
||||
`CREATE TABLE laying_transfers (
|
||||
id INTEGER PRIMARY KEY,
|
||||
transfer_date DATETIME NULL,
|
||||
effective_move_date DATETIME NULL,
|
||||
economic_cutoff_date DATETIME NULL,
|
||||
executed_at DATETIME NULL,
|
||||
deleted_at DATETIME NULL
|
||||
)`,
|
||||
`CREATE TABLE laying_transfer_targets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
laying_transfer_id INTEGER NULL,
|
||||
target_project_flock_kandang_id INTEGER NULL,
|
||||
deleted_at DATETIME NULL
|
||||
)`,
|
||||
`CREATE TABLE approvals (
|
||||
id INTEGER PRIMARY KEY,
|
||||
approvable_type TEXT NULL,
|
||||
approvable_id INTEGER NULL,
|
||||
action TEXT NULL
|
||||
)`,
|
||||
)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func mustExecHppV2(t *testing.T, db *gorm.DB, statements ...string) {
|
||||
t.Helper()
|
||||
|
||||
for _, statement := range statements {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
t.Fatalf("failed executing statement %q: %v", statement, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustJakartaTime(t *testing.T, raw string) time.Time {
|
||||
t.Helper()
|
||||
|
||||
location, err := time.LoadLocation("Asia/Jakarta")
|
||||
if err != nil {
|
||||
t.Fatalf("failed loading timezone: %v", err)
|
||||
}
|
||||
|
||||
value, err := time.ParseInLocation("2006-01-02 15:04:05", raw, location)
|
||||
if err != nil {
|
||||
t.Fatalf("failed parsing time %q: %v", raw, err)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func assertFloatEquals(t *testing.T, got float64, want float64) {
|
||||
t.Helper()
|
||||
|
||||
if math.Abs(got-want) > 0.000001 {
|
||||
t.Fatalf("expected %.6f, got %.6f", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHppV2RepositoryConstantsStayAlignedWithProductionQueries(t *testing.T) {
|
||||
if fifo.UsableKeyStockTransferOut.String() != "STOCK_TRANSFER_OUT" {
|
||||
t.Fatalf("unexpected stock transfer usable key: %s", fifo.UsableKeyStockTransferOut.String())
|
||||
}
|
||||
if fifo.StockableKeyAdjustmentIn.String() != "ADJUSTMENT_IN" {
|
||||
t.Fatalf("unexpected adjustment stockable key: %s", fifo.StockableKeyAdjustmentIn.String())
|
||||
}
|
||||
if entity.StockAllocationStatusActive != "ACTIVE" {
|
||||
t.Fatalf("unexpected active stock allocation status: %s", entity.StockAllocationStatusActive)
|
||||
}
|
||||
if entity.StockAllocationPurposeConsume != "CONSUME" {
|
||||
t.Fatalf("unexpected consume stock allocation purpose: %s", entity.StockAllocationPurposeConsume)
|
||||
}
|
||||
if string(utils.AdjustmentTransactionSubtypeRecordingEggIn) != "RECORDING_EGG_IN" {
|
||||
t.Fatalf("unexpected adjustment function code: %s", utils.AdjustmentTransactionSubtypeRecordingEggIn)
|
||||
}
|
||||
if string(utils.AdjustmentTransactionTypeRecording) != "RECORDING" {
|
||||
t.Fatalf("unexpected adjustment transaction type: %s", utils.AdjustmentTransactionTypeRecording)
|
||||
}
|
||||
}
|
||||
@@ -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,104 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
depreciationStartAgeDayCloseHouse = 155
|
||||
depreciationStartAgeDayOpenHouse = 176
|
||||
)
|
||||
|
||||
func NormalizeDepreciationHouseType(raw string) string {
|
||||
return strings.TrimSpace(strings.ToLower(raw))
|
||||
}
|
||||
|
||||
func DepreciationStartAgeDay(houseType string) int {
|
||||
switch NormalizeDepreciationHouseType(houseType) {
|
||||
case "close_house":
|
||||
return depreciationStartAgeDayCloseHouse
|
||||
case "open_house":
|
||||
return depreciationStartAgeDayOpenHouse
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func FlockAgeDay(originDate time.Time, periodDate time.Time) int {
|
||||
origin := time.Date(originDate.Year(), originDate.Month(), originDate.Day(), 0, 0, 0, 0, originDate.Location())
|
||||
period := time.Date(periodDate.Year(), periodDate.Month(), periodDate.Day(), 0, 0, 0, 0, periodDate.Location())
|
||||
if period.Before(origin) {
|
||||
return 0
|
||||
}
|
||||
return int(period.Sub(origin).Hours() / 24)
|
||||
}
|
||||
|
||||
func DepreciationScheduleDay(originDate time.Time, periodDate time.Time, houseType string) int {
|
||||
ageDay := FlockAgeDay(originDate, periodDate)
|
||||
startAgeDay := DepreciationStartAgeDay(houseType)
|
||||
if ageDay <= 0 || startAgeDay <= 0 || ageDay < startAgeDay {
|
||||
return 0
|
||||
}
|
||||
return ageDay - startAgeDay + 1
|
||||
}
|
||||
|
||||
func CalculateDepreciationAtDayN(
|
||||
initialPulletCost float64,
|
||||
dayN int,
|
||||
houseType string,
|
||||
percentByHouseType map[string]map[int]float64,
|
||||
) (float64, float64, float64) {
|
||||
return CalculateDepreciationFromDayRange(initialPulletCost, 1, dayN, houseType, percentByHouseType)
|
||||
}
|
||||
|
||||
func CalculateDepreciationFromDayRange(
|
||||
initialPulletCost float64,
|
||||
startDay int,
|
||||
endDay int,
|
||||
houseType string,
|
||||
percentByHouseType map[string]map[int]float64,
|
||||
) (float64, float64, float64) {
|
||||
if initialPulletCost <= 0 || endDay <= 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
if startDay <= 0 {
|
||||
startDay = 1
|
||||
}
|
||||
if endDay < startDay {
|
||||
return 0, 0, 0
|
||||
}
|
||||
|
||||
normalizedHouseType := NormalizeDepreciationHouseType(houseType)
|
||||
housePercent, exists := percentByHouseType[normalizedHouseType]
|
||||
if !exists {
|
||||
return 0, 0, 0
|
||||
}
|
||||
|
||||
current := initialPulletCost
|
||||
pulletCostDayN := 0.0
|
||||
depreciationValue := 0.0
|
||||
depreciationPercent := 0.0
|
||||
for day := startDay; day <= endDay; day++ {
|
||||
pct := housePercent[day]
|
||||
dep := current * (pct / 100)
|
||||
if day == endDay {
|
||||
pulletCostDayN = current
|
||||
depreciationValue = dep
|
||||
depreciationPercent = pct
|
||||
}
|
||||
current -= dep
|
||||
if current < 0 {
|
||||
current = 0
|
||||
}
|
||||
}
|
||||
|
||||
return pulletCostDayN, depreciationValue, depreciationPercent
|
||||
}
|
||||
|
||||
func CalculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN float64) float64 {
|
||||
if totalPulletCostDayN <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (totalDepreciationValue / totalPulletCostDayN) * 100
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDepreciationScheduleDay_UsesHouseTypeOffsets(t *testing.T) {
|
||||
openOrigin := mustDepreciationDate(t, "2026-01-01")
|
||||
if got := DepreciationScheduleDay(openOrigin, mustDepreciationDate(t, "2026-06-24"), "open_house"); got != 0 {
|
||||
t.Fatalf("expected open house day before start to be 0, got %d", got)
|
||||
}
|
||||
if got := DepreciationScheduleDay(openOrigin, mustDepreciationDate(t, "2026-06-25"), "open_house"); got != 1 {
|
||||
t.Fatalf("expected open house start day to map to schedule day 1, got %d", got)
|
||||
}
|
||||
|
||||
closeOrigin := mustDepreciationDate(t, "2026-01-01")
|
||||
if got := DepreciationScheduleDay(closeOrigin, mustDepreciationDate(t, "2026-06-03"), "close_house"); got != 0 {
|
||||
t.Fatalf("expected close house day before start to be 0, got %d", got)
|
||||
}
|
||||
if got := DepreciationScheduleDay(closeOrigin, mustDepreciationDate(t, "2026-06-04"), "close_house"); got != 1 {
|
||||
t.Fatalf("expected close house start day to map to schedule day 1, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDepreciationAtDayN_UsesRemainingBasisRecursively(t *testing.T) {
|
||||
percentByHouseType := map[string]map[int]float64{
|
||||
"close_house": {
|
||||
1: 10,
|
||||
2: 20,
|
||||
},
|
||||
}
|
||||
|
||||
pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationAtDayN(1000, 2, "close_house", percentByHouseType)
|
||||
if pulletCostDayN != 900 {
|
||||
t.Fatalf("expected remaining basis entering day 2 to be 900, got %v", pulletCostDayN)
|
||||
}
|
||||
if depreciationValue != 180 {
|
||||
t.Fatalf("expected day 2 depreciation to be 180, got %v", depreciationValue)
|
||||
}
|
||||
if depreciationPercent != 20 {
|
||||
t.Fatalf("expected day 2 depreciation percent to be 20, got %v", depreciationPercent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDepreciationFromDayRange_StartsFromProvidedScheduleDay(t *testing.T) {
|
||||
percentByHouseType := map[string]map[int]float64{
|
||||
"close_house": {
|
||||
1: 10,
|
||||
2: 20,
|
||||
3: 5,
|
||||
},
|
||||
}
|
||||
|
||||
pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationFromDayRange(1000, 2, 3, "close_house", percentByHouseType)
|
||||
if pulletCostDayN != 800 {
|
||||
t.Fatalf("expected remaining basis entering day 3 to be 800, got %v", pulletCostDayN)
|
||||
}
|
||||
if depreciationValue != 40 {
|
||||
t.Fatalf("expected day 3 depreciation to be 40, got %v", depreciationValue)
|
||||
}
|
||||
if depreciationPercent != 5 {
|
||||
t.Fatalf("expected day 3 depreciation percent to be 5, got %v", depreciationPercent)
|
||||
}
|
||||
}
|
||||
|
||||
func mustDepreciationDate(t *testing.T, raw string) time.Time {
|
||||
t.Helper()
|
||||
|
||||
location, err := time.LoadLocation("Asia/Jakarta")
|
||||
if err != nil {
|
||||
t.Fatalf("failed loading timezone: %v", err)
|
||||
}
|
||||
|
||||
value, err := time.ParseInLocation("2006-01-02", raw, location)
|
||||
if err != nil {
|
||||
t.Fatalf("failed parsing date %q: %v", raw, err)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
@@ -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,381 @@
|
||||
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"`
|
||||
DebugValues *HppCostDebugValues `json:"debug_values,omitempty"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
result, err := s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay)
|
||||
if err != nil {
|
||||
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
total := docCost + budgetCost + expedisionCost + feedCost + ovkCost
|
||||
return total, 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
|
||||
}
|
||||
|
||||
// fmt.Println(costBudget, costExpedision, costOvk, costFeed, costPullet, depresiasiTransfer)
|
||||
|
||||
// depresiasiTransfer = 0
|
||||
|
||||
total := depresiasiTransfer + costPullet + costFeed + costOvk + costExpedision + costBudget
|
||||
return total, 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
|
||||
}
|
||||
|
||||
result := (totalBudgetCost * eggProduksiPiecesKandang) / eggProduksiPiecesFlock
|
||||
return result, 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
|
||||
}
|
||||
if sourceProjectFlockID == 0 || transferTotalQty <= 0 {
|
||||
result, fallbackErr := s.getManualDepresiasiTransferFallback(projectFlockKandangId)
|
||||
if fallbackErr != nil {
|
||||
return 0, fallbackErr
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
result := (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *hppService) getManualDepresiasiTransferFallback(projectFlockKandangId uint) (float64, error) {
|
||||
projectFlockID, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId)
|
||||
if err != nil {
|
||||
|
||||
return 0, err
|
||||
}
|
||||
if projectFlockID == 0 {
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
manualCost, err := s.hppRepo.GetManualDepreciationCostByProjectFlockID(context.Background(), projectFlockID)
|
||||
if err != nil {
|
||||
|
||||
return 0, err
|
||||
}
|
||||
if manualCost <= 0 {
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockID)
|
||||
if err != nil {
|
||||
|
||||
return 0, err
|
||||
}
|
||||
if len(kandangIDs) == 0 {
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
totalUsageQty, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDs)
|
||||
if err != nil {
|
||||
|
||||
return 0, err
|
||||
}
|
||||
if totalUsageQty <= 0 {
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
kandangUsageQty, err := s.hppRepo.GetTotalPopulation(context.Background(), []uint{projectFlockKandangId})
|
||||
if err != nil {
|
||||
|
||||
return 0, err
|
||||
}
|
||||
if kandangUsageQty <= 0 {
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
result := manualCost * (kandangUsageQty / totalUsageQty)
|
||||
return result, 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)
|
||||
}
|
||||
|
||||
result := &HppCostResponse{
|
||||
Estimation: estimation,
|
||||
Real: real,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func roundToTwoDecimals(value float64) float64 {
|
||||
result := math.Round(value*100) / 100
|
||||
return result
|
||||
}
|
||||
|
||||
func formatTimePtr(value *time.Time) string {
|
||||
if value == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return value.Format(time.RFC3339)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package service
|
||||
|
||||
type HppV2DateWindow struct {
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
}
|
||||
|
||||
type HppV2Proration struct {
|
||||
Basis string `json:"basis"`
|
||||
Numerator float64 `json:"numerator"`
|
||||
Denominator float64 `json:"denominator"`
|
||||
Ratio float64 `json:"ratio"`
|
||||
}
|
||||
|
||||
type HppV2Reference struct {
|
||||
Type string `json:"type"`
|
||||
ID uint `json:"id"`
|
||||
StockableType string `json:"stockable_type,omitempty"`
|
||||
ProjectFlockKandangID *uint `json:"project_flock_kandang_id,omitempty"`
|
||||
ProductID uint `json:"product_id,omitempty"`
|
||||
ProductName string `json:"product_name,omitempty"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Qty float64 `json:"qty"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
Total float64 `json:"total"`
|
||||
AppliedTotal float64 `json:"applied_total"`
|
||||
}
|
||||
|
||||
type HppV2ComponentPart struct {
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
Total float64 `json:"total"`
|
||||
Proration *HppV2Proration `json:"proration,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
References []HppV2Reference `json:"references,omitempty"`
|
||||
}
|
||||
|
||||
type HppV2Component struct {
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
Total float64 `json:"total"`
|
||||
Parts []HppV2ComponentPart `json:"parts"`
|
||||
}
|
||||
|
||||
type HppCostDebugValues struct {
|
||||
RecordingEggQty float64 `json:"recording_egg_qty"`
|
||||
RecordingEggWeight float64 `json:"recording_egg_weight"`
|
||||
AdjustmentEggQty float64 `json:"adjustment_egg_qty"`
|
||||
AdjustmentEggWeight float64 `json:"adjustment_egg_weight"`
|
||||
SoldEggQty float64 `json:"sold_egg_qty"`
|
||||
SoldEggWeight float64 `json:"sold_egg_weight"`
|
||||
}
|
||||
|
||||
type HppV2Breakdown struct {
|
||||
ProjectFlockKandangID uint `json:"project_flock_kandang_id"`
|
||||
ProjectFlockID uint `json:"project_flock_id"`
|
||||
ProjectFlockCategory string `json:"project_flock_category,omitempty"`
|
||||
HouseType string `json:"house_type,omitempty"`
|
||||
KandangID uint `json:"kandang_id,omitempty"`
|
||||
KandangName string `json:"kandang_name,omitempty"`
|
||||
LocationID uint `json:"location_id,omitempty"`
|
||||
PeriodDate string `json:"period_date"`
|
||||
Window HppV2DateWindow `json:"window"`
|
||||
TotalPulletCost float64 `json:"total_pullet_cost"`
|
||||
TotalProductionCost float64 `json:"total_production_cost"`
|
||||
Components []HppV2Component `json:"components"`
|
||||
Hpp HppCostResponse `json:"hpp"`
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,888 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
)
|
||||
|
||||
type hppV2RepoStub struct {
|
||||
contextByPFK map[uint]*commonRepo.HppV2ProjectFlockKandangContext
|
||||
pfkIDsByProject map[uint][]uint
|
||||
latestTransferByPFK map[uint]*commonRepo.HppV2LatestTransferInputRow
|
||||
manualInputByProject map[uint]*commonRepo.HppV2ManualDepreciationInputRow
|
||||
snapshotByProjectKey map[string]*commonRepo.HppV2FarmDepreciationSnapshotRow
|
||||
chickInDateByProject map[uint]*time.Time
|
||||
depreciationByHouse map[string]map[int]float64
|
||||
usageRowsByKey map[string][]commonRepo.HppV2UsageCostRow
|
||||
adjustRowsByKey map[string][]commonRepo.HppV2AdjustmentCostRow
|
||||
chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow
|
||||
expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow
|
||||
expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow
|
||||
routeCostByProject map[uint]float64
|
||||
totalPopulationByKey map[string]float64
|
||||
transferSummaryByPFK map[uint]struct {
|
||||
projectFlockID uint
|
||||
totalQty float64
|
||||
}
|
||||
eggProductionByPFK map[uint]struct {
|
||||
pieces float64
|
||||
kg float64
|
||||
}
|
||||
eggSalesByPFK map[uint]struct {
|
||||
pieces float64
|
||||
kg float64
|
||||
}
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) GetProjectFlockKandangContext(_ context.Context, projectFlockKandangId uint) (*commonRepo.HppV2ProjectFlockKandangContext, error) {
|
||||
row := s.contextByPFK[projectFlockKandangId]
|
||||
if row == nil {
|
||||
return nil, fmt.Errorf("pfk %d not found", projectFlockKandangId)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) GetProjectFlockKandangIDs(_ context.Context, projectFlockId uint) ([]uint, error) {
|
||||
return append([]uint{}, s.pfkIDsByProject[projectFlockId]...), nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) GetLatestTransferInputByProjectFlockKandangID(_ context.Context, projectFlockKandangId uint, _ time.Time) (*commonRepo.HppV2LatestTransferInputRow, error) {
|
||||
return s.latestTransferByPFK[projectFlockKandangId], nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) GetManualDepreciationInputByProjectFlockID(_ context.Context, projectFlockID uint) (*commonRepo.HppV2ManualDepreciationInputRow, error) {
|
||||
return s.manualInputByProject[projectFlockID], nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) GetRecordingStockRoutingAdjustmentCostByProjectFlockID(_ context.Context, projectFlockID uint, _ time.Time) (float64, error) {
|
||||
return s.routeCostByProject[projectFlockID], nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(_ context.Context, projectFlockID uint, periodDate time.Time) (*commonRepo.HppV2FarmDepreciationSnapshotRow, error) {
|
||||
if s.snapshotByProjectKey == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.snapshotByProjectKey[fmt.Sprintf("%d|%s", projectFlockID, periodDate.Format("2006-01-02"))], nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) GetEarliestChickInDateByProjectFlockID(_ context.Context, projectFlockID uint) (*time.Time, error) {
|
||||
return s.chickInDateByProject[projectFlockID], nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) GetDepreciationPercents(_ context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error) {
|
||||
result := make(map[string]map[int]float64)
|
||||
for _, houseType := range houseTypes {
|
||||
source := s.depreciationByHouse[houseType]
|
||||
if len(source) == 0 {
|
||||
continue
|
||||
}
|
||||
result[houseType] = make(map[int]float64)
|
||||
for day, pct := range source {
|
||||
if day <= maxDay {
|
||||
result[houseType][day] = pct
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) ListUsageCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2UsageCostRow, error) {
|
||||
return append([]commonRepo.HppV2UsageCostRow{}, s.usageRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) ListAdjustmentCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2AdjustmentCostRow, error) {
|
||||
return append([]commonRepo.HppV2AdjustmentCostRow{}, s.adjustRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockKandangIDs(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) {
|
||||
return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByPFKKey[expenseStubKey(projectFlockKandangIDs, ekspedisi)]...), nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockID(_ context.Context, projectFlockID uint, _ *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) {
|
||||
return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByFarmKey[expenseFarmKey(projectFlockID, ekspedisi)]...), nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) ListChickinCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time, excludeTransferToLaying bool) ([]commonRepo.HppV2ChickinCostRow, error) {
|
||||
return append([]commonRepo.HppV2ChickinCostRow{}, s.chickinRowsByKey[chickinStubKey(projectFlockKandangIDs, flagNames, excludeTransferToLaying)]...), nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) GetFeedUsageCost(_ context.Context, _ []uint, _ *time.Time) (float64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) GetTotalPopulation(_ context.Context, projectFlockKandangIDs []uint) (float64, error) {
|
||||
return s.totalPopulationByKey[stubKey(projectFlockKandangIDs, nil)], nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time) (float64, float64, error) {
|
||||
totalPieces := 0.0
|
||||
totalKg := 0.0
|
||||
for _, projectFlockKandangID := range projectFlockKandangIDs {
|
||||
row := s.eggProductionByPFK[projectFlockKandangID]
|
||||
totalPieces += row.pieces
|
||||
totalKg += row.kg
|
||||
}
|
||||
return totalPieces, totalKg, nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) GetEggProduksiBreakdownByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time) (float64, float64, float64, float64, error) {
|
||||
totalPieces := 0.0
|
||||
totalKg := 0.0
|
||||
for _, projectFlockKandangID := range projectFlockKandangIDs {
|
||||
row := s.eggProductionByPFK[projectFlockKandangID]
|
||||
totalPieces += row.pieces
|
||||
totalKg += row.kg
|
||||
}
|
||||
return totalPieces, totalKg, 0, 0, nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time, _ *time.Time) (float64, float64, error) {
|
||||
if len(projectFlockKandangIDs) != 1 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
row := s.eggSalesByPFK[projectFlockKandangIDs[0]]
|
||||
return row.pieces, row.kg, nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) GetTransferSourceSummary(_ context.Context, projectFlockKandangId uint) (uint, float64, error) {
|
||||
row := s.transferSummaryByPFK[projectFlockKandangId]
|
||||
return row.projectFlockID, row.totalQty, nil
|
||||
}
|
||||
|
||||
func TestHppV2CalculateHppBreakdown_ComposesPakanSlices(t *testing.T) {
|
||||
repo := &hppV2RepoStub{
|
||||
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
|
||||
10: {
|
||||
ProjectFlockKandangID: 10,
|
||||
ProjectFlockID: 2,
|
||||
ProjectFlockCategory: "LAYING",
|
||||
KandangID: 100,
|
||||
KandangName: "Kandang A",
|
||||
LocationID: 16,
|
||||
},
|
||||
},
|
||||
pfkIDsByProject: map[uint][]uint{
|
||||
1: {101, 102},
|
||||
},
|
||||
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
|
||||
stubKey([]uint{101, 102}, []string{"PAKAN"}): {
|
||||
{StockableType: "purchase_items", StockableID: 9001, SourceProductID: 8, SourceProductName: "Pakan Growing", Qty: 100, UnitPrice: 40, TotalCost: 4000},
|
||||
},
|
||||
stubKey([]uint{10}, []string{"PAKAN"}): {
|
||||
{StockableType: "purchase_items", StockableID: 9002, SourceProductID: 9, SourceProductName: "Pakan Laying", Qty: 50, UnitPrice: 30, TotalCost: 1500},
|
||||
},
|
||||
},
|
||||
adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{
|
||||
stubKey([]uint{101, 102}, []string{"PAKAN-CUTOVER"}): {
|
||||
{AdjustmentID: 8001, ProductID: 11, ProductName: "Pakan Growing Cut-over", Qty: 20, Price: 30, GrandTotal: 600},
|
||||
},
|
||||
stubKey([]uint{10}, []string{"PAKAN-CUTOVER"}): {
|
||||
{AdjustmentID: 8002, ProductID: 12, ProductName: "Pakan Laying Cut-over", Qty: 10, Price: 30, GrandTotal: 300},
|
||||
},
|
||||
},
|
||||
totalPopulationByKey: map[string]float64{
|
||||
stubKey([]uint{101, 102}, nil): 1000,
|
||||
},
|
||||
transferSummaryByPFK: map[uint]struct {
|
||||
projectFlockID uint
|
||||
totalQty float64
|
||||
}{
|
||||
10: {projectFlockID: 1, totalQty: 250},
|
||||
},
|
||||
eggProductionByPFK: map[uint]struct {
|
||||
pieces float64
|
||||
kg float64
|
||||
}{
|
||||
10: {pieces: 100, kg: 10},
|
||||
},
|
||||
eggSalesByPFK: map[uint]struct {
|
||||
pieces float64
|
||||
kg float64
|
||||
}{
|
||||
10: {pieces: 40, kg: 4},
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewHppV2Service(repo)
|
||||
result, err := svc.CalculateHppBreakdown(10, mustDate(t, "2026-04-19"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected breakdown result")
|
||||
}
|
||||
if got := result.TotalPulletCost; got != 1150 {
|
||||
t.Fatalf("expected total pullet cost 1150, got %v", got)
|
||||
}
|
||||
if got := result.TotalProductionCost; got != 1800 {
|
||||
t.Fatalf("expected total production cost 1800, got %v", got)
|
||||
}
|
||||
if len(result.Components) != 1 {
|
||||
t.Fatalf("expected 1 component, got %d", len(result.Components))
|
||||
}
|
||||
component := result.Components[0]
|
||||
if component.Code != "PAKAN" {
|
||||
t.Fatalf("expected PAKAN component, got %s", component.Code)
|
||||
}
|
||||
partTotals := map[string]float64{}
|
||||
for _, part := range component.Parts {
|
||||
partTotals[part.Code] = part.Total
|
||||
}
|
||||
if partTotals[hppV2PartGrowingNormal] != 1000 {
|
||||
t.Fatalf("expected growing normal 1000, got %v", partTotals[hppV2PartGrowingNormal])
|
||||
}
|
||||
if partTotals[hppV2PartGrowingCutover] != 150 {
|
||||
t.Fatalf("expected growing cutover 150, got %v", partTotals[hppV2PartGrowingCutover])
|
||||
}
|
||||
if partTotals[hppV2PartLayingNormal] != 1500 {
|
||||
t.Fatalf("expected laying normal 1500, got %v", partTotals[hppV2PartLayingNormal])
|
||||
}
|
||||
if partTotals[hppV2PartLayingCutover] != 300 {
|
||||
t.Fatalf("expected laying cutover 300, got %v", partTotals[hppV2PartLayingCutover])
|
||||
}
|
||||
if component.Parts[0].Proration == nil || component.Parts[0].Proration.Ratio != 0.25 {
|
||||
t.Fatalf("expected growing proration ratio 0.25, got %+v", component.Parts[0].Proration)
|
||||
}
|
||||
if result.Hpp.Estimation.HargaKg != 180 {
|
||||
t.Fatalf("expected estimation harga/kg 180, got %v", result.Hpp.Estimation.HargaKg)
|
||||
}
|
||||
if result.Hpp.Real.HargaKg != 450 {
|
||||
t.Fatalf("expected real harga/kg 450, got %v", result.Hpp.Real.HargaKg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHppV2CalculateHppBreakdown_ManualCutoverUsesLayingSlicesOnly(t *testing.T) {
|
||||
repo := &hppV2RepoStub{
|
||||
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
|
||||
20: {
|
||||
ProjectFlockKandangID: 20,
|
||||
ProjectFlockID: 3,
|
||||
ProjectFlockCategory: "LAYING",
|
||||
KandangID: 200,
|
||||
KandangName: "Kandang B",
|
||||
LocationID: 17,
|
||||
},
|
||||
},
|
||||
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
|
||||
stubKey([]uint{20}, []string{"PAKAN"}): {
|
||||
{StockableType: "purchase_items", StockableID: 9100, SourceProductID: 21, SourceProductName: "Pakan Laying", Qty: 20, UnitPrice: 10, TotalCost: 200},
|
||||
},
|
||||
},
|
||||
adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{
|
||||
stubKey([]uint{20}, []string{"PAKAN-CUTOVER"}): {
|
||||
{AdjustmentID: 8100, ProductID: 22, ProductName: "Pakan Laying Cut-over", Qty: 30, Price: 10, GrandTotal: 300},
|
||||
},
|
||||
},
|
||||
eggProductionByPFK: map[uint]struct {
|
||||
pieces float64
|
||||
kg float64
|
||||
}{
|
||||
20: {pieces: 50, kg: 5},
|
||||
},
|
||||
eggSalesByPFK: map[uint]struct {
|
||||
pieces float64
|
||||
kg float64
|
||||
}{
|
||||
20: {pieces: 25, kg: 2.5},
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewHppV2Service(repo)
|
||||
result, err := svc.CalculateHppBreakdown(20, mustDate(t, "2026-04-19"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if result.TotalProductionCost != 500 {
|
||||
t.Fatalf("expected total production cost 500, got %v", result.TotalProductionCost)
|
||||
}
|
||||
component := result.Components[0]
|
||||
if len(component.Parts) != 2 {
|
||||
t.Fatalf("expected 2 laying parts, got %d", len(component.Parts))
|
||||
}
|
||||
for _, part := range component.Parts {
|
||||
if strings.HasPrefix(part.Code, "growing_") {
|
||||
t.Fatalf("expected no growing parts, got %s", part.Code)
|
||||
}
|
||||
}
|
||||
if result.Hpp.Estimation.HargaKg != 100 {
|
||||
t.Fatalf("expected estimation harga/kg 100, got %v", result.Hpp.Estimation.HargaKg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHppV2CalculateHppBreakdown_IncludesOvkComponent(t *testing.T) {
|
||||
repo := &hppV2RepoStub{
|
||||
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
|
||||
30: {
|
||||
ProjectFlockKandangID: 30,
|
||||
ProjectFlockID: 4,
|
||||
ProjectFlockCategory: "LAYING",
|
||||
KandangID: 300,
|
||||
KandangName: "Kandang C",
|
||||
LocationID: 18,
|
||||
},
|
||||
},
|
||||
pfkIDsByProject: map[uint][]uint{
|
||||
5: {301, 302},
|
||||
},
|
||||
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
|
||||
stubKey([]uint{30}, []string{"PAKAN"}): {
|
||||
{StockableType: "purchase_items", StockableID: 9200, SourceProductID: 31, SourceProductName: "Pakan Laying", Qty: 20, UnitPrice: 25, TotalCost: 500},
|
||||
},
|
||||
stubKey([]uint{301, 302}, []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}): {
|
||||
{StockableType: "purchase_items", StockableID: 9201, SourceProductID: 32, SourceProductName: "OVK Growing", Qty: 40, UnitPrice: 10, TotalCost: 400},
|
||||
},
|
||||
stubKey([]uint{30}, []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}): {
|
||||
{StockableType: "purchase_items", StockableID: 9202, SourceProductID: 33, SourceProductName: "OVK Laying", Qty: 15, UnitPrice: 10, TotalCost: 150},
|
||||
},
|
||||
},
|
||||
adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{
|
||||
stubKey([]uint{301, 302}, []string{"OVK"}): {
|
||||
{AdjustmentID: 8201, ProductID: 34, ProductName: "OVK Growing Cut-over", Qty: 10, Price: 10, GrandTotal: 100},
|
||||
},
|
||||
stubKey([]uint{30}, []string{"OVK"}): {
|
||||
{AdjustmentID: 8202, ProductID: 35, ProductName: "OVK Laying Cut-over", Qty: 5, Price: 10, GrandTotal: 50},
|
||||
},
|
||||
},
|
||||
totalPopulationByKey: map[string]float64{
|
||||
stubKey([]uint{301, 302}, nil): 1000,
|
||||
},
|
||||
transferSummaryByPFK: map[uint]struct {
|
||||
projectFlockID uint
|
||||
totalQty float64
|
||||
}{
|
||||
30: {projectFlockID: 5, totalQty: 500},
|
||||
},
|
||||
eggProductionByPFK: map[uint]struct {
|
||||
pieces float64
|
||||
kg float64
|
||||
}{
|
||||
30: {pieces: 120, kg: 12},
|
||||
},
|
||||
eggSalesByPFK: map[uint]struct {
|
||||
pieces float64
|
||||
kg float64
|
||||
}{
|
||||
30: {pieces: 60, kg: 6},
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewHppV2Service(repo)
|
||||
result, err := svc.CalculateHppBreakdown(30, mustDate(t, "2026-04-19"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected breakdown result")
|
||||
}
|
||||
if len(result.Components) != 2 {
|
||||
t.Fatalf("expected 2 components, got %d", len(result.Components))
|
||||
}
|
||||
|
||||
componentTotals := map[string]float64{}
|
||||
for _, component := range result.Components {
|
||||
componentTotals[component.Code] = component.Total
|
||||
}
|
||||
|
||||
if componentTotals[hppV2ComponentPakan] != 500 {
|
||||
t.Fatalf("expected pakan total 500, got %v", componentTotals[hppV2ComponentPakan])
|
||||
}
|
||||
if componentTotals[hppV2ComponentOvk] != 450 {
|
||||
t.Fatalf("expected ovk total 450, got %v", componentTotals[hppV2ComponentOvk])
|
||||
}
|
||||
if result.TotalPulletCost != 250 {
|
||||
t.Fatalf("expected total pullet cost 250, got %v", result.TotalPulletCost)
|
||||
}
|
||||
if result.TotalProductionCost != 700 {
|
||||
t.Fatalf("expected total production cost 700, got %v", result.TotalProductionCost)
|
||||
}
|
||||
if result.Hpp.Estimation.HargaKg != 58.33 {
|
||||
t.Fatalf("expected estimation harga/kg 58.33, got %v", result.Hpp.Estimation.HargaKg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHppV2CalculateHppBreakdown_IncludesDocAndDirectPulletChickin(t *testing.T) {
|
||||
repo := &hppV2RepoStub{
|
||||
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
|
||||
35: {
|
||||
ProjectFlockKandangID: 35,
|
||||
ProjectFlockID: 8,
|
||||
ProjectFlockCategory: "LAYING",
|
||||
KandangID: 350,
|
||||
KandangName: "Kandang E",
|
||||
LocationID: 20,
|
||||
},
|
||||
},
|
||||
pfkIDsByProject: map[uint][]uint{
|
||||
9: {901, 902},
|
||||
},
|
||||
totalPopulationByKey: map[string]float64{
|
||||
stubKey([]uint{901, 902}, nil): 1000,
|
||||
},
|
||||
transferSummaryByPFK: map[uint]struct {
|
||||
projectFlockID uint
|
||||
totalQty float64
|
||||
}{
|
||||
35: {projectFlockID: 9, totalQty: 250},
|
||||
},
|
||||
chickinRowsByKey: map[string][]commonRepo.HppV2ChickinCostRow{
|
||||
chickinStubKey([]uint{901, 902}, []string{string(utils.FlagDOC)}, false): {
|
||||
{ProjectChickinID: 1, ProjectFlockKandangID: 901, ChickInDate: mustTime(t, "2026-04-01"), StockableType: "purchase_items", StockableID: 1001, SourceProductID: 77, SourceProductName: "DOC", Qty: 1000, UnitPrice: 2, TotalCost: 2000},
|
||||
},
|
||||
chickinStubKey([]uint{35}, []string{string(utils.FlagPullet), string(utils.FlagLayer)}, true): {
|
||||
{ProjectChickinID: 2, ProjectFlockKandangID: 35, ChickInDate: mustTime(t, "2026-04-15"), StockableType: "purchase_items", StockableID: 1002, SourceProductID: 78, SourceProductName: "Pullet", Qty: 50, UnitPrice: 20, TotalCost: 1000},
|
||||
},
|
||||
},
|
||||
eggProductionByPFK: map[uint]struct {
|
||||
pieces float64
|
||||
kg float64
|
||||
}{
|
||||
35: {pieces: 100, kg: 10},
|
||||
},
|
||||
eggSalesByPFK: map[uint]struct {
|
||||
pieces float64
|
||||
kg float64
|
||||
}{
|
||||
35: {pieces: 80, kg: 8},
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewHppV2Service(repo)
|
||||
result, err := svc.CalculateHppBreakdown(35, mustDate(t, "2026-04-19"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
componentTotals := map[string]float64{}
|
||||
for _, component := range result.Components {
|
||||
componentTotals[component.Code] = component.Total
|
||||
}
|
||||
|
||||
if componentTotals[hppV2ComponentDocChickin] != 500 {
|
||||
t.Fatalf("expected doc chickin total 500, got %v", componentTotals[hppV2ComponentDocChickin])
|
||||
}
|
||||
if componentTotals[hppV2ComponentDirectPulletPurchase] != 1000 {
|
||||
t.Fatalf("expected direct pullet purchase total 1000, got %v", componentTotals[hppV2ComponentDirectPulletPurchase])
|
||||
}
|
||||
if result.TotalPulletCost != 500 {
|
||||
t.Fatalf("expected total pullet cost 500, got %v", result.TotalPulletCost)
|
||||
}
|
||||
if result.TotalProductionCost != 1000 {
|
||||
t.Fatalf("expected total production cost 1000, got %v", result.TotalProductionCost)
|
||||
}
|
||||
if result.Hpp.Estimation.HargaKg != 100 {
|
||||
t.Fatalf("expected estimation harga/kg 100, got %v", result.Hpp.Estimation.HargaKg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHppV2CalculateHppBreakdown_IncludesBopRegularAndEkspedisi(t *testing.T) {
|
||||
repo := &hppV2RepoStub{
|
||||
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
|
||||
40: {
|
||||
ProjectFlockKandangID: 40,
|
||||
ProjectFlockID: 6,
|
||||
ProjectFlockCategory: "LAYING",
|
||||
KandangID: 400,
|
||||
KandangName: "Kandang D",
|
||||
LocationID: 19,
|
||||
},
|
||||
},
|
||||
pfkIDsByProject: map[uint][]uint{
|
||||
6: {40, 41},
|
||||
7: {701, 702},
|
||||
},
|
||||
totalPopulationByKey: map[string]float64{
|
||||
stubKey([]uint{701, 702}, nil): 1000,
|
||||
},
|
||||
transferSummaryByPFK: map[uint]struct {
|
||||
projectFlockID uint
|
||||
totalQty float64
|
||||
}{
|
||||
40: {projectFlockID: 7, totalQty: 200},
|
||||
},
|
||||
expenseRowsByPFKKey: map[string][]commonRepo.HppV2ExpenseCostRow{
|
||||
expenseStubKey([]uint{701, 702}, false): {
|
||||
{ExpenseRealizationID: 1, NonstockID: 11, NonstockName: "Growing BOP", Qty: 1, Price: 500, TotalCost: 500, RealizationDate: mustTime(t, "2026-04-10")},
|
||||
},
|
||||
expenseStubKey([]uint{40}, false): {
|
||||
{ExpenseRealizationID: 2, NonstockID: 12, NonstockName: "Laying BOP", Qty: 1, Price: 80, TotalCost: 80, RealizationDate: mustTime(t, "2026-04-19")},
|
||||
},
|
||||
expenseStubKey([]uint{701, 702}, true): {
|
||||
{ExpenseRealizationID: 3, NonstockID: 13, NonstockName: "Growing Expedition", Qty: 1, Price: 100, TotalCost: 100, RealizationDate: mustTime(t, "2026-04-11")},
|
||||
},
|
||||
expenseStubKey([]uint{40}, true): {
|
||||
{ExpenseRealizationID: 4, NonstockID: 14, NonstockName: "Laying Expedition", Qty: 1, Price: 40, TotalCost: 40, RealizationDate: mustTime(t, "2026-04-19")},
|
||||
},
|
||||
},
|
||||
expenseRowsByFarmKey: map[string][]commonRepo.HppV2ExpenseCostRow{
|
||||
expenseFarmKey(7, false): {
|
||||
{ExpenseRealizationID: 5, NonstockID: 15, NonstockName: "Growing Farm BOP", Qty: 1, Price: 300, TotalCost: 300, RealizationDate: mustTime(t, "2026-04-12")},
|
||||
},
|
||||
expenseFarmKey(6, false): {
|
||||
{ExpenseRealizationID: 6, NonstockID: 16, NonstockName: "Laying Farm BOP", Qty: 1, Price: 100, TotalCost: 100, RealizationDate: mustTime(t, "2026-04-19")},
|
||||
},
|
||||
expenseFarmKey(7, true): {
|
||||
{ExpenseRealizationID: 7, NonstockID: 17, NonstockName: "Growing Farm Expedition", Qty: 1, Price: 50, TotalCost: 50, RealizationDate: mustTime(t, "2026-04-12")},
|
||||
},
|
||||
expenseFarmKey(6, true): {
|
||||
{ExpenseRealizationID: 8, NonstockID: 18, NonstockName: "Laying Farm Expedition", Qty: 1, Price: 60, TotalCost: 60, RealizationDate: mustTime(t, "2026-04-19")},
|
||||
},
|
||||
},
|
||||
eggProductionByPFK: map[uint]struct {
|
||||
pieces float64
|
||||
kg float64
|
||||
}{
|
||||
40: {pieces: 30, kg: 3},
|
||||
41: {pieces: 70, kg: 7},
|
||||
},
|
||||
eggSalesByPFK: map[uint]struct {
|
||||
pieces float64
|
||||
kg float64
|
||||
}{
|
||||
40: {pieces: 50, kg: 5},
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewHppV2Service(repo)
|
||||
result, err := svc.CalculateHppBreakdown(40, mustDate(t, "2026-04-19"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
componentTotals := map[string]float64{}
|
||||
for _, component := range result.Components {
|
||||
componentTotals[component.Code] = component.Total
|
||||
}
|
||||
|
||||
if componentTotals[hppV2ComponentBopRegular] != 270 {
|
||||
t.Fatalf("expected regular BOP total 270, got %v", componentTotals[hppV2ComponentBopRegular])
|
||||
}
|
||||
if componentTotals[hppV2ComponentBopEksp] != 88 {
|
||||
t.Fatalf("expected expedition BOP total 88, got %v", componentTotals[hppV2ComponentBopEksp])
|
||||
}
|
||||
if result.TotalPulletCost != 190 {
|
||||
t.Fatalf("expected total pullet cost 190, got %v", result.TotalPulletCost)
|
||||
}
|
||||
if result.TotalProductionCost != 168 {
|
||||
t.Fatalf("expected total production cost 168, got %v", result.TotalProductionCost)
|
||||
}
|
||||
if result.Hpp.Estimation.HargaKg != 56 {
|
||||
t.Fatalf("expected estimation harga/kg 56, got %v", result.Hpp.Estimation.HargaKg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHppV2CalculateHppBreakdown_AddsDepreciationForNormalTransfer(t *testing.T) {
|
||||
sourceChickIn := mustTime(t, "2026-01-01")
|
||||
reportDate := sourceChickIn.AddDate(0, 0, 154)
|
||||
|
||||
repo := &hppV2RepoStub{
|
||||
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
|
||||
50: {
|
||||
ProjectFlockKandangID: 50,
|
||||
ProjectFlockID: 10,
|
||||
ProjectFlockCategory: "LAYING",
|
||||
KandangID: 500,
|
||||
KandangName: "Kandang F",
|
||||
LocationID: 21,
|
||||
HouseType: "close_house",
|
||||
},
|
||||
},
|
||||
pfkIDsByProject: map[uint][]uint{
|
||||
11: {501},
|
||||
},
|
||||
latestTransferByPFK: map[uint]*commonRepo.HppV2LatestTransferInputRow{
|
||||
50: {
|
||||
ProjectFlockKandangID: 50,
|
||||
SourceProjectFlockID: 11,
|
||||
TransferDate: mustTime(t, "2026-05-20"),
|
||||
TransferQty: 100,
|
||||
TransferID: 701,
|
||||
},
|
||||
},
|
||||
chickInDateByProject: map[uint]*time.Time{
|
||||
11: &sourceChickIn,
|
||||
},
|
||||
depreciationByHouse: map[string]map[int]float64{
|
||||
"close_house": {
|
||||
1: 10,
|
||||
},
|
||||
},
|
||||
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
|
||||
stubKey([]uint{501}, []string{"PAKAN"}): {
|
||||
{StockableType: "purchase_items", StockableID: 9301, SourceProductID: 41, SourceProductName: "Pakan Growing", Qty: 25, UnitPrice: 40, TotalCost: 1000},
|
||||
},
|
||||
},
|
||||
totalPopulationByKey: map[string]float64{
|
||||
stubKey([]uint{501}, nil): 100,
|
||||
},
|
||||
transferSummaryByPFK: map[uint]struct {
|
||||
projectFlockID uint
|
||||
totalQty float64
|
||||
}{
|
||||
50: {projectFlockID: 11, totalQty: 100},
|
||||
},
|
||||
eggProductionByPFK: map[uint]struct {
|
||||
pieces float64
|
||||
kg float64
|
||||
}{
|
||||
50: {pieces: 20, kg: 10},
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewHppV2Service(repo)
|
||||
result, err := svc.CalculateHppBreakdown(50, &reportDate)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if result.TotalPulletCost != 1000 {
|
||||
t.Fatalf("expected total pullet cost 1000, got %v", result.TotalPulletCost)
|
||||
}
|
||||
if result.TotalProductionCost != 100 {
|
||||
t.Fatalf("expected total production cost 100, got %v", result.TotalProductionCost)
|
||||
}
|
||||
|
||||
var depreciation *HppV2Component
|
||||
for i := range result.Components {
|
||||
if result.Components[i].Code == hppV2ComponentDepreciation {
|
||||
depreciation = &result.Components[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if depreciation == nil {
|
||||
t.Fatal("expected depreciation component")
|
||||
}
|
||||
if depreciation.Total != 100 {
|
||||
t.Fatalf("expected depreciation total 100, got %v", depreciation.Total)
|
||||
}
|
||||
if len(depreciation.Parts) != 1 {
|
||||
t.Fatalf("expected single depreciation part, got %d", len(depreciation.Parts))
|
||||
}
|
||||
if depreciation.Parts[0].Details["schedule_day"] != 1 {
|
||||
t.Fatalf("expected schedule day 1, got %+v", depreciation.Parts[0].Details)
|
||||
}
|
||||
if depreciation.Parts[0].Details["origin_date"] != "2026-01-01" {
|
||||
t.Fatalf("expected origin date 2026-01-01, got %+v", depreciation.Parts[0].Details)
|
||||
}
|
||||
if result.Hpp.Estimation.HargaKg != 10 {
|
||||
t.Fatalf("expected estimation harga/kg 10, got %v", result.Hpp.Estimation.HargaKg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHppV2CalculateHppBreakdown_AddsDepreciationForManualCutoverFromCutoverDate(t *testing.T) {
|
||||
originDate := mustTime(t, "2026-01-01")
|
||||
cutoverDate := originDate.AddDate(0, 0, 155)
|
||||
|
||||
repo := &hppV2RepoStub{
|
||||
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
|
||||
60: {
|
||||
ProjectFlockKandangID: 60,
|
||||
ProjectFlockID: 12,
|
||||
ProjectFlockCategory: "LAYING",
|
||||
KandangID: 600,
|
||||
KandangName: "Kandang G",
|
||||
LocationID: 22,
|
||||
HouseType: "close_house",
|
||||
},
|
||||
},
|
||||
pfkIDsByProject: map[uint][]uint{
|
||||
12: {60},
|
||||
},
|
||||
manualInputByProject: map[uint]*commonRepo.HppV2ManualDepreciationInputRow{
|
||||
12: {
|
||||
ID: 801,
|
||||
ProjectFlockID: 12,
|
||||
TotalCost: 1000,
|
||||
CutoverDate: cutoverDate,
|
||||
},
|
||||
},
|
||||
chickInDateByProject: map[uint]*time.Time{
|
||||
12: &originDate,
|
||||
},
|
||||
depreciationByHouse: map[string]map[int]float64{
|
||||
"close_house": {
|
||||
1: 10,
|
||||
2: 20,
|
||||
},
|
||||
},
|
||||
totalPopulationByKey: map[string]float64{
|
||||
stubKey([]uint{60}, nil): 100,
|
||||
},
|
||||
eggProductionByPFK: map[uint]struct {
|
||||
pieces float64
|
||||
kg float64
|
||||
}{
|
||||
60: {pieces: 20, kg: 10},
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewHppV2Service(repo)
|
||||
result, err := svc.CalculateHppBreakdown(60, &cutoverDate)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if result.TotalPulletCost != 1000 {
|
||||
t.Fatalf("expected total pullet cost 1000, got %v", result.TotalPulletCost)
|
||||
}
|
||||
if result.TotalProductionCost != 200 {
|
||||
t.Fatalf("expected total production cost 200, got %v", result.TotalProductionCost)
|
||||
}
|
||||
|
||||
componentTotals := map[string]float64{}
|
||||
for _, component := range result.Components {
|
||||
componentTotals[component.Code] = component.Total
|
||||
}
|
||||
if componentTotals[hppV2ComponentManualPulletCost] != 1000 {
|
||||
t.Fatalf("expected manual pullet cost 1000, got %v", componentTotals[hppV2ComponentManualPulletCost])
|
||||
}
|
||||
if componentTotals[hppV2ComponentDepreciation] != 200 {
|
||||
t.Fatalf("expected depreciation 200, got %v", componentTotals[hppV2ComponentDepreciation])
|
||||
}
|
||||
|
||||
var depreciation *HppV2Component
|
||||
for i := range result.Components {
|
||||
if result.Components[i].Code == hppV2ComponentDepreciation {
|
||||
depreciation = &result.Components[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if depreciation == nil || len(depreciation.Parts) != 1 {
|
||||
t.Fatalf("expected one depreciation part, got %+v", depreciation)
|
||||
}
|
||||
if depreciation.Parts[0].Details["schedule_day"] != 2 {
|
||||
t.Fatalf("expected schedule day 2, got %+v", depreciation.Parts[0].Details)
|
||||
}
|
||||
if depreciation.Parts[0].Details["start_schedule_day"] != 2 {
|
||||
t.Fatalf("expected start schedule day 2, got %+v", depreciation.Parts[0].Details)
|
||||
}
|
||||
if result.Hpp.Estimation.HargaKg != 20 {
|
||||
t.Fatalf("expected estimation harga/kg 20, got %v", result.Hpp.Estimation.HargaKg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHppV2CalculateHppBreakdown_UsesFarmSnapshotDepreciationProratedByEggProduction(t *testing.T) {
|
||||
reportDate := mustTime(t, "2026-06-05")
|
||||
|
||||
repo := &hppV2RepoStub{
|
||||
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
|
||||
70: {
|
||||
ProjectFlockKandangID: 70,
|
||||
ProjectFlockID: 15,
|
||||
ProjectFlockCategory: "LAYING",
|
||||
KandangID: 700,
|
||||
KandangName: "Kandang Snapshot",
|
||||
LocationID: 25,
|
||||
HouseType: "close_house",
|
||||
},
|
||||
},
|
||||
pfkIDsByProject: map[uint][]uint{
|
||||
15: {70, 71},
|
||||
},
|
||||
snapshotByProjectKey: map[string]*commonRepo.HppV2FarmDepreciationSnapshotRow{
|
||||
"15|2026-06-05": {
|
||||
ID: 901,
|
||||
ProjectFlockID: 15,
|
||||
PeriodDate: reportDate,
|
||||
DepreciationPercentEffective: 10,
|
||||
DepreciationValue: 1000,
|
||||
PulletCostDayNTotal: 10000,
|
||||
},
|
||||
},
|
||||
eggProductionByPFK: map[uint]struct {
|
||||
pieces float64
|
||||
kg float64
|
||||
}{
|
||||
70: {pieces: 200, kg: 20},
|
||||
71: {pieces: 800, kg: 80},
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewHppV2Service(repo)
|
||||
result, err := svc.CalculateHppBreakdown(70, &reportDate)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected breakdown result")
|
||||
}
|
||||
|
||||
var depreciation *HppV2Component
|
||||
for i := range result.Components {
|
||||
if result.Components[i].Code == hppV2ComponentDepreciation {
|
||||
depreciation = &result.Components[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if depreciation == nil {
|
||||
t.Fatal("expected depreciation component")
|
||||
}
|
||||
if depreciation.Total != 200 {
|
||||
t.Fatalf("expected depreciation total 200, got %v", depreciation.Total)
|
||||
}
|
||||
if result.TotalProductionCost != 200 {
|
||||
t.Fatalf("expected total production cost 200, got %v", result.TotalProductionCost)
|
||||
}
|
||||
if len(depreciation.Parts) != 1 {
|
||||
t.Fatalf("expected one depreciation part, got %d", len(depreciation.Parts))
|
||||
}
|
||||
if depreciation.Parts[0].Code != hppV2PartDepreciationFarmSnapshot {
|
||||
t.Fatalf("expected farm snapshot depreciation part, got %s", depreciation.Parts[0].Code)
|
||||
}
|
||||
if depreciation.Parts[0].Proration == nil || depreciation.Parts[0].Proration.Ratio != 0.2 {
|
||||
t.Fatalf("expected proration ratio 0.2, got %+v", depreciation.Parts[0].Proration)
|
||||
}
|
||||
if depreciation.Parts[0].Details["snapshot_id"] != uint(901) {
|
||||
t.Fatalf("expected snapshot id 901, got %+v", depreciation.Parts[0].Details)
|
||||
}
|
||||
}
|
||||
|
||||
func stubKey(ids []uint, flags []string) string {
|
||||
idParts := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
idParts = append(idParts, fmt.Sprintf("%d", id))
|
||||
}
|
||||
sort.Strings(idParts)
|
||||
|
||||
flagParts := append([]string{}, flags...)
|
||||
sort.Strings(flagParts)
|
||||
|
||||
return strings.Join(idParts, ",") + "|" + strings.Join(flagParts, ",")
|
||||
}
|
||||
|
||||
func mustDate(t *testing.T, raw string) *time.Time {
|
||||
t.Helper()
|
||||
loc, err := time.LoadLocation("Asia/Jakarta")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load timezone: %v", err)
|
||||
}
|
||||
value, err := time.ParseInLocation("2006-01-02", raw, loc)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse date %s: %v", raw, err)
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func mustTime(t *testing.T, raw string) time.Time {
|
||||
t.Helper()
|
||||
value := mustDate(t, raw)
|
||||
return *value
|
||||
}
|
||||
|
||||
func expenseStubKey(ids []uint, ekspedisi bool) string {
|
||||
return stubKey(ids, []string{fmt.Sprintf("ekspedisi=%t", ekspedisi)})
|
||||
}
|
||||
|
||||
func expenseFarmKey(projectFlockID uint, ekspedisi bool) string {
|
||||
return fmt.Sprintf("farm=%d|ekspedisi=%t", projectFlockID, ekspedisi)
|
||||
}
|
||||
|
||||
func chickinStubKey(ids []uint, flags []string, excludeTransferToLaying bool) string {
|
||||
return stubKey(ids, append(append([]string{}, flags...), fmt.Sprintf("exclude_transfer_to_laying=%t", excludeTransferToLaying)))
|
||||
}
|
||||
@@ -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,103 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const farmDepreciationSnapshotTable = "farm_depreciation_snapshots"
|
||||
|
||||
func NormalizeDateOnlyUTC(value time.Time) time.Time {
|
||||
if value.IsZero() {
|
||||
return value
|
||||
}
|
||||
v := value.UTC()
|
||||
return time.Date(v.Year(), v.Month(), v.Day(), 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func MinNonZeroDateOnlyUTC(values ...time.Time) time.Time {
|
||||
var out time.Time
|
||||
for _, value := range values {
|
||||
if value.IsZero() {
|
||||
continue
|
||||
}
|
||||
normalized := NormalizeDateOnlyUTC(value)
|
||||
if out.IsZero() || normalized.Before(out) {
|
||||
out = normalized
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func InvalidateFarmDepreciationSnapshotsFromDate(ctx context.Context, db *gorm.DB, farmIDs []uint, fromDate time.Time) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
if fromDate.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
fromDate = NormalizeDateOnlyUTC(fromDate)
|
||||
query := db.WithContext(ctx).
|
||||
Table(farmDepreciationSnapshotTable).
|
||||
Where("period_date >= ?", fromDate)
|
||||
if len(farmIDs) > 0 {
|
||||
query = query.Where("project_flock_id IN ?", farmIDs)
|
||||
}
|
||||
return query.Delete(nil).Error
|
||||
}
|
||||
|
||||
func ResolveProjectFlockIDsByProjectFlockKandangIDs(ctx context.Context, db *gorm.DB, pfkIDs []uint) ([]uint, error) {
|
||||
if db == nil || len(pfkIDs) == 0 {
|
||||
return []uint{}, nil
|
||||
}
|
||||
|
||||
var projectFlockIDs []uint
|
||||
if err := db.WithContext(ctx).
|
||||
Table("project_flock_kandangs").
|
||||
Distinct("project_flock_id").
|
||||
Where("id IN ?", pfkIDs).
|
||||
Pluck("project_flock_id", &projectFlockIDs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return projectFlockIDs, nil
|
||||
}
|
||||
|
||||
func ResolveProjectFlockIDsByExpenseID(ctx context.Context, db *gorm.DB, expenseID uint) ([]uint, error) {
|
||||
if db == nil || expenseID == 0 {
|
||||
return []uint{}, nil
|
||||
}
|
||||
|
||||
query := `
|
||||
WITH direct_farms AS (
|
||||
SELECT DISTINCT pfk.project_flock_id
|
||||
FROM expense_nonstocks ens
|
||||
JOIN project_flock_kandangs pfk ON pfk.id = ens.project_flock_kandang_id
|
||||
WHERE ens.expense_id = @expense_id
|
||||
),
|
||||
json_farms AS (
|
||||
SELECT DISTINCT (jsonb_array_elements_text(e.project_flock_id::jsonb))::bigint AS project_flock_id
|
||||
FROM expenses e
|
||||
WHERE e.id = @expense_id
|
||||
AND e.project_flock_id IS NOT NULL
|
||||
)
|
||||
SELECT DISTINCT project_flock_id
|
||||
FROM (
|
||||
SELECT project_flock_id FROM direct_farms
|
||||
UNION ALL
|
||||
SELECT project_flock_id FROM json_farms
|
||||
) x
|
||||
`
|
||||
|
||||
var ids []uint
|
||||
if err := db.WithContext(ctx).Raw(query, map[string]any{
|
||||
"expense_id": expenseID,
|
||||
}).Scan(&ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ids, 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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user