Compare commits
814 Commits
fix/chenxi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c65b17d979 | |||
| 5f248672c7 | |||
| 4a1afd5246 | |||
| b3e6f85934 | |||
| fcf49d1264 | |||
| 04948081b8 | |||
| 9f67e2a308 | |||
| 79063fffbe | |||
| a9869771fb | |||
| 4519a0e627 | |||
| 2cf75ac10b | |||
| 1dec86d1d9 | |||
| 9eb5990485 | |||
| 71e76b0a12 | |||
| 6f4883a9ec | |||
| 20de1a28bb | |||
| 97b05b5c3b | |||
| 62857782d0 | |||
| 401334bdcb | |||
| c083c62661 | |||
| d071a68452 | |||
| fa8ec043a5 | |||
| f202fbf85c | |||
| 79a09022ca | |||
| 16ff4dcfaa | |||
| cb0b9e017f | |||
| 7b468d00e2 | |||
| cf52eadbf9 | |||
| dec5e0d9e5 | |||
| 6cd209ea71 | |||
| 05726c6751 | |||
| be3b95e70e | |||
| 426d9724f7 | |||
| b3e8523cc4 | |||
| f593cd5bb0 | |||
| 32e8811f71 | |||
| d1b5f92d5b | |||
| 9a95b734c3 | |||
| 0249d67c9b | |||
| 9105d8584f | |||
| b412e649fa | |||
| c0256501f5 | |||
| 135886a939 | |||
| 7ea2d9c7ef | |||
| e08d578b5d | |||
| f194000c99 | |||
| d3a77ba941 | |||
| ca673ee239 | |||
| c4b61e7daa | |||
| 27f59d9d72 | |||
| d58b0ea479 | |||
| de6aa14969 | |||
| af13f70094 | |||
| 59053b459a | |||
| 6f0c99c3c0 | |||
| e39d9b860a | |||
| 996e4aed69 | |||
| 02398f1e18 | |||
| 88ec7d01f2 | |||
| d9b5d80c60 | |||
| aa0333fa5e | |||
| 38a765fa47 | |||
| eda75f96a9 | |||
| a8c2169134 | |||
| d8ca78f3f9 | |||
| fa92ae8b6e | |||
| 3309c2320c | |||
| cca9b19571 | |||
| fa53dadab1 | |||
| 88b3f1c830 | |||
| 6499934c47 | |||
| ce25b1aef2 | |||
| a7252c176c | |||
| 6c93e4c3c1 | |||
| 69fceac72d | |||
| f156fc28b4 | |||
| ceddfa6f09 | |||
| b1a817255b | |||
| b91fa6e177 | |||
| bd962a126e | |||
| d0979879e7 | |||
| e2e77d6d20 | |||
| 4d5b6ee718 | |||
| dfc3dcd0d0 | |||
| deae0c3e21 | |||
| fa86be7bc5 | |||
| e3867968e0 | |||
| 450a9e42f9 | |||
| b434adbf61 | |||
| a2344d9136 | |||
| 2f3852271a | |||
| 99b5fa1e5a | |||
| aa4c47785d | |||
| 705524831e | |||
| 93cbc83bb0 | |||
| b25ae15a4b | |||
| d1ff01803b | |||
| e2e31e3658 | |||
| 4089cdca93 | |||
| 3ae7bf7dc8 | |||
| d9dce54ae3 | |||
| 72e7f254b7 | |||
| acafcf1abb | |||
| 83b2898189 | |||
| e85a2e90ef | |||
| d2e20bd441 | |||
| 4cc4589d8d | |||
| 141c518da3 | |||
| d18aa82678 | |||
| f88d849c15 | |||
| d5cc75082c | |||
| fb3845ec64 | |||
| 537aa8d21e | |||
| 88ce009fbf | |||
| 0bf1ae67af | |||
| 2dcb739d72 | |||
| 257fd65496 | |||
| 507162cc2b | |||
| 9a695e49dd | |||
| cf9bcf8b72 | |||
| d5e607f356 | |||
| 85b82bfe47 | |||
| f28ea39677 | |||
| 4c30f2e628 | |||
| 3c7757d848 | |||
| e75946ae88 | |||
| 86306f9901 | |||
| 9db0092c53 | |||
| 630630b144 | |||
| 9f214868ab | |||
| 4401b0450a | |||
| 1dc006ad29 | |||
| 44b2bf2433 | |||
| 03acb8b1a0 | |||
| c4f06427fe | |||
| d488892693 | |||
| c78d684d45 | |||
| 11d6b2899c | |||
| 6fd059ae98 | |||
| 32724712ba | |||
| 0b69f30714 | |||
| 4cf90cd3a6 | |||
| fcd54a8080 | |||
| ff70c61007 | |||
| 3cff23c595 | |||
| 716ac44182 | |||
| 2edb87c6d0 | |||
| 27031e9067 | |||
| c9f5e358c4 | |||
| 98436bfd0a | |||
| 06707791e8 | |||
| 7170b4626a | |||
| dd8dfddacf | |||
| 50ab5974fb | |||
| 4386db5647 | |||
| bcbb5d5a50 | |||
| 13c9d031c1 | |||
| 75e1db5270 | |||
| 9ba0508412 | |||
| cf5c7b80f3 | |||
| 65868b7065 | |||
| 6ba00cdf42 | |||
| b5a1d55477 | |||
| 7be91948ad | |||
| 46b9acae2e | |||
| 0f8ab6e5ae | |||
| 4ab072b37a | |||
| 8c5ec8b34f | |||
| d22cc9e279 | |||
| 091494113a | |||
| 14e6f6465e | |||
| 3d0300fbab | |||
| 1d9b57898f | |||
| 89bd1703c7 | |||
| ea8a94bccd | |||
| 9c4a86aa24 | |||
| e87400880b | |||
| 5d8bbc36a2 | |||
| 4a343b92a1 | |||
| 9577099087 | |||
| 01104d722d | |||
| abf4b92fe0 | |||
| 412f4129cc | |||
| 0c92ae62b1 | |||
| c0287e0e26 | |||
| 252f2bed83 | |||
| 41db35f1e9 | |||
| 37090291b1 | |||
| 43e278f1b6 | |||
| 054b239867 | |||
| 4e5896d8c3 | |||
| eeaa386b04 | |||
| 9a9e01461c | |||
| c78ce5e00c | |||
| 3066d2c7d8 | |||
| b4ce8f32a9 | |||
| 4468b7f28c | |||
| ab308fe388 | |||
| f340a295d5 | |||
| 7ce3ea340c | |||
| 4f58ad967b | |||
| b1c3bcd851 | |||
| d2b1f9e736 | |||
| 51db06d919 | |||
| 955d706e56 | |||
| 4ffd853e7a | |||
| 85e7fc096d | |||
| e15b6b9901 | |||
| 127287a289 | |||
| f7197b4976 | |||
| 53bd1dc44a | |||
| f1ecd4a978 | |||
| e3bcffd0f7 | |||
| 1ad248dda6 | |||
| 2fa66ef1d1 | |||
| 1991b7d319 | |||
| c9ecf033d5 | |||
| f61fbb5028 | |||
| 6908546d44 | |||
| 238bff99e9 | |||
| 3bdc3382db | |||
| 1d33cc2654 | |||
| ee9205dca7 | |||
| acab9b2905 | |||
| cfb56c98a7 | |||
| 0893f3d8d3 | |||
| 9711ec4f57 | |||
| 812b633146 | |||
| 562973438d | |||
| 3fe7833f87 | |||
| 4206f2f9ba | |||
| 58e0c990e2 | |||
| fa469fafec | |||
| 2c1bea34e0 | |||
| 2852c1332f | |||
| 00511c4577 | |||
| 260014a4a0 | |||
| 920fbcb650 | |||
| 446da08445 | |||
| 7280d86de9 | |||
| edc27345ee | |||
| 9976ca6b0a | |||
| ec3cd39413 | |||
| 9ab4e1e261 | |||
| 9028f6aff4 | |||
| cc0cdbeed7 | |||
| 66c5864455 | |||
| 873d306277 | |||
| 04034948e3 | |||
| ea1bff1d5a | |||
| c2a11b3064 | |||
| a6f4ca8ccc | |||
| 0942f31d7a | |||
| 18297f1845 | |||
| 9a7ecf879c | |||
| 0ab30d5bb9 | |||
| eaeae97752 | |||
| 36af0e211a | |||
| 639ff3d0b3 | |||
| 023342c6a8 | |||
| de770efce7 | |||
| 20c11719e0 | |||
| a1f921be2f | |||
| 1a7a647907 | |||
| 0e0ab438c0 | |||
| 8ba241a868 | |||
| e829ab2094 | |||
| 2186e9085a | |||
| a57a8116bf | |||
| 0b2bd343c5 | |||
| 345790175b | |||
| 36b8b5c616 | |||
| 176357871b | |||
| 6feea82305 | |||
| cd25916d9d | |||
| 20dc695549 | |||
| 93a27725b6 | |||
| 5633ed2430 | |||
| d68548646d | |||
|
|
0ec6f703fe | ||
| a317ab9dea | |||
| db08b2c264 | |||
|
|
32cd0d0a15 | ||
| 8162afa969 | |||
|
|
bcb29aac1c | ||
| d1b751c79c | |||
|
|
fce31f0c09 | ||
| acad1f4577 | |||
| ad8da1264d | |||
| 6470346392 | |||
| f08c600961 | |||
| 144937bfa3 | |||
|
|
5fe92bd05c | ||
| 81bf2bd779 | |||
| cad8369fdb | |||
| 2a2df7451e | |||
|
|
5e05a8728e | ||
|
|
6b044e727b | ||
| 145a62c397 | |||
| 21b29d06eb | |||
| ffa4bbf15f | |||
| 75aac2e5aa | |||
| 0104b9b18e | |||
| 4511f6c71a | |||
| cdabffb3a6 | |||
| 4e174963e8 | |||
| 9b6c3d2477 | |||
| 366dcc1da7 | |||
| 56909bd669 | |||
| 19bb6a8a75 | |||
| dd9cab671b | |||
| 1acd97b9e0 | |||
| c0cc8c7e60 | |||
| bf290d5353 | |||
| 31f6c72bf3 | |||
| bbb967e110 | |||
| a6e0b2ac7d | |||
| 8e4bf99483 | |||
| d735de7864 | |||
| e4d82dab2a | |||
| 17429d65ab | |||
| a124e9906b | |||
| 4c617fd063 | |||
| 528701fdec | |||
|
|
e0abc6732b | ||
|
|
c44d267902 | ||
|
|
02d21ce9ac | ||
| eec820bfc5 | |||
| 424311a441 | |||
| 79f6937e75 | |||
| 1f4f6f48d4 | |||
| eb0f246765 | |||
| 9e199243b6 | |||
| 74906ac5f6 | |||
| ea479733ab | |||
| 566a3ef4db | |||
| 9f4f4f0cbb | |||
| cbbf32bb5c | |||
| 664fc05faf | |||
|
|
9b2edfe1de | ||
| 0cb970eeec | |||
|
|
5fdd3bba17 | ||
| 87788d87ff | |||
| 13d54d0c76 | |||
| b22eb61541 | |||
| efb6d9c992 | |||
| a3b36b1fb6 | |||
| 9d8133cc73 | |||
| 09b4ffcb8a | |||
| 68b16a95c0 | |||
| bcbb9e72af | |||
| ed58e8ef43 | |||
| b2dd1e5e6b | |||
| e428cf5de2 | |||
| 2d30aea624 | |||
| 409da4cae8 | |||
| da794c3ddb | |||
| b49d23d090 | |||
| 23887059f1 | |||
| 10d9c22e3e | |||
| bd39a7fdda | |||
| 077701d24a | |||
| d24eefb687 | |||
| 3638a8cee9 | |||
| 41f6946a2b | |||
| 3fedeef59a | |||
| a0a6e9285a | |||
| 18d03fe092 | |||
| b0d7096241 | |||
| c69796b0d3 | |||
| a8ffb6fc23 | |||
| 670faf2b80 | |||
| feea2ed8b6 | |||
| 2848c81a5e | |||
| 8aa7676853 | |||
| 3b4db0e273 | |||
| 2ffb49ca92 | |||
| 2a80f380e6 | |||
| 87eaf48c2c | |||
| 82c79dadc3 | |||
| bcf8ea8fb7 | |||
| 656c655c91 | |||
| 7742163632 | |||
| 8429ad0642 | |||
| 0a54775b3b | |||
| b26ab9f85d | |||
| 688ae30747 | |||
| b4eaa1e102 | |||
| e6f4b61761 | |||
| 3e1d211d1b | |||
| 7fbcd061af | |||
| da2a26c485 | |||
| 37c11f4e8e | |||
| 2399d42d6d | |||
|
|
5b89692318 | ||
| c4a39ec265 | |||
| c50244544d | |||
| f3df42f46b | |||
| 706b07b4ad | |||
| 9a25db91cc | |||
| 6c76a373a3 | |||
| 8fbc9abd53 | |||
| b0ea247ca1 | |||
| e064492a95 | |||
| 422497cfcc | |||
| 74a9b5b81f | |||
| 23b8087d35 | |||
| d1b14fc10d | |||
| 5c20defc79 | |||
| 01abeb4124 | |||
| 173ca7c2ec | |||
| 564eb1ef08 | |||
| 1706131d9c | |||
| f4a0014690 | |||
| 288184c8dc | |||
| 60163f6c32 | |||
| c30cff7d0d | |||
| 6518995e45 | |||
| 3e222e0035 | |||
| f41a8fa6ba | |||
| 1214fc365e | |||
| a4952dfa42 | |||
| 0d215fe20c | |||
| cccd321195 | |||
| 1131d073ce | |||
| 72b3f43da5 | |||
| c314209705 | |||
| 4e38a1cf11 | |||
| fd4b155979 | |||
| 8d7f6da49f | |||
| 8ba7ac8327 | |||
| c0aab60862 | |||
| 9c63f36944 | |||
| afd8487cd4 | |||
| 71652c5a64 | |||
| 1684902863 | |||
| e26c1e467e | |||
| f6497f5afc | |||
| b4f8416794 | |||
| 0c04860d69 | |||
| ea80148dfc | |||
| 530c9858ec | |||
| 8a67c7d768 | |||
| 10d3067aee | |||
| d8d5196628 | |||
| 5ab7786a90 | |||
| 7258d09238 | |||
| ae54706ceb | |||
| 2be4d4700e | |||
| 45a28aa76c | |||
| 1c00548396 | |||
| a8a0c6d4c9 | |||
| add787edf5 | |||
| c5c71dab74 | |||
| 5970572274 | |||
| a884da7057 | |||
| cdcb1c6662 | |||
| d3049686b4 | |||
| 8905192b78 | |||
| 26b83bd8e7 | |||
| 7a5f04287c | |||
| 57f2dccfac | |||
| b962c23e92 | |||
| 0fa52090f7 | |||
| 7223a2eb2e | |||
| 6b6b97a375 | |||
| d47a72c175 | |||
| ac40c6d8a9 | |||
| 7e1ded5112 | |||
| f8a83cf684 | |||
| d1ffaf773c | |||
| 53b0821384 | |||
| 8b62ba0504 | |||
| bbf8c1f16c | |||
| 03fea599fb | |||
| f663ddc51f | |||
| 7ecd5a59e6 | |||
| 63f9e2a418 | |||
| e61e3b50fb | |||
| c55c06026f | |||
| fba094ce26 | |||
| e883d262f0 | |||
| 26f945ba29 | |||
| a90736592a | |||
| 20eccf616c | |||
| 45b9feb8c4 | |||
| 9c62f4364b | |||
| 66d6146c19 | |||
| 0da349e51b | |||
| c8eb6e2e5f | |||
| 9f6a5f6fde | |||
| 3e24bc5111 | |||
| 10c22a655d | |||
| f7547bc9a1 | |||
| f3143e6959 | |||
| f805920fda | |||
| 50cfbf1c14 | |||
| d8dfb33437 | |||
| 1e689d254e | |||
|
|
d9646b4ac9 | ||
| 597104430e | |||
| 976db7e4f1 | |||
| cbec6a8b62 | |||
| ad7226081a | |||
| ad3961b82d | |||
| 87e5ce0df2 | |||
| 49c6e1ecd1 | |||
| 665b8fafd2 | |||
| ad6d70a0a2 | |||
| 48553c001d | |||
| 766a85b097 | |||
| 0e1feaf1e6 | |||
| 118f030220 | |||
| 4eb09a1fcc | |||
| 22133450a6 | |||
| 29f801bd92 | |||
| 0a89a24f28 | |||
| 53cd0c3ce6 | |||
| 2da46a2a86 | |||
| 158c5e1b23 | |||
| 37d18612a6 | |||
| 107c883eb2 | |||
| 153b823ab8 | |||
| 0234bae83f | |||
| 951fde9c46 | |||
| dd03037105 | |||
| c1431b8b38 | |||
| b92a3b0ed0 | |||
| 2bf8d3538a | |||
| 25cf3f8f0f | |||
| 073e82ea32 | |||
|
|
fe8c271fcf | ||
| 41d0e5169f | |||
| df9ec2cb1e | |||
| 217b953a03 | |||
| 499a9e12a6 | |||
| f82856a6f7 | |||
| 9ce9ee74eb | |||
| 74b2d3a086 | |||
| 9ba90667b1 | |||
| ed89882661 | |||
|
|
cb26d5da17 | ||
| 7e335b19a9 | |||
| a4899e440e | |||
| 35bd7cb7f0 | |||
| 3136f29d33 | |||
| 0f7f72be71 | |||
| cfdda94da8 | |||
| a0faaa116b | |||
| 65112a2bff | |||
| f56bd9d3ea | |||
| 473b6f4ebb | |||
| b4821ca933 | |||
|
|
a334f65962 | ||
| 0cca2aac70 | |||
| 8207dc0d95 | |||
| 09077c3661 | |||
| 03b728a0fc | |||
| 68d4466810 | |||
| b7e4e1a556 | |||
| e0e197a38b | |||
| ddbf32a0ee | |||
| d95abebf9c | |||
|
|
a0ce32ead1 | ||
|
|
f17a90c7a3 | ||
| 6255179bf5 | |||
| ad65f0cc75 | |||
| 4d5b9bf440 | |||
| 0c84afb875 | |||
| daf3fd64d8 | |||
| d69a7d2ca7 | |||
| e01b575e74 | |||
| 9710c3baa7 | |||
| c88c0f165e | |||
| a8b086766b | |||
| 30f69f2af9 | |||
| 18b4b9f0fb | |||
|
|
203aea523a | ||
| 885961084e | |||
|
|
3dd90dc52c | ||
| 29defd2025 | |||
|
|
12eb5d03d9 | ||
|
|
946d794e12 | ||
| 39f7797a35 | |||
| cddb5e6145 | |||
|
|
c02ecb8b44 | ||
| 7fe426211d | |||
|
|
085ea33ff4 | ||
| 8c626c65de | |||
| 9f1cd6f8c1 | |||
| 69f3d9228f | |||
| 6d8fdb5dce | |||
| 09ae442b87 | |||
| 401dfb089a | |||
| 1c90b64c29 | |||
| 09907d63f8 | |||
| 2cf181f1bf | |||
| 627fa966ef | |||
| 8ffcd58118 | |||
| 88adb33c45 | |||
| 7b09d0de05 | |||
| dcdea11f0f | |||
| c75c3b27a7 | |||
| c515da02d0 | |||
| 8261b17328 | |||
| ef62cac0d9 | |||
| 636187a9d8 | |||
| c1995319e6 | |||
| dba61e5c8e | |||
| c8a035a18f | |||
| 4bcfde58f7 | |||
| 8a70fd4482 | |||
| daa4c939e7 | |||
|
|
55e1533b40 | ||
| 471aa714e8 | |||
|
|
701bc42f44 | ||
|
|
77b808245b | ||
| ae694004cf | |||
| 0933941533 | |||
| 1cbc8312a6 | |||
| db2ee47fd8 | |||
|
|
9f1f4d08a7 | ||
|
|
73617e4899 | ||
|
|
57c1ea7046 | ||
| 4b3baeaaf0 | |||
|
|
74d3558bfa | ||
|
|
d7e6e50a04 | ||
|
|
9160ca77a0 | ||
| c4a976751d | |||
| a9da8d4447 | |||
| 916fb30f37 | |||
|
|
c38299b078 | ||
|
|
4310a58104 | ||
| e5922dd78e | |||
| 6c054c7081 | |||
| 4b97b30a3a | |||
|
|
2a30d9bac3 | ||
|
|
35ee115522 | ||
| d76b102bca | |||
| af83d75a00 | |||
| e8b8b816f4 | |||
| 96b8f503c6 | |||
| d877735507 | |||
| 5b95b655bb | |||
|
|
1a024c2b26 | ||
|
|
d7582cdb46 | ||
| af8f1403fb | |||
| 40832546da | |||
|
|
2341a839f3 | ||
|
|
0bb4df6512 | ||
| 4258bd4e0e | |||
| fb15402003 | |||
| cf7924fdc6 | |||
| 83e5108ce6 | |||
| eda10bd740 | |||
| cd641ddfbd | |||
|
|
b9c2b73655 | ||
|
|
278f641018 | ||
| ac21fc4079 | |||
| 5ef4119a3d | |||
| 5d7bc3f2c4 | |||
| bfc119608f | |||
| 35fe10bc69 | |||
| 8419d4097c | |||
| 5f1fc25578 | |||
| e777e75176 | |||
| 13a9697447 | |||
| 78857a51d9 | |||
| f774e75fce | |||
| 73af15f2db | |||
| 80978061de | |||
| 2c1ac0bb3b | |||
| d6b0b7f1d7 | |||
| 4523682d1e | |||
| c04054ca0b | |||
|
|
7d2980d5e3 | ||
| 44a2664372 | |||
| e147c90a2c | |||
|
|
98cea16655 | ||
| 6af12c030e | |||
|
|
e0f89288d2 | ||
|
|
08aaed9241 | ||
|
|
ffd851fc0c | ||
|
|
1ab9a45d1d | ||
|
|
05760bd0a5 | ||
|
|
26d096a3a5 | ||
|
|
04e021700e | ||
|
|
9526173fec | ||
|
|
d736ab0fd3 | ||
|
|
1ca789807b | ||
|
|
790df169b5 | ||
|
|
2b06595331 | ||
|
|
22ec9ab333 | ||
|
|
a77d369d15 | ||
|
|
c0938893c7 | ||
|
|
b49332e5d9 | ||
|
|
8ab825ed9e | ||
| 8132f83713 | |||
| c46a439d73 | |||
| 625763176d | |||
| b2647fa26d | |||
| 5bcd6b6c52 | |||
| 77ac3c58fa | |||
|
|
cf07ad499f | ||
|
|
5ee0813490 | ||
|
|
f43ab57c49 | ||
|
|
ebe4d006e5 | ||
|
|
7cc9f13eac | ||
|
|
1c679f0215 | ||
|
|
fdb7f77173 | ||
| fc6b83aee0 | |||
| e5d6c76f27 | |||
|
|
793c160f12 | ||
|
|
5d4d2ef4df | ||
|
|
9ca01b5d65 | ||
| 149d7ef74f | |||
|
|
6947aafc79 | ||
|
|
64a82cf135 | ||
| 960d418c8e | |||
| a8f3178ab1 | |||
| ff3f3424c5 | |||
|
|
37d37b0844 | ||
|
|
bb5b03ad80 | ||
|
|
126ef735a7 | ||
| cfde842d51 | |||
| ff23b285db | |||
| f3ebfb942e | |||
|
|
0c994d2465 | ||
| 8613fa21c7 | |||
| d27f8d0299 | |||
| 494b60de54 | |||
| 304bb3dc8e | |||
| b106569905 | |||
| 28b9271b3c | |||
|
|
d5a8209664 | ||
|
|
aafbfb20c7 | ||
|
|
e54b7f8d25 | ||
|
|
27c351f0c5 | ||
|
|
69225ef529 | ||
|
|
379ba0eb99 | ||
|
|
d0a5b03dd1 | ||
|
|
09d473bd91 | ||
|
|
05abcb6eaa | ||
|
|
ea1a23c4b8 | ||
|
|
634f82fdc4 | ||
|
|
57ea42ffb8 | ||
|
|
1cd0bc8053 | ||
|
|
6cc84caf33 | ||
|
|
9f73d1915c | ||
|
|
9b05d99cd8 | ||
|
|
ddfd4b052f | ||
|
|
af1a98e1a1 | ||
|
|
a412fcb12f | ||
|
|
538b5e9a10 | ||
| ada44a4e04 | |||
| 88626ad404 | |||
|
|
2df6542234 | ||
|
|
34762d61be | ||
|
|
2d91935304 | ||
|
|
ea7dcff178 | ||
|
|
45305fefa2 | ||
|
|
57810d6e98 | ||
| cf4fd5f59c | |||
| f0e8803626 | |||
|
|
78127cb18f | ||
|
|
7f3aeb233e | ||
| c7a8baea76 | |||
|
|
55e92cc95b | ||
|
|
189ae747f7 | ||
|
|
6e57ef6b86 | ||
|
|
bc2df131e3 | ||
|
|
2370c50bb5 | ||
|
|
7b43d3e22c | ||
| 48b436510e | |||
| 46689d99d3 | |||
| bba79d2d69 | |||
| 5830b01bc6 | |||
| fad45b053f | |||
|
|
b93a662614 | ||
| 9a0565e1eb | |||
|
|
0d0482a8fe | ||
| 79158103de | |||
|
|
0b1de5b3ce | ||
| 305d511535 | |||
| bc383d90db | |||
| 5d6c007c96 | |||
| 727de5055c | |||
| 944466ad5b | |||
| b6e52f7bff | |||
| c56109f81c | |||
| 047a527b3f | |||
| 813e956df5 | |||
| ab3580b694 | |||
| 21cb3dcbcf | |||
| 2ec60574ff | |||
| 2d59e63861 | |||
| 3c4c47fb94 | |||
| d7dfc95192 | |||
| 666efc26b6 | |||
|
|
073a7dafb1 | ||
| c815067376 | |||
| 7de1d30605 | |||
| cdc8164aa5 | |||
| a0c99c3e8d | |||
| 9bee8e3f46 | |||
| a08b28199d | |||
| 26556a9169 | |||
| e73183f52e | |||
| 469992982e | |||
| 4fd1a792d4 | |||
| 454919b0c7 | |||
|
|
403f66b94f | ||
|
|
bc65d7c7e2 | ||
|
|
36fee605c6 |
@ -10,6 +10,7 @@ runtime
|
||||
*.md
|
||||
JetBrainsMono-字体
|
||||
node_modules
|
||||
**/__pycache__
|
||||
vendor
|
||||
bootstrap/cache/*
|
||||
.caiwu-build
|
||||
@ -22,6 +23,7 @@ storage/debugbar
|
||||
storage/framework/cache/data/*
|
||||
storage/framework/sessions/*
|
||||
storage/framework/views/*
|
||||
storage/framework/testing
|
||||
storage/logs/*
|
||||
tests
|
||||
test-data
|
||||
@ -29,3 +31,6 @@ docs
|
||||
scripts
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
public/net-layer-demo.html
|
||||
public/perf-diff-plan.html
|
||||
public/plan-a-changes-202608.csv
|
||||
|
||||
8
.github/workflows/tests.yml
vendored
8
.github/workflows/tests.yml
vendored
@ -38,8 +38,14 @@ jobs:
|
||||
- name: Copy .env
|
||||
run: cp .env.testing .env
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install NPM
|
||||
run: npm install
|
||||
run: npm ci --legacy-peer-deps --no-audit --no-fund
|
||||
|
||||
- name: Compile assets
|
||||
run: npm run production
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -113,3 +113,7 @@ modules/*
|
||||
.workbuddy/
|
||||
runtime/.env
|
||||
/.private/kaifa-progress-sync/
|
||||
__pycache__/
|
||||
public/net-layer-demo.html
|
||||
public/perf-diff-plan.html
|
||||
public/plan-a-changes-202608.csv
|
||||
|
||||
15
AGENTS.md
15
AGENTS.md
@ -38,6 +38,15 @@
|
||||
- 数据页面按本次影响验证代表性数据量下的首页、翻页和筛选耗时;明显长时间等待不能以“返回正确”判定通过。低风险改动不增加重复或仅复述实现的测试。
|
||||
- 明确区分:本地已验证、已提交/推送、已合并、已上线和仍需生产验收。
|
||||
|
||||
### 性能与资源验收(强制)
|
||||
|
||||
- 涉及查询、报表、导入导出、同步或后台任务时,开发前先确认实际数据规模、单次数据量、文件大小和运行频率,并据此选择实现与验收数据;不能只验证几十行样例。
|
||||
- Excel/CSV 必须流式读取;表扫描、关联和排序使用合适索引,页面数据使用分页。分批写库只控制写入批次,不能据此声称读取过程内存受控。
|
||||
- 重型任务复用项目现有独立批处理容器,完成命令登记,并明确内存上限、超时、互斥和失败恢复;不得占用网页服务资源执行全量处理。
|
||||
- 禁止通过取消 `memory_limit`、无限重试、放大数据库参数或其他绕过资源边界的办法掩盖性能问题。
|
||||
- 使用代表性数据测量耗时、进程峰值内存和数据库开销,并在交付说明中写明测量范围、数据量及未验证项。生产环境不运行压测,不随意重跑全量任务。
|
||||
- 性能优化不得改变金额口径、筛选范围或现有业务结果;相关金额和功能回归必须同时通过。
|
||||
|
||||
## 5. 前端与视觉
|
||||
|
||||
- 只有前端任务需要先阅读根目录 `DESIGN.md`;`.impeccable/design.json` 是同源机器规范。
|
||||
@ -64,12 +73,14 @@
|
||||
|
||||
## 8. 构建与生产部署
|
||||
|
||||
- 发布脚本会拒绝覆盖“不在本次版本里”的线上提交(防止互相冲掉已上线的改动)。热修复必须基于线上当前版本构建,发布后立即合入 `main`;遇到拦截先 `git fetch` 并合入最新 `main` 或线上热修复分支,不得随手加 `-AllowRollback`(`--allow-rollback`),撤回线上发布需负责人明确决定。
|
||||
- 普通代码发布由任务 Codex 在完成验证与合并后直接执行,不要求用户再次说“部署、上线”;用户明确要求仅本地开发、仅审查或暂不上线时遵循其范围。正式发布只使用已合并的最新 `main`。
|
||||
- Windows 开发机统一使用 `scripts/deploy-from-workstation.ps1`。服务器只运行开发机已构建的 `caiwu_akaunting:<commit>` 镜像,禁止在服务器执行 Composer、npm 或 Docker 构建。
|
||||
- Windows 开发机统一使用 `scripts/deploy-from-workstation.ps1`,macOS 开发机使用 `bash scripts/deploy-from-mac.sh`;两者流程一致(本机构建前端与镜像、迁移拦截、增量推送服务器本机 Registry、激活与健康检查、双版本保留)。服务器只运行开发机已构建的 `caiwu_akaunting:<commit>` 镜像,禁止在服务器执行 Composer、npm 或 Docker 构建。
|
||||
- 新人只需表达部署需求;Codex 使用默认生产入口运行脚本。首次通过本机窗口提供一次服务器密码后,加密保存在本人电脑供后续自动使用,不要求新人准备私钥、执行命令或每次重输密码。已有私钥认证仍兼容。
|
||||
- 当前生产入口为 `https://caiwu.jiansuntuzhi.cn:8442`;公网仍只使用 `8442`,日常发布不得改成无端口地址。
|
||||
- 发布脚本检测到新增数据库迁移时,由 Codex 核对全部待执行迁移。仅涉及本任务共享镜像建表、字段、索引和同步登记的迁移,完成备份后自主使用 `-AllowMigrations`;其他迁移仍按第 6 节确认授权。不能因需要迁移开关就把镜像接入停给负责人。
|
||||
- 发布脚本检测到新增数据库迁移时,由 Codex 核对全部待执行迁移。仅涉及本任务共享镜像建表、字段、索引和同步登记的迁移,完成备份后自主使用 `-AllowMigrations`(macOS 为 `--allow-migrations`);其他迁移仍按第 6 节确认授权。不能因需要迁移开关就把镜像接入停给负责人。
|
||||
- 容器内 Laravel 命令使用 `www-data`。发布后只需确认部署 SHA、应用容器健康、受影响的真实页面/接口、迁移是否执行,以及当前和上一版镜像可回滚。
|
||||
- 生产重型 CLI 导入统一使用 `sh /opt/caiwuxitong/run-finance-batch.sh <命令> <原参数>`,不得在网页容器内直接执行或取消 PHP 内存限制。新增导入登记 `deploy/finance-batch-imports.json`;入口提供独立容器、互斥、资源与时间上限,主应用保留自己的内存边界。
|
||||
- 程序静态资源随应用镜像同源发布;七牛只存用户上传的业务图片、票据和附件。
|
||||
- 只有本地初始化、服务器维护、生产发布或数据库迁移任务需要读取 `docs/开发与部署SOP.md` 的对应章节;普通功能开发无需加载整份 SOP。
|
||||
|
||||
|
||||
169
app/Business/BusinessSummaryCards.php
Normal file
169
app/Business/BusinessSummaryCards.php
Normal file
@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
/**
|
||||
* 经营报表页面顶部的小格子指标。
|
||||
*
|
||||
* 汇总卡片只描述当前表本身,不把其他目录的数据拼成跨表大看板。
|
||||
*/
|
||||
class BusinessSummaryCards
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $summary
|
||||
* @return list<array{label:string,value:mixed,hint:string,format:string,query:array<string,mixed>,tone:string}>
|
||||
*/
|
||||
public function forPage(string $key, array $summary = []): array
|
||||
{
|
||||
return match ($key) {
|
||||
'store-management/store-report' => $summary === [] ? [] : $this->storeReportCards($summary),
|
||||
'company-management/subject-operating-report' => $summary === [] ? [] : $this->subjectOperatingReportCards($summary),
|
||||
'store-management/internal-flow-report' => $summary === [] ? [] : $this->internalFlowReportCards($summary),
|
||||
'company-management/company-cost-arrangement' => $this->companyCostArrangementCards(),
|
||||
'company-management/tax-forecast' => $this->taxForecastCards(),
|
||||
'company-management/personnel-inventory' => $this->personnelInventoryCards(),
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $summary */
|
||||
private function subjectOperatingReportCards(array $summary): array
|
||||
{
|
||||
$ready = (bool) ($summary['company_status_ready'] ?? false);
|
||||
$previousLabel = (string) ($summary['previous_month_label'] ?? '上月');
|
||||
$currentLabel = (string) ($summary['current_month_label'] ?? '当月');
|
||||
$previousStores = (int) ($summary['previous_income_entities'] ?? 0);
|
||||
$currentStores = (int) ($summary['current_income_entities'] ?? 0);
|
||||
|
||||
if (! $ready) {
|
||||
return [
|
||||
$this->card('正常存续主体', '—', '公司资料镜像未就绪,暂时无法剔除注销主体', [], 'text', 'warning'),
|
||||
$this->card('上月收入', '—', '公司资料镜像未就绪', [], 'text', 'warning'),
|
||||
$this->card('当月收入', '—', '公司资料镜像未就绪', [], 'text', 'warning'),
|
||||
$this->card('小规模纳税人', '—', '公司资料镜像未就绪', [], 'text'),
|
||||
$this->card('一般纳税人', '—', '公司资料镜像未就绪', [], 'text'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
$this->card('正常存续主体', $summary['normal'] ?? 0, '只统计公司台账标签为保留的主体', ['company_status' => '正常'], 'integer', 'accent'),
|
||||
$this->card($previousLabel . '收入', $summary['previous_income'] ?? 0, $previousStores . ' 个存续主体有收入', [
|
||||
'company_status' => '正常',
|
||||
'sort' => $summary['previous_month_key'] ?? null,
|
||||
'direction' => 'desc',
|
||||
], 'amount', 'success'),
|
||||
$this->card($currentLabel . '收入', $summary['current_month_income'] ?? 0, $currentStores . ' 个存续主体有收入', [
|
||||
'company_status' => '正常',
|
||||
'sort' => $summary['current_month_key'] ?? null,
|
||||
'direction' => 'desc',
|
||||
], 'amount', 'warning'),
|
||||
$this->card('小规模纳税人', $summary['small_taxpayer'] ?? 0, '正常存续主体中的小规模纳税人', [
|
||||
'company_status' => '正常',
|
||||
'tax_type' => '小规模纳税人',
|
||||
]),
|
||||
$this->card('一般纳税人', $summary['general_taxpayer'] ?? 0, '正常存续主体中的一般纳税人', [
|
||||
'company_status' => '正常',
|
||||
'tax_type' => '一般纳税人',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $summary */
|
||||
private function storeReportCards(array $summary): array
|
||||
{
|
||||
$previousLabel = (string) ($summary['previous_month_label'] ?? '上月');
|
||||
$currentLabel = (string) ($summary['current_month_label'] ?? '当月');
|
||||
$normalStores = (int) ($summary['normal_stores'] ?? 0);
|
||||
$previousStores = (int) ($summary['previous_month_stores'] ?? 0);
|
||||
$currentStores = (int) ($summary['current_month_stores'] ?? 0);
|
||||
|
||||
return [
|
||||
$this->card('正常存续店铺', $normalStores, '只统计开店中店铺,不含关店和已注销', ['status' => '0'], 'integer', 'accent'),
|
||||
$this->card($previousLabel . '销量', $summary['previous_month_sales'] ?? 0, $previousStores . ' 家存续店铺有销量', [
|
||||
'status' => '0',
|
||||
'sort' => $summary['previous_month_key'] ?? null,
|
||||
'direction' => 'desc',
|
||||
], 'amount', 'success'),
|
||||
$this->card($currentLabel . '销量', $summary['current_month_sales'] ?? 0, $currentStores . ' 家存续店铺有销量', [
|
||||
'status' => '0',
|
||||
'sort' => $summary['current_month_key'] ?? null,
|
||||
'direction' => 'desc',
|
||||
], 'amount', 'warning'),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $summary */
|
||||
private function internalFlowReportCards(array $summary): array
|
||||
{
|
||||
return [
|
||||
$this->card('公司数', $summary['companies'] ?? 0, '当前筛选内内部流转公司', [], 'integer', 'accent'),
|
||||
$this->card('预计销量合计', $summary['projected_sales'] ?? 0, '当前筛选内预计销量合计', [
|
||||
'sort' => 'projected_sales',
|
||||
'direction' => 'desc',
|
||||
], 'amount'),
|
||||
$this->card('已收票合计', $summary['receipt_total'] ?? 0, '供应商、关联公司与内部流转收票合计', [
|
||||
'sort' => 'receipt_total',
|
||||
'direction' => 'desc',
|
||||
], 'amount', 'success'),
|
||||
$this->card('剩余缺口合计', $summary['remaining_gap'] ?? 0, '金额越大越应优先处理', [
|
||||
'sort' => 'remaining_gap',
|
||||
'direction' => 'desc',
|
||||
], 'amount', 'warning'),
|
||||
$this->card('平均开票比例', $summary['average_invoicing_ratio'] ?? 0, '按有比例的公司简单平均', [], 'percent'),
|
||||
];
|
||||
}
|
||||
|
||||
private function companyCostArrangementCards(): array
|
||||
{
|
||||
return [
|
||||
$this->card('数据状态', '待接入', '当前表尚无业务数据', [], 'text', 'warning'),
|
||||
$this->card('完整表头', '11 列', '包含序号,收入、成本、税金、人工等字段全部保留', [], 'text'),
|
||||
$this->card('核心结果', '预计结余 / 缺口', '当月收入预测减去成本与费用', [], 'text'),
|
||||
$this->card('缺口提示', '负值标红', '成本或费用未覆盖收入时提醒', [], 'text', 'danger'),
|
||||
$this->card('配平提示', '零或正结余标绿', '表示当月预算已覆盖', [], 'text', 'success'),
|
||||
];
|
||||
}
|
||||
|
||||
private function taxForecastCards(): array
|
||||
{
|
||||
return [
|
||||
$this->card('数据状态', '待取数', '规则已定义,业务数据尚未接入', [], 'text', 'warning'),
|
||||
$this->card('完整表头', '22 列', '包含序号、征管口径、收入、税额与备注', [], 'text'),
|
||||
$this->card('计算税种', '4 类', '增值税、附加税、印花税、所得税', [], 'text'),
|
||||
$this->card('计税口径', '3 项', '公司税务类型、工商类型和征收方式', [], 'text'),
|
||||
$this->card('当前记录', '0 条', '数据接入后按完整字段展示', [], 'text'),
|
||||
];
|
||||
}
|
||||
|
||||
private function personnelInventoryCards(): array
|
||||
{
|
||||
return [
|
||||
$this->card('数据状态', '待接入', '当前表尚无人员数据', [], 'text', 'warning'),
|
||||
$this->card('完整表头', '17 列', '包含序号和全部人员、容量字段', [], 'text'),
|
||||
$this->card('敏感字段', '4 项', '密码、身份证、实名手机和身份证地址只显示维护状态', [], 'text', 'danger'),
|
||||
$this->card('容量规则', '3 档', '可担任公司数量按风险档位提示', [], 'text'),
|
||||
$this->card('当前记录', '0 条', '接入后按正常在职人员汇总', [], 'text'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label:string,value:mixed,hint:string,format:string,query:array<string,mixed>,tone:string}
|
||||
*/
|
||||
private function card(
|
||||
string $label,
|
||||
mixed $value,
|
||||
string $hint,
|
||||
array $query = [],
|
||||
string $format = 'integer',
|
||||
string $tone = 'neutral',
|
||||
): array {
|
||||
return [
|
||||
'label' => $label,
|
||||
'value' => $value,
|
||||
'hint' => $hint,
|
||||
'format' => $format,
|
||||
'query' => ['page' => null, ...$query],
|
||||
'tone' => $tone,
|
||||
];
|
||||
}
|
||||
}
|
||||
163
app/Business/CourierContractPageData.php
Normal file
163
app/Business/CourierContractPageData.php
Normal file
@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\CourierAgreementSignature;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
|
||||
/** Read the signed snapshot directly: no duplicate contracts, attachment copies or archive backfill. */
|
||||
class CourierContractPageData
|
||||
{
|
||||
public const REMINDER_DAYS = 30;
|
||||
|
||||
public const STATUSES = [
|
||||
'due' => '即将到期',
|
||||
'expired' => '约定期限已届满',
|
||||
'active' => '有效期内',
|
||||
'upcoming' => '未到开始日期',
|
||||
'unknown' => '期限待核对',
|
||||
];
|
||||
|
||||
public function page(bool $includeRows = true): array
|
||||
{
|
||||
$search = mb_substr(trim((string) request()->query('contract_search', '')), 0, 255);
|
||||
$status = (string) request()->query('contract_status', '');
|
||||
$status = array_key_exists($status, self::STATUSES) ? $status : '';
|
||||
$from = $this->date(request()->query('contract_end_from'));
|
||||
$to = $this->date(request()->query('contract_end_to'));
|
||||
if ($from && $to && $from > $to) {
|
||||
[$from, $to] = [$to, $from];
|
||||
}
|
||||
$filters = ['contract_search' => $search, 'contract_status' => $status,
|
||||
'contract_end_from' => $from ?? '', 'contract_end_to' => $to ?? ''];
|
||||
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
|
||||
$page = max((int) request()->query('page', 1), 1);
|
||||
$options = ['path' => request()->url(), 'query' => request()->except('page')];
|
||||
$data = [
|
||||
'courierContractFilters' => $filters,
|
||||
'courierContractRows' => new LengthAwarePaginator([], 0, $limit, $page, $options),
|
||||
'courierContractCounts' => array_fill_keys(array_keys(self::STATUSES), 0),
|
||||
'courierContractUnreadable' => 0,
|
||||
'courierContractError' => null,
|
||||
];
|
||||
|
||||
try {
|
||||
if (! Schema::hasTable('business_courier_agreement_signatures')) {
|
||||
$data['courierContractError'] = '协议签署数据表尚未建立,合同管理暂时无法读取。';
|
||||
return $data;
|
||||
}
|
||||
|
||||
$matched = [];
|
||||
$today = Carbon::today(config('app.timezone'));
|
||||
// Dates and partner types are encrypted. Stream bounded chunks instead of SQL JSON queries
|
||||
// or persisting a second, potentially stale copy of the signed business snapshot.
|
||||
$signatures = CourierAgreementSignature::query()->where('company_id', (int) company_id())
|
||||
->where('document_type', 'cooperation-contract')->where('status', CourierAgreementSignature::STATUS_ARCHIVED)
|
||||
->select(['id', 'company_id', 'business_no', 'courier_name', 'payload', 'document_type', 'status',
|
||||
'signed_pdf_path', 'certificate_path', 'archived_at', 'created_by', 'created_at'])
|
||||
->lazyById(250);
|
||||
foreach ($signatures as $signature) {
|
||||
$payload = $signature->archivedCourierContractPayload();
|
||||
if ($payload === null) {
|
||||
try {
|
||||
$signature->payload;
|
||||
} catch (Throwable) {
|
||||
$data['courierContractUnreadable']++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$row = $this->snapshot($signature, $payload, $today);
|
||||
$data['courierContractCounts'][$row['status']]++;
|
||||
if (! $includeRows || ($status !== '' && $status !== $row['status'])) {
|
||||
continue;
|
||||
}
|
||||
if (($from && (! $row['end_date'] || $row['end_date'] < $from))
|
||||
|| ($to && (! $row['end_date'] || $row['end_date'] > $to))) {
|
||||
continue;
|
||||
}
|
||||
if ($search !== '' && ! str_contains(mb_strtolower(implode(' ', [
|
||||
$row['courier_name'], $row['number'], $row['business_no'], $row['party_a'], $row['party_b'],
|
||||
])), mb_strtolower($search))) {
|
||||
continue;
|
||||
}
|
||||
$matched[] = $row;
|
||||
}
|
||||
if ($includeRows) {
|
||||
$priority = ['due' => 0, 'expired' => 1, 'unknown' => 2, 'active' => 3, 'upcoming' => 4];
|
||||
usort($matched, static fn (array $a, array $b): int =>
|
||||
[$priority[$a['status']], $a['end_date'] ?? '9999-12-31', -$a['id']]
|
||||
<=> [$priority[$b['status']], $b['end_date'] ?? '9999-12-31', -$b['id']]);
|
||||
$total = count($matched);
|
||||
$items = array_slice($matched, ($page - 1) * $limit, $limit);
|
||||
$owners = user_model_class()::query()->whereIn('id', array_filter(array_column($items, 'created_by')))
|
||||
->pluck('name', 'id');
|
||||
foreach ($items as &$row) {
|
||||
$row['creator'] = $owners[$row['created_by']] ?? '未记录';
|
||||
$row['has_signed_file'] = $this->listedFileAvailable($row['signed_path']);
|
||||
$row['has_certificate'] = $this->listedFileAvailable($row['certificate_path']);
|
||||
unset($row['signed_path'], $row['certificate_path']);
|
||||
}
|
||||
unset($row);
|
||||
$data['courierContractRows'] = new LengthAwarePaginator($items, $total, $limit, $page, $options);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$data['courierContractCounts'] = array_fill_keys(array_keys(self::STATUSES), 0);
|
||||
$data['courierContractError'] = '归档合同暂时无法读取,请稍后重试。';
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function snapshot(CourierAgreementSignature $signature, array $payload, Carbon $today): array
|
||||
{
|
||||
$start = $this->date($payload['cooperation_start_date'] ?? null);
|
||||
$end = $this->date($payload['cooperation_end_date'] ?? null);
|
||||
$days = $end ? (int) $today->diffInDays(Carbon::parse($end, config('app.timezone')), false) : null;
|
||||
$status = match (true) {
|
||||
! $start || ! $end || $start > $end => 'unknown',
|
||||
$end < $today->toDateString() => 'expired',
|
||||
$start > $today->toDateString() => 'upcoming',
|
||||
$days <= self::REMINDER_DAYS => 'due',
|
||||
default => 'active',
|
||||
};
|
||||
$person = ($payload['counterparty_signer_type'] ?? 'organization') === 'person';
|
||||
|
||||
return [
|
||||
'id' => (int) $signature->id,
|
||||
'courier_name' => (string) $signature->courier_name,
|
||||
'business_no' => (string) $signature->business_no,
|
||||
'number' => trim((string) ($payload['cooperation_contract_number'] ?? '')),
|
||||
'party_a' => trim((string) ($payload['cooperation_party_a_name'] ?? '')),
|
||||
'party_b' => trim((string) ($payload['cooperation_party_b_name'] ?? '')),
|
||||
'legal_representative' => $person ? '不适用(个人签署)' : trim((string) ($payload['cooperation_party_b_legal_representative'] ?? '')),
|
||||
'start_date' => $start, 'end_date' => $end, 'days' => $status === 'unknown' ? null : $days,
|
||||
'status' => $status, 'created_by' => $signature->created_by,
|
||||
'created_at' => $signature->created_at, 'archived_at' => $signature->archived_at,
|
||||
'signed_path' => $signature->signed_pdf_path, 'certificate_path' => $signature->certificate_path,
|
||||
];
|
||||
}
|
||||
|
||||
private function date(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
|
||||
return null;
|
||||
}
|
||||
[$year, $month, $day] = array_map('intval', explode('-', $value));
|
||||
return checkdate($month, $day, $year) ? $value : null;
|
||||
}
|
||||
|
||||
private function listedFileAvailable(?string $path): bool
|
||||
{
|
||||
if (blank($path)) {
|
||||
return false;
|
||||
}
|
||||
// Do not issue up to 50 sequential object-storage HEAD requests for one list page.
|
||||
// Downloads always verify the actual file; local files can be checked cheaply here.
|
||||
return config('filesystems.disks.uploads.driver') !== 'local' || Storage::disk('uploads')->exists($path);
|
||||
}
|
||||
}
|
||||
222
app/Business/CourierFreightImporter.php
Normal file
222
app/Business/CourierFreightImporter.php
Normal file
@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 快递运费导入(.xlsx / .csv)。
|
||||
*
|
||||
* 源表头(按表头名匹配,列顺序无关):
|
||||
* - 必需:来源 / 成品配件(也接受「成品运费」)/ 交易单号 / 提供成本运费;
|
||||
* - 可选:快递单号 / 条形码 / 店铺名称(「财务系统运费整理.xlsx」起提供的列,老文件没有也能导)。
|
||||
* 快递方给的文件常见几十万行,xlsx 走 XlsxSheetReader 流式读取,不整表进内存。
|
||||
*/
|
||||
class CourierFreightImporter
|
||||
{
|
||||
/** 源表头 → 数据库列 */
|
||||
private const COLUMNS = [
|
||||
'来源' => 'source',
|
||||
'成品配件' => 'part',
|
||||
'成品运费' => 'part',
|
||||
'交易单号' => 'transaction_no',
|
||||
'快递单号' => 'express_no',
|
||||
'条形码' => 'barcode',
|
||||
'店铺名称' => 'shop_name',
|
||||
'提供成本运费' => 'freight_amount',
|
||||
];
|
||||
|
||||
private const HEADERS = ['来源', '成品配件', '交易单号', '提供成本运费'];
|
||||
|
||||
/** 可选列:文件里有就导入,没有就留空(老文件兼容)。 */
|
||||
private const OPTIONAL_HEADERS = ['快递单号', '条形码', '店铺名称'];
|
||||
|
||||
/** 表头别名:源文件的列名写法不统一(成品配件 / 成品运费;店铺名称 / 店铺)。 */
|
||||
private const HEADER_ALIASES = [
|
||||
'成品配件' => ['成品配件', '成品运费'],
|
||||
'店铺名称' => ['店铺名称', '店铺'],
|
||||
];
|
||||
|
||||
private const CHUNK = 2000;
|
||||
|
||||
public function __construct(
|
||||
private readonly DatabaseManager $databases,
|
||||
private readonly ?XlsxSheetReader $reader = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{file:string,rows:int,inserted:int,skipped:int,amount:string,sources:array<string,int>,seconds:float}
|
||||
*/
|
||||
public function import(string $path, int $companyId = 1, bool $replace = false, string $source = 'courier-freight-import'): array
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
throw new InvalidArgumentException('找不到要导入的文件:' . $path);
|
||||
}
|
||||
|
||||
$target = $this->databases->connection();
|
||||
$startedAt = microtime(true);
|
||||
$now = now();
|
||||
|
||||
if ($replace) {
|
||||
$target->table('courier_freight_rows')->where('company_id', $companyId)->delete();
|
||||
}
|
||||
|
||||
$inserted = 0;
|
||||
$skipped = 0;
|
||||
$amount = 0.0;
|
||||
$sources = [];
|
||||
$buffer = [];
|
||||
|
||||
$flush = function () use (&$buffer, &$inserted, $target): void {
|
||||
foreach (array_chunk($buffer, self::CHUNK) as $chunk) {
|
||||
$target->table('courier_freight_rows')->insert($chunk);
|
||||
$inserted += count($chunk);
|
||||
}
|
||||
$buffer = [];
|
||||
};
|
||||
|
||||
foreach ($this->lines($path) as $line) {
|
||||
$sourceValue = $this->text($line['来源'] ?? null, 64);
|
||||
$transactionNo = $this->text($line['交易单号'] ?? null, 191);
|
||||
if ($sourceValue === null && $transactionNo === null) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $this->number($line['提供成本运费'] ?? null);
|
||||
$sources[$sourceValue ?? ''] = ($sources[$sourceValue ?? ''] ?? 0) + 1;
|
||||
$amount += $value;
|
||||
|
||||
$buffer[] = [
|
||||
'company_id' => $companyId,
|
||||
'source' => $sourceValue ?? '',
|
||||
'part' => $this->text($line['成品配件'] ?? null, 191),
|
||||
'transaction_no' => $transactionNo ?? '',
|
||||
'express_no' => $this->text($line['快递单号'] ?? null, 64),
|
||||
'barcode' => $this->text($line['条形码'] ?? null, 191),
|
||||
'shop_name' => $this->text($line['店铺名称'] ?? null, 191),
|
||||
'freight_amount' => number_format($value, 4, '.', ''),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if (count($buffer) >= self::CHUNK * 4) {
|
||||
$flush();
|
||||
}
|
||||
}
|
||||
|
||||
$flush();
|
||||
arsort($sources);
|
||||
|
||||
return [
|
||||
'file' => basename($path),
|
||||
'rows' => $inserted + $skipped,
|
||||
'inserted' => $inserted,
|
||||
'skipped' => $skipped,
|
||||
'amount' => number_format($amount, 2, '.', ''),
|
||||
'sources' => $sources,
|
||||
'seconds' => round(microtime(true) - $startedAt, 1),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return \Generator<int, array<string,string>> */
|
||||
private function lines(string $path): \Generator
|
||||
{
|
||||
if (mb_strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'csv') {
|
||||
$handle = fopen($path, 'rb');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('无法读取文件:' . $path);
|
||||
}
|
||||
|
||||
try {
|
||||
$header = null;
|
||||
while (($cells = fgetcsv($handle)) !== false) {
|
||||
if ($header === null) {
|
||||
$cells[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) ($cells[0] ?? ''));
|
||||
$header = array_map(static fn ($value): string => trim((string) $value), $cells);
|
||||
$this->assertHeader($header);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$line = [];
|
||||
foreach ($header as $index => $name) {
|
||||
$line[$this->canonicalName($name)] = (string) ($cells[$index] ?? '');
|
||||
}
|
||||
|
||||
yield $line;
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (mb_strtolower(pathinfo($path, PATHINFO_EXTENSION)) !== 'xlsx') {
|
||||
throw new InvalidArgumentException('只支持 xlsx、csv。');
|
||||
}
|
||||
|
||||
$columns = array_map(
|
||||
static fn (string $name): array|string => self::HEADER_ALIASES[$name] ?? $name,
|
||||
self::HEADERS,
|
||||
);
|
||||
$optionalColumns = array_map(
|
||||
static fn (string $name): array|string => self::HEADER_ALIASES[$name] ?? $name,
|
||||
self::OPTIONAL_HEADERS,
|
||||
);
|
||||
|
||||
yield from ($this->reader ?? app(XlsxSheetReader::class))->rows($path, $columns, $optionalColumns);
|
||||
}
|
||||
|
||||
/** 把表头别名折回规范列名。 */
|
||||
private function canonicalName(string $header): string
|
||||
{
|
||||
foreach (self::HEADER_ALIASES as $canonical => $names) {
|
||||
if (in_array($header, $names, true)) {
|
||||
return $canonical;
|
||||
}
|
||||
}
|
||||
|
||||
return $header;
|
||||
}
|
||||
|
||||
/** @param list<string> $header */
|
||||
private function assertHeader(array $header): void
|
||||
{
|
||||
foreach (self::HEADERS as $required) {
|
||||
$names = self::HEADER_ALIASES[$required] ?? [$required];
|
||||
if (array_intersect($names, $header) === []) {
|
||||
throw new InvalidArgumentException('文件缺少列:' . $required);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function text(mixed $value, int $limit): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = trim((string) (is_scalar($value) ? $value : ''));
|
||||
|
||||
return $text === '' ? null : mb_substr($text, 0, $limit);
|
||||
}
|
||||
|
||||
private function number(mixed $value): float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return 0.0;
|
||||
}
|
||||
if (is_numeric($value)) {
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
$text = str_replace([',', ' ', '¥', '¥'], '', (string) $value);
|
||||
|
||||
return is_numeric($text) ? (float) $text : 0.0;
|
||||
}
|
||||
}
|
||||
98
app/Business/CourierOnboardingPageData.php
Normal file
98
app/Business/CourierOnboardingPageData.php
Normal file
@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\CourierOnboarding;
|
||||
use App\Support\CourierOnboardingService;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* 「快递入驻」列表页数据:状态统计、筛选和分页。
|
||||
*/
|
||||
class CourierOnboardingPageData
|
||||
{
|
||||
/** 列表页状态分组,键为页面筛选值。 */
|
||||
public const STATUS_GROUPS = [
|
||||
'pending' => ['label' => '待处理', 'statuses' => [CourierOnboarding::STATUS_SUBMITTED, CourierOnboarding::STATUS_FIRST_APPROVED, CourierOnboarding::STATUS_APPROVED]],
|
||||
'filling' => ['label' => '待快递填写', 'statuses' => [CourierOnboarding::STATUS_INVITED, CourierOnboarding::STATUS_RETURNED]],
|
||||
'activated' => ['label' => '已开通', 'statuses' => [CourierOnboarding::STATUS_ACTIVATED]],
|
||||
'closed' => ['label' => '已关闭', 'statuses' => [CourierOnboarding::STATUS_REJECTED, CourierOnboarding::STATUS_CANCELLED]],
|
||||
'all' => ['label' => '全部', 'statuses' => []],
|
||||
];
|
||||
|
||||
public function __construct(private readonly CourierOnboardingService $service)
|
||||
{
|
||||
}
|
||||
|
||||
public function page(): array
|
||||
{
|
||||
$group = (string) request()->query('group', 'pending');
|
||||
$group = array_key_exists($group, self::STATUS_GROUPS) ? $group : 'pending';
|
||||
$search = trim((string) request()->query('search', ''));
|
||||
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
|
||||
|
||||
$data = [
|
||||
'view' => 'business.pages.courier-payables.courier-onboarding',
|
||||
'onboardingReady' => $this->service->ready(),
|
||||
'onboardingGroup' => $group,
|
||||
'onboardingSearch' => $search,
|
||||
'onboardingGroups' => self::STATUS_GROUPS,
|
||||
'onboardingCounts' => [],
|
||||
'onboardingRows' => new LengthAwarePaginator([], 0, $limit),
|
||||
];
|
||||
|
||||
if (! $data['onboardingReady']) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$counts = $this->service->statusCounts();
|
||||
$data['onboardingCounts'] = collect(self::STATUS_GROUPS)
|
||||
->map(static fn (array $config): int => $config['statuses'] === []
|
||||
? array_sum($counts)
|
||||
: array_sum(array_intersect_key($counts, array_flip($config['statuses']))))
|
||||
->all();
|
||||
|
||||
$query = CourierOnboarding::query()->withCount('files');
|
||||
$statuses = self::STATUS_GROUPS[$group]['statuses'];
|
||||
if ($statuses !== []) {
|
||||
$query->whereIn('status', $statuses);
|
||||
}
|
||||
if ($search !== '') {
|
||||
$like = '%' . addcslashes($search, '%_\\') . '%';
|
||||
$query->where(static function ($builder) use ($like): void {
|
||||
foreach (['number', 'invite_label', 'company_full_name', 'network_name', 'courier_name', 'contact_name', 'contact_phone'] as $column) {
|
||||
$builder->orWhere($column, 'like', $like);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$data['onboardingRows'] = $query->latest('updated_at')->latest('id')
|
||||
->paginate($limit)
|
||||
->withQueryString();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 快递资料页「入驻快递」页签:已开通的入驻快递,作为新快递的主数据。
|
||||
*/
|
||||
public function activated(int $page, int $limit): LengthAwarePaginator
|
||||
{
|
||||
if (! $this->service->ready()) {
|
||||
return new LengthAwarePaginator([], 0, $limit, $page);
|
||||
}
|
||||
|
||||
$search = trim((string) request()->query('search', ''));
|
||||
$query = CourierOnboarding::query()->where('status', CourierOnboarding::STATUS_ACTIVATED);
|
||||
if ($search !== '') {
|
||||
$like = '%' . addcslashes($search, '%_\\') . '%';
|
||||
$query->where(static function ($builder) use ($like): void {
|
||||
foreach (['courier_name', 'company_full_name', 'network_name', 'contact_name'] as $column) {
|
||||
$builder->orWhere($column, 'like', $like);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $query->latest('activated_at')->paginate($limit, ['*'], 'page', $page)->withQueryString();
|
||||
}
|
||||
}
|
||||
314
app/Business/CourierOverviewData.php
Normal file
314
app/Business/CourierOverviewData.php
Normal file
@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\CourierOnboarding;
|
||||
use App\Models\Business\CourierOnboardingFile;
|
||||
use App\Support\CourierRequoteService;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 快递总览:工作平台(钉钉流程 → wp_express)和财务系统快递入驻按快递合成一行。
|
||||
*
|
||||
* 字段各有归属:快递代码、发货账号、ERP账户ID 归工作平台;主体、对公/对私账户、证照、报价、签署归财务系统。
|
||||
* 两边先按入驻里关联的「工作平台快递代码」对,没关联的按系统快递名称对。
|
||||
*/
|
||||
final class CourierOverviewData
|
||||
{
|
||||
public const SOURCES = [
|
||||
'erp_pending' => '待 ERP 开通',
|
||||
'both' => '入驻 + 工作平台',
|
||||
'onboarding' => '只有入驻',
|
||||
'platform' => '只在工作平台(缺入驻资料)',
|
||||
'mismatch' => '账户对不上',
|
||||
];
|
||||
|
||||
private ?Collection $onboardingCache = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly CourierInformationMirrorRepository $mirror,
|
||||
private readonly CourierRequoteService $requotes,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{rows:LengthAwarePaginator,counts:array<string,int>,platform_ready:bool}
|
||||
*/
|
||||
public function page(array $filters, int $page, int $perPage): array
|
||||
{
|
||||
$all = $this->rows();
|
||||
$platformReady = $all['platform_ready'];
|
||||
$rows = collect($all['rows']);
|
||||
$counts = [
|
||||
'all' => $rows->count(),
|
||||
'both' => $rows->where('source', 'both')->count(),
|
||||
'platform' => $rows->where('source', 'platform')->count(),
|
||||
'onboarding' => $rows->where('source', 'onboarding')->count(),
|
||||
'mismatch' => $rows->where('account_status', 'mismatch')->count(),
|
||||
'erp_pending' => $rows->where('erp_status', 'pending')->count(),
|
||||
];
|
||||
|
||||
$source = (string) ($filters['source'] ?? '');
|
||||
if ($source === 'mismatch') {
|
||||
$rows = $rows->where('account_status', 'mismatch');
|
||||
} elseif ($source === 'erp_pending') {
|
||||
$rows = $rows->where('erp_status', 'pending');
|
||||
} elseif (array_key_exists($source, self::SOURCES)) {
|
||||
$rows = $rows->where('source', $source);
|
||||
}
|
||||
$keyword = trim((string) ($filters['search'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$rows = $rows->filter(static fn (array $row): bool => str_contains(
|
||||
mb_strtolower($row['name'] . ' ' . ($row['expcode'] ?? '') . ' ' . ($row['onboarding']?->company_full_name ?? '')),
|
||||
mb_strtolower($keyword),
|
||||
));
|
||||
}
|
||||
$rows = $rows->values();
|
||||
|
||||
return [
|
||||
'rows' => new LengthAwarePaginator(
|
||||
$rows->forPage($page, $perPage)->values(),
|
||||
$rows->count(),
|
||||
$perPage,
|
||||
$page,
|
||||
['path' => request()->url(), 'query' => request()->except('page')],
|
||||
),
|
||||
'counts' => $counts,
|
||||
'platform_ready' => $platformReady,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{rows:list<array<string,mixed>>,platform_ready:bool}
|
||||
*/
|
||||
public function rows(): array
|
||||
{
|
||||
try {
|
||||
$platform = $this->mirror->overviewSource();
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$platform = null;
|
||||
}
|
||||
|
||||
$onboardings = Schema::hasTable('business_courier_onboardings')
|
||||
? CourierOnboarding::query()
|
||||
->with('files')
|
||||
->whereNotIn('status', [CourierOnboarding::STATUS_CANCELLED, CourierOnboarding::STATUS_REJECTED])
|
||||
->latest('id')
|
||||
->get()
|
||||
: collect();
|
||||
|
||||
$byCode = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->workplatform_expcode))
|
||||
->keyBy(static fn (CourierOnboarding $o): string => (string) $o->workplatform_expcode);
|
||||
$byName = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->courier_name))
|
||||
->keyBy(static fn (CourierOnboarding $o): string => CourierOnboarding::normalizeName($o->courier_name));
|
||||
$used = [];
|
||||
$rows = [];
|
||||
|
||||
foreach ($platform['express'] ?? [] as $express) {
|
||||
$code = (string) $express->expcode;
|
||||
$onboarding = $byCode->get($code) ?? $byName->get(CourierOnboarding::normalizeName($express->expname));
|
||||
if ($onboarding !== null && isset($used[$onboarding->id])) {
|
||||
$onboarding = null;
|
||||
}
|
||||
if ($onboarding !== null) {
|
||||
$used[$onboarding->id] = true;
|
||||
}
|
||||
$rows[] = $this->row($onboarding, $express, $platform['accounts'][$code] ?? []);
|
||||
}
|
||||
|
||||
foreach ($onboardings as $onboarding) {
|
||||
if (! isset($used[$onboarding->id])) {
|
||||
$rows[] = $this->row($onboarding, null, []);
|
||||
}
|
||||
}
|
||||
|
||||
usort($rows, static fn (array $a, array $b): int => [$a['sort'], $a['name']] <=> [$b['sort'], $b['name']]);
|
||||
|
||||
return ['rows' => $rows, 'platform_ready' => $platform !== null];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<object> $accounts
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function row(?CourierOnboarding $onboarding, ?object $express, array $accounts): array
|
||||
{
|
||||
$source = $onboarding && $express ? 'both' : ($express ? 'platform' : 'onboarding');
|
||||
$platformNumbers = collect($accounts)
|
||||
->map(static fn ($account): string => preg_replace('/\D+/', '', (string) ($account->account ?? '')))
|
||||
->filter()
|
||||
->values();
|
||||
$onboardingNumbers = collect([$onboarding?->bank_account_number, $onboarding?->private_account_number])
|
||||
->map(static fn ($number): string => preg_replace('/\D+/', '', (string) $number))
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
$accountStatus = match (true) {
|
||||
$source !== 'both' => null,
|
||||
$platformNumbers->isEmpty() => 'platform_missing',
|
||||
$onboardingNumbers->isEmpty() => 'onboarding_missing',
|
||||
$onboardingNumbers->diff($platformNumbers)->isEmpty() => 'match',
|
||||
$onboardingNumbers->intersect($platformNumbers)->isNotEmpty() => 'partial',
|
||||
default => 'mismatch',
|
||||
};
|
||||
|
||||
$required = $onboarding ? CourierOnboardingFile::requiredKindsFor($onboarding->entity_type) : [];
|
||||
$kinds = $onboarding ? $onboarding->files->pluck('kind')->unique()->all() : [];
|
||||
$missingFiles = array_values(array_diff($required, $kinds));
|
||||
$effective = $onboarding && filled($onboarding->courier_name) && Schema::hasTable('business_courier_quote_versions')
|
||||
? $this->requotes->effectiveVersion((string) $onboarding->courier_name)
|
||||
: null;
|
||||
|
||||
// ERP 开通:入驻开通后等钉钉「新建快递」流程回填快递代码;工作平台老快递视为已开通。
|
||||
$erpStatus = match (true) {
|
||||
$onboarding === null => 'opened',
|
||||
$onboarding->status !== CourierOnboarding::STATUS_ACTIVATED => 'na',
|
||||
$onboarding->erp_opened_at !== null || $express !== null => 'opened',
|
||||
default => 'pending',
|
||||
};
|
||||
$erpAccountId = $onboarding?->erp_account_id
|
||||
?: collect($accounts)->pluck('erpAccountId')->filter()->unique()->implode(' / ');
|
||||
|
||||
return [
|
||||
'erp_status' => $erpStatus,
|
||||
'erp_account_id' => $erpAccountId ?: null,
|
||||
'erp_source' => $onboarding?->erpSourceLabel() ?: ($express ? '工作平台' : null),
|
||||
'name' => (string) ($express->expname ?? $onboarding?->courier_name ?? $onboarding?->company_full_name ?? ''),
|
||||
'expcode' => $express?->expcode ?? $onboarding?->workplatform_expcode,
|
||||
'source' => $source,
|
||||
'sort' => match (true) {
|
||||
$erpStatus === 'pending' => 0,
|
||||
$accountStatus === 'mismatch' => 1,
|
||||
$source === 'onboarding' => 2,
|
||||
$source === 'both' => 3,
|
||||
default => 4,
|
||||
},
|
||||
'platform' => $express,
|
||||
'platform_accounts' => $accounts,
|
||||
'onboarding' => $onboarding,
|
||||
'account_status' => $accountStatus,
|
||||
'missing_files' => array_map(static fn (string $kind): string => CourierOnboardingFile::labelFor($kind, $onboarding?->entity_type), $missingFiles),
|
||||
'effective_version' => $effective?->version_code,
|
||||
'linked_by' => $onboarding && $express ? ((string) $onboarding->workplatform_expcode === (string) $express->expcode ? 'code' : 'name') : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 「快递资料」表格右侧接上的入驻信息:本页工作平台快递按快递代码 / 系统快递名称对上的入驻,
|
||||
* 以及还没进工作平台(待 ERP 开通、或停用工作平台后新入驻)的快递,放在第一页最前面。
|
||||
*
|
||||
* @param list<object> $expressRows 本页工作平台快递
|
||||
* @return array{matches:array<string,array<string,mixed>>,extra:list<array<string,mixed>>}
|
||||
*/
|
||||
public function expressAppend(array $expressRows, int $page, string $nameKeyword = '', string $codeFilter = ''): array
|
||||
{
|
||||
$onboardings = $this->onboardings();
|
||||
if ($onboardings->isEmpty()) {
|
||||
return ['matches' => [], 'extra' => []];
|
||||
}
|
||||
|
||||
$byCode = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->workplatform_expcode))
|
||||
->keyBy(static fn (CourierOnboarding $o): string => (string) $o->workplatform_expcode);
|
||||
$byName = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->courier_name))
|
||||
->keyBy(static fn (CourierOnboarding $o): string => CourierOnboarding::normalizeName($o->courier_name));
|
||||
|
||||
$matches = [];
|
||||
foreach ($expressRows as $express) {
|
||||
$onboarding = $byCode->get((string) ($express->expcode ?? '')) ?? $byName->get(CourierOnboarding::normalizeName($express->expname ?? ''));
|
||||
if ($onboarding !== null) {
|
||||
$matches[(string) ($express->Id ?? $express->expcode)] = $this->onboardingInfo($onboarding, true);
|
||||
}
|
||||
}
|
||||
|
||||
$extra = [];
|
||||
if ($page === 1) {
|
||||
try {
|
||||
$platform = $this->mirror->overviewSource();
|
||||
} catch (Throwable) {
|
||||
$platform = null;
|
||||
}
|
||||
$codes = collect($platform['express'] ?? [])->pluck('expcode')->map(static fn ($code): string => (string) $code)->flip();
|
||||
$names = collect($platform['express'] ?? [])->pluck('expname')->map(static fn ($name): string => CourierOnboarding::normalizeName($name))->flip();
|
||||
foreach ($onboardings as $onboarding) {
|
||||
$inPlatform = (filled($onboarding->workplatform_expcode) && $codes->has((string) $onboarding->workplatform_expcode))
|
||||
|| (filled($onboarding->courier_name) && $names->has(CourierOnboarding::normalizeName($onboarding->courier_name)));
|
||||
if ($inPlatform) {
|
||||
continue;
|
||||
}
|
||||
if ($codeFilter !== '' && strcasecmp((string) $onboarding->workplatform_expcode, $codeFilter) !== 0) {
|
||||
continue;
|
||||
}
|
||||
if ($nameKeyword !== '' && ! str_contains(mb_strtolower((string) $onboarding->courier_name . ' ' . $onboarding->company_full_name), mb_strtolower($nameKeyword))) {
|
||||
continue;
|
||||
}
|
||||
$extra[] = $this->onboardingInfo($onboarding, false);
|
||||
}
|
||||
}
|
||||
|
||||
return ['matches' => $matches, 'extra' => $extra];
|
||||
}
|
||||
|
||||
/** 快递资料里选中的一行对应的入驻(先按快递公司代码,再按系统快递名称)。 */
|
||||
public function infoFor(?string $code, ?string $name): ?array
|
||||
{
|
||||
$onboardings = $this->onboardings();
|
||||
$onboarding = (filled($code) ? $onboardings->first(static fn (CourierOnboarding $o): bool => strcasecmp((string) $o->workplatform_expcode, (string) $code) === 0) : null)
|
||||
?? (filled($name) ? $onboardings->first(static fn (CourierOnboarding $o): bool => CourierOnboarding::normalizeName($o->courier_name) === CourierOnboarding::normalizeName($name)) : null);
|
||||
|
||||
return $onboarding !== null ? $this->onboardingInfo($onboarding, true) : null;
|
||||
}
|
||||
|
||||
/** @return Collection<int,CourierOnboarding> */
|
||||
private function onboardings(): Collection
|
||||
{
|
||||
return $this->onboardingCache ??= Schema::hasTable('business_courier_onboardings')
|
||||
? CourierOnboarding::query()
|
||||
->with('files')
|
||||
->whereNotIn('status', [CourierOnboarding::STATUS_CANCELLED, CourierOnboarding::STATUS_REJECTED])
|
||||
->latest('id')
|
||||
->get()
|
||||
: collect();
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function onboardingInfo(CourierOnboarding $onboarding, bool $inPlatform): array
|
||||
{
|
||||
$required = CourierOnboardingFile::requiredKindsFor($onboarding->entity_type);
|
||||
$missing = array_values(array_diff($required, $onboarding->files->pluck('kind')->unique()->all()));
|
||||
$effective = filled($onboarding->courier_name) && Schema::hasTable('business_courier_quote_versions')
|
||||
? $this->requotes->effectiveVersion((string) $onboarding->courier_name)
|
||||
: null;
|
||||
|
||||
// 合同起止:协议签署里「合作合同」已签署的那份(最新一份)上填的合作期限。
|
||||
$contract = app(\App\Support\CourierOnboardingService::class)->agreementSignatures($onboarding)
|
||||
->first(static fn (\App\Models\Business\CourierAgreementSignature $signature): bool => $signature->document_type === 'cooperation-contract'
|
||||
&& in_array($signature->status, [\App\Models\Business\CourierAgreementSignature::STATUS_COMPLETED, \App\Models\Business\CourierAgreementSignature::STATUS_ARCHIVED], true));
|
||||
$contractPayload = $contract !== null && is_array($contract->payload) ? $contract->payload : [];
|
||||
|
||||
return [
|
||||
'contract_start' => $contractPayload['cooperation_start_date'] ?? null,
|
||||
'contract_end' => $contractPayload['cooperation_end_date'] ?? null,
|
||||
'onboarding' => $onboarding,
|
||||
'erp_status' => match (true) {
|
||||
$onboarding->status !== CourierOnboarding::STATUS_ACTIVATED => 'na',
|
||||
$inPlatform || $onboarding->erp_opened_at !== null => 'opened',
|
||||
default => 'pending',
|
||||
},
|
||||
'checks' => $onboarding->accountChecks(),
|
||||
'missing_files' => array_map(static fn (string $kind): string => CourierOnboardingFile::labelFor($kind, $onboarding->entity_type), $missing),
|
||||
'effective_version' => $effective?->version_code,
|
||||
];
|
||||
}
|
||||
|
||||
public static function mask(?string $number): string
|
||||
{
|
||||
$digits = preg_replace('/\s+/', '', (string) $number);
|
||||
|
||||
return $digits === '' ? '' : '尾号 ' . mb_substr($digits, -4);
|
||||
}
|
||||
}
|
||||
139
app/Business/CourierPayableSummaryMap.php
Normal file
139
app/Business/CourierPayableSummaryMap.php
Normal file
@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 快递应付的两个汇总页 → 「交易单号 ⇒ 金额类取值」映射。
|
||||
*
|
||||
* 这些数据都是财务在「应付款项 › 快递应付 › 运费差 / 其他附加费汇总 / 退件费」页面上传的汇总快照
|
||||
* (JSON 文件,不是数据库表),快递运费页面按交易单号把对应金额带出来展示。
|
||||
* 快照重新导入后文件时间会变,缓存键跟着变,自动失效。
|
||||
*/
|
||||
class CourierPayableSummaryMap
|
||||
{
|
||||
/** 运费差:差异(结算运费-提供成本运费)。 */
|
||||
public const PAGE_FREIGHT_DIFFERENCE = 'freight-difference';
|
||||
|
||||
/** 其他附加费汇总:金额。 */
|
||||
public const PAGE_OTHER_SURCHARGE = 'other-surcharge-summary';
|
||||
|
||||
/** 退件费:结算金额(单号列叫「交易订单」)。 */
|
||||
public const PAGE_RETURN_FEE = 'return-fee';
|
||||
|
||||
/**
|
||||
* 汇总页 → [单号列的关键字候选, 取值列的关键字候选]。
|
||||
*
|
||||
* 各页表头写法不统一(「交易单号」/「交易订单」、「金额」/「结算金额」),
|
||||
* 所以按候选顺序找:先全等匹配,再退化成包含匹配。
|
||||
*/
|
||||
private const PAGES = [
|
||||
self::PAGE_FREIGHT_DIFFERENCE => [['交易单号', '交易订单'], ['差异', '金额']],
|
||||
self::PAGE_OTHER_SURCHARGE => [['交易单号', '交易订单'], ['金额']],
|
||||
self::PAGE_RETURN_FEE => [['交易订单', '交易单号'], ['结算金额', '金额']],
|
||||
];
|
||||
|
||||
private const CACHE_MINUTES = 10;
|
||||
|
||||
public function __construct(private readonly CourierPayableSummaryStore $store) {}
|
||||
|
||||
/**
|
||||
* @param string $page self::PAGE_* 之一
|
||||
* @return array<string,string> 交易单号 => 金额(保留源文件里的原值字符串)
|
||||
*/
|
||||
public function map(int $companyId, string $page): array
|
||||
{
|
||||
if (! isset(self::PAGES[$page])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$disk = Storage::disk('uploads');
|
||||
$path = $this->store->path($companyId, $page);
|
||||
if (! $disk->exists($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 文件时间戳进缓存键:财务重新导入运费差后不用等缓存过期。
|
||||
$key = sprintf('courier-payable-summary-map:v1:%s:%d:%d', $page, $companyId, (int) $disk->lastModified($path));
|
||||
|
||||
return Cache::remember($key, now()->addMinutes(self::CACHE_MINUTES), function () use ($companyId, $page): array {
|
||||
try {
|
||||
return $this->build($companyId, $page);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private function build(int $companyId, string $page): array
|
||||
{
|
||||
$snapshot = $this->store->snapshot($companyId, $page);
|
||||
if ($snapshot === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
[$orderHeaders, $valueHeaders] = self::PAGES[$page];
|
||||
$headers = array_map(static fn ($value): string => trim((string) $value), (array) $snapshot['headers']);
|
||||
$orderColumn = $this->columnIndex($headers, $orderHeaders);
|
||||
$valueColumn = $this->columnIndex($headers, $valueHeaders);
|
||||
if ($orderColumn === null || $valueColumn === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ((array) $snapshot['rows'] as $row) {
|
||||
$orderNos = trim((string) ($row[$orderColumn] ?? ''));
|
||||
if ($orderNos === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = trim((string) ($row[$valueColumn] ?? ''));
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 源表一个格子可能放多个交易单号(逗号分隔),逐个挂到同一个金额上。
|
||||
foreach (explode(',', $orderNos) as $orderNo) {
|
||||
$orderNo = trim($orderNo);
|
||||
if ($orderNo !== '' && ! isset($map[$orderNo])) {
|
||||
$map[$orderNo] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按表头名定位:候选顺序优先,每个候选先找完全相等的表头,再退化成包含匹配
|
||||
* (例如「差异(结算运费-提供成本运费)」只能靠包含匹配)。
|
||||
*
|
||||
* @param list<string> $needles
|
||||
*/
|
||||
private function columnIndex(array $headers, array $needles): ?int
|
||||
{
|
||||
foreach ($needles as $needle) {
|
||||
foreach ($headers as $index => $header) {
|
||||
if ($header === $needle) {
|
||||
return (int) $index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($needles as $needle) {
|
||||
foreach ($headers as $index => $header) {
|
||||
if ($header !== '' && mb_strpos($header, $needle) !== false) {
|
||||
return (int) $index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
382
app/Business/CourierProfileData.php
Normal file
382
app/Business/CourierProfileData.php
Normal file
@ -0,0 +1,382 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\CourierAgreementSignature;
|
||||
use App\Models\Business\CourierOnboarding;
|
||||
use App\Models\Business\CourierPaymentAccount;
|
||||
use App\Models\Business\CourierProfile;
|
||||
use App\Models\Business\CourierProfileEvent;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 快递资料在财务系统这一侧的数据:编辑覆盖值、打款信息(入驻对公/对私 + 新增/变更记录)、操作日志。
|
||||
* 工作平台镜像只读,这里的数据都存在财务系统自己的表里。
|
||||
*/
|
||||
final class CourierProfileData
|
||||
{
|
||||
/** @return array<string,array<string,mixed>> 快递代码 => 覆盖值 */
|
||||
public function overrides(array $codes): array
|
||||
{
|
||||
$codes = array_values(array_unique(array_filter(array_map('strval', $codes), 'filled')));
|
||||
if ($codes === [] || ! Schema::hasTable('business_courier_profiles')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return CourierProfile::query()->whereIn('expcode', $codes)->get()
|
||||
->mapWithKeys(static fn (CourierProfile $profile): array => [(string) $profile->expcode => (array) $profile->overrides])
|
||||
->all();
|
||||
}
|
||||
|
||||
/** 把覆盖值盖到快递资料行上,`_overridden` 记下改过的字段。 */
|
||||
public function apply(object $row, array $overrides): object
|
||||
{
|
||||
$row = clone $row;
|
||||
$changed = [];
|
||||
foreach ($overrides as $field => $value) {
|
||||
if (array_key_exists($field, CourierProfile::EDITABLE)) {
|
||||
$row->{$field} = $value;
|
||||
$changed[] = $field;
|
||||
}
|
||||
}
|
||||
$row->_overridden = $changed;
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/** 财务系统入驻(还没有工作平台记录)的快递,按快递资料的列铺成一行。 */
|
||||
public function onboardingRow(array $info): object
|
||||
{
|
||||
/** @var CourierOnboarding $onboarding */
|
||||
$onboarding = $info['onboarding'];
|
||||
|
||||
return (object) [
|
||||
'Id' => null,
|
||||
'expcode' => $onboarding->workplatform_expcode,
|
||||
'expname' => $onboarding->courier_name,
|
||||
'paymethod' => $onboarding->settlement_cycle,
|
||||
'status' => $onboarding->status === CourierOnboarding::STATUS_ACTIVATED ? '1' : '0',
|
||||
'kuaiyunsgn' => $onboarding->service_type === '快运' ? '1' : '0',
|
||||
'tradebtime' => $info['contract_start'] ?? null,
|
||||
'tradeetime' => $info['contract_end'] ?? null,
|
||||
'linker' => $onboarding->contact_name,
|
||||
'linkmobile' => $onboarding->contact_phone,
|
||||
'dzqzjbr' => $onboarding->esign_account,
|
||||
'undefined3' => $onboarding->deduction_method,
|
||||
'modified' => $onboarding->updated_at?->toDateTimeString(),
|
||||
'_onboarding_only' => true,
|
||||
'_info' => $info,
|
||||
];
|
||||
}
|
||||
|
||||
/** 按快递公司代码取当前显示的一行主信息(工作平台镜像优先,没有则用财务系统入驻),已盖上编辑覆盖值。 */
|
||||
public function currentRow(string $code): ?object
|
||||
{
|
||||
$mirror = app(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class);
|
||||
$overview = app(CourierOverviewData::class);
|
||||
$row = null;
|
||||
try {
|
||||
if ($mirror->available() && $mirror->ready()) {
|
||||
$found = $mirror->paginate(array_merge(
|
||||
array_fill_keys(['expname', 'undefined1', 'undefined2', 'undefined3', 'tradebtime_from', 'tradebtime_to', 'status'], ''),
|
||||
['expcode' => $code],
|
||||
), 1, 1)['data'][0] ?? null;
|
||||
if ($found !== null) {
|
||||
$row = clone $found;
|
||||
$row->_info = $overview->infoFor($code, $found->expname ?? null);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
}
|
||||
if ($row === null && ($info = $overview->infoFor($code, null)) !== null) {
|
||||
$row = $this->onboardingRow($info);
|
||||
}
|
||||
|
||||
return $row !== null ? $this->apply($row, $this->overrides([$code])[$code] ?? []) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 协议签署页预填:打款信息新增 → 收款账户证明;变更 → 收款账户变更。
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public static function agreementPrefill(mixed $id): ?array
|
||||
{
|
||||
if (! is_numeric($id) || ! Schema::hasTable('business_courier_payment_accounts')) {
|
||||
return null;
|
||||
}
|
||||
$record = CourierPaymentAccount::query()->find((int) $id);
|
||||
if ($record === null) {
|
||||
return null;
|
||||
}
|
||||
$onboarding = app(CourierOverviewData::class)->infoFor($record->expcode, $record->courier_name)['onboarding'] ?? null;
|
||||
$fields = $record->action === CourierPaymentAccount::ACTION_CHANGE
|
||||
? [
|
||||
'account_change_date' => $record->change_date?->toDateString(),
|
||||
'old_account_name' => $record->old_account_name,
|
||||
'old_account_number' => $record->old_account_number,
|
||||
'old_account_stop_date' => $record->old_stop_date?->toDateString(),
|
||||
'new_account_effective_date' => $record->effective_date?->toDateString(),
|
||||
'new_account_name' => $record->account_name,
|
||||
'new_bank_name' => $record->bank_name,
|
||||
'new_bank_account' => $record->account_number,
|
||||
]
|
||||
: [
|
||||
'account_proof_effective_date' => $record->effective_date?->toDateString(),
|
||||
'account_proof_account_name' => $record->account_name,
|
||||
'account_proof_bank_name' => $record->bank_name,
|
||||
'account_proof_bank_account' => $record->account_number,
|
||||
];
|
||||
if ($onboarding !== null && filled($onboarding->company_full_name)) {
|
||||
$fields['counterparty_organization_name'] = $onboarding->company_full_name;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $record->id,
|
||||
'action' => $record->actionLabel(),
|
||||
'courier_name' => $record->courier_name,
|
||||
'expcode' => $record->expcode,
|
||||
'document_type' => $record->documentType(),
|
||||
'signer_type' => $onboarding !== null && $onboarding->entity_type === CourierOnboarding::ENTITY_PERSON ? 'person' : 'organization',
|
||||
'esign_account' => $onboarding?->esign_account,
|
||||
'fields' => array_filter($fields, static fn ($value): bool => filled($value)),
|
||||
];
|
||||
}
|
||||
|
||||
/** 快递资料右侧接的入驻字段(我方填写;扣款方式对齐到左边「扣款方式」列,不重复显示)。 */
|
||||
public const APPENDED_PROFILE_FIELDS = [
|
||||
'courier_category', 'cooperation_mode', 'courier_attribute', 'waybill_type', 'shipping_mode',
|
||||
'need_logistics_check', 'workplatform_account', 'courier_email', 'network_code', 'network_region',
|
||||
];
|
||||
|
||||
/** 工作平台的行:扣款方式没填时用入驻里我方填的。 */
|
||||
public function fillFromOnboarding(object $row): object
|
||||
{
|
||||
$onboarding = $row->_info['onboarding'] ?? null;
|
||||
if ($onboarding !== null && blank($row->undefined3 ?? null) && filled($onboarding->deduction_method)) {
|
||||
$row = clone $row;
|
||||
$row->undefined3 = $onboarding->deduction_method;
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/** 入驻里填的网点,按工作平台网点表的列铺成一行(排在网点明细最前面)。 */
|
||||
public function onboardingNetwork(CourierOnboarding $onboarding): ?object
|
||||
{
|
||||
if (blank($onboarding->network_code) && blank($onboarding->network_name) && blank($onboarding->network_region)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (object) [
|
||||
'id' => '入驻 ' . $onboarding->number,
|
||||
'expcode' => $onboarding->workplatform_expcode,
|
||||
'netcode' => $onboarding->network_code,
|
||||
'netname' => $onboarding->network_name,
|
||||
'address' => $onboarding->network_region,
|
||||
'linker' => $onboarding->contact_name,
|
||||
'linkermobile' => $onboarding->contact_phone,
|
||||
'status' => $onboarding->status === CourierOnboarding::STATUS_ACTIVATED ? '1' : '0',
|
||||
'created' => $onboarding->created_at?->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
|
||||
/** 保存编辑:只记和当前显示值不同的字段,并写一条操作日志。 */
|
||||
public function update(string $expcode, ?string $courierName, object $current, array $values, int $userId): int
|
||||
{
|
||||
$profile = CourierProfile::query()->firstOrNew(['company_id' => company_id(), 'expcode' => $expcode]);
|
||||
$overrides = (array) ($profile->overrides ?? []);
|
||||
$changes = [];
|
||||
foreach (CourierProfile::EDITABLE as $field => $label) {
|
||||
if (! array_key_exists($field, $values)) {
|
||||
continue;
|
||||
}
|
||||
$new = $this->normalize($field, $values[$field]);
|
||||
$old = $this->normalize($field, $current->{$field} ?? null);
|
||||
if ($new === $old) {
|
||||
continue;
|
||||
}
|
||||
$overrides[$field] = $new;
|
||||
$changes[$field] = ['label' => $label, 'old' => $old, 'new' => $new];
|
||||
}
|
||||
if ($changes === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$profile->fill([
|
||||
'courier_name' => $courierName ?: $profile->courier_name,
|
||||
'overrides' => $overrides,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
$profile->created_by ??= $userId;
|
||||
$profile->save();
|
||||
|
||||
$this->log($expcode, 'edit', '修改 ' . implode('、', array_column($changes, 'label')), $changes, $userId);
|
||||
|
||||
return count($changes);
|
||||
}
|
||||
|
||||
public function log(string $expcode, string $action, string $summary, ?array $changes, int $userId): void
|
||||
{
|
||||
CourierProfileEvent::query()->create([
|
||||
'company_id' => company_id(),
|
||||
'expcode' => $expcode,
|
||||
'action' => $action,
|
||||
'summary' => mb_substr($summary, 0, 500),
|
||||
'changes' => $changes,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return Collection<int,CourierProfileEvent> */
|
||||
public function events(?string $expcode = null, int $limit = 100): Collection
|
||||
{
|
||||
if (! Schema::hasTable('business_courier_profile_events')) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return CourierProfileEvent::query()
|
||||
->with('creator')
|
||||
->when(filled($expcode), static fn ($query) => $query->where('expcode', $expcode))
|
||||
->latest('id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 财务系统这一侧的打款信息(完整显示):入驻时填的对公/对私账户 + 打款信息新增/变更记录。
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function financeAccounts(?string $expcode = null, ?CourierOnboarding $onboarding = null): array
|
||||
{
|
||||
$rows = [];
|
||||
if (Schema::hasTable('business_courier_payment_accounts')) {
|
||||
$records = CourierPaymentAccount::query()
|
||||
->when(filled($expcode), static fn ($query) => $query->where('expcode', $expcode))
|
||||
->latest('id')
|
||||
->get();
|
||||
foreach ($records as $record) {
|
||||
$status = $this->paymentStatus($record);
|
||||
$rows[] = [
|
||||
'source' => $record->actionLabel(),
|
||||
'expcode' => $record->expcode,
|
||||
'courier_name' => $record->courier_name,
|
||||
'type' => $record->account_type,
|
||||
'name' => $record->account_name,
|
||||
'bank' => $record->bank_name,
|
||||
'number' => $record->account_number,
|
||||
'effective_date' => $record->effective_date?->toDateString(),
|
||||
'old' => $record->action === CourierPaymentAccount::ACTION_CHANGE
|
||||
? trim($record->old_account_name . ' ' . $record->old_account_number) . ($record->old_stop_date ? '(' . $record->old_stop_date->toDateString() . ' 停用)' : '')
|
||||
: null,
|
||||
'status' => $status['label'],
|
||||
'tone' => $status['tone'],
|
||||
'erp_account_id' => null,
|
||||
'created_at' => $record->created_at?->toDateTimeString(),
|
||||
'record' => $record,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$onboardings = $onboarding !== null
|
||||
? collect([$onboarding])
|
||||
: (Schema::hasTable('business_courier_onboardings')
|
||||
? CourierOnboarding::query()
|
||||
->whereNotIn('status', [CourierOnboarding::STATUS_CANCELLED, CourierOnboarding::STATUS_REJECTED])
|
||||
->when(filled($expcode), static fn ($query) => $query->where('workplatform_expcode', $expcode))
|
||||
->latest('id')
|
||||
->get()
|
||||
: collect());
|
||||
foreach ($onboardings as $item) {
|
||||
$checks = $item->accountChecks();
|
||||
foreach ([
|
||||
['对公', $item->bank_account_name, $item->bank_name, $item->bank_account_number, $checks['corporate'] ?? null],
|
||||
['对私', $item->private_account_name, $item->private_bank_name, $item->private_account_number, $checks['private'] ?? null],
|
||||
] as [$type, $name, $bank, $number, $check]) {
|
||||
if (! filled($name) && ! filled($number)) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = [
|
||||
'source' => '快递入驻 ' . $item->number,
|
||||
'expcode' => $item->workplatform_expcode,
|
||||
'courier_name' => $item->courier_name,
|
||||
'type' => $type,
|
||||
'name' => $name,
|
||||
'bank' => $bank,
|
||||
'number' => $number,
|
||||
'effective_date' => $item->activated_at?->toDateString(),
|
||||
'old' => null,
|
||||
'status' => $check === false ? ($type === '对公' ? '户名与营业执照主体不一致' : '收款人与法定代表人不一致') : $item->statusLabel(),
|
||||
'tone' => $check === false ? 'is-bad' : ($item->status === CourierOnboarding::STATUS_ACTIVATED ? 'is-ok' : 'is-warn'),
|
||||
'erp_account_id' => $item->erp_account_id,
|
||||
'created_at' => $item->created_at?->toDateTimeString(),
|
||||
'onboarding' => $item,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打款信息新增/变更的状态:按协议签署里同一快递、同类文件、同一新账号的签署任务推导。
|
||||
*
|
||||
* @return array{label:string,tone:string,signature:?CourierAgreementSignature}
|
||||
*/
|
||||
public function paymentStatus(CourierPaymentAccount $record): array
|
||||
{
|
||||
$signature = null;
|
||||
if (Schema::hasTable('business_courier_agreement_signatures') && filled($record->courier_name)) {
|
||||
$field = $record->action === CourierPaymentAccount::ACTION_CHANGE ? 'new_bank_account' : 'account_proof_bank_account';
|
||||
$number = $this->digits($record->account_number);
|
||||
$signature = CourierAgreementSignature::query()
|
||||
->where('courier_name', $record->courier_name)
|
||||
->where('document_type', $record->documentType())
|
||||
->where('created_at', '>=', $record->created_at)
|
||||
->latest('id')
|
||||
->get()
|
||||
->first(function (CourierAgreementSignature $signature) use ($field, $number): bool {
|
||||
try {
|
||||
return $this->digits((string) data_get($signature->payload, $field)) === $number;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$signature === null => ['label' => '待推送协议', 'tone' => 'is-warn', 'signature' => null],
|
||||
in_array($signature->status, [CourierAgreementSignature::STATUS_COMPLETED, CourierAgreementSignature::STATUS_ARCHIVED], true) => ['label' => '已生效', 'tone' => 'is-ok', 'signature' => $signature],
|
||||
in_array($signature->status, [CourierAgreementSignature::STATUS_REVOKED, CourierAgreementSignature::STATUS_EXPIRED, CourierAgreementSignature::STATUS_REJECTED, CourierAgreementSignature::STATUS_FAILED], true) => ['label' => '签署未完成,需重新推送', 'tone' => 'is-bad', 'signature' => $signature],
|
||||
default => ['label' => '签署中', 'tone' => 'is-warn', 'signature' => $signature],
|
||||
};
|
||||
}
|
||||
|
||||
private function digits(?string $value): string
|
||||
{
|
||||
return (string) preg_replace('/[\s\-]+/u', '', (string) $value);
|
||||
}
|
||||
|
||||
private function normalize(string $field, mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$value = trim((string) $value);
|
||||
if ($value === '' || $value === '-') {
|
||||
return null;
|
||||
}
|
||||
if (in_array($field, CourierProfile::BOOLEAN_FIELDS, true)) {
|
||||
return in_array(strtolower($value), ['1', 'true', 'yes', 'y', '是'], true) ? '1' : '0';
|
||||
}
|
||||
if (in_array($field, CourierProfile::DATE_FIELDS, true)) {
|
||||
return substr($value, 0, 10);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
237
app/Business/InventoryInboundDetailImporter.php
Normal file
237
app/Business/InventoryInboundDetailImporter.php
Normal file
@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\InventoryWarehouseNameMapping;
|
||||
use DateTimeImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use RuntimeException;
|
||||
|
||||
class InventoryInboundDetailImporter
|
||||
{
|
||||
private const TABLE = 'inventory_inbound_detail_rows';
|
||||
|
||||
/**
|
||||
* Replace the imported inbound detail snapshot only after the workbook has
|
||||
* been opened successfully. The delete and inserts share one transaction,
|
||||
* so a malformed file cannot leave the page with a partially imported table.
|
||||
*
|
||||
* @return array{imported:int,mapped:int,skipped:int,total:int}
|
||||
*/
|
||||
public function import(string $file, string $forcedMonth = ''): array
|
||||
{
|
||||
$path = realpath($file);
|
||||
if ($path === false || ! is_file($path) || ! is_readable($path)) {
|
||||
throw new RuntimeException('入库明细文件不存在或不可读取。');
|
||||
}
|
||||
|
||||
$forcedMonth = $this->normalizeForcedMonth($forcedMonth);
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$now = now();
|
||||
$maps = $this->shortNameMap();
|
||||
|
||||
$reader = IOFactory::createReaderForFile($path);
|
||||
$reader->setReadDataOnly(true);
|
||||
$spreadsheet = $reader->load($path);
|
||||
$sheet = $spreadsheet->getSheet(0);
|
||||
|
||||
try {
|
||||
return DB::connection($connection)->transaction(function () use (
|
||||
$connection,
|
||||
$sheet,
|
||||
$maps,
|
||||
$forcedMonth,
|
||||
$now,
|
||||
): array {
|
||||
DB::connection($connection)->table(self::TABLE)->delete();
|
||||
|
||||
$batch = [];
|
||||
$imported = 0;
|
||||
$mapped = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($sheet->getRowIterator(2) as $row) {
|
||||
$rowNumber = $row->getRowIndex();
|
||||
$cells = [];
|
||||
foreach ($row->getCellIterator('A', 'U') as $cell) {
|
||||
$cells[] = $cell->getFormattedValue();
|
||||
}
|
||||
|
||||
$warehouse = trim((string) ($cells[14] ?? ''));
|
||||
$createdRaw = trim((string) ($cells[16] ?? ''));
|
||||
if ($warehouse === '' && $createdRaw === '') {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$month = $forcedMonth !== '' ? $forcedMonth : $this->monthOf($createdRaw);
|
||||
if ($month === '') {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 代发仓按仓储方匹配;其它仓库按仓库名称做唯一匹配。
|
||||
$shortName = '';
|
||||
if ($warehouse !== '') {
|
||||
$shortName = str_contains($warehouse, '代发仓')
|
||||
? ($maps['provider'][$warehouse] ?? $maps['warehouse'][$warehouse] ?? '')
|
||||
: ($maps['warehouse'][$warehouse] ?? '');
|
||||
}
|
||||
if ($shortName !== '') {
|
||||
$mapped++;
|
||||
}
|
||||
|
||||
$batch[] = [
|
||||
'report_month' => $month,
|
||||
'row_key' => sha1(substr($month, 0, 7) . '|' . $rowNumber),
|
||||
'source_no' => $this->text($cells[0] ?? ''),
|
||||
'document_no' => $this->text($cells[1] ?? ''),
|
||||
'created_serial' => $this->text($cells[2] ?? ''),
|
||||
'supplier' => $this->text($cells[3] ?? ''),
|
||||
'item_no' => $this->text($cells[4] ?? ''),
|
||||
'barcode' => $this->text($cells[5] ?? ''),
|
||||
'spec_name' => $this->text($cells[6] ?? ''),
|
||||
'quantity' => $this->number($cells[7] ?? ''),
|
||||
'unit_price' => $this->number($cells[8] ?? ''),
|
||||
'amount' => $this->number($cells[9] ?? ''),
|
||||
'estimated_unit_price' => $this->number($cells[10] ?? ''),
|
||||
'estimated_amount' => $this->number($cells[11] ?? ''),
|
||||
'supplier_barcode' => $this->text($cells[12] ?? ''),
|
||||
'remark' => $this->text($cells[13] ?? ''),
|
||||
'warehouse' => $warehouse !== '' ? $warehouse : null,
|
||||
'warehouse_short_name' => $shortName !== '' ? $shortName : null,
|
||||
'created_at_raw' => $this->moment($createdRaw),
|
||||
'express_name' => $this->text($cells[17] ?? ''),
|
||||
'express_no' => $this->text($cells[18] ?? ''),
|
||||
'not_sent' => $this->text($cells[19] ?? ''),
|
||||
'owner_name' => $this->text($cells[20] ?? ''),
|
||||
'imported_at' => $now,
|
||||
];
|
||||
$imported++;
|
||||
|
||||
if (count($batch) >= 1000) {
|
||||
DB::connection($connection)->table(self::TABLE)->insertOrIgnore($batch);
|
||||
$batch = [];
|
||||
}
|
||||
}
|
||||
|
||||
if ($batch !== []) {
|
||||
DB::connection($connection)->table(self::TABLE)->insertOrIgnore($batch);
|
||||
}
|
||||
if ($imported === 0) {
|
||||
throw new RuntimeException('入库明细文件中没有可导入的数据。');
|
||||
}
|
||||
|
||||
return [
|
||||
'imported' => $imported,
|
||||
'mapped' => $mapped,
|
||||
'skipped' => $skipped,
|
||||
'total' => DB::connection($connection)->table(self::TABLE)->count(),
|
||||
];
|
||||
});
|
||||
} finally {
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeForcedMonth(string $month): string
|
||||
{
|
||||
$month = trim($month);
|
||||
if ($month === '') {
|
||||
return '';
|
||||
}
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $month) === 1) {
|
||||
return $month . '-01';
|
||||
}
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $month) === 1) {
|
||||
return $month;
|
||||
}
|
||||
|
||||
throw new RuntimeException('指定月份格式应为 YYYY-MM。');
|
||||
}
|
||||
|
||||
/** @return array{warehouse:array<string,string>,provider:array<string,string>} */
|
||||
private function shortNameMap(): array
|
||||
{
|
||||
$rows = InventoryWarehouseNameMapping::withoutGlobalScopes()
|
||||
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']);
|
||||
$grouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$warehouse = trim((string) $row->warehouse);
|
||||
if ($warehouse === '') {
|
||||
continue;
|
||||
}
|
||||
$grouped[$warehouse][] = trim((string) $row->warehouse_short_name);
|
||||
}
|
||||
|
||||
$byWarehouse = [];
|
||||
foreach ($grouped as $warehouse => $names) {
|
||||
$unique = array_values(array_unique($names));
|
||||
if (count($unique) === 1 && $unique[0] !== '') {
|
||||
$byWarehouse[$warehouse] = $unique[0];
|
||||
}
|
||||
}
|
||||
|
||||
$byProvider = [];
|
||||
foreach ($rows as $row) {
|
||||
$provider = trim((string) $row->warehouse_provider);
|
||||
if ($provider !== '') {
|
||||
$byProvider[$provider][] = trim((string) $row->warehouse_short_name);
|
||||
}
|
||||
}
|
||||
foreach ($byProvider as $provider => $names) {
|
||||
$unique = array_values(array_unique($names));
|
||||
if (count($unique) === 1 && $unique[0] !== '') {
|
||||
$byProvider[$provider] = $unique[0];
|
||||
}
|
||||
}
|
||||
|
||||
return ['warehouse' => $byWarehouse, 'provider' => $byProvider];
|
||||
}
|
||||
|
||||
private function monthOf(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
foreach (['Y/m/d H:i:s', 'Y-m-d H:i:s', 'Y/m/d', 'Y-m-d'] as $format) {
|
||||
$parsed = DateTimeImmutable::createFromFormat($format, $value);
|
||||
if ($parsed instanceof DateTimeImmutable) {
|
||||
return $parsed->format('Y-m') . '-01';
|
||||
}
|
||||
}
|
||||
$timestamp = strtotime($value);
|
||||
|
||||
return $timestamp === false ? '' : date('Y-m-01', $timestamp);
|
||||
}
|
||||
|
||||
private function moment(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
$timestamp = strtotime($value);
|
||||
|
||||
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
|
||||
private function text(mixed $value): ?string
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
|
||||
return $text === '' ? null : $text;
|
||||
}
|
||||
|
||||
private function number(mixed $value): ?float
|
||||
{
|
||||
$text = str_replace(',', '', trim((string) $value));
|
||||
if ($text === '' || ! is_numeric($text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (float) $text;
|
||||
}
|
||||
}
|
||||
35
app/Business/InventoryWarehouseShortNames.php
Normal file
35
app/Business/InventoryWarehouseShortNames.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\InventoryWarehouseNameMapping;
|
||||
|
||||
class InventoryWarehouseShortNames
|
||||
{
|
||||
/** @return array<string,string> */
|
||||
public function forCompany(int $companyId): array
|
||||
{
|
||||
return InventoryWarehouseNameMapping::allCompanies()
|
||||
->companyId($companyId)
|
||||
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name'])
|
||||
->mapWithKeys(static fn (InventoryWarehouseNameMapping $mapping): array => [
|
||||
self::key($mapping->warehouse_provider, $mapping->warehouse) => $mapping->warehouse_short_name,
|
||||
])->all();
|
||||
}
|
||||
|
||||
/** @param array<string,string> $names */
|
||||
public function fill(object $row, array $names): object
|
||||
{
|
||||
$row->warehouse_short_name = $names[self::key(
|
||||
(string) ($row->warehouse_provider ?? ''),
|
||||
(string) ($row->warehouse ?? ''),
|
||||
)] ?? '';
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private static function key(string $provider, string $warehouse): string
|
||||
{
|
||||
return $provider . "\0" . $warehouse;
|
||||
}
|
||||
}
|
||||
367
app/Business/JstOrderLabelImporter.php
Normal file
367
app/Business/JstOrderLabelImporter.php
Normal file
@ -0,0 +1,367 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 聚水潭订单标签明细导入(拆标签写入 jst_order_labels)。
|
||||
*
|
||||
* 一个标签一行:一单有 N 个标签就写 N 行;订单不去重、标签不汇总。
|
||||
* 无标签的订单保留一行,标签记为「(无标签)」,方便与订单总数核对。
|
||||
*
|
||||
* 两个数据源:
|
||||
* - 共享镜像 ops_jst_orders(常规,覆盖镜像有数据的区间);
|
||||
* - 聚水潭「导出订单」Excel(镜像没有的历史区间,例如 2026-03,只能靠财务导出补)。
|
||||
*/
|
||||
class JstOrderLabelImporter
|
||||
{
|
||||
public const NO_LABEL = '(无标签)';
|
||||
|
||||
private const READ_CHUNK = 2000;
|
||||
|
||||
private const INSERT_CHUNK = 2000;
|
||||
|
||||
/** 聚水潭导出文件里用到的表头。 */
|
||||
private const EXPORT_COLUMNS = ['线上订单号', '店铺名称', '下单时间', '标签'];
|
||||
|
||||
public function __construct(
|
||||
private readonly DatabaseManager $databases,
|
||||
private readonly ?XlsxSheetReader $exportReader = null,
|
||||
private readonly ?JstOrderLabelItemCodes $itemCodes = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{from:string,to:string,orders:int,rows:int,no_label_orders:int,labels:int,seconds:float}
|
||||
*/
|
||||
public function import(string $from, string $to, int $companyId = 1, bool $replace = false, string $source = 'jst-order-label-import'): array
|
||||
{
|
||||
$from = $this->normalizeDate($from, '起始日期');
|
||||
$to = $this->normalizeDate($to, '结束日期');
|
||||
if ($from > $to) {
|
||||
throw new InvalidArgumentException('起始日期不能晚于结束日期。');
|
||||
}
|
||||
|
||||
$start = $from . ' 00:00:00';
|
||||
$end = date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00';
|
||||
|
||||
$mirror = $this->databases->connection(config('upstream_mirrors.connection', 'business_mirror'));
|
||||
if (! $mirror->getSchemaBuilder()->hasTable('ops_jst_orders')) {
|
||||
throw new RuntimeException('聚水潭订单镜像尚未建立(ops_jst_orders)。');
|
||||
}
|
||||
|
||||
$target = $this->databases->connection();
|
||||
$startedAt = microtime(true);
|
||||
$itemCodes = $this->itemCodes ?? new JstOrderLabelItemCodes();
|
||||
$hasItemTable = $mirror->getSchemaBuilder()->hasTable('ops_jst_order_item');
|
||||
|
||||
if ($replace) {
|
||||
$target->table('jst_order_labels')
|
||||
->where('company_id', $companyId)
|
||||
->where('ordered_at', '>=', $start)
|
||||
->where('ordered_at', '<', $end)
|
||||
->delete();
|
||||
app(PlantingOrderLabelLookup::class)->forget($companyId);
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$orders = 0;
|
||||
$rows = 0;
|
||||
$noLabelOrders = 0;
|
||||
$buffer = [];
|
||||
|
||||
$flush = function () use (&$buffer, &$rows, $target, $companyId): void {
|
||||
foreach (array_chunk($buffer, self::INSERT_CHUNK) as $chunk) {
|
||||
$target->table('jst_order_labels')->insert($chunk);
|
||||
app(PlantingOrderLabelLookup::class)->forget($companyId);
|
||||
$rows += count($chunk);
|
||||
}
|
||||
$buffer = [];
|
||||
};
|
||||
|
||||
$mirror->table('ops_jst_orders')
|
||||
->where('order_date', '>=', $start)
|
||||
->where('order_date', '<', $end)
|
||||
->orderBy('Id')
|
||||
->chunkById(self::READ_CHUNK, function ($records) use (&$buffer, &$orders, &$noLabelOrders, $flush, $companyId, $source, $now, $itemCodes, $hasItemTable, $mirror): void {
|
||||
// 货号:同批订单的商品编码(镜像商品明细,按 o_id 取),导入时一并写入
|
||||
$orderCodes = $hasItemTable
|
||||
? $itemCodes->forOrderIds($mirror, 'ops_jst_order_item', $records->pluck('o_id')->all())
|
||||
: [];
|
||||
|
||||
foreach ($records as $record) {
|
||||
$orders++;
|
||||
$orderedAt = $record->order_date === null ? null : substr((string) $record->order_date, 0, 19);
|
||||
if ($orderedAt === null || $orderedAt === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$labels = $this->splitLabels((string) ($record->labels ?? ''));
|
||||
if ($labels === []) {
|
||||
$labels = [self::NO_LABEL];
|
||||
$noLabelOrders++;
|
||||
}
|
||||
|
||||
$itemCode = $orderCodes[(int) $record->o_id] ?? null;
|
||||
|
||||
foreach ($labels as $label) {
|
||||
$buffer[] = [
|
||||
'company_id' => $companyId,
|
||||
'order_no' => mb_substr((string) $record->so_id, 0, 100),
|
||||
'shop_name' => mb_substr((string) ($record->shop_name ?? ''), 0, 191),
|
||||
'ordered_at' => $orderedAt,
|
||||
'label' => $label,
|
||||
'item_code' => $itemCode,
|
||||
'created_from' => $source,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
if (count($buffer) >= self::INSERT_CHUNK * 2) {
|
||||
$flush();
|
||||
}
|
||||
}
|
||||
}, 'Id');
|
||||
|
||||
$flush();
|
||||
|
||||
$labels = $target->table('jst_order_labels')
|
||||
->where('company_id', $companyId)
|
||||
->where('ordered_at', '>=', $start)
|
||||
->where('ordered_at', '<', $end)
|
||||
->distinct()
|
||||
->count('label');
|
||||
|
||||
// 导入会改变筛选选项,重新预热缓存,避免页面下次打开时做几秒的 distinct 扫描。
|
||||
app(JstOrderLabelOptions::class)->forget($companyId);
|
||||
app(JstOrderLabelOptions::class)->warm($companyId);
|
||||
|
||||
return [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'orders' => $orders,
|
||||
'rows' => $rows,
|
||||
'no_label_orders' => $noLabelOrders,
|
||||
'labels' => (int) $labels,
|
||||
'seconds' => round(microtime(true) - $startedAt, 1),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从聚水潭「导出订单」Excel 导入。
|
||||
*
|
||||
* 导出文件是一单多行(商品明细),且同一单里可能只有部分明细行带标签:
|
||||
* 第一遍先挑出真正有标签的订单号,第二遍再按「线上订单号 + 标签」去重拆行写入,
|
||||
* 只对整单没标签的订单写「(无标签)」。同时兼容只勾 4 列导出的极简表头。
|
||||
*
|
||||
* 传 $from/$to 时会顺带按窗口过滤;带 $replace 时必须给窗口,
|
||||
* 以便在写入前先清掉该区间,避免重复导入翻倍。
|
||||
*
|
||||
* @return array{file:string,company_id:int,from:?string,to:?string,orders:int,rows:int,no_label_orders:int,skipped:int,labels:int,seconds:float}
|
||||
*/
|
||||
public function importFromFile(
|
||||
string $path,
|
||||
?string $from = null,
|
||||
?string $to = null,
|
||||
int $companyId = 1,
|
||||
bool $replace = false,
|
||||
string $source = 'jst-order-label-file-import',
|
||||
): array {
|
||||
$from = $from === null || trim($from) === '' ? null : $this->normalizeDate(trim($from), '起始日期');
|
||||
$to = $to === null || trim($to) === '' ? null : $this->normalizeDate(trim($to), '结束日期');
|
||||
if ($from !== null && $to !== null && $from > $to) {
|
||||
throw new InvalidArgumentException('起始日期不能晚于结束日期。');
|
||||
}
|
||||
if ($replace && ($from === null || $to === null)) {
|
||||
throw new InvalidArgumentException('按文件导入并使用 --replace 时,必须同时给出 --from 与 --to,才能确定要清空的区间。');
|
||||
}
|
||||
|
||||
$start = $from === null ? null : $from . ' 00:00:00';
|
||||
$end = $to === null ? null : date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00';
|
||||
|
||||
$target = $this->databases->connection();
|
||||
$startedAt = microtime(true);
|
||||
|
||||
// 清空区间推迟到第一次成功读到数据行之后:万一文件打不开或表头不对,
|
||||
// 不能先把线上已有行删掉又导入失败。
|
||||
$clearWindow = function () use ($target, $companyId, $start, $end): void {
|
||||
$target->table('jst_order_labels')
|
||||
->where('company_id', $companyId)
|
||||
->where('ordered_at', '>=', $start)
|
||||
->where('ordered_at', '<', $end)
|
||||
->delete();
|
||||
app(PlantingOrderLabelLookup::class)->forget($companyId);
|
||||
};
|
||||
$cleared = ! $replace;
|
||||
|
||||
$now = now();
|
||||
$orders = 0;
|
||||
$rows = 0;
|
||||
$noLabelOrders = 0;
|
||||
$skipped = 0;
|
||||
$seen = [];
|
||||
$orderSeen = [];
|
||||
$buffer = [];
|
||||
|
||||
$flush = function () use (&$buffer, &$rows, $target, $companyId): void {
|
||||
foreach (array_chunk($buffer, self::INSERT_CHUNK) as $chunk) {
|
||||
$target->table('jst_order_labels')->insert($chunk);
|
||||
app(PlantingOrderLabelLookup::class)->forget($companyId);
|
||||
$rows += count($chunk);
|
||||
}
|
||||
$buffer = [];
|
||||
};
|
||||
|
||||
$reader = $this->exportReader ?? app(XlsxSheetReader::class);
|
||||
|
||||
// 第一遍:挑出「有标签」的订单号。聚水潭导出是一单多行,同一单常常只有部分
|
||||
// 明细行带标签;不按订单判断就会给本来有标签的订单多写一行「(无标签)」。
|
||||
$labeledOrders = [];
|
||||
$anyRow = false;
|
||||
foreach ($reader->rows($path, self::EXPORT_COLUMNS) as $line) {
|
||||
$anyRow = true;
|
||||
if ($this->splitLabels((string) ($line['标签'] ?? '')) === []) {
|
||||
continue;
|
||||
}
|
||||
$orderNo = trim((string) ($line['线上订单号'] ?? ''));
|
||||
if ($orderNo !== '') {
|
||||
$labeledOrders[$orderNo] = true;
|
||||
}
|
||||
}
|
||||
if (! $anyRow) {
|
||||
throw new RuntimeException('订单导出文件里没有数据行:' . basename($path));
|
||||
}
|
||||
|
||||
if (! $cleared) {
|
||||
$clearWindow();
|
||||
$cleared = true;
|
||||
}
|
||||
|
||||
// 第二遍:按「线上订单号 + 标签」去重后写入。
|
||||
foreach ($reader->rows($path, self::EXPORT_COLUMNS) as $line) {
|
||||
$orderNo = trim((string) ($line['线上订单号'] ?? ''));
|
||||
$orderedAt = $this->normalizeOrderedAt((string) ($line['下单时间'] ?? ''));
|
||||
if ($orderNo === '' || $orderedAt === null) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($start !== null && ($orderedAt < $start || $orderedAt >= $end)) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$labels = $this->splitLabels((string) ($line['标签'] ?? ''));
|
||||
$withoutLabel = $labels === [];
|
||||
if ($withoutLabel) {
|
||||
if (isset($labeledOrders[$orderNo])) {
|
||||
continue;
|
||||
}
|
||||
$labels = [self::NO_LABEL];
|
||||
}
|
||||
|
||||
if (! isset($orderSeen[$orderNo])) {
|
||||
$orderSeen[$orderNo] = true;
|
||||
$orders++;
|
||||
}
|
||||
|
||||
$shopName = mb_substr(trim((string) ($line['店铺名称'] ?? '')), 0, 191);
|
||||
|
||||
foreach ($labels as $label) {
|
||||
$fingerprint = hash('xxh3', $orderNo . "\0" . $label);
|
||||
if (isset($seen[$fingerprint])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$fingerprint] = true;
|
||||
if ($withoutLabel) {
|
||||
$noLabelOrders++;
|
||||
}
|
||||
|
||||
$buffer[] = [
|
||||
'company_id' => $companyId,
|
||||
'order_no' => mb_substr($orderNo, 0, 100),
|
||||
'shop_name' => $shopName,
|
||||
'ordered_at' => $orderedAt,
|
||||
'label' => $label,
|
||||
'created_from' => $source,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
if (count($buffer) >= self::INSERT_CHUNK * 2) {
|
||||
$flush();
|
||||
}
|
||||
}
|
||||
|
||||
$flush();
|
||||
|
||||
$labelQuery = $target->table('jst_order_labels')->where('company_id', $companyId);
|
||||
if ($start !== null) {
|
||||
$labelQuery->where('ordered_at', '>=', $start)->where('ordered_at', '<', $end);
|
||||
}
|
||||
$labels = $labelQuery->distinct()->count('label');
|
||||
|
||||
app(JstOrderLabelOptions::class)->forget($companyId);
|
||||
app(JstOrderLabelOptions::class)->warm($companyId);
|
||||
|
||||
return [
|
||||
'file' => basename($path),
|
||||
'company_id' => $companyId,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'orders' => $orders,
|
||||
'rows' => $rows,
|
||||
'no_label_orders' => $noLabelOrders,
|
||||
'skipped' => $skipped,
|
||||
'labels' => (int) $labels,
|
||||
'seconds' => round(microtime(true) - $startedAt, 1),
|
||||
];
|
||||
}
|
||||
|
||||
/** 下单时间归一成 Y-m-d H:i:s;解析不了就返回 null(该行跳过)。 */
|
||||
private function normalizeOrderedAt(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$timestamp = strtotime($value);
|
||||
|
||||
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function splitLabels(string $labels): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (explode(',', $labels) as $label) {
|
||||
$label = trim($label);
|
||||
if ($label !== '') {
|
||||
$out[] = mb_substr($label, 0, 191);
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
|
||||
private function normalizeDate(string $value, string $label): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
|
||||
throw new InvalidArgumentException($label . '必须是 YYYY-MM-DD。');
|
||||
}
|
||||
|
||||
[$year, $month, $day] = array_map('intval', explode('-', $value));
|
||||
if (! checkdate($month, $day, $year)) {
|
||||
throw new InvalidArgumentException($label . '不是有效日期。');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
59
app/Business/JstOrderLabelItemCodes.php
Normal file
59
app/Business/JstOrderLabelItemCodes.php
Normal file
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
|
||||
/**
|
||||
* 订单「货号」查询:从聚水潭商品明细镜像按内部订单号取商品编码(sku_id)。
|
||||
*
|
||||
* 「被替换(Replaced)/取消(Cancelled、SentCancelled)」的明细行不算订单当前货号,
|
||||
* 所以换过商品的订单(如信誉订单虚拟条码 X00006)只保留换入后的编码。
|
||||
* 一单多个编码用英文逗号连接。
|
||||
*/
|
||||
class JstOrderLabelItemCodes
|
||||
{
|
||||
/** 商品明细状态:这些行不计入订单当前货号。 */
|
||||
public const EXCLUDED_STATUSES = ['Replaced', 'Cancelled', 'SentCancelled'];
|
||||
|
||||
/**
|
||||
* @param list<int|string> $orderIds 内部订单号(o_id)
|
||||
* @return array<int,string> [内部订单号 => 货号]
|
||||
*/
|
||||
public function forOrderIds(ConnectionInterface $connection, string $itemsTable, array $orderIds): array
|
||||
{
|
||||
$orderIds = array_values(array_unique(array_filter(
|
||||
array_map(static fn ($value): int => (int) $value, $orderIds),
|
||||
static fn (int $value): bool => $value > 0,
|
||||
)));
|
||||
if ($orderIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $connection->table($itemsTable)
|
||||
->whereIn('o_id', $orderIds)
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('item_status')
|
||||
->orWhereNotIn('item_status', self::EXCLUDED_STATUSES);
|
||||
})
|
||||
->get(['o_id', 'sku_id']);
|
||||
|
||||
$skus = [];
|
||||
foreach ($rows as $row) {
|
||||
$sku = trim((string) $row->sku_id);
|
||||
if ($sku === '') {
|
||||
continue;
|
||||
}
|
||||
$skus[(int) $row->o_id][$sku] = true;
|
||||
}
|
||||
|
||||
$codes = [];
|
||||
foreach ($skus as $orderId => $list) {
|
||||
$list = array_keys($list);
|
||||
sort($list);
|
||||
$codes[$orderId] = mb_substr(implode(',', $list), 0, 191);
|
||||
}
|
||||
|
||||
return $codes;
|
||||
}
|
||||
}
|
||||
99
app/Business/JstOrderLabelOptions.php
Normal file
99
app/Business/JstOrderLabelOptions.php
Normal file
@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\JstOrderLabel;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* 聚水潭订单标签明细的筛选下拉选项。
|
||||
*
|
||||
* 表里有 200 万行以上,distinct 扫描一次要几秒;这里按公司缓存 24 小时,
|
||||
* 导入命令结束时会主动预热,避免每次打开页面都慢。
|
||||
*
|
||||
* 上限只是防呆(选项过多会拖慢页面),要高于实际店铺数:2026-10 导入 4~7 月后
|
||||
* 店铺已达 604 个,原来卡在 500 会让一部分店铺选不出来。
|
||||
*/
|
||||
class JstOrderLabelOptions
|
||||
{
|
||||
private const TTL_HOURS = 24;
|
||||
|
||||
private const TOTAL_TTL_MINUTES = 30;
|
||||
|
||||
private const LIMIT = 5000;
|
||||
|
||||
/** @return list<string> */
|
||||
public function labels(int $companyId): array
|
||||
{
|
||||
return $this->remember($companyId, 'label');
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function shops(int $companyId): array
|
||||
{
|
||||
return $this->remember($companyId, 'shop_name');
|
||||
}
|
||||
|
||||
public function warm(int $companyId): void
|
||||
{
|
||||
$this->labels($companyId);
|
||||
$this->shops($companyId);
|
||||
$this->total($companyId);
|
||||
}
|
||||
|
||||
public function forget(int $companyId): void
|
||||
{
|
||||
Cache::forget($this->key($companyId, 'label'));
|
||||
Cache::forget($this->key($companyId, 'shop_name'));
|
||||
Cache::forget($this->totalKey($companyId));
|
||||
}
|
||||
|
||||
/** 无筛选时的总行数:233 万行上 COUNT(*) 要 2 秒,缓存后翻页不再重复统计。 */
|
||||
public function total(int $companyId): int
|
||||
{
|
||||
$key = $this->totalKey($companyId);
|
||||
$cached = Cache::get($key);
|
||||
if (is_numeric($cached)) {
|
||||
return (int) $cached;
|
||||
}
|
||||
|
||||
$count = (int) JstOrderLabel::query()->where('company_id', $companyId)->count();
|
||||
Cache::put($key, $count, now()->addMinutes(self::TOTAL_TTL_MINUTES));
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function remember(int $companyId, string $column): array
|
||||
{
|
||||
$key = $this->key($companyId, $column);
|
||||
$cached = Cache::get($key);
|
||||
if (is_array($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$values = JstOrderLabel::query()
|
||||
->where('company_id', $companyId)
|
||||
->whereNotNull($column)
|
||||
->where($column, '<>', '')
|
||||
->distinct()
|
||||
->orderBy($column)
|
||||
->limit(self::LIMIT)
|
||||
->pluck($column)
|
||||
->all();
|
||||
|
||||
Cache::put($key, $values, now()->addHours(self::TTL_HOURS));
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
private function key(int $companyId, string $column): string
|
||||
{
|
||||
return "jst-order-labels:options:v1:{$companyId}:{$column}";
|
||||
}
|
||||
|
||||
private function totalKey(int $companyId): string
|
||||
{
|
||||
return "jst-order-labels:total:v1:{$companyId}";
|
||||
}
|
||||
}
|
||||
92
app/Business/JstOrderLabelPlantingRules.php
Normal file
92
app/Business/JstOrderLabelPlantingRules.php
Normal file
@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\JstOrderLabelPlantingRule;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 聚水潭订单标签明细的「是否种菜订单」判断:
|
||||
* - 按(公司 + 标签)匹配财务维护的映射表;
|
||||
* - 订单货号含信誉订单虚拟条码(X00006)的直接判为「是」。
|
||||
*
|
||||
* 映射表由财务维护(判断种菜单.xlsx)并按公司缓存,页面与导出共用同一份结果;
|
||||
* 映射表缺失或未导入时不报错,统一按「否」展示。
|
||||
*/
|
||||
class JstOrderLabelPlantingRules
|
||||
{
|
||||
private const TTL_HOURS = 24;
|
||||
|
||||
/** 该货号(信誉订单虚拟条码)的订单直接判为种菜订单。 */
|
||||
public const PLANTING_ITEM_CODE = 'X00006';
|
||||
|
||||
/** @return array<string,string> [标签 => 是/否] */
|
||||
public function map(int $companyId): array
|
||||
{
|
||||
return Cache::remember($this->key($companyId), now()->addHours(self::TTL_HOURS), function () use ($companyId): array {
|
||||
try {
|
||||
if (! Schema::hasTable('jst_order_label_planting_rules')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 直接用表查询:避开基类的软删除/公司作用域与模型缓存,映射表由命令写入后立即失效重读
|
||||
$map = [];
|
||||
foreach (DB::table('jst_order_label_planting_rules')
|
||||
->where('company_id', $companyId)
|
||||
->get(['label', 'is_planting']) as $row) {
|
||||
$label = trim((string) $row->label);
|
||||
if ($label === '') {
|
||||
continue;
|
||||
}
|
||||
$map[$label] = trim((string) $row->is_planting) === '否' ? '否' : '是';
|
||||
}
|
||||
|
||||
return $map;
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条(标签 + 货号)的判断结果:标签命中映射表取其值;
|
||||
* 货号含 X00006 的一律为「是」,其余未命中为「否」。
|
||||
*/
|
||||
public function flag(int $companyId, ?string $label, ?string $itemCode = null): string
|
||||
{
|
||||
$key = trim((string) $label);
|
||||
$flag = $key === '' ? '否' : ($this->map($companyId)[$key] ?? '否');
|
||||
|
||||
if ($flag === '否' && $this->hasPlantingItemCode($itemCode)) {
|
||||
return '是';
|
||||
}
|
||||
|
||||
return $flag;
|
||||
}
|
||||
|
||||
/** 货号(可能多个,逗号连接)里是否含信誉订单虚拟条码。 */
|
||||
private function hasPlantingItemCode(?string $itemCode): bool
|
||||
{
|
||||
foreach (explode(',', (string) $itemCode) as $code) {
|
||||
if (mb_strtoupper(trim($code)) === self::PLANTING_ITEM_CODE) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function forget(int $companyId): void
|
||||
{
|
||||
Cache::forget($this->key($companyId));
|
||||
app(PlantingOrderLabelLookup::class)->forget($companyId);
|
||||
}
|
||||
|
||||
private function key(int $companyId): string
|
||||
{
|
||||
return "jst-order-labels:planting-rules:v1:{$companyId}";
|
||||
}
|
||||
}
|
||||
299
app/Business/PlantingDetailImporter.php
Normal file
299
app/Business/PlantingDetailImporter.php
Normal file
@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\PlantingDetail;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 种菜明细表导入(xlsx / xls / csv)。
|
||||
*
|
||||
* 源表头与 planting_details 列一一对应;按表头名匹配,列顺序变化不影响。
|
||||
* 日期兼容 Excel 序列号与常见字符串;金额按 4 位小数保存。
|
||||
*/
|
||||
class PlantingDetailImporter
|
||||
{
|
||||
/** 源表头 → 数据库列 */
|
||||
private const COLUMNS = [
|
||||
'日期' => 'bill_date',
|
||||
'订单编号' => 'order_no',
|
||||
'财务售价' => 'finance_selling_price',
|
||||
'财务运费' => 'finance_freight',
|
||||
'财务佣金' => 'finance_commission',
|
||||
'京喜差价' => 'jingxi_price_diff',
|
||||
'扣款' => 'deduction',
|
||||
'财务空包费' => 'finance_empty_package_fee',
|
||||
'财务打款金额' => 'finance_payment_amount',
|
||||
'笔数' => 'pieces',
|
||||
'备注' => 'remark',
|
||||
'正确店铺' => 'store',
|
||||
'负责人' => 'owner',
|
||||
'账户' => 'account',
|
||||
'类型' => 'type',
|
||||
'创建人' => 'creator',
|
||||
'标签' => 'tag',
|
||||
'所属利润中心' => 'profit_center',
|
||||
'负责平台' => 'responsible_platform',
|
||||
'ID' => 'external_id',
|
||||
];
|
||||
|
||||
private const MONEY_COLUMNS = [
|
||||
'finance_selling_price', 'finance_freight', 'finance_commission',
|
||||
'jingxi_price_diff', 'deduction', 'finance_empty_package_fee', 'finance_payment_amount',
|
||||
];
|
||||
|
||||
private const CHUNK = 1000;
|
||||
|
||||
/**
|
||||
* @return array{rows:int,inserted:int,skipped:int,selling_price:string,payment_amount:string,date_min:?string,date_max:?string}
|
||||
*/
|
||||
public function import(string $path, int $companyId = 1, bool $replace = false, ?int $userId = null, string $source = 'planting-detail-import'): array
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
throw new InvalidArgumentException('找不到要导入的文件:' . $path);
|
||||
}
|
||||
|
||||
$grid = $this->read($path);
|
||||
if ($grid === []) {
|
||||
throw new InvalidArgumentException('文件里没有可读的数据行。');
|
||||
}
|
||||
|
||||
$headerIndex = $this->locateHeader($grid);
|
||||
if ($headerIndex === null) {
|
||||
throw new InvalidArgumentException('没找到表头:至少要有「日期」「订单编号」「财务打款金额」。');
|
||||
}
|
||||
|
||||
$map = $this->mapHeader($grid[$headerIndex]);
|
||||
if (! isset($map['bill_date'], $map['order_no'], $map['finance_payment_amount'])) {
|
||||
throw new InvalidArgumentException('表头缺少「日期」或「订单编号」或「财务打款金额」。');
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$rows = [];
|
||||
$skipped = 0;
|
||||
$sellingPrice = 0.0;
|
||||
$paymentAmount = 0.0;
|
||||
$dateMin = null;
|
||||
$dateMax = null;
|
||||
$inserted = 0;
|
||||
|
||||
$connection = DB::connection();
|
||||
if ($replace) {
|
||||
PlantingDetail::query()->where('company_id', $companyId)->forceDelete();
|
||||
}
|
||||
|
||||
$flush = function () use (&$rows, &$inserted, $connection): void {
|
||||
if ($rows !== []) {
|
||||
foreach (array_chunk($rows, self::CHUNK) as $chunk) {
|
||||
$connection->table('planting_details')->insert($chunk);
|
||||
$inserted += count($chunk);
|
||||
}
|
||||
$rows = [];
|
||||
}
|
||||
};
|
||||
|
||||
foreach (array_slice($grid, $headerIndex + 1) as $gridRow) {
|
||||
if (! $this->hasContent($gridRow)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = $this->toDate($this->cell($gridRow, $map, 'bill_date'));
|
||||
$orderNo = $this->toText($this->cell($gridRow, $map, 'order_no'));
|
||||
if ($date === null || $orderNo === null) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$record = [
|
||||
'company_id' => $companyId,
|
||||
'bill_date' => $date,
|
||||
'order_no' => mb_substr($orderNo, 0, 100),
|
||||
'pieces' => (int) ($this->toNumber($this->cell($gridRow, $map, 'pieces')) ?? 0),
|
||||
'remark' => $this->toText($this->cell($gridRow, $map, 'remark')),
|
||||
'store' => $this->toText($this->cell($gridRow, $map, 'store')),
|
||||
'owner' => $this->toText($this->cell($gridRow, $map, 'owner')),
|
||||
'account' => $this->toText($this->cell($gridRow, $map, 'account')),
|
||||
'type' => $this->toText($this->cell($gridRow, $map, 'type')),
|
||||
'creator' => $this->toText($this->cell($gridRow, $map, 'creator')),
|
||||
'tag' => $this->toText($this->cell($gridRow, $map, 'tag')),
|
||||
'profit_center' => $this->toText($this->cell($gridRow, $map, 'profit_center')),
|
||||
'responsible_platform' => $this->toText($this->cell($gridRow, $map, 'responsible_platform')),
|
||||
// 源表存在一个单元格放多个 ID(换行分隔)的情况,这里保留到 500 字符。
|
||||
'external_id' => $this->toText($this->cell($gridRow, $map, 'external_id'), 500),
|
||||
'created_from' => $source,
|
||||
'created_by' => $userId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
foreach (self::MONEY_COLUMNS as $column) {
|
||||
$value = $this->toNumber($this->cell($gridRow, $map, $column)) ?? 0.0;
|
||||
$record[$column] = number_format($value, 4, '.', '');
|
||||
}
|
||||
$sellingPrice += (float) $record['finance_selling_price'];
|
||||
$paymentAmount += (float) $record['finance_payment_amount'];
|
||||
|
||||
$dateMin = $dateMin === null || $date < $dateMin ? $date : $dateMin;
|
||||
$dateMax = $dateMax === null || $date > $dateMax ? $date : $dateMax;
|
||||
|
||||
$rows[] = $record;
|
||||
if (count($rows) >= self::CHUNK * 4) {
|
||||
$flush();
|
||||
}
|
||||
}
|
||||
|
||||
$flush();
|
||||
|
||||
return [
|
||||
'rows' => $inserted + $skipped,
|
||||
'inserted' => $inserted,
|
||||
'skipped' => $skipped,
|
||||
'selling_price' => number_format($sellingPrice, 4, '.', ''),
|
||||
'payment_amount' => number_format($paymentAmount, 4, '.', ''),
|
||||
'date_min' => $dateMin,
|
||||
'date_max' => $dateMax,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int,array<int,mixed>> */
|
||||
private function read(string $path): array
|
||||
{
|
||||
$extension = mb_strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
if ($extension === 'csv') {
|
||||
$handle = fopen($path, 'rb');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('无法读取文件。');
|
||||
}
|
||||
$grid = [];
|
||||
$first = true;
|
||||
while (($cells = fgetcsv($handle)) !== false) {
|
||||
if ($first && isset($cells[0])) {
|
||||
$cells[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $cells[0]);
|
||||
$first = false;
|
||||
}
|
||||
$grid[] = $cells;
|
||||
}
|
||||
fclose($handle);
|
||||
|
||||
return $grid;
|
||||
}
|
||||
|
||||
if (! in_array($extension, ['xlsx', 'xls'], true)) {
|
||||
throw new InvalidArgumentException('只支持 xlsx、xls、csv。');
|
||||
}
|
||||
|
||||
try {
|
||||
$reader = IOFactory::createReader($extension === 'xlsx' ? 'Xlsx' : 'Xls');
|
||||
$reader->setReadDataOnly(true);
|
||||
$spreadsheet = $reader->load($path);
|
||||
} catch (Throwable $exception) {
|
||||
throw new InvalidArgumentException('读不出这个表格:' . $exception->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
return $spreadsheet->getSheet(0)->toArray(null, true, false, false);
|
||||
} finally {
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<int,array<int,mixed>> $grid */
|
||||
private function locateHeader(array $grid): ?int
|
||||
{
|
||||
foreach ($grid as $index => $row) {
|
||||
$map = $this->mapHeader($row);
|
||||
if (isset($map['bill_date'], $map['order_no'], $map['finance_payment_amount'])) {
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param array<int,mixed> $row @return array<string,int> */
|
||||
private function mapHeader(array $row): array
|
||||
{
|
||||
$map = [];
|
||||
foreach ($row as $index => $value) {
|
||||
$label = trim((string) (is_scalar($value) ? $value : ''));
|
||||
if ($label === '') {
|
||||
continue;
|
||||
}
|
||||
$column = self::COLUMNS[$label] ?? null;
|
||||
if ($column !== null && ! isset($map[$column])) {
|
||||
$map[$column] = (int) $index;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/** @param array<int,mixed> $row @param array<string,int> $map */
|
||||
private function cell(array $row, array $map, string $column): mixed
|
||||
{
|
||||
return isset($map[$column]) ? ($row[$map[$column]] ?? null) : null;
|
||||
}
|
||||
|
||||
private function hasContent(array $row): bool
|
||||
{
|
||||
foreach ($row as $value) {
|
||||
if ($value !== null && trim((string) (is_scalar($value) ? $value : '')) !== '') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function toDate(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_numeric($value) && (float) $value > 20000 && (float) $value < 60000) {
|
||||
try {
|
||||
return ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d');
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$text = trim((string) $value);
|
||||
$timestamp = strtotime($text);
|
||||
|
||||
return $timestamp === false ? null : date('Y-m-d', $timestamp);
|
||||
}
|
||||
|
||||
private function toText(mixed $value, int $limit = 191): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = trim((string) (is_scalar($value) ? $value : ''));
|
||||
|
||||
return $text === '' ? null : mb_substr($text, 0, $limit);
|
||||
}
|
||||
|
||||
private function toNumber(mixed $value): ?float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
if (is_numeric($value)) {
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
$text = str_replace([',', ' ', '¥', '¥'], '', (string) $value);
|
||||
|
||||
return is_numeric($text) ? (float) $text : null;
|
||||
}
|
||||
}
|
||||
68
app/Business/PlantingOrderLabelLookup.php
Normal file
68
app/Business/PlantingOrderLabelLookup.php
Normal file
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Reuse label classification across monthly/platform jobs; writers invalidate it. */
|
||||
class PlantingOrderLabelLookup
|
||||
{
|
||||
/** @param list<string> $positiveLabels @return list<string> */
|
||||
public function orderNumbers(int $companyId, array $positiveLabels): array
|
||||
{
|
||||
sort($positiveLabels, SORT_STRING);
|
||||
$rules = hash('sha256', json_encode($positiveLabels, JSON_THROW_ON_ERROR));
|
||||
$key = $this->key($companyId);
|
||||
$read = function () use ($key, $rules, $companyId): ?array {
|
||||
$cached = Cache::get($key);
|
||||
|
||||
return is_array($cached) && ($cached['rules'] ?? null) === $rules
|
||||
&& ($cached['revision'] ?? null) === $this->revision($companyId)
|
||||
? $cached['orders'] : null;
|
||||
};
|
||||
if (($orders = $read()) !== null) {
|
||||
return $orders;
|
||||
}
|
||||
|
||||
return Cache::lock($key . ':build', 300)->block(305, function () use ($companyId, $positiveLabels, $key, $rules, $read): array {
|
||||
if (($orders = $read()) !== null) {
|
||||
return $orders;
|
||||
}
|
||||
$revision = $this->revision($companyId);
|
||||
$orders = [];
|
||||
foreach (DB::table('jst_order_labels')->where('company_id', $companyId)
|
||||
->where(function ($query) use ($positiveLabels): void {
|
||||
if ($positiveLabels !== []) {
|
||||
$query->whereIn('label', $positiveLabels);
|
||||
}
|
||||
$query->orWhereRaw('FIND_IN_SET(?, item_code) > 0', [JstOrderLabelPlantingRules::PLANTING_ITEM_CODE]);
|
||||
})->select('order_no')->distinct()->cursor() as $row) {
|
||||
$orders[] = (string) $row->order_no;
|
||||
}
|
||||
// An import/rule change during the scan must not publish an old result.
|
||||
if ($this->revision($companyId) === $revision) {
|
||||
Cache::put($key, ['rules' => $rules, 'revision' => $revision, 'orders' => $orders], now()->addHours(24));
|
||||
}
|
||||
|
||||
return $orders;
|
||||
});
|
||||
}
|
||||
|
||||
public function forget(int $companyId): void
|
||||
{
|
||||
$key = $this->key($companyId);
|
||||
Cache::store('upstream_shared')->forever($key . ':revision', bin2hex(random_bytes(16)));
|
||||
Cache::forget($key);
|
||||
}
|
||||
|
||||
public function revision(int $companyId): ?string
|
||||
{
|
||||
return Cache::store('upstream_shared')->get($this->key($companyId) . ':revision');
|
||||
}
|
||||
|
||||
private function key(int $companyId): string
|
||||
{
|
||||
return 'planting-order-labels:v1:' . $companyId;
|
||||
}
|
||||
}
|
||||
396
app/Business/PlantingWorkOrderSynchronizer.php
Normal file
396
app/Business/PlantingWorkOrderSynchronizer.php
Normal file
@ -0,0 +1,396 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\PlantingDetail;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use InvalidArgumentException;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 聚水潭「种菜报销」协同工单 → 种菜明细表(账单流水数据 › 种菜数据汇总 › 种菜明细表)月度同步。
|
||||
*
|
||||
* 口径(用 1~8 月已入库的 7 万多行反推、命中率 100%):
|
||||
* - 只取「工单类型 = 种菜报销、工单状态 = 已完成」的工单;
|
||||
* - 明细表「日期」= 工单完成日 − 1 天。所以某个月的数据 = 完成时间落在该月次日到下月次日的工单;
|
||||
* - 一张工单一行,金额列与工单表单字段一一对应(见 FIELD_* 常量)。
|
||||
*
|
||||
* 排重:写入前先删掉「本月 + 本次订单号」的已有行,所以同一个月重复执行不会翻倍;
|
||||
* 想整月覆盖(连没有对应工单的人工行一起清掉)用 --force-clear。
|
||||
*
|
||||
* 「所属利润中心」没有工单来源,按这个顺序取:
|
||||
* 1. 表里已有的 店铺→利润中心 历史值(人工维护过的不覆盖);
|
||||
* 2. 店铺维度报表的 ERP 系统店铺资料(ops_fys_shop_info.erpShopName → responsibleDepartment);
|
||||
* 3. 两边都没有就留空,并在命令输出里列出来。
|
||||
*/
|
||||
class PlantingWorkOrderSynchronizer
|
||||
{
|
||||
/** 店铺维度报表用的店铺资料数据集(ERP系统店铺名称 → 店铺所属利润中心)。 */
|
||||
public const SHOP_INFO_DATASET = 'operations_replica.rpt.fys_shopInfo';
|
||||
|
||||
/** 聚水潭协同工单镜像(jiansun.dbo.jst_workOrderInfo → js_jst_work_order_info)。 */
|
||||
public const WORK_ORDER_DATASET = 'jiansun.dbo.jst_workOrderInfo';
|
||||
|
||||
/** 「种菜报销」这个工单类型的固定 id。 */
|
||||
public const WORK_TYPE_ID = '6F03128403F2442CABCF283B59FAD88D';
|
||||
|
||||
/** 已完成的工单状态。 */
|
||||
private const STATUS_CONFIRMED = 'CONFIRMED';
|
||||
|
||||
public const SOURCE = 'work-order-sync';
|
||||
|
||||
/** 工单表单字段(聚水潭只给 fieldId,不给字段名,这里的对应关系是拿历史数据逐列对撞出来的)。 */
|
||||
private const FIELD_SELLING_PRICE = 'NUMBERField1413856620250924131615'; // 财务售价 = 任务付款金额
|
||||
private const FIELD_FREIGHT = 'NUMBERField1413856620251113150537'; // 财务运费 = 任务快递费
|
||||
private const FIELD_COMMISSION = 'NUMBERField1413856620251230154457'; // 财务佣金 = 任务佣金
|
||||
private const FIELD_JINGXI_DIFF = 'NUMBERField1413856620250924131623'; // 京喜差价
|
||||
private const FIELD_DEDUCTION = 'NUMBERField1413856620251113150612'; // 扣款 = 任务扣款费用
|
||||
private const FIELD_EMPTY_PACKAGE = 'NUMBERField1413856620251113145620'; // 财务空包费 = 任务空包费
|
||||
private const FIELD_PAYMENT = 'NUMBERField1413856620251117101250'; // 财务打款金额 = 最终报销金额
|
||||
private const FIELD_OWNER = 'TEXTField1413856620251117180931'; // 负责人(收款人姓名)
|
||||
private const FIELD_ACCOUNT = 'TEXTField1413856620251117113451'; // 账户(打款账号)
|
||||
private const FIELD_TYPE = 'TEXTField1413856620251113150546'; // 类型(任务备注)
|
||||
private const FIELD_TAG = 'businessRemarkField'; // 标签(业务备注)
|
||||
private const FIELD_ORDER_NO = 'orderField'; // 订单编号(线上订单号)
|
||||
private const FIELD_STORE = 'shopField'; // 正确店铺(店铺名称)
|
||||
|
||||
/** 金额列,统一按 4 位小数落库。 */
|
||||
private const MONEY_COLUMNS = [
|
||||
'finance_selling_price',
|
||||
'finance_freight',
|
||||
'finance_commission',
|
||||
'jingxi_price_diff',
|
||||
'deduction',
|
||||
'finance_empty_package_fee',
|
||||
'finance_payment_amount',
|
||||
];
|
||||
|
||||
private const INSERT_CHUNK = 1000;
|
||||
|
||||
private const DELETE_CHUNK = 500;
|
||||
|
||||
public function __construct(
|
||||
private readonly DatabaseManager $databases,
|
||||
private readonly BusinessMirrorDatasetRegistry $datasets,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* from:string,to:string,orders:int,inserted:int,deleted:int,
|
||||
* selling_price:string,payment_amount:string,date_min:?string,date_max:?string,
|
||||
* profit_center_from_erp:int,unmatched_stores:array<string,int>,dry_run:bool
|
||||
* }
|
||||
*/
|
||||
public function sync(
|
||||
string $from,
|
||||
string $to,
|
||||
int $companyId = 1,
|
||||
bool $dryRun = false,
|
||||
bool $forceClear = false,
|
||||
): array {
|
||||
$from = $this->normalizeDate($from, '起始日期');
|
||||
$to = $this->normalizeDate($to, '结束日期');
|
||||
if ($from > $to) {
|
||||
throw new InvalidArgumentException('起始日期不能晚于结束日期。');
|
||||
}
|
||||
|
||||
// 「日期」= 工单完成日 − 1,所以完成时间窗口整体后移一天。
|
||||
$completeFrom = date('Y-m-d', strtotime($from . ' +1 day')) . ' 00:00:00';
|
||||
$completeTo = date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00';
|
||||
|
||||
$target = $this->databases->connection();
|
||||
$mirror = $this->databases->connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$table = $this->datasets->get(self::WORK_ORDER_DATASET)->targetTable();
|
||||
if (! $mirror->getSchemaBuilder()->hasTable($table)) {
|
||||
throw new RuntimeException('聚水潭协同工单镜像尚未建立(' . $table . ')。');
|
||||
}
|
||||
|
||||
// 历史值优先(人工维护过的不覆盖),没有的再用 ERP 店铺资料补齐。
|
||||
$storeToProfitCenter = $this->storeToProfitCenter($companyId);
|
||||
$erpProfitCenters = $this->erpStoreToProfitCenter($mirror);
|
||||
$now = now();
|
||||
$rows = [];
|
||||
$orders = 0;
|
||||
$fromErp = 0;
|
||||
$unmatchedStores = [];
|
||||
$sellingPrice = 0.0;
|
||||
$paymentAmount = 0.0;
|
||||
$dateMin = null;
|
||||
$dateMax = null;
|
||||
|
||||
$records = $mirror->table($table)
|
||||
->where('woTypeId', self::WORK_TYPE_ID)
|
||||
->where('status', self::STATUS_CONFIRMED)
|
||||
->where('completeTime', '>=', $completeFrom)
|
||||
->where('completeTime', '<', $completeTo)
|
||||
->orderBy('completeTime')
|
||||
->orderBy('woNo')
|
||||
->get(['woNo', 'createby', 'completeTime', 'businessFieldList']);
|
||||
|
||||
foreach ($records as $record) {
|
||||
$fields = $this->fields((string) $record->businessFieldList);
|
||||
$orderNo = $this->text($fields[self::FIELD_ORDER_NO] ?? null);
|
||||
if ($orderNo === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$billDate = date('Y-m-d', strtotime((string) $record->completeTime . ' -1 day'));
|
||||
if ($billDate < $from || $billDate > $to) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$store = $this->text($fields[self::FIELD_STORE . '#label'] ?? null);
|
||||
$profitCenter = null;
|
||||
if ($store !== null) {
|
||||
$profitCenter = $storeToProfitCenter[$store] ?? null;
|
||||
if ($profitCenter === null && isset($erpProfitCenters[$store])) {
|
||||
$profitCenter = $erpProfitCenters[$store];
|
||||
$fromErp++;
|
||||
}
|
||||
}
|
||||
if ($store !== null && $profitCenter === null) {
|
||||
$unmatchedStores[$store] = ($unmatchedStores[$store] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$row = [
|
||||
'company_id' => $companyId,
|
||||
'bill_date' => $billDate,
|
||||
'order_no' => mb_substr($orderNo, 0, 100),
|
||||
'pieces' => 1,
|
||||
'remark' => null,
|
||||
'store' => $store,
|
||||
'owner' => $this->text($fields[self::FIELD_OWNER] ?? null),
|
||||
'account' => $this->text($fields[self::FIELD_ACCOUNT] ?? null),
|
||||
'type' => $this->text($fields[self::FIELD_TYPE] ?? null),
|
||||
'creator' => $this->text($record->createby),
|
||||
'tag' => $this->text($fields[self::FIELD_TAG] ?? null),
|
||||
'profit_center' => $profitCenter,
|
||||
'responsible_platform' => null,
|
||||
'external_id' => $this->text($this->platformItemId($fields), 100),
|
||||
'created_from' => self::SOURCE,
|
||||
'created_by' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
foreach (self::MONEY_COLUMNS as $column) {
|
||||
$row[$column] = number_format(0.0, 4, '.', '');
|
||||
}
|
||||
foreach ([
|
||||
'finance_selling_price' => self::FIELD_SELLING_PRICE,
|
||||
'finance_freight' => self::FIELD_FREIGHT,
|
||||
'finance_commission' => self::FIELD_COMMISSION,
|
||||
'jingxi_price_diff' => self::FIELD_JINGXI_DIFF,
|
||||
'deduction' => self::FIELD_DEDUCTION,
|
||||
'finance_empty_package_fee' => self::FIELD_EMPTY_PACKAGE,
|
||||
'finance_payment_amount' => self::FIELD_PAYMENT,
|
||||
] as $column => $field) {
|
||||
$row[$column] = number_format($this->number($fields[$field] ?? null), 4, '.', '');
|
||||
}
|
||||
|
||||
$orders++;
|
||||
$sellingPrice += (float) $row['finance_selling_price'];
|
||||
$paymentAmount += (float) $row['finance_payment_amount'];
|
||||
$dateMin = $dateMin === null || $billDate < $dateMin ? $billDate : $dateMin;
|
||||
$dateMax = $dateMax === null || $billDate > $dateMax ? $billDate : $dateMax;
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
$orderNumbers = array_values(array_unique(array_column($rows, 'order_no')));
|
||||
$deleted = 0;
|
||||
|
||||
if (! $dryRun) {
|
||||
$deleted = $this->clear($target, $companyId, $from, $to, $orderNumbers, $forceClear);
|
||||
foreach (array_chunk($rows, self::INSERT_CHUNK) as $chunk) {
|
||||
$target->table('planting_details')->insert($chunk);
|
||||
}
|
||||
}
|
||||
|
||||
arsort($unmatchedStores);
|
||||
|
||||
return [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'orders' => $orders,
|
||||
'inserted' => $dryRun ? 0 : count($rows),
|
||||
'deleted' => $deleted,
|
||||
'selling_price' => number_format($sellingPrice, 4, '.', ''),
|
||||
'payment_amount' => number_format($paymentAmount, 4, '.', ''),
|
||||
'date_min' => $dateMin,
|
||||
'date_max' => $dateMax,
|
||||
'profit_center_from_erp' => $fromErp,
|
||||
'unmatched_stores' => $unmatchedStores,
|
||||
'dry_run' => $dryRun,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 排重:删掉本月内、这次要写入的那批订单号对应的已有行。
|
||||
*
|
||||
* @param list<string> $orderNumbers
|
||||
*/
|
||||
private function clear(
|
||||
\Illuminate\Database\ConnectionInterface $target,
|
||||
int $companyId,
|
||||
string $from,
|
||||
string $to,
|
||||
array $orderNumbers,
|
||||
bool $forceClear,
|
||||
): int {
|
||||
$base = static fn () => $target->table('planting_details')
|
||||
->where('company_id', $companyId)
|
||||
->where('bill_date', '>=', $from)
|
||||
->where('bill_date', '<=', $to);
|
||||
|
||||
if ($forceClear) {
|
||||
return (int) $base()->delete();
|
||||
}
|
||||
|
||||
$deleted = 0;
|
||||
foreach (array_chunk($orderNumbers, self::DELETE_CHUNK) as $chunk) {
|
||||
$deleted += (int) $base()->whereIn('order_no', $chunk)->delete();
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/** @return array<string,string> 店铺 → 利润中心(同店铺多个取值时取出现次数最多的) */
|
||||
private function storeToProfitCenter(int $companyId): array
|
||||
{
|
||||
$counts = [];
|
||||
$rows = PlantingDetail::query()
|
||||
->where('company_id', $companyId)
|
||||
->whereNotNull('store')
|
||||
->whereNotNull('profit_center')
|
||||
->selectRaw('store, profit_center, COUNT(*) AS total')
|
||||
->groupBy('store', 'profit_center')
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$store = (string) $row->store;
|
||||
$counts[$store][(string) $row->profit_center] = (int) $row->total;
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ($counts as $store => $values) {
|
||||
arsort($values);
|
||||
$map[$store] = (string) array_key_first($values);
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 店铺维度报表的 ERP 系统店铺 → 店铺所属利润中心(ops_fys_shop_info.responsibleDepartment)。
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function erpStoreToProfitCenter(\Illuminate\Database\ConnectionInterface $mirror): array
|
||||
{
|
||||
$table = $this->datasets->get(self::SHOP_INFO_DATASET)->targetTable();
|
||||
if (! $mirror->getSchemaBuilder()->hasTable($table)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ($mirror->table($table)
|
||||
->whereNotNull('erpShopName')
|
||||
->where('responsibleDepartment', '<>', '')
|
||||
->get(['erpShopName', 'responsibleDepartment']) as $row) {
|
||||
$name = trim((string) $row->erpShopName);
|
||||
$department = trim((string) $row->responsibleDepartment);
|
||||
if ($name !== '' && $department !== '' && ! isset($map[$name])) {
|
||||
$map[$name] = mb_substr($department, 0, 191);
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> fieldId → value(带 valueLabel 的另存为 fieldId#label) */
|
||||
private function fields(string $businessFieldList): array
|
||||
{
|
||||
$fields = [];
|
||||
$items = json_decode($businessFieldList, true);
|
||||
if (! is_array($items)) {
|
||||
return $fields;
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (! is_array($item) || ! isset($item['fieldId'])) {
|
||||
continue;
|
||||
}
|
||||
if ($item['fieldId'] === 'itemInfo') {
|
||||
// 商品明细是嵌套结构,原样留着给 platformItemId 取平台商品 ID。
|
||||
$fields['itemInfo'] = $item;
|
||||
|
||||
continue;
|
||||
}
|
||||
$value = $item['value'] ?? null;
|
||||
if (is_array($value)) {
|
||||
$value = implode(',', array_map('strval', $value));
|
||||
}
|
||||
$fields[(string) $item['fieldId']] = $value;
|
||||
if (($item['valueLabel'] ?? null) !== null) {
|
||||
$fields[(string) $item['fieldId'] . '#label'] = $item['valueLabel'];
|
||||
}
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $fields */
|
||||
private function platformItemId(array $fields): ?string
|
||||
{
|
||||
$itemInfo = $fields['itemInfo'] ?? null;
|
||||
if (! is_array($itemInfo)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ((array) ($itemInfo['childFieldList'] ?? []) as $childRow) {
|
||||
foreach ((array) $childRow as $child) {
|
||||
if (is_array($child) && ($child['fieldId'] ?? null) === 'platformItemId') {
|
||||
return $this->text($child['value'] ?? null, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function text(mixed $value, int $limit = 191): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = trim((string) (is_scalar($value) ? $value : ''));
|
||||
|
||||
return $text === '' ? null : mb_substr($text, 0, $limit);
|
||||
}
|
||||
|
||||
private function number(mixed $value): float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return is_numeric($value) ? (float) $value : 0.0;
|
||||
}
|
||||
|
||||
private function normalizeDate(string $value, string $label): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
|
||||
throw new InvalidArgumentException($label . '必须是 YYYY-MM-DD。');
|
||||
}
|
||||
|
||||
[$year, $month, $day] = array_map('intval', explode('-', $value));
|
||||
if (! checkdate($month, $day, $year)) {
|
||||
throw new InvalidArgumentException($label . '不是有效日期。');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
261
app/Business/ProductMonthlyWeightedAverageReporter.php
Normal file
261
app/Business/ProductMonthlyWeightedAverageReporter.php
Normal file
@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 汇总商品月加权平均单价结果,并将入库明细差异按原因分类。
|
||||
*/
|
||||
class ProductMonthlyWeightedAverageReporter
|
||||
{
|
||||
// 异常阈值未由业务另行指定:取至少 100 行,且不超过总行数的 0.1%。
|
||||
private const ANOMALY_THRESHOLD_MIN_ROWS = 100;
|
||||
|
||||
private const ANOMALY_THRESHOLD_RATE = 0.001;
|
||||
|
||||
private const ROW_TABLE = 'product_monthly_weighted_average_rows';
|
||||
|
||||
private const STATE_TABLE = 'product_monthly_weighted_average_states';
|
||||
|
||||
private const INBOUND_TABLE = 'inventory_inbound_detail_rows';
|
||||
|
||||
private const INVENTORY_MONTH_TABLE = 'inventory_report_authoritative_months';
|
||||
|
||||
private const INVENTORY_ROW_TABLE = 'inventory_report_authoritative_rows';
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function build(string $connection, CarbonImmutable $month): array
|
||||
{
|
||||
$month = $month->startOfMonth();
|
||||
$db = DB::connection($connection);
|
||||
$baseQuery = static fn () => $db->table(self::ROW_TABLE)
|
||||
->where('report_month', $month->toDateString());
|
||||
|
||||
$totals = $baseQuery()
|
||||
->selectRaw('count(*) as total_rows')
|
||||
->selectRaw('coalesce(sum(opening_quantity), 0) as opening_quantity')
|
||||
->selectRaw('coalesce(sum(opening_amount), 0) as opening_amount')
|
||||
->selectRaw('coalesce(sum(purchase_quantity), 0) as purchase_quantity')
|
||||
->selectRaw('coalesce(sum(purchase_amount), 0) as purchase_amount')
|
||||
->selectRaw('sum(case when weighted_average_unit_price is not null then 1 else 0 end) as weighted_price_rows')
|
||||
->selectRaw('sum(case when weighted_average_unit_price is null then 1 else 0 end) as missing_weighted_rows')
|
||||
->selectRaw('sum(case when pricing_status = ? then 1 else 0 end) as manual_review_rows', ['待人工核查'])
|
||||
->first();
|
||||
|
||||
if ($totals === null || (int) $totals->total_rows === 0) {
|
||||
return [
|
||||
'status' => 'missing',
|
||||
'report_month' => $month->toDateString(),
|
||||
'message' => '该月份尚未生成商品月加权平均单价。',
|
||||
'generated_at' => now()->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
|
||||
$weightedKeys = [];
|
||||
foreach ($baseQuery()->select('product_code', 'warehouse_short_name')->cursor() as $row) {
|
||||
$weightedKeys[$this->key($row->product_code, $row->warehouse_short_name)] = true;
|
||||
}
|
||||
|
||||
$inbound = [
|
||||
'source_rows' => 0,
|
||||
'source_quantity' => 0.0,
|
||||
'source_amount' => 0.0,
|
||||
'matched_rows' => 0,
|
||||
'matched_quantity' => 0.0,
|
||||
'matched_amount' => 0.0,
|
||||
'unmatched_rows' => 0,
|
||||
'unmatched_quantity' => 0.0,
|
||||
'unmatched_amount' => 0.0,
|
||||
'unmatched_groups' => [],
|
||||
];
|
||||
foreach ($db->table(self::INBOUND_TABLE)
|
||||
->where('report_month', $month->toDateString())
|
||||
->selectRaw('upper(barcode) as code, warehouse_short_name, count(*) as rows_count')
|
||||
->selectRaw('coalesce(sum(quantity), 0) as quantity')
|
||||
->selectRaw('coalesce(sum(coalesce(amount, 0) + coalesce(estimated_amount, 0)), 0) as amount')
|
||||
->groupByRaw('upper(barcode), warehouse_short_name')
|
||||
->cursor() as $row) {
|
||||
$rows = (int) $row->rows_count;
|
||||
$quantity = (float) $row->quantity;
|
||||
$amount = (float) $row->amount;
|
||||
$inbound['source_rows'] += $rows;
|
||||
$inbound['source_quantity'] += $quantity;
|
||||
$inbound['source_amount'] += $amount;
|
||||
|
||||
if (isset($weightedKeys[$this->key($row->code, $row->warehouse_short_name)])) {
|
||||
$inbound['matched_rows'] += $rows;
|
||||
$inbound['matched_quantity'] += $quantity;
|
||||
$inbound['matched_amount'] += $amount;
|
||||
continue;
|
||||
}
|
||||
|
||||
$inbound['unmatched_rows'] += $rows;
|
||||
$inbound['unmatched_quantity'] += $quantity;
|
||||
$inbound['unmatched_amount'] += $amount;
|
||||
$inbound['unmatched_groups'][] = [
|
||||
'product_code' => trim((string) $row->code),
|
||||
'warehouse_short_name' => trim((string) $row->warehouse_short_name),
|
||||
'source_rows' => $rows,
|
||||
'quantity' => $this->number($quantity),
|
||||
'amount' => $this->number($amount),
|
||||
'reason' => '入库明细未匹配到商品编码 + 仓库简称行',
|
||||
];
|
||||
}
|
||||
usort(
|
||||
$inbound['unmatched_groups'],
|
||||
static fn (array $left, array $right): int =>
|
||||
abs((float) $right['amount']) <=> abs((float) $left['amount']),
|
||||
);
|
||||
$inbound = $this->roundNestedNumbers($inbound);
|
||||
|
||||
$monthState = $db->table(self::INVENTORY_MONTH_TABLE)
|
||||
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
|
||||
->where('source_month', $month->toDateString())
|
||||
->first();
|
||||
$generation = $monthState?->active_generation;
|
||||
$actualSourceRows = $generation === null
|
||||
? 0
|
||||
: $db->table(self::INVENTORY_ROW_TABLE)
|
||||
->where('generation', (string) $generation)
|
||||
->count();
|
||||
$expectedSourceRows = (int) ($monthState?->source_row_count ?? 0);
|
||||
|
||||
$weightedAnomalyRows = $baseQuery()
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('weighted_average_unit_price')
|
||||
->orWhere('pricing_status', '待人工核查');
|
||||
})
|
||||
->count();
|
||||
$reasons = [];
|
||||
if ((int) $totals->missing_weighted_rows > 0) {
|
||||
$reasons[] = [
|
||||
'code' => 'missing_weighted_price',
|
||||
'label' => '加权平均单价为空',
|
||||
'rows' => (int) $totals->missing_weighted_rows,
|
||||
];
|
||||
}
|
||||
if ((int) $totals->manual_review_rows > 0) {
|
||||
$reasons[] = [
|
||||
'code' => 'manual_review',
|
||||
'label' => '待人工核查',
|
||||
'rows' => (int) $totals->manual_review_rows,
|
||||
];
|
||||
}
|
||||
if ((int) $inbound['unmatched_rows'] > 0) {
|
||||
$reasons[] = [
|
||||
'code' => 'inbound_without_inventory_dimension',
|
||||
'label' => '入库明细未匹配到商品编码 + 仓库简称行',
|
||||
'rows' => (int) $inbound['unmatched_rows'],
|
||||
'groups' => count($inbound['unmatched_groups']),
|
||||
];
|
||||
}
|
||||
if ($actualSourceRows !== $expectedSourceRows) {
|
||||
$reasons[] = [
|
||||
'code' => 'upstream_source_row_mismatch',
|
||||
'label' => '上游进销存源表行数与已激活明细不一致',
|
||||
'rows' => abs($actualSourceRows - $expectedSourceRows),
|
||||
];
|
||||
}
|
||||
|
||||
$anomalyRows = $weightedAnomalyRows + (int) $inbound['unmatched_rows'];
|
||||
$threshold = max(
|
||||
self::ANOMALY_THRESHOLD_MIN_ROWS,
|
||||
(int) ceil((int) $totals->total_rows * self::ANOMALY_THRESHOLD_RATE),
|
||||
);
|
||||
|
||||
return [
|
||||
'status' => $reasons === [] ? 'computed' : 'review_required',
|
||||
'report_month' => $month->toDateString(),
|
||||
'totals' => [
|
||||
'rows' => (int) $totals->total_rows,
|
||||
'opening_quantity' => $this->number((float) $totals->opening_quantity),
|
||||
'opening_amount' => $this->number((float) $totals->opening_amount),
|
||||
'purchase_quantity' => $this->number((float) $totals->purchase_quantity),
|
||||
'purchase_amount' => $this->number((float) $totals->purchase_amount),
|
||||
'weighted_price_rows' => (int) $totals->weighted_price_rows,
|
||||
'missing_weighted_rows' => (int) $totals->missing_weighted_rows,
|
||||
'manual_review_rows' => (int) $totals->manual_review_rows,
|
||||
],
|
||||
'inbound' => $inbound,
|
||||
'source' => [
|
||||
'expected_rows' => $expectedSourceRows,
|
||||
'active_rows' => $actualSourceRows,
|
||||
'row_difference' => $actualSourceRows - $expectedSourceRows,
|
||||
'status' => (string) ($monthState?->status ?? 'missing'),
|
||||
'active_generation' => $generation,
|
||||
'source_filename' => $monthState?->source_filename,
|
||||
],
|
||||
'anomalies' => [
|
||||
'affected_rows' => $anomalyRows,
|
||||
'threshold_rows' => $threshold,
|
||||
'threshold_exceeded' => $anomalyRows > $threshold,
|
||||
'reasons' => $reasons,
|
||||
],
|
||||
'state' => $db->table(self::STATE_TABLE)
|
||||
->where('report_month', $month->toDateString())
|
||||
->first(),
|
||||
'generated_at' => now()->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $report */
|
||||
public function brief(array $report): string
|
||||
{
|
||||
if (($report['status'] ?? null) === 'missing') {
|
||||
return (string) ($report['message'] ?? '商品月加权平均单价尚未生成');
|
||||
}
|
||||
|
||||
$totals = $report['totals'] ?? [];
|
||||
$inbound = $report['inbound'] ?? [];
|
||||
$anomalies = $report['anomalies'] ?? [];
|
||||
$reasonLabels = array_map(
|
||||
static fn (array $reason): string =>
|
||||
(string) ($reason['label'] ?? $reason['code'] ?? '未知异常'),
|
||||
$anomalies['reasons'] ?? [],
|
||||
);
|
||||
|
||||
return sprintf(
|
||||
'%s:总行数 %s;期初 %s 件 / %s;入库 %s 件 / %s;加权单价 %s 行;异常 %s 行%s',
|
||||
substr((string) ($report['report_month'] ?? ''), 0, 7),
|
||||
number_format((int) ($totals['rows'] ?? 0)),
|
||||
number_format((float) ($totals['opening_quantity'] ?? 0), 2),
|
||||
number_format((float) ($totals['opening_amount'] ?? 0), 2),
|
||||
number_format((float) ($inbound['matched_quantity'] ?? 0), 2),
|
||||
number_format((float) ($inbound['matched_amount'] ?? 0), 2),
|
||||
number_format((int) ($totals['weighted_price_rows'] ?? 0)),
|
||||
number_format((int) ($anomalies['affected_rows'] ?? 0)),
|
||||
$reasonLabels === [] ? '' : '(' . implode('、', $reasonLabels) . ')',
|
||||
);
|
||||
}
|
||||
|
||||
private function key(mixed $productCode, mixed $warehouseShortName): string
|
||||
{
|
||||
return mb_strtoupper(trim((string) $productCode))
|
||||
. "\0"
|
||||
. trim((string) $warehouseShortName);
|
||||
}
|
||||
|
||||
private function number(float $value): float
|
||||
{
|
||||
return round($value, 6);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $value
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function roundNestedNumbers(array $value): array
|
||||
{
|
||||
foreach ($value as $key => $item) {
|
||||
if (is_array($item)) {
|
||||
$value[$key] = $this->roundNestedNumbers($item);
|
||||
} elseif (is_float($item)) {
|
||||
$value[$key] = round($item, 6);
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
95
app/Business/StoreInformationDailyArchive.php
Normal file
95
app/Business/StoreInformationDailyArchive.php
Normal file
@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository;
|
||||
use RuntimeException;
|
||||
|
||||
class StoreInformationDailyArchive
|
||||
{
|
||||
public const TABLE = 'store_information_daily_archives';
|
||||
|
||||
public function __construct(
|
||||
private readonly BusinessInformationMirrorRepository $mirror,
|
||||
private readonly UpstreamBusinessInformation $information,
|
||||
private readonly DatabaseManager $database,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 保存指定日期的完整店铺基础信息快照;同一天重跑时覆盖当天版本。
|
||||
*/
|
||||
public function capture(CarbonInterface $snapshotDate): int
|
||||
{
|
||||
if (! $this->mirror->ready('store-information.read')) {
|
||||
throw new RuntimeException('店铺基础信息共享镜像尚未完成首次同步。');
|
||||
}
|
||||
|
||||
$rows = $this->information->storesWithAccountOwnership(
|
||||
$this->mirror->allStores(),
|
||||
true,
|
||||
);
|
||||
if ($rows === []) {
|
||||
throw new RuntimeException('店铺基础信息共享镜像没有可存档的数据。');
|
||||
}
|
||||
|
||||
$snapshotDateValue = $snapshotDate->format('Y-m-d');
|
||||
$capturedAt = now('Asia/Shanghai')->format('Y-m-d H:i:s.u');
|
||||
$values = [];
|
||||
foreach ($rows as $row) {
|
||||
$recordId = trim((string) ($row['id'] ?? ''));
|
||||
if ($recordId === '') {
|
||||
continue;
|
||||
}
|
||||
$values[] = [
|
||||
'snapshot_date' => $snapshotDateValue,
|
||||
'store_record_id' => mb_substr($recordId, 0, 64),
|
||||
'erp_store_name' => mb_substr(trim((string) ($row['erp_store_name'] ?? '')), 0, 191),
|
||||
'store_name' => $this->nullable($row['store_name'] ?? null, 191),
|
||||
'company_name' => $this->nullable($row['company_name'] ?? null, 255),
|
||||
'store_manager' => $this->nullable($row['store_manager'] ?? null, 191),
|
||||
'department_manager' => $this->nullable($row['department_manager'] ?? null, 191),
|
||||
'profit_center' => $this->nullable($row['profit_center'] ?? null, 191),
|
||||
'store_status' => $this->nullable($row['store_status'] ?? null, 64),
|
||||
'payload' => json_encode(
|
||||
$row,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR,
|
||||
),
|
||||
'captured_at' => $capturedAt,
|
||||
'created_at' => $capturedAt,
|
||||
'updated_at' => $capturedAt,
|
||||
];
|
||||
}
|
||||
|
||||
if ($values === []) {
|
||||
throw new RuntimeException('店铺基础信息缺少可用的店铺记录 ID,未写入快照。');
|
||||
}
|
||||
|
||||
$connection = $this->database->connection(
|
||||
(string) config('upstream_mirrors.connection', 'business_mirror'),
|
||||
);
|
||||
$connection->transaction(function () use ($connection, $values): void {
|
||||
foreach (array_chunk($values, 250) as $chunk) {
|
||||
$connection->table(self::TABLE)->upsert(
|
||||
$chunk,
|
||||
['snapshot_date', 'store_record_id'],
|
||||
[
|
||||
'erp_store_name', 'store_name', 'company_name',
|
||||
'store_manager', 'department_manager', 'profit_center',
|
||||
'store_status', 'payload', 'captured_at', 'updated_at',
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return count($values);
|
||||
}
|
||||
|
||||
private function nullable(mixed $value, int $limit): ?string
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
|
||||
return $text === '' ? null : mb_substr($text, 0, $limit);
|
||||
}
|
||||
}
|
||||
217
app/Business/StoreReceiptDifferenceMaterialization.php
Normal file
217
app/Business/StoreReceiptDifferenceMaterialization.php
Normal file
@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Http\Controllers\Business\Pages;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 「业绩明细」差额规则的物化状态。
|
||||
*
|
||||
* 备注分类、有效差额、有/无差额原先都在查询时按同一套规则链逐行现算,整月
|
||||
* 30 多万行需要十几到几十秒。store-receipt:materialize-differences 把结果写进
|
||||
* store_receipt_order_match_rows 的 difference_reason_index / base_difference /
|
||||
* has_difference 三列,页面改读列。
|
||||
*
|
||||
* 规则表达式始终取自 Pages 的静态方法(唯一口径),这里只负责:
|
||||
* - 用规则表达式的指纹判断物化结果是否仍与当前代码一致;
|
||||
* - 记录/读取每个月是否已物化。
|
||||
*/
|
||||
class StoreReceiptDifferenceMaterialization
|
||||
{
|
||||
public const REASON_INDEX_COLUMN = 'difference_reason_index';
|
||||
|
||||
public const BASE_DIFFERENCE_COLUMN = 'base_difference';
|
||||
|
||||
public const HAS_DIFFERENCE_COLUMN = 'has_difference';
|
||||
|
||||
private const TABLE = 'store_receipt_order_match_rows';
|
||||
|
||||
private const STATE_TABLE = 'store_receipt_order_match_materializations';
|
||||
|
||||
/** @var array<string,bool> */
|
||||
private static array $memo = [];
|
||||
|
||||
public static function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public static function table(): string
|
||||
{
|
||||
return self::TABLE;
|
||||
}
|
||||
|
||||
public static function stateTable(): string
|
||||
{
|
||||
return self::STATE_TABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前规则表达式的指纹。规则改动后指纹变化,旧的物化结果会自动视为过期。
|
||||
*/
|
||||
public static function ruleVersion(): string
|
||||
{
|
||||
return md5(implode('|', [
|
||||
Pages::receiptDefaultDifferenceSql(),
|
||||
Pages::receiptDifferenceRemarkIndexSql(),
|
||||
Pages::receiptEffectiveDifferenceSql(),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 物化列与状态表是否已就绪(迁移是否执行过)。
|
||||
*
|
||||
* 结果缓存 10 分钟:schema 探测在共享镜像库上每次要 0.3~0.5 秒,
|
||||
* 不能放在每个页面请求的热路径上。迁移执行后由 markMaterialized() 主动失效。
|
||||
*/
|
||||
public static function available(bool $fresh = false): bool
|
||||
{
|
||||
static $memo = null;
|
||||
|
||||
if (! $fresh) {
|
||||
if ($memo !== null) {
|
||||
return $memo;
|
||||
}
|
||||
|
||||
return $memo = (bool) Cache::remember(
|
||||
self::availabilityCacheKey(),
|
||||
now()->addMinutes(10),
|
||||
static fn (): bool => self::detectAvailability(),
|
||||
);
|
||||
}
|
||||
|
||||
$available = self::detectAvailability();
|
||||
if ($available) {
|
||||
Cache::put(self::availabilityCacheKey(), true, now()->addMinutes(10));
|
||||
}
|
||||
|
||||
return $memo = $available;
|
||||
}
|
||||
|
||||
private static function detectAvailability(): bool
|
||||
{
|
||||
try {
|
||||
$schema = Schema::connection(self::connection());
|
||||
|
||||
return $schema->hasTable(self::TABLE)
|
||||
&& $schema->hasTable(self::STATE_TABLE)
|
||||
&& $schema->hasColumn(self::TABLE, self::REASON_INDEX_COLUMN)
|
||||
&& $schema->hasColumn(self::TABLE, self::BASE_DIFFERENCE_COLUMN)
|
||||
&& $schema->hasColumn(self::TABLE, self::HAS_DIFFERENCE_COLUMN);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function availabilityCacheKey(): string
|
||||
{
|
||||
return 'store-receipt-materialization-available:v1';
|
||||
}
|
||||
|
||||
/**
|
||||
* 该账期是否已经按当前规则物化完成。未物化时页面回退到实时规则表达式。
|
||||
*/
|
||||
public static function materialized(string $month): bool
|
||||
{
|
||||
$month = substr(trim($month), 0, 10);
|
||||
if ($month === '' || ! self::available()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (array_key_exists($month, self::$memo)) {
|
||||
return self::$memo[$month];
|
||||
}
|
||||
|
||||
$cacheKey = 'store-receipt-materialized:' . $month . ':' . self::ruleVersion();
|
||||
|
||||
return self::$memo[$month] = (bool) Cache::remember($cacheKey, now()->addMinutes(10), function () use ($month): bool {
|
||||
$row = DB::connection(self::connection())
|
||||
->table(self::STATE_TABLE)
|
||||
->where('report_month', $month)
|
||||
->first();
|
||||
|
||||
if ($row === null || (string) ($row->rule_version ?? '') !== self::ruleVersion()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 过渡期保护:尚未部署新代码的同步任务重刷账期时不会写物化列,
|
||||
// 一旦出现 NULL 就回退实时口径,避免页面把未物化的行当成 0 差额。
|
||||
return ! DB::connection(self::connection())
|
||||
->table(self::TABLE)
|
||||
->where('report_month', $month)
|
||||
->whereNull(self::HAS_DIFFERENCE_COLUMN)
|
||||
->limit(1)
|
||||
->exists();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 该账期的数据版本:物化后自增,页面聚合/联动统计的缓存键带上它即可精确失效。
|
||||
*/
|
||||
public static function dataVersion(string $month): string
|
||||
{
|
||||
return (string) Cache::remember(
|
||||
self::dataVersionKey($month),
|
||||
now()->addDays(30),
|
||||
static fn (): int => 1,
|
||||
);
|
||||
}
|
||||
|
||||
public static function bumpDataVersion(string $month): void
|
||||
{
|
||||
$key = self::dataVersionKey($month);
|
||||
$current = (int) Cache::get($key, 1);
|
||||
Cache::put($key, $current + 1, now()->addDays(30));
|
||||
}
|
||||
|
||||
private static function dataVersionKey(string $month): string
|
||||
{
|
||||
return 'store-receipt-data-version:' . substr(trim($month), 0, 10);
|
||||
}
|
||||
|
||||
public static function markMaterialized(string $month, int $rowCount): void
|
||||
{
|
||||
$month = substr(trim($month), 0, 10);
|
||||
$connection = self::connection();
|
||||
|
||||
DB::connection($connection)->table(self::STATE_TABLE)->updateOrInsert(
|
||||
['report_month' => $month],
|
||||
[
|
||||
'rule_version' => self::ruleVersion(),
|
||||
'row_count' => $rowCount,
|
||||
'materialized_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
Cache::forget('store-receipt-materialized:' . $month . ':' . self::ruleVersion());
|
||||
Cache::forget(self::availabilityCacheKey());
|
||||
self::bumpDataVersion($month);
|
||||
unset(self::$memo[$month]);
|
||||
}
|
||||
|
||||
public static function forget(string $month): void
|
||||
{
|
||||
$month = substr(trim($month), 0, 10);
|
||||
Cache::forget('store-receipt-materialized:' . $month . ':' . self::ruleVersion());
|
||||
unset(self::$memo[$month]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 账期相邻月份(物化跨月备注规则时需要一起重算)。
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function adjacentMonths(string $month): array
|
||||
{
|
||||
$date = \Illuminate\Support\Carbon::parse(substr($month, 0, 10) . ' 00:00:00');
|
||||
|
||||
return [
|
||||
$date->copy()->subMonthNoOverflow()->format('Y-m-01'),
|
||||
$date->copy()->format('Y-m-01'),
|
||||
$date->copy()->addMonthNoOverflow()->format('Y-m-01'),
|
||||
];
|
||||
}
|
||||
}
|
||||
84
app/Business/StoreReceiptPlatformSkuMapper.php
Normal file
84
app/Business/StoreReceiptPlatformSkuMapper.php
Normal file
@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
/**
|
||||
* 平台数字 SKU → 历史已拆分内部商品编码。
|
||||
*
|
||||
* 平台 shop_sku_id 已包含平台规格维度;仅当同一 shop_sku_id + shop_i_id
|
||||
* 的历史内部编码唯一时才允许替换,多候选保持待核验。
|
||||
*/
|
||||
class StoreReceiptPlatformSkuMapper
|
||||
{
|
||||
/** @var array<string,array<string,int>> */
|
||||
private array $internalSkus = [];
|
||||
|
||||
/** @var array<string,array<string,int>> */
|
||||
private array $internalProperties = [];
|
||||
|
||||
/** @var array<string,int> */
|
||||
private array $historyRows = [];
|
||||
|
||||
/** @param iterable<object|array<string,mixed>> $historyRows */
|
||||
public function __construct(iterable $historyRows)
|
||||
{
|
||||
foreach ($historyRows as $row) {
|
||||
$shopSkuId = trim((string) $this->value($row, 'shop_sku_id'));
|
||||
$shopItemId = trim((string) $this->value($row, 'shop_i_id'));
|
||||
$skuId = trim((string) $this->value($row, 'sku_id'));
|
||||
$itemId = trim((string) $this->value($row, 'i_id'));
|
||||
if ($shopSkuId === '' || $shopItemId === '' || $skuId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 平台原始行的 i_id 等于 shop_i_id;内部拆分行的 i_id 是内部款式编码。
|
||||
if ($itemId === '' || $itemId === $shopItemId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $this->key($shopSkuId, $shopItemId);
|
||||
$this->internalSkus[$key][$skuId] = ($this->internalSkus[$key][$skuId] ?? 0) + 1;
|
||||
$property = trim((string) $this->value($row, 'properties_value'));
|
||||
if ($property !== '') {
|
||||
$this->internalProperties[$key][$property] = ($this->internalProperties[$key][$property] ?? 0) + 1;
|
||||
}
|
||||
$this->historyRows[$key] = ($this->historyRows[$key] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* status:string,
|
||||
* internal_sku:?string,
|
||||
* candidates:list<string>,
|
||||
* history_rows:int,
|
||||
* internal_properties:list<string>
|
||||
* }
|
||||
*/
|
||||
public function resolve(string $shopSkuId, string $shopItemId): array
|
||||
{
|
||||
$key = $this->key(trim($shopSkuId), trim($shopItemId));
|
||||
$skus = array_keys($this->internalSkus[$key] ?? []);
|
||||
sort($skus);
|
||||
$properties = array_keys($this->internalProperties[$key] ?? []);
|
||||
sort($properties);
|
||||
|
||||
return [
|
||||
'status' => count($skus) === 1 ? 'unique' : ($skus === [] ? 'missing' : 'ambiguous'),
|
||||
'internal_sku' => count($skus) === 1 ? $skus[0] : null,
|
||||
'candidates' => $skus,
|
||||
'history_rows' => (int) ($this->historyRows[$key] ?? 0),
|
||||
'internal_properties' => $properties,
|
||||
];
|
||||
}
|
||||
|
||||
private function key(string $shopSkuId, string $shopItemId): string
|
||||
{
|
||||
return $shopSkuId . "\0" . $shopItemId;
|
||||
}
|
||||
|
||||
private function value(object|array $row, string $key): mixed
|
||||
{
|
||||
return is_array($row) ? ($row[$key] ?? null) : ($row->{$key} ?? null);
|
||||
}
|
||||
}
|
||||
@ -4,11 +4,13 @@ namespace App\Business;
|
||||
|
||||
use App\Models\Business\StoreReceivingAccount;
|
||||
use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\StoreReportPeriodBuilder;
|
||||
|
||||
class StoreReceivingAccountSynchronizer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BusinessInformationMirrorRepository $mirror,
|
||||
private readonly StoreReportPeriodBuilder $storeReportPeriods,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -35,6 +37,15 @@ class StoreReceivingAccountSynchronizer
|
||||
$byWallet = [];
|
||||
$byPlatformAndName = [];
|
||||
$byName = [];
|
||||
$sourceStores = $this->mirror->receivingAccountStores();
|
||||
// 店铺主体跟随店铺维度报表的“存续中”期间,不再直接取店铺档案当前主体。
|
||||
$currentCompanies = $this->storeReportPeriods->currentCompanies(array_map(
|
||||
static fn (array $row): array => [
|
||||
'erp_store_name' => trim((string) ($row['erp_store_name'] ?? '')),
|
||||
'system_company_name' => trim((string) ($row['company_name'] ?? '')),
|
||||
],
|
||||
$sourceStores,
|
||||
));
|
||||
|
||||
foreach ($existing as $account) {
|
||||
$platform = (string) $account->store_platform;
|
||||
@ -49,7 +60,7 @@ class StoreReceivingAccountSynchronizer
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->mirror->receivingAccountStores() as $row) {
|
||||
foreach ($sourceStores as $row) {
|
||||
$erpStoreName = trim((string) ($row['erp_store_name'] ?? ''));
|
||||
$platform = $this->classify($erpStoreName, (string) ($row['source_platform'] ?? ''));
|
||||
|
||||
@ -75,7 +86,8 @@ class StoreReceivingAccountSynchronizer
|
||||
'company_id' => $companyId,
|
||||
'store_platform' => $platform,
|
||||
'store_name' => $erpStoreName,
|
||||
'store_entity_name' => trim((string) ($row['company_name'] ?? '')),
|
||||
'store_entity_name' => $currentCompanies[$erpStoreName]
|
||||
?? trim((string) ($row['company_name'] ?? '')),
|
||||
'shop_name' => trim((string) ($row['shop_name'] ?? '')) ?: $erpStoreName,
|
||||
'store_status' => $this->status($row['store_status'] ?? null),
|
||||
'wallet_number' => $storeId,
|
||||
|
||||
274
app/Business/StoreViolationAlertCalculator.php
Normal file
274
app/Business/StoreViolationAlertCalculator.php
Normal file
@ -0,0 +1,274 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Repositories\StoreAccountMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreViolationRuleRepository;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 店铺违规信息预警:预警级别、责任人、处理截止等派生字段的计算。
|
||||
*
|
||||
* - 级别/是否提醒:按「平台 + 违规类型」匹配违规字典;原文先去(…)括号处置说明,
|
||||
* 再做 相等/前缀/关键词 匹配;匹配不到 = 不需要提醒(只入库不推送)。
|
||||
* - 责任人:店铺账号镜像的运营负责人(与店铺维度报表同口径),
|
||||
* 未命中时回退店铺档案(ops_fys_shop_info.shopHead)。
|
||||
* - 处理截止:申诉截止日期非空→当天 23:59:59;否则按字典时效(小时)或
|
||||
* 级别默认天数(P0+1 / P1+2 / P2+3 天)的当天 23:59:59。
|
||||
*/
|
||||
class StoreViolationAlertCalculator
|
||||
{
|
||||
/** @var list<array<string,mixed>>|null */
|
||||
private ?array $rules = null;
|
||||
|
||||
/** @var array<string,array{operation_owner:string,operation_department_head:string,operation_director:string,profit_center:string}>|null */
|
||||
private ?array $ownerMap = null;
|
||||
|
||||
/** @var array<string,?string> */
|
||||
private array $ownerFallback = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly StoreViolationRuleRepository $ruleRepository,
|
||||
private readonly StoreAccountMirrorRepository $storeAccounts,
|
||||
) {
|
||||
}
|
||||
|
||||
public function flush(): void
|
||||
{
|
||||
$this->rules = null;
|
||||
$this->ownerMap = null;
|
||||
$this->ownerFallback = [];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> 预警相关字段(用于同步/导入写入) */
|
||||
public function calculate(
|
||||
string $platform,
|
||||
?string $shopName,
|
||||
?string $violationType,
|
||||
?string $violationDate,
|
||||
?string $appealDeadline,
|
||||
): array {
|
||||
$rule = self::matchRule($this->rules(), $platform, $violationType);
|
||||
$needsAlert = $rule !== null && (bool) ($rule['needs_alert'] ?? true);
|
||||
$level = null;
|
||||
if ($needsAlert) {
|
||||
$level = trim((string) ($rule['alert_level'] ?? ''));
|
||||
if ($level === '' || ! array_key_exists($level, (array) config('store_violations.alert_levels', []))) {
|
||||
$level = 'P2';
|
||||
}
|
||||
}
|
||||
|
||||
$hours = isset($rule['handling_hours']) && $rule['handling_hours'] !== null
|
||||
? (int) $rule['handling_hours']
|
||||
: null;
|
||||
|
||||
return [
|
||||
'needs_alert' => $needsAlert,
|
||||
'alert_level' => $level,
|
||||
'alert_rule_id' => $needsAlert ? ($rule['id'] ?? null) : null,
|
||||
'alert_action' => $needsAlert ? self::nullableText($rule['suggested_action'] ?? null, 500) : null,
|
||||
'alert_channel' => $needsAlert ? self::nullableText($rule['remind_channel'] ?? null, 64) : null,
|
||||
'handling_hours' => $needsAlert ? $hours : null,
|
||||
'owner_name' => $this->ownerFor($shopName),
|
||||
'handling_deadline' => self::deadlineFor($violationDate, $appealDeadline, $hours, $level),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function rules(): array
|
||||
{
|
||||
if ($this->rules === null) {
|
||||
try {
|
||||
$this->rules = $this->ruleRepository->activeRules();
|
||||
} catch (Throwable) {
|
||||
$this->rules = [];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理违规类型原文:反复去掉成对括号(含处置说明)并去除空白。
|
||||
*/
|
||||
public static function cleanViolationType(?string $text): string
|
||||
{
|
||||
$text = trim((string) $text);
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$previous = null;
|
||||
while ($previous !== $text) {
|
||||
$previous = $text;
|
||||
$text = preg_replace('/[((【\[][^((【\[))】\]]*[))】\]]/u', '', $text) ?? $text;
|
||||
}
|
||||
|
||||
return preg_replace('/[\s\x{3000}]+/u', '', $text) ?? $text;
|
||||
}
|
||||
|
||||
private static function loose(string $text): string
|
||||
{
|
||||
return preg_replace('/[\/、,,。.;;::·\-—_]+/u', '', $text) ?? $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按顺序匹配字典:相等 → 前缀 → 分词包含 → 宽松包含 → 关键词包含。
|
||||
*
|
||||
* @param list<array<string,mixed>> $rules 已按优先级排序
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public static function matchRule(array $rules, string $platform, ?string $violationType): ?array
|
||||
{
|
||||
$record = self::cleanViolationType($violationType);
|
||||
if ($record === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$looseRecord = self::loose($record);
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
if (trim((string) ($rule['platform'] ?? '')) !== $platform) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ruleType = self::cleanViolationType($rule['violation_type'] ?? null);
|
||||
if ($ruleType !== '') {
|
||||
if ($record === $ruleType || str_starts_with($record, $ruleType)) {
|
||||
return $rule;
|
||||
}
|
||||
foreach (preg_split('/[\/、]/u', $ruleType) ?: [] as $part) {
|
||||
$part = trim((string) $part);
|
||||
if ($part !== '' && mb_strlen($part) >= 2 && mb_stripos($record, $part) !== false) {
|
||||
return $rule;
|
||||
}
|
||||
}
|
||||
$looseRule = self::loose($ruleType);
|
||||
if ($looseRule !== '' && str_contains($looseRecord, $looseRule)) {
|
||||
return $rule;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (preg_split('/[|、,\/]/u', (string) ($rule['match_keywords'] ?? '')) ?: [] as $keyword) {
|
||||
$keyword = self::loose(self::cleanViolationType($keyword));
|
||||
if ($keyword !== '' && str_contains($looseRecord, $keyword)) {
|
||||
return $rule;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理截止:申诉截止优先;否则字典时效(小时)或级别默认天数(当天 23:59:59)。
|
||||
*/
|
||||
public static function deadlineFor(
|
||||
?string $violationDate,
|
||||
?string $appealDeadline,
|
||||
?int $handlingHours,
|
||||
?string $alertLevel,
|
||||
): ?string {
|
||||
$appeal = trim((string) $appealDeadline);
|
||||
if ($appeal !== '') {
|
||||
try {
|
||||
return Carbon::parse($appeal)->endOfDay()->toDateTimeString();
|
||||
} catch (Throwable) {
|
||||
// 解析失败时退回按级别计算
|
||||
}
|
||||
}
|
||||
|
||||
$date = trim((string) $violationDate);
|
||||
if ($date === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$base = Carbon::parse($date)->startOfDay();
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($handlingHours !== null && $handlingHours > 0) {
|
||||
return $base->addHours($handlingHours)->toDateTimeString();
|
||||
}
|
||||
|
||||
$level = trim((string) $alertLevel);
|
||||
if ($level === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$days = (int) (config('store_violations.alert_default_days.' . $level) ?? 0);
|
||||
|
||||
return $days > 0 ? $base->addDays($days)->endOfDay()->toDateTimeString() : null;
|
||||
}
|
||||
|
||||
/** 是否超时:处理状态不在终态且当前时间已过处理截止。 */
|
||||
public static function isOverdue(mixed $handlingStatus, mixed $handlingDeadline, ?Carbon $now = null): bool
|
||||
{
|
||||
$status = trim((string) $handlingStatus);
|
||||
if (in_array($status, ['处理完成', '已忽略', '已处理', '无需处理'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$deadline = trim((string) $handlingDeadline);
|
||||
if ($deadline === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return ($now ?? Carbon::now())->greaterThan(Carbon::parse($deadline));
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function ownerFor(?string $shopName): ?string
|
||||
{
|
||||
$name = trim((string) $shopName);
|
||||
if ($name === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->ownerMap === null) {
|
||||
$this->ownerMap = $this->storeAccounts->ownershipByStoreName();
|
||||
}
|
||||
$key = StoreAccountMirrorRepository::storeNameKey($name);
|
||||
$owner = trim((string) ($this->ownerMap[$key]['operation_owner'] ?? ''));
|
||||
if ($owner !== '') {
|
||||
return mb_substr($owner, 0, 64);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
$this->ownerMap ??= [];
|
||||
}
|
||||
|
||||
// 兜底:店铺档案(经营分析副本库)的店铺运营负责人。
|
||||
if (array_key_exists($name, $this->ownerFallback)) {
|
||||
return $this->ownerFallback[$name];
|
||||
}
|
||||
|
||||
$head = null;
|
||||
try {
|
||||
$row = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
|
||||
->table('ops_fys_shop_info')
|
||||
->where('shopName', $name)
|
||||
->orderByDesc('id')
|
||||
->first(['shopHead']);
|
||||
$head = self::nullableText($row->shopHead ?? null, 64);
|
||||
} catch (Throwable) {
|
||||
$head = null;
|
||||
}
|
||||
|
||||
return $this->ownerFallback[$name] = $head;
|
||||
}
|
||||
|
||||
private static function nullableText(mixed $value, int $limit): ?string
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
|
||||
return $text === '' ? null : mb_substr($text, 0, $limit);
|
||||
}
|
||||
}
|
||||
151
app/Business/StoreViolationMapper.php
Normal file
151
app/Business/StoreViolationMapper.php
Normal file
@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use InvalidArgumentException;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
|
||||
/**
|
||||
* 店铺违规信息预警:上游抓取数据与导入文件到统一表头的映射。
|
||||
*/
|
||||
class StoreViolationMapper
|
||||
{
|
||||
public function platformForSourceValue(mixed $value): ?string
|
||||
{
|
||||
$normalized = mb_strtolower(trim((string) $value));
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ((array) config('store_violations.platforms', []) as $slug => $platform) {
|
||||
if ($normalized === $slug) {
|
||||
return $slug;
|
||||
}
|
||||
|
||||
foreach ($platform['source_values'] ?? [] as $sourceValue) {
|
||||
if ($normalized === mb_strtolower(trim((string) $sourceValue))) {
|
||||
return $slug;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function categoryForContent(?string $content): string
|
||||
{
|
||||
$content = trim((string) $content);
|
||||
if ($content === '') {
|
||||
return (string) config('store_violations.default_category', '其他');
|
||||
}
|
||||
|
||||
foreach ((array) config('store_violations.category_rules', []) as $category => $keywords) {
|
||||
foreach ((array) $keywords as $keyword) {
|
||||
if ($keyword !== '' && mb_stripos($content, (string) $keyword) !== false) {
|
||||
return (string) $category;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (string) config('store_violations.default_category', '其他');
|
||||
}
|
||||
|
||||
/**
|
||||
* 上游抓取行(rpt.crawl_shop_violation)→ 统一字段。
|
||||
*
|
||||
* @param array<string,mixed> $row
|
||||
* @return array<string,mixed>|null 平台无法识别时返回 null
|
||||
*/
|
||||
public function mapCrawlRow(array $row): ?array
|
||||
{
|
||||
$platform = $this->platformForSourceValue($row['platform'] ?? null);
|
||||
if ($platform === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = trim((string) ($row['violation_content'] ?? ''));
|
||||
|
||||
return [
|
||||
'platform' => $platform,
|
||||
'shop_name' => mb_substr(trim((string) ($row['shop_name'] ?? '')), 0, 191),
|
||||
'violation_type' => $content === '' ? null : mb_substr($content, 0, 500),
|
||||
'violation_category' => $this->categoryForContent($content),
|
||||
// 上游视图补齐这两列后由同步命令自动带出(见 SyncStoreViolations 的列探测)。
|
||||
'violation_reason' => $this->normalizeLabel($row['violation_reason'] ?? null, 500),
|
||||
'disposition' => $this->normalizeLabel($row['disposition'] ?? null, 500),
|
||||
'violation_no' => mb_substr(trim((string) ($row['violation_no'] ?? '')), 0, 100),
|
||||
'violation_date' => $this->normalizeDate($row['violation_date'] ?? null),
|
||||
'status' => $this->normalizeLabel($row['status'] ?? null, 64),
|
||||
'appeal_deadline' => $this->normalizeDate($row['deadline'] ?? null),
|
||||
'window_name' => $this->normalizeLabel($row['window_name'] ?? null, 191),
|
||||
'crawl_date' => $this->normalizeDate($row['crawl_date'] ?? null),
|
||||
'source' => 'crawl',
|
||||
];
|
||||
}
|
||||
|
||||
public function normalizeDate(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return $value->format('Y-m-d');
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
$number = (float) $value;
|
||||
if ($number >= 20000 && $number <= 80000) {
|
||||
return ExcelDate::excelToDateTimeObject($number)->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
$text = trim((string) $value);
|
||||
if ($text === '' || in_array($text, ['-', '—', '无'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = str_replace(['年', '月'], '-', str_replace('日', '', $text));
|
||||
$text = str_replace(['/', '.'], '-', $text);
|
||||
$text = trim(preg_replace('/\s+/', ' ', $text) ?? $text);
|
||||
|
||||
try {
|
||||
return (new DateTimeImmutable($text))->format('Y-m-d');
|
||||
} catch (\Throwable) {
|
||||
throw new InvalidArgumentException('无法识别的日期:' . $value);
|
||||
}
|
||||
}
|
||||
|
||||
public function normalizePoints(mixed $value): ?float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
$text = trim((string) $value);
|
||||
if ($text === '' || in_array($text, ['-', '—', '无', '不扣分'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = preg_replace('/(分|点|个?扣|扣除)/u', '', $text) ?? $text;
|
||||
$text = trim($text);
|
||||
if ($text === '' || ! is_numeric($text)) {
|
||||
throw new InvalidArgumentException('无法识别的扣分:' . $value);
|
||||
}
|
||||
|
||||
return (float) $text;
|
||||
}
|
||||
|
||||
private function normalizeLabel(mixed $value, int $limit): ?string
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
|
||||
return $text === '' ? null : mb_substr($text, 0, $limit);
|
||||
}
|
||||
}
|
||||
237
app/Business/StoreViolationNotifier.php
Normal file
237
app/Business/StoreViolationNotifier.php
Normal file
@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Common\Company;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Modules\EcommerceFinance\Repositories\StoreViolationPushRepository;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 店铺违规信息预警:内部群机器人推送(先留接口,配置 Webhook 后启用)。
|
||||
*
|
||||
* - P0/P1 即时推送(每天 09:00 起由定时任务检查),消息含去认领签名链接;
|
||||
* - P2 不即时推,每日 10:00 汇总成一条日报;
|
||||
* - 以违规记录为唯一对象:已推送的不再重复推送(失败可显式重试)。
|
||||
*/
|
||||
class StoreViolationNotifier
|
||||
{
|
||||
public function __construct(private readonly StoreViolationPushRepository $pushes)
|
||||
{
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
$config = (array) config('store_violations.push', []);
|
||||
|
||||
return (bool) ($config['enabled'] ?? false)
|
||||
&& trim((string) ($config['webhook'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{attempted:int,succeeded:int,failed:int}
|
||||
*/
|
||||
public function pushInstant(int $limit = 200, bool $retryFailed = false): array
|
||||
{
|
||||
$levels = array_values(array_map('strval', (array) config('store_violations.push.instant_levels', ['P0', 'P1'])));
|
||||
$candidates = $this->pushes->instantCandidates($levels, $retryFailed, $limit);
|
||||
$summary = ['attempted' => $candidates->count(), 'succeeded' => 0, 'failed' => 0];
|
||||
$ownerContacts = (array) config('store_violations.push.owner_contacts', []);
|
||||
|
||||
foreach ($candidates as $record) {
|
||||
$owner = trim((string) ($record->owner_name ?? ''));
|
||||
$atMobiles = [];
|
||||
if ($owner !== '' && ! empty($ownerContacts[$owner])) {
|
||||
$atMobiles[] = (string) $ownerContacts[$owner];
|
||||
}
|
||||
|
||||
[$title, $text] = $this->instantMessage($record);
|
||||
$pushNo = $this->pushNo();
|
||||
$now = Carbon::now();
|
||||
|
||||
try {
|
||||
$this->send($title, $text, $atMobiles);
|
||||
$this->pushes->markPushed([(int) $record->id], $now);
|
||||
$summary['succeeded']++;
|
||||
$this->pushes->log([
|
||||
'push_no' => $pushNo,
|
||||
'pushed_at' => $now,
|
||||
'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'),
|
||||
'message_type' => 'instant',
|
||||
'platform' => (string) ($record->platform ?? ''),
|
||||
'violation_no' => (string) ($record->violation_no ?? ''),
|
||||
'message_digest' => mb_substr($title, 0, 500),
|
||||
'mentioned' => $owner,
|
||||
'result' => 'success',
|
||||
'error' => null,
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
$summary['failed']++;
|
||||
$this->pushes->markPushFailed((int) $record->id, $now);
|
||||
$this->pushes->log([
|
||||
'push_no' => $pushNo,
|
||||
'pushed_at' => $now,
|
||||
'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'),
|
||||
'message_type' => 'instant',
|
||||
'platform' => (string) ($record->platform ?? ''),
|
||||
'violation_no' => (string) ($record->violation_no ?? ''),
|
||||
'message_digest' => mb_substr($title, 0, 500),
|
||||
'mentioned' => $owner,
|
||||
'result' => 'failed',
|
||||
'error' => mb_substr($exception->getMessage(), 0, 500),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{pending:array<string,int>,overdue:int,p2:int,sent:bool}
|
||||
*/
|
||||
public function pushDigest(): array
|
||||
{
|
||||
$data = $this->pushes->digestData();
|
||||
$pending = $data['pending'];
|
||||
$overdue = $data['overdue'];
|
||||
$p2Ids = $data['p2_pending']->pluck('id')->map(static fn ($id): int => (int) $id)->all();
|
||||
|
||||
$lines = [
|
||||
'### 店铺违规预警 · 每日汇总(' . Carbon::now()->format('m-d') . ')',
|
||||
'- 待处理:P0 ' . (int) ($pending['P0'] ?? 0)
|
||||
. ' 条 / P1 ' . (int) ($pending['P1'] ?? 0)
|
||||
. ' 条 / P2 ' . (int) ($pending['P2'] ?? 0) . ' 条',
|
||||
'- 已超时未认领:' . $overdue->count() . ' 条',
|
||||
];
|
||||
foreach ($overdue as $index => $record) {
|
||||
$lines[] = sprintf(
|
||||
' %d. [%s] %s(责任人:%s) · 截止 %s',
|
||||
$index + 1,
|
||||
(string) ($record->alert_level ?? ''),
|
||||
(string) ($record->shop_name ?? ''),
|
||||
trim((string) ($record->owner_name ?? '')) !== '' ? (string) $record->owner_name : '未分配',
|
||||
(string) ($record->handling_deadline ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
$now = Carbon::now();
|
||||
try {
|
||||
$this->send('店铺违规预警 · 每日汇总', implode("\n", $lines), []);
|
||||
} catch (Throwable $exception) {
|
||||
$this->pushes->log([
|
||||
'push_no' => $this->pushNo(),
|
||||
'pushed_at' => $now,
|
||||
'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'),
|
||||
'message_type' => 'daily',
|
||||
'platform' => null,
|
||||
'violation_no' => null,
|
||||
'message_digest' => mb_substr(implode(' ', array_slice($lines, 0, 3)), 0, 500),
|
||||
'mentioned' => null,
|
||||
'result' => 'failed',
|
||||
'error' => mb_substr($exception->getMessage(), 0, 500),
|
||||
]);
|
||||
|
||||
return ['pending' => $pending, 'overdue' => $overdue->count(), 'p2' => count($p2Ids), 'sent' => false];
|
||||
}
|
||||
|
||||
// 汇总成功后把已点名的 P2 记录标记为已推送;未点名的明细汇总不重复提醒。
|
||||
$this->pushes->markPushed($p2Ids, $now);
|
||||
$this->pushes->log([
|
||||
'push_no' => $this->pushNo(),
|
||||
'pushed_at' => $now,
|
||||
'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'),
|
||||
'message_type' => 'daily',
|
||||
'platform' => null,
|
||||
'violation_no' => null,
|
||||
'message_digest' => mb_substr(implode(' ', array_slice($lines, 0, 3)), 0, 500),
|
||||
'mentioned' => null,
|
||||
'result' => 'success',
|
||||
'error' => null,
|
||||
]);
|
||||
|
||||
return ['pending' => $pending, 'overdue' => $overdue->count(), 'p2' => count($p2Ids), 'sent' => true];
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string} */
|
||||
private function instantMessage(object $record): array
|
||||
{
|
||||
$level = (string) ($record->alert_level ?? '');
|
||||
$levelLabel = (string) (config('store_violations.alert_levels.' . $level . '.label') ?? $level);
|
||||
$platformLabel = (string) (config('store_violations.platforms.' . (string) ($record->platform ?? '') . '.label') ?? ($record->platform ?? ''));
|
||||
$title = '店铺违规预警(' . $levelLabel . ')';
|
||||
$lines = [
|
||||
'### 【' . $levelLabel . '】店铺违规预警',
|
||||
'- 平台/店铺:' . $platformLabel . ' / ' . (string) ($record->shop_name ?? ''),
|
||||
'- 违规类型:' . mb_substr((string) ($record->violation_type ?? ''), 0, 200),
|
||||
'- 处罚措施:' . (trim((string) ($record->disposition ?? '')) !== '' ? (string) $record->disposition : '—'),
|
||||
'- 处理建议:' . (trim((string) ($record->alert_action ?? '')) !== '' ? (string) $record->alert_action : '按平台规则处理'),
|
||||
'- 责任人:' . (trim((string) ($record->owner_name ?? '')) !== '' ? (string) $record->owner_name : '未分配'),
|
||||
'- 处理截止:' . (string) ($record->handling_deadline ?? '—'),
|
||||
];
|
||||
|
||||
$claimUrl = $this->claimUrl((int) $record->id);
|
||||
if ($claimUrl !== null) {
|
||||
$lines[] = '- [去认领](' . $claimUrl . ')(认领后处理状态自动变为「处理中」)';
|
||||
}
|
||||
|
||||
return [$title, implode("\n", $lines)];
|
||||
}
|
||||
|
||||
private function claimUrl(int $recordId): ?string
|
||||
{
|
||||
try {
|
||||
$companyId = (int) (Company::query()->orderBy('id')->value('id') ?? 1);
|
||||
|
||||
return URL::signedRoute('business.store-violation-alert.claim', [
|
||||
'record' => $recordId,
|
||||
'company_id' => $companyId,
|
||||
]);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> $atMobiles */
|
||||
private function send(string $title, string $text, array $atMobiles): void
|
||||
{
|
||||
$config = (array) config('store_violations.push', []);
|
||||
$webhook = trim((string) ($config['webhook'] ?? ''));
|
||||
if ($webhook === '') {
|
||||
throw new RuntimeException('推送 Webhook 未配置。');
|
||||
}
|
||||
|
||||
$url = $webhook;
|
||||
$secret = trim((string) ($config['secret'] ?? ''));
|
||||
if ($secret !== '') {
|
||||
$timestamp = (string) (int) (microtime(true) * 1000);
|
||||
$sign = urlencode(base64_encode(hash_hmac('sha256', $timestamp . "\n" . $secret, $secret, true)));
|
||||
$url .= (str_contains($url, '?') ? '&' : '?') . 'timestamp=' . $timestamp . '&sign=' . $sign;
|
||||
}
|
||||
|
||||
$response = Http::timeout(10)->post($url, [
|
||||
'msgtype' => 'markdown',
|
||||
'markdown' => ['title' => $title, 'text' => $text],
|
||||
'at' => [
|
||||
'atMobiles' => array_values($atMobiles),
|
||||
'isAtAll' => (bool) ($config['at_all'] ?? false),
|
||||
],
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException('推送接口返回 HTTP ' . $response->status());
|
||||
}
|
||||
|
||||
$body = (array) $response->json();
|
||||
if ((int) ($body['errcode'] ?? 0) !== 0) {
|
||||
throw new RuntimeException('推送接口错误:' . (string) ($body['errcode'] ?? '?') . ' ' . (string) ($body['errmsg'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
private function pushNo(): string
|
||||
{
|
||||
return 'VP' . Carbon::now()->format('ymdHis') . strtoupper(substr(bin2hex(random_bytes(4)), 0, 6));
|
||||
}
|
||||
}
|
||||
519
app/Business/StoreViolationWorkbookImporter.php
Normal file
519
app/Business/StoreViolationWorkbookImporter.php
Normal file
@ -0,0 +1,519 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 店铺违规信息预警:导入文件按统一表头映射到 store_violation_records。
|
||||
*
|
||||
* preview() 只解析和校验,不写库;commit() 按 (platform, violation_no) 幂等写入。
|
||||
*/
|
||||
class StoreViolationWorkbookImporter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StoreViolationMapper $mapper,
|
||||
private readonly StoreViolationRecordRepository $repository,
|
||||
private readonly StoreViolationAlertCalculator $alerts,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{platform:string,sheet:string,columns:list<array{key:string,label:string}>,unknown_headers:list<string>,rows:list<array>,summary:array,existing_checked:bool}
|
||||
*/
|
||||
public function preview(UploadedFile $file, string $platform): array
|
||||
{
|
||||
$platforms = (array) config('store_violations.platforms', []);
|
||||
if (! array_key_exists($platform, $platforms)) {
|
||||
throw ValidationException::withMessages(['import' => '未知平台,请刷新页面后重试。']);
|
||||
}
|
||||
|
||||
if (! $this->repository->available()) {
|
||||
throw ValidationException::withMessages(['import' => '导入功能尚未就绪:统一表尚未建立,请联系管理员完成迁移。']);
|
||||
}
|
||||
|
||||
try {
|
||||
$spreadsheet = IOFactory::load($file->getRealPath());
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
throw ValidationException::withMessages(['import' => '无法读取该文件,请上传 xlsx / xls / csv 格式的表格。']);
|
||||
}
|
||||
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
$matrix = $worksheet->toArray(null, true, true, false);
|
||||
|
||||
[$headerIndex, $columnMap, $unknownHeaders] = $this->detectHeaders($matrix);
|
||||
if ($headerIndex === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '未找到可识别的表头行,请使用统一表头或下载导入模板。',
|
||||
]);
|
||||
}
|
||||
|
||||
$missing = [];
|
||||
foreach (['shop_name', 'violation_no'] as $required) {
|
||||
if (! in_array($required, $columnMap, true)) {
|
||||
$missing[] = $this->labelFor($required);
|
||||
}
|
||||
}
|
||||
if ($missing !== []) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '缺少必填列:' . implode('、', $missing) . '。请使用统一表头或下载导入模板。',
|
||||
]);
|
||||
}
|
||||
|
||||
$maxRows = (int) config('store_violations.import.max_rows', 20000);
|
||||
$dataRows = array_slice($matrix, $headerIndex + 1);
|
||||
if (count($dataRows) > $maxRows) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '单次最多导入 ' . number_format($maxRows) . ' 行,请拆分文件后重试。',
|
||||
]);
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$seen = [];
|
||||
$invalid = 0;
|
||||
|
||||
foreach ($dataRows as $offset => $row) {
|
||||
$rowNumber = $headerIndex + 2 + $offset;
|
||||
$raw = $this->rowToFields($row, $columnMap);
|
||||
if ($this->isEmptyRow($raw)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$item = $this->normalizeRow($raw, $platform, $rowNumber, $columnMap);
|
||||
|
||||
if ($item['status'] === 'valid') {
|
||||
$key = $platform . '|' . $item['data']['violation_no'];
|
||||
if (isset($seen[$key])) {
|
||||
$item = $seen[$key]['data'] === $item['data']
|
||||
? $this->markRow($item, 'same', '与文件内前一行重复,将跳过')
|
||||
: $this->markRow($item, 'invalid', '与文件内前一行的数据冲突,请先合并');
|
||||
} else {
|
||||
$seen[$key] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
if ($item['status'] === 'invalid') {
|
||||
$invalid++;
|
||||
}
|
||||
|
||||
$rows[] = $item;
|
||||
}
|
||||
|
||||
$summary = ['new' => 0, 'update' => 0, 'same' => 0, 'invalid' => $invalid];
|
||||
$this->compareWithExisting($rows, $platform, $summary);
|
||||
|
||||
return [
|
||||
'platform' => $platform,
|
||||
'sheet' => $worksheet->getTitle(),
|
||||
'columns' => collect((array) config('store_violations.columns', []))
|
||||
->map(fn (array $column): array => ['key' => $column['key'], 'label' => $column['label']])
|
||||
->all(),
|
||||
'unknown_headers' => $unknownHeaders,
|
||||
'provided_columns' => array_values($columnMap),
|
||||
'rows' => $rows,
|
||||
'summary' => $summary,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array{rows:list<array>} $preview */
|
||||
public function commit(array $preview): array
|
||||
{
|
||||
$platform = (string) ($preview['platform'] ?? '');
|
||||
$rows = $preview['rows'] ?? [];
|
||||
$provided = array_values(array_unique((array) ($preview['provided_columns'] ?? [])));
|
||||
$now = now();
|
||||
|
||||
$unifiedColumns = array_map(
|
||||
static fn (array $column): string => (string) $column['key'],
|
||||
(array) config('store_violations.columns', []),
|
||||
);
|
||||
$updateColumns = array_values(array_intersect($unifiedColumns, $provided));
|
||||
if (! in_array('violation_category', $updateColumns, true) && in_array('violation_type', $provided, true)) {
|
||||
$updateColumns[] = 'violation_category';
|
||||
}
|
||||
$updateColumns[] = 'source';
|
||||
$updateColumns[] = 'updated_at';
|
||||
// 预警派生字段(级别/责任人/处理截止等):导入时按当前字典自动计算写入。
|
||||
$updateColumns = array_values(array_unique(array_merge(
|
||||
$updateColumns,
|
||||
StoreViolationRecordRepository::ALERT_DERIVED_COLUMNS,
|
||||
)));
|
||||
|
||||
$payload = [];
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$status = $row['status'] ?? '';
|
||||
if (! in_array($status, ['new', 'update'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = $row['data'] ?? [];
|
||||
if (($data['platform'] ?? null) !== $platform || ($data['violation_no'] ?? '') === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matchType = trim((string) ($data['violation_type'] ?? '')) !== ''
|
||||
? (string) $data['violation_type']
|
||||
: ($data['violation_reason'] ?? null);
|
||||
$data = array_merge($data, $this->alerts->calculate(
|
||||
$platform,
|
||||
(string) ($data['shop_name'] ?? ''),
|
||||
$matchType,
|
||||
$data['violation_date'] ?? null,
|
||||
$data['appeal_deadline'] ?? null,
|
||||
));
|
||||
|
||||
foreach (array_values(array_diff($updateColumns, ['source', 'updated_at'])) as $column) {
|
||||
if (! array_key_exists($column, $data)) {
|
||||
$data[$column] = null;
|
||||
}
|
||||
}
|
||||
|
||||
$payload[] = $data + [
|
||||
'source' => 'import',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
$status === 'new' ? $created++ : $updated++;
|
||||
}
|
||||
|
||||
$affected = $this->repository->upsertRows($payload, $updateColumns);
|
||||
|
||||
return ['created' => $created, 'updated' => $updated, 'affected' => $affected];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<list<mixed>> $matrix
|
||||
* @return array{0:?int,1:list<string>,2:list<string>}
|
||||
*/
|
||||
private function detectHeaders(array $matrix): array
|
||||
{
|
||||
$bestIndex = null;
|
||||
$bestMap = [];
|
||||
$bestUnknown = [];
|
||||
$bestScore = 0;
|
||||
|
||||
foreach ($matrix as $index => $row) {
|
||||
$map = [];
|
||||
$unknown = [];
|
||||
foreach ($row as $cell) {
|
||||
$label = trim((string) $cell);
|
||||
if ($label === '') {
|
||||
continue;
|
||||
}
|
||||
$key = $this->matchHeader($label);
|
||||
if ($key === null) {
|
||||
$unknown[] = $label;
|
||||
continue;
|
||||
}
|
||||
if (! in_array($key, $map, true)) {
|
||||
$map[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
if (count($map) > $bestScore) {
|
||||
$bestIndex = $index;
|
||||
$bestMap = $map;
|
||||
$bestUnknown = $unknown;
|
||||
$bestScore = count($map);
|
||||
}
|
||||
|
||||
// 命中 6 列以上视为表头,避免把数据行误判为表头
|
||||
if ($bestScore >= 6) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($bestIndex === null || $bestScore < 2) {
|
||||
return [null, [], []];
|
||||
}
|
||||
|
||||
return [$bestIndex, $bestMap, array_values(array_unique($bestUnknown))];
|
||||
}
|
||||
|
||||
private function matchHeader(string $label): ?string
|
||||
{
|
||||
$normalized = mb_strtolower(preg_replace('/[\s"* ]/u', '', $label) ?? $label);
|
||||
|
||||
foreach ((array) config('store_violations.header_aliases', []) as $key => $aliases) {
|
||||
foreach ((array) $aliases as $alias) {
|
||||
$alias = mb_strtolower(preg_replace('/[\s"* ]/u', '', (string) $alias) ?? (string) $alias);
|
||||
if ($alias !== '' && $normalized === $alias) {
|
||||
return (string) $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<mixed> $row
|
||||
* @param list<string> $columnMap
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function rowToFields(array $row, array $columnMap): array
|
||||
{
|
||||
$fields = [];
|
||||
foreach ($row as $index => $cell) {
|
||||
$key = $columnMap[$index] ?? null;
|
||||
if ($key !== null) {
|
||||
$fields[$key] = $cell;
|
||||
}
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $fields */
|
||||
private function isEmptyRow(array $fields): bool
|
||||
{
|
||||
foreach ($fields as $value) {
|
||||
if (trim((string) $value) !== '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $raw
|
||||
* @return array{row_number:int,status:string,message:string,data:array<string,mixed>}
|
||||
*/
|
||||
private function normalizeRow(array $raw, string $platform, int $rowNumber, array $provided): array
|
||||
{
|
||||
$errors = [];
|
||||
$data = ['platform' => $platform];
|
||||
$has = static fn (string $key): bool => in_array($key, $provided, true);
|
||||
|
||||
if ($has('shop_name')) {
|
||||
$shopName = trim((string) ($raw['shop_name'] ?? ''));
|
||||
if ($shopName === '') {
|
||||
$errors[] = '店铺名不能为空';
|
||||
} elseif (mb_strlen($shopName) > 191) {
|
||||
$errors[] = '店铺名超过 191 字';
|
||||
}
|
||||
$data['shop_name'] = mb_substr($shopName, 0, 191);
|
||||
}
|
||||
|
||||
if ($has('violation_no')) {
|
||||
$violationNo = trim((string) ($raw['violation_no'] ?? ''));
|
||||
if ($violationNo === '') {
|
||||
$errors[] = '违规单号不能为空';
|
||||
} elseif (mb_strlen($violationNo) > 100) {
|
||||
$errors[] = '违规单号超过 100 字';
|
||||
}
|
||||
$data['violation_no'] = mb_substr($violationNo, 0, 100);
|
||||
}
|
||||
|
||||
$type = $has('violation_type') ? trim((string) ($raw['violation_type'] ?? '')) : null;
|
||||
if ($has('violation_type')) {
|
||||
$data['violation_type'] = $type === '' ? null : mb_substr($type, 0, 500);
|
||||
}
|
||||
|
||||
$reason = $has('violation_reason') ? trim((string) ($raw['violation_reason'] ?? '')) : null;
|
||||
if ($has('violation_reason')) {
|
||||
$data['violation_reason'] = $reason === '' ? null : mb_substr($reason, 0, 500);
|
||||
}
|
||||
|
||||
if ($has('disposition')) {
|
||||
$disposition = trim((string) ($raw['disposition'] ?? ''));
|
||||
$data['disposition'] = $disposition === '' ? null : mb_substr($disposition, 0, 500);
|
||||
}
|
||||
|
||||
if ($has('violation_category')) {
|
||||
$categorySource = ($type !== null && $type !== '') ? $type : (string) $reason;
|
||||
$category = trim((string) ($raw['violation_category'] ?? ''));
|
||||
$data['violation_category'] = $category === ''
|
||||
? $this->mapper->categoryForContent($categorySource)
|
||||
: mb_substr($category, 0, 64);
|
||||
} elseif ($has('violation_type') || $has('violation_reason')) {
|
||||
$categorySource = ($type !== null && $type !== '') ? $type : (string) $reason;
|
||||
$data['violation_category'] = $this->mapper->categoryForContent($categorySource);
|
||||
}
|
||||
|
||||
if ($has('violation_date')) {
|
||||
try {
|
||||
$data['violation_date'] = $this->mapper->normalizeDate($raw['violation_date'] ?? null);
|
||||
} catch (Throwable $exception) {
|
||||
$errors[] = '违规日期:' . $exception->getMessage();
|
||||
$data['violation_date'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($has('points_deducted')) {
|
||||
try {
|
||||
$data['points_deducted'] = $this->mapper->normalizePoints($raw['points_deducted'] ?? null);
|
||||
} catch (Throwable $exception) {
|
||||
$errors[] = '扣分:' . $exception->getMessage();
|
||||
$data['points_deducted'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($has('status')) {
|
||||
$status = trim((string) ($raw['status'] ?? ''));
|
||||
$data['status'] = $status === '' ? null : mb_substr($status, 0, 64);
|
||||
}
|
||||
|
||||
if ($has('penalty_date')) {
|
||||
try {
|
||||
$data['penalty_date'] = $this->mapper->normalizeDate($raw['penalty_date'] ?? null);
|
||||
} catch (Throwable $exception) {
|
||||
$errors[] = '处罚日期:' . $exception->getMessage();
|
||||
$data['penalty_date'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($has('appeal_deadline')) {
|
||||
try {
|
||||
$data['appeal_deadline'] = $this->mapper->normalizeDate($raw['appeal_deadline'] ?? null);
|
||||
} catch (Throwable $exception) {
|
||||
$errors[] = '申诉截止日期:' . $exception->getMessage();
|
||||
$data['appeal_deadline'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($has('handling_status')) {
|
||||
$handling = trim((string) ($raw['handling_status'] ?? ''));
|
||||
$legacyMap = (array) config('store_violations.handling_status_legacy', []);
|
||||
$handling = (string) ($legacyMap[$handling] ?? $handling);
|
||||
$options = (array) config('store_violations.handling_statuses', []);
|
||||
if ($handling === '') {
|
||||
$data['handling_status'] = (string) config('store_violations.default_handling_status', '未处理');
|
||||
} elseif (! in_array($handling, $options, true)) {
|
||||
$errors[] = '处理状态必须是:' . implode('、', $options);
|
||||
$data['handling_status'] = $handling;
|
||||
} else {
|
||||
$data['handling_status'] = $handling;
|
||||
}
|
||||
}
|
||||
|
||||
if ($has('ignore_reason')) {
|
||||
$ignoreReason = trim((string) ($raw['ignore_reason'] ?? ''));
|
||||
$data['ignore_reason'] = $ignoreReason === '' ? null : mb_substr($ignoreReason, 0, 191);
|
||||
}
|
||||
|
||||
if (($data['handling_status'] ?? null) === '已忽略' && trim((string) ($data['ignore_reason'] ?? '')) === '') {
|
||||
$errors[] = '处理状态为“已忽略”时必须填写忽略原因';
|
||||
}
|
||||
|
||||
return [
|
||||
'row_number' => $rowNumber,
|
||||
'status' => $errors === [] ? 'valid' : 'invalid',
|
||||
'message' => $errors === [] ? '待导入' : implode(';', $errors),
|
||||
'data' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
private function markRow(array $row, string $status, string $message): array
|
||||
{
|
||||
$row['status'] = $status;
|
||||
$row['message'] = $message;
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 与库内已有记录比对:无 → new,字段完全一致 → same,否则 → update。
|
||||
*
|
||||
* @param list<array> $rows
|
||||
*/
|
||||
private function compareWithExisting(array &$rows, string $platform, array &$summary): void
|
||||
{
|
||||
$keys = collect($rows)
|
||||
->filter(fn (array $row): bool => $row['status'] === 'valid' && ($row['data']['violation_no'] ?? '') !== '')
|
||||
->pluck('data.violation_no')
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($keys->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = collect();
|
||||
foreach ($keys->chunk(500) as $chunk) {
|
||||
$existing = $existing->merge(
|
||||
DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
|
||||
->table(StoreViolationRecordRepository::TABLE)
|
||||
->where('platform', $platform)
|
||||
->whereIn('violation_no', $chunk->all())
|
||||
->get()
|
||||
->keyBy('violation_no'),
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($rows as $index => $row) {
|
||||
if ($row['status'] !== 'valid') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$record = $existing->get($row['data']['violation_no']);
|
||||
if ($record === null) {
|
||||
$summary['new']++;
|
||||
$rows[$index] = $this->markRow($row, 'new', '新增记录');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[$index] = $this->sameAsExisting($row['data'], $record)
|
||||
? $this->markRow($row, 'same', '与库内记录一致,将跳过')
|
||||
: $this->markRow($row, 'update', '库内已有该单号,将按文件内容更新');
|
||||
$rows[$index]['status'] === 'same' ? $summary['same']++ : $summary['update']++;
|
||||
}
|
||||
}
|
||||
|
||||
private function sameAsExisting(array $data, object $record): bool
|
||||
{
|
||||
$normalizePoints = static function (mixed $value): string {
|
||||
if ($value === null || $value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return number_format((float) $value, 2, '.', '');
|
||||
};
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if ($key === 'platform') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$current = $record->{$key} ?? null;
|
||||
|
||||
if ($key === 'points_deducted') {
|
||||
if ($normalizePoints($value) !== $normalizePoints($current)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trim((string) $value) !== trim((string) $current)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function labelFor(string $key): string
|
||||
{
|
||||
foreach ((array) config('store_violations.columns', []) as $column) {
|
||||
if (($column['key'] ?? null) === $key) {
|
||||
return (string) $column['label'];
|
||||
}
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
939
app/Business/SupplierSummaryInboundImporter.php
Normal file
939
app/Business/SupplierSummaryInboundImporter.php
Normal file
@ -0,0 +1,939 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\InventoryWarehouseNameMapping;
|
||||
use DateTimeImmutable;
|
||||
use DOMDocument;
|
||||
use DOMXPath;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use XMLReader;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* 供应商汇总表月度增量导入。
|
||||
*
|
||||
* 仅追加目标月份的新行,不删除其他月份。内容相同的源明细按出现次序分配
|
||||
* 稳定标识后全部保留;重复执行时只跳过已经写入的同一源行。
|
||||
* 单行字段异常会进入失败清单,不阻断其他有效行。
|
||||
*/
|
||||
class SupplierSummaryInboundImporter
|
||||
{
|
||||
private const TABLE = 'inventory_inbound_detail_rows';
|
||||
|
||||
private const DETAIL_SHEET = '明细';
|
||||
|
||||
/** @var array<string,list<string>> */
|
||||
private const REQUIRED_HEADERS = [
|
||||
'source_no' => ['来源单号'],
|
||||
'document_no' => ['单据编号(采购单号)', '单据编号(采购单号)', '单据编号'],
|
||||
'supplier' => ['供应商'],
|
||||
'item_no' => ['货号'],
|
||||
'barcode' => ['条码'],
|
||||
'spec_name' => ['规格名'],
|
||||
'quantity' => ['库存变动量'],
|
||||
'unit_price' => ['单价'],
|
||||
'amount' => ['金额'],
|
||||
'estimated_unit_price' => ['预估单价'],
|
||||
'estimated_amount' => ['预估金额'],
|
||||
'supplier_barcode' => ['供应商条码'],
|
||||
'remark' => ['备注'],
|
||||
'warehouse' => ['仓库'],
|
||||
];
|
||||
|
||||
/** @var array<string,list<string>> */
|
||||
private const OPTIONAL_HEADERS = [
|
||||
'express_name' => ['快递名称'],
|
||||
'express_no' => ['快递单号'],
|
||||
'not_sent' => ['未发出'],
|
||||
'owner_name' => ['负责人'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* status:string,
|
||||
* report_month:string,
|
||||
* source_filename:string,
|
||||
* success:int,
|
||||
* duplicates:int,
|
||||
* failed:int,
|
||||
* mapped:int,
|
||||
* total_in_table:int,
|
||||
* failure_reason_count:int,
|
||||
* failure_reasons:list<array{row:int,reason:string}>
|
||||
* }
|
||||
*/
|
||||
public function import(string $file, string $reportMonth): array
|
||||
{
|
||||
$path = realpath($file);
|
||||
if ($path === false || ! is_file($path) || ! is_readable($path)) {
|
||||
throw new RuntimeException('供应商汇总文件不存在或不可读取。');
|
||||
}
|
||||
|
||||
$month = $this->normalizeMonth($reportMonth);
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$maps = $this->shortNameMap();
|
||||
|
||||
[$reader, $sharedStrings] = $this->streamDetailSheet($path);
|
||||
|
||||
try {
|
||||
$success = 0;
|
||||
$duplicates = 0;
|
||||
$mapped = 0;
|
||||
$failed = 0;
|
||||
$reasons = [];
|
||||
$occurrences = [];
|
||||
$batch = [];
|
||||
|
||||
DB::connection($connection)->transaction(function () use (
|
||||
$connection,
|
||||
$reader,
|
||||
$sharedStrings,
|
||||
$maps,
|
||||
$month,
|
||||
&$success,
|
||||
&$duplicates,
|
||||
&$mapped,
|
||||
&$failed,
|
||||
&$reasons,
|
||||
&$occurrences,
|
||||
&$batch,
|
||||
): void {
|
||||
$flush = function () use (
|
||||
$connection,
|
||||
$month,
|
||||
&$success,
|
||||
&$duplicates,
|
||||
&$batch,
|
||||
): void {
|
||||
if ($batch === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$keys = array_column($batch, 'row_key');
|
||||
$existing = DB::connection($connection)
|
||||
->table(self::TABLE)
|
||||
->where('report_month', $month)
|
||||
->whereIn('row_key', $keys)
|
||||
->pluck('row_key')
|
||||
->map(static fn (mixed $key): string => (string) $key)
|
||||
->all();
|
||||
$existing = array_fill_keys($existing, true);
|
||||
|
||||
$insertable = [];
|
||||
foreach ($batch as $record) {
|
||||
if (isset($existing[$record['row_key']])) {
|
||||
$duplicates++;
|
||||
continue;
|
||||
}
|
||||
$insertable[] = $record;
|
||||
}
|
||||
|
||||
if ($insertable !== []) {
|
||||
$inserted = DB::connection($connection)
|
||||
->table(self::TABLE)
|
||||
->insertOrIgnore($insertable);
|
||||
$success += (int) $inserted;
|
||||
$duplicates += count($insertable) - (int) $inserted;
|
||||
}
|
||||
$batch = [];
|
||||
};
|
||||
|
||||
$headerRow = null;
|
||||
$columns = [];
|
||||
$headerCandidates = [];
|
||||
|
||||
while ($reader->read()) {
|
||||
if (
|
||||
$reader->nodeType !== XMLReader::ELEMENT
|
||||
|| $reader->name !== 'row'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rowNumber = (int) $reader->getAttribute('r');
|
||||
$values = $this->readRow($reader, $sharedStrings);
|
||||
|
||||
if ($headerRow === null) {
|
||||
$headerCandidates[$rowNumber] = $values;
|
||||
$header = $this->headerColumns($headerCandidates);
|
||||
if ($header !== null) {
|
||||
[$headerRow, $columns] = $header;
|
||||
$headerCandidates = [];
|
||||
continue;
|
||||
}
|
||||
if ($rowNumber >= 20) {
|
||||
throw new RuntimeException(
|
||||
'「明细」子表前 20 行未找到供应商汇总标准表头。',
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($rowNumber <= $headerRow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->emptyRow($values)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$record = $this->record(
|
||||
$values,
|
||||
$columns,
|
||||
$maps,
|
||||
$month,
|
||||
$rowNumber,
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$failed++;
|
||||
$reasons[] = [
|
||||
'row' => $rowNumber,
|
||||
'reason' => $exception->getMessage(),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$baseKey = (string) $record['row_key'];
|
||||
$occurrence = ($occurrences[$baseKey] ?? 0) + 1;
|
||||
$occurrences[$baseKey] = $occurrence;
|
||||
if ($occurrence > 1) {
|
||||
$record['row_key'] = hash(
|
||||
'sha256',
|
||||
$baseKey . '|occurrence:' . $occurrence,
|
||||
);
|
||||
}
|
||||
$mapped++;
|
||||
$batch[] = $record;
|
||||
|
||||
if (count($batch) >= 500) {
|
||||
$flush();
|
||||
}
|
||||
}
|
||||
|
||||
if ($headerRow === null) {
|
||||
throw new RuntimeException(
|
||||
'「明细」子表前 20 行未找到供应商汇总标准表头。',
|
||||
);
|
||||
}
|
||||
|
||||
$flush();
|
||||
});
|
||||
|
||||
$status = $failed === 0
|
||||
? 'success'
|
||||
: ($success > 0 ? 'partial' : 'failed');
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'report_month' => $month,
|
||||
'source_filename' => basename($path),
|
||||
'success' => $success,
|
||||
'duplicates' => $duplicates,
|
||||
'failed' => $failed,
|
||||
'mapped' => $mapped,
|
||||
'total_in_table' => DB::connection($connection)
|
||||
->table(self::TABLE)
|
||||
->where('report_month', $month)
|
||||
->count(),
|
||||
'failure_reason_count' => count($reasons),
|
||||
'failure_reasons' => $reasons,
|
||||
];
|
||||
} finally {
|
||||
$reader->close();
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeMonth(string $month): string
|
||||
{
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
|
||||
throw new RuntimeException('月份格式应为 YYYY-MM。');
|
||||
}
|
||||
|
||||
return $month . '-01';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<int,mixed>> $rows
|
||||
* @return array{0:int,1:array<string,int>}|null
|
||||
*/
|
||||
private function headerColumns(array $rows): ?array
|
||||
{
|
||||
ksort($rows);
|
||||
foreach ($rows as $row => $values) {
|
||||
$positions = [];
|
||||
foreach ($values as $index => $value) {
|
||||
$heading = $this->normalizeHeader(
|
||||
(string) $this->plainValue($value),
|
||||
);
|
||||
if ($heading !== '') {
|
||||
$positions[$heading][] = $index;
|
||||
}
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
foreach (self::REQUIRED_HEADERS as $field => $aliases) {
|
||||
$position = $this->firstHeaderPosition($positions, $aliases);
|
||||
if ($position !== null) {
|
||||
$columns[$field] = $position;
|
||||
}
|
||||
}
|
||||
|
||||
$createdPositions = $positions[$this->normalizeHeader('创建时间')] ?? [];
|
||||
if (count($createdPositions) >= 2) {
|
||||
$columns['created_serial'] = $createdPositions[0];
|
||||
$columns['created_at_raw'] = $createdPositions[1];
|
||||
}
|
||||
|
||||
if (count($columns) < count(self::REQUIRED_HEADERS) + 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (self::OPTIONAL_HEADERS as $field => $aliases) {
|
||||
$position = $this->firstHeaderPosition($positions, $aliases);
|
||||
if ($position !== null) {
|
||||
$columns[$field] = $position;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['created_serial', 'created_at_raw'] as $field) {
|
||||
if (! isset($columns[$field])) {
|
||||
throw new RuntimeException('「明细」表头缺少第二个「创建时间」列。');
|
||||
}
|
||||
}
|
||||
|
||||
return [$row, $columns];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param array<string,list<int>> $positions @param list<string> $aliases */
|
||||
private function firstHeaderPosition(array $positions, array $aliases): ?int
|
||||
{
|
||||
foreach ($aliases as $alias) {
|
||||
$heading = $this->normalizeHeader($alias);
|
||||
if (isset($positions[$heading][0])) {
|
||||
return $positions[$heading][0];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeHeader(string $heading): string
|
||||
{
|
||||
return preg_replace('/[\s\x{3000}]+/u', '', trim($heading)) ?? trim($heading);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $values
|
||||
* @param array<string,int> $columns
|
||||
* @param array{warehouse:array<string,string>,provider:array<string,string>} $maps
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function record(
|
||||
array $values,
|
||||
array $columns,
|
||||
array $maps,
|
||||
string $month,
|
||||
int $rowNumber,
|
||||
): array {
|
||||
$text = fn (string $field): ?string => $this->text(
|
||||
$values[$columns[$field] ?? -1] ?? null,
|
||||
);
|
||||
$number = function (string $label, string $field) use (
|
||||
$values,
|
||||
$columns,
|
||||
$rowNumber,
|
||||
): ?float {
|
||||
$value = $values[$columns[$field] ?? -1] ?? null;
|
||||
if (is_array($value)) {
|
||||
$formula = trim((string) ($value['__formula'] ?? ''));
|
||||
if ($formula !== '') {
|
||||
return $this->formulaValue(
|
||||
$label,
|
||||
$field,
|
||||
$formula,
|
||||
$rowNumber,
|
||||
$values,
|
||||
$columns,
|
||||
);
|
||||
}
|
||||
$value = $value['__cached'] ?? null;
|
||||
}
|
||||
|
||||
$text = str_replace(
|
||||
[',', ','],
|
||||
'',
|
||||
trim((string) $this->plainValue($value)),
|
||||
);
|
||||
if ($text === '') {
|
||||
return null;
|
||||
}
|
||||
if (! is_numeric($text)) {
|
||||
throw new RuntimeException("字段「{$label}」不是有效数字:" . $text);
|
||||
}
|
||||
|
||||
return (float) $text;
|
||||
};
|
||||
|
||||
$barcode = $text('barcode');
|
||||
$warehouse = $text('warehouse');
|
||||
if ($barcode === null) {
|
||||
throw new RuntimeException('商品编码(条码)为空');
|
||||
}
|
||||
if ($warehouse === null) {
|
||||
throw new RuntimeException('仓库为空');
|
||||
}
|
||||
|
||||
$shortName = $maps['warehouse'][$warehouse]
|
||||
?? $maps['provider'][$warehouse]
|
||||
?? null;
|
||||
if ($shortName === null || trim($shortName) === '') {
|
||||
throw new RuntimeException('仓库未匹配到唯一仓库简称:' . $warehouse);
|
||||
}
|
||||
|
||||
$createdRaw = $this->moment($values[$columns['created_at_raw']] ?? null);
|
||||
if ($createdRaw === null) {
|
||||
throw new RuntimeException('第二个「创建时间」为空或格式无效');
|
||||
}
|
||||
|
||||
$record = [
|
||||
'report_month' => $month,
|
||||
'source_no' => $text('source_no'),
|
||||
'document_no' => $text('document_no'),
|
||||
'created_serial' => $text('created_serial'),
|
||||
'supplier' => $text('supplier'),
|
||||
'item_no' => $text('item_no'),
|
||||
'barcode' => mb_strtoupper($barcode),
|
||||
'spec_name' => $text('spec_name'),
|
||||
'quantity' => $number('库存变动量', 'quantity'),
|
||||
'unit_price' => $number('单价', 'unit_price'),
|
||||
'amount' => $number('金额', 'amount'),
|
||||
'estimated_unit_price' => $number('预估单价', 'estimated_unit_price'),
|
||||
'estimated_amount' => $number('预估金额', 'estimated_amount'),
|
||||
'supplier_barcode' => $text('supplier_barcode'),
|
||||
'remark' => $text('remark'),
|
||||
'warehouse' => $warehouse,
|
||||
'warehouse_short_name' => $shortName,
|
||||
'created_at_raw' => $createdRaw,
|
||||
'express_name' => $text('express_name'),
|
||||
'express_no' => $text('express_no'),
|
||||
'not_sent' => $text('not_sent'),
|
||||
'owner_name' => $text('owner_name'),
|
||||
];
|
||||
|
||||
unset($record['warehouse_short_name']);
|
||||
$record['row_key'] = hash(
|
||||
'sha256',
|
||||
json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
);
|
||||
$record['warehouse_short_name'] = $shortName;
|
||||
$record['imported_at'] = now();
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅接受金额类字段当前行内的乘积公式,避免执行来源文件中的任意公式。
|
||||
*
|
||||
* @param array<int,mixed> $values
|
||||
* @param array<string,int> $columns
|
||||
*/
|
||||
private function formulaValue(
|
||||
string $label,
|
||||
string $field,
|
||||
string $formula,
|
||||
int $rowNumber,
|
||||
array $values,
|
||||
array $columns,
|
||||
): float {
|
||||
$expectedFields = match ($field) {
|
||||
'amount' => ['quantity', 'unit_price'],
|
||||
'estimated_amount' => ['estimated_unit_price', 'quantity'],
|
||||
default => [],
|
||||
};
|
||||
if ($expectedFields === []) {
|
||||
throw new RuntimeException(
|
||||
"字段「{$label}」不允许使用公式:" . $formula,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
preg_match(
|
||||
'/^=?\s*\$?([A-Z]{1,3})\$?(\d+)\s*\*\s*\$?([A-Z]{1,3})\$?(\d+)\s*$/i',
|
||||
$formula,
|
||||
$matches,
|
||||
) !== 1
|
||||
) {
|
||||
throw new RuntimeException(
|
||||
"字段「{$label}」公式无法安全识别:" . $formula,
|
||||
);
|
||||
}
|
||||
|
||||
if ((int) $matches[2] !== $rowNumber || (int) $matches[4] !== $rowNumber) {
|
||||
throw new RuntimeException(
|
||||
"字段「{$label}」公式引用了其他行:" . $formula,
|
||||
);
|
||||
}
|
||||
|
||||
$actualColumns = [
|
||||
$this->columnIndex($matches[1]),
|
||||
$this->columnIndex($matches[3]),
|
||||
];
|
||||
$expectedColumns = [
|
||||
$columns[$expectedFields[0]] + 1,
|
||||
$columns[$expectedFields[1]] + 1,
|
||||
];
|
||||
sort($actualColumns);
|
||||
sort($expectedColumns);
|
||||
if ($actualColumns !== $expectedColumns) {
|
||||
throw new RuntimeException(
|
||||
"字段「{$label}」公式与取数口径不一致:" . $formula,
|
||||
);
|
||||
}
|
||||
|
||||
$left = $this->numericReference(
|
||||
$values[$actualColumns[0] - 1] ?? null,
|
||||
$label,
|
||||
);
|
||||
$right = $this->numericReference(
|
||||
$values[$actualColumns[1] - 1] ?? null,
|
||||
$label,
|
||||
);
|
||||
|
||||
return $left * $right;
|
||||
}
|
||||
|
||||
private function numericReference(mixed $value, string $label): float
|
||||
{
|
||||
if (is_array($value)) {
|
||||
if (($value['__formula'] ?? '') !== '') {
|
||||
throw new RuntimeException(
|
||||
"字段「{$label}」的公式引用了另一个公式单元格",
|
||||
);
|
||||
}
|
||||
$value = $value['__cached'] ?? null;
|
||||
}
|
||||
|
||||
$text = str_replace(
|
||||
[',', ','],
|
||||
'',
|
||||
trim((string) $this->plainValue($value)),
|
||||
);
|
||||
if (! is_numeric($text)) {
|
||||
throw new RuntimeException(
|
||||
"字段「{$label}」的公式引用值不是有效数字:" . $text,
|
||||
);
|
||||
}
|
||||
|
||||
return (float) $text;
|
||||
}
|
||||
|
||||
/** @param array<int,mixed> $values */
|
||||
private function emptyRow(array $values): bool
|
||||
{
|
||||
foreach ($values as $value) {
|
||||
if (trim((string) $this->plainValue($value)) !== '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function text(mixed $value): ?string
|
||||
{
|
||||
$text = trim((string) $this->plainValue($value));
|
||||
|
||||
return $text === '' ? null : $text;
|
||||
}
|
||||
|
||||
private function plainValue(mixed $value): mixed
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $value['__cached'] ?? null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function moment(mixed $value): ?string
|
||||
{
|
||||
$value = $this->plainValue($value);
|
||||
if ($value === null || trim((string) $value) === '') {
|
||||
return null;
|
||||
}
|
||||
if (is_numeric($value)) {
|
||||
try {
|
||||
return ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d H:i:s');
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$value = trim((string) $value);
|
||||
foreach (['Y/m/d H:i:s', 'Y-m-d H:i:s', 'Y/m/d', 'Y-m-d'] as $format) {
|
||||
$date = DateTimeImmutable::createFromFormat($format, $value);
|
||||
if ($date instanceof DateTimeImmutable) {
|
||||
return $date->format('Y-m-d H:i:s');
|
||||
}
|
||||
}
|
||||
$timestamp = strtotime($value);
|
||||
|
||||
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
|
||||
/** @return array{warehouse:array<string,string>,provider:array<string,string>} */
|
||||
private function shortNameMap(): array
|
||||
{
|
||||
$rows = InventoryWarehouseNameMapping::withoutGlobalScopes()
|
||||
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']);
|
||||
$grouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$warehouse = trim((string) $row->warehouse);
|
||||
if ($warehouse !== '') {
|
||||
$grouped[$warehouse][] = trim((string) $row->warehouse_short_name);
|
||||
}
|
||||
}
|
||||
|
||||
$byWarehouse = [];
|
||||
foreach ($grouped as $warehouse => $names) {
|
||||
$unique = array_values(array_unique($names));
|
||||
if (count($unique) === 1 && $unique[0] !== '') {
|
||||
$byWarehouse[$warehouse] = $unique[0];
|
||||
}
|
||||
}
|
||||
|
||||
$providerGrouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$provider = trim((string) $row->warehouse_provider);
|
||||
if ($provider !== '') {
|
||||
$providerGrouped[$provider][] = [
|
||||
'warehouse' => trim((string) $row->warehouse),
|
||||
'short_name' => trim((string) $row->warehouse_short_name),
|
||||
];
|
||||
}
|
||||
}
|
||||
$byProvider = [];
|
||||
foreach ($providerGrouped as $provider => $providerRows) {
|
||||
$unique = array_values(array_unique(array_column(
|
||||
$providerRows,
|
||||
'short_name',
|
||||
)));
|
||||
if (count($unique) === 1 && $unique[0] !== '') {
|
||||
$byProvider[$provider] = $unique[0];
|
||||
continue;
|
||||
}
|
||||
|
||||
$mainShortNames = [];
|
||||
foreach ($providerRows as $providerRow) {
|
||||
if (str_contains($providerRow['warehouse'], '主仓')) {
|
||||
$mainShortNames[] = $providerRow['short_name'];
|
||||
}
|
||||
}
|
||||
$mainShortNames = array_values(array_unique($mainShortNames));
|
||||
if (count($mainShortNames) === 1 && $mainShortNames[0] !== '') {
|
||||
$byProvider[$provider] = $mainShortNames[0];
|
||||
}
|
||||
}
|
||||
|
||||
return ['warehouse' => $byWarehouse, 'provider' => $byProvider];
|
||||
}
|
||||
|
||||
/** @return array{0:XMLReader,1:list<string>} */
|
||||
private function streamDetailSheet(string $path): array
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($path) !== true) {
|
||||
throw new RuntimeException('供应商汇总文件不是有效的 XLSX 工作簿。');
|
||||
}
|
||||
|
||||
try {
|
||||
$sheetPath = $this->detailSheetPath($zip);
|
||||
$sharedStrings = $zip->locateName('xl/sharedStrings.xml') !== false
|
||||
? $this->readSharedStrings($path)
|
||||
: [];
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
return [
|
||||
$this->openZipEntry($path, $sheetPath),
|
||||
$sharedStrings,
|
||||
];
|
||||
}
|
||||
|
||||
private function detailSheetPath(ZipArchive $zip): string
|
||||
{
|
||||
$workbookXml = $zip->getFromName('xl/workbook.xml');
|
||||
$relationshipsXml = $zip->getFromName('xl/_rels/workbook.xml.rels');
|
||||
if ($workbookXml === false || $relationshipsXml === false) {
|
||||
throw new RuntimeException('供应商汇总工作簿缺少工作簿结构文件。');
|
||||
}
|
||||
|
||||
$workbook = new DOMDocument();
|
||||
$relationships = new DOMDocument();
|
||||
if (
|
||||
! $workbook->loadXML($workbookXml, LIBXML_NONET)
|
||||
|| ! $relationships->loadXML($relationshipsXml, LIBXML_NONET)
|
||||
) {
|
||||
throw new RuntimeException('供应商汇总工作簿结构无法读取。');
|
||||
}
|
||||
|
||||
$relationshipId = null;
|
||||
$xpath = new DOMXPath($workbook);
|
||||
$xpath->registerNamespace(
|
||||
'main',
|
||||
'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
|
||||
);
|
||||
$xpath->registerNamespace(
|
||||
'office',
|
||||
'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
||||
);
|
||||
foreach ($xpath->query('/main:workbook/main:sheets/main:sheet') ?: [] as $sheet) {
|
||||
if ($sheet instanceof \DOMElement
|
||||
&& $sheet->getAttribute('name') === self::DETAIL_SHEET
|
||||
) {
|
||||
$relationshipId = $sheet->getAttributeNS(
|
||||
'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
||||
'id',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($relationshipId === null || $relationshipId === '') {
|
||||
throw new RuntimeException('供应商汇总表缺少「明细」子表。');
|
||||
}
|
||||
|
||||
$target = null;
|
||||
foreach ($relationships->getElementsByTagName('Relationship') as $relationship) {
|
||||
if ($relationship->getAttribute('Id') === $relationshipId) {
|
||||
$target = $relationship->getAttribute('Target');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($target === null || $target === '') {
|
||||
throw new RuntimeException('「明细」子表的工作簿关系不存在。');
|
||||
}
|
||||
|
||||
return $this->normalizeZipPath(
|
||||
str_starts_with($target, '/') ? ltrim($target, '/') : 'xl/' . $target,
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizeZipPath(string $path): string
|
||||
{
|
||||
$segments = [];
|
||||
foreach (explode('/', str_replace('\\', '/', $path)) as $segment) {
|
||||
if ($segment === '' || $segment === '.') {
|
||||
continue;
|
||||
}
|
||||
if ($segment === '..') {
|
||||
array_pop($segments);
|
||||
continue;
|
||||
}
|
||||
$segments[] = $segment;
|
||||
}
|
||||
|
||||
return implode('/', $segments);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function readSharedStrings(string $path): array
|
||||
{
|
||||
$reader = $this->openZipEntry($path, 'xl/sharedStrings.xml');
|
||||
$strings = [];
|
||||
$current = null;
|
||||
$stringDepth = null;
|
||||
$phoneticDepth = null;
|
||||
|
||||
try {
|
||||
while ($reader->read()) {
|
||||
if (
|
||||
$reader->nodeType === XMLReader::ELEMENT
|
||||
&& $reader->name === 'si'
|
||||
) {
|
||||
$current = '';
|
||||
$stringDepth = $reader->depth;
|
||||
$phoneticDepth = null;
|
||||
continue;
|
||||
}
|
||||
if ($current === null) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
$reader->nodeType === XMLReader::ELEMENT
|
||||
&& $reader->name === 'rPh'
|
||||
) {
|
||||
$phoneticDepth = $reader->depth;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
$reader->nodeType === XMLReader::END_ELEMENT
|
||||
&& $reader->name === 'rPh'
|
||||
&& $reader->depth === $phoneticDepth
|
||||
) {
|
||||
$phoneticDepth = null;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
$reader->nodeType === XMLReader::ELEMENT
|
||||
&& $reader->name === 't'
|
||||
&& $phoneticDepth === null
|
||||
) {
|
||||
$current .= $reader->readString();
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
$reader->nodeType === XMLReader::END_ELEMENT
|
||||
&& $reader->name === 'si'
|
||||
&& $reader->depth === $stringDepth
|
||||
) {
|
||||
$strings[] = $current;
|
||||
$current = null;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$reader->close();
|
||||
}
|
||||
|
||||
return $strings;
|
||||
}
|
||||
|
||||
private function openZipEntry(string $path, string $entry): XMLReader
|
||||
{
|
||||
$reader = new XMLReader();
|
||||
$flags = LIBXML_NONET | LIBXML_COMPACT;
|
||||
if (! $reader->open("zip://{$path}#{$entry}", null, $flags)) {
|
||||
throw new RuntimeException("无法读取工作簿内容:{$entry}");
|
||||
}
|
||||
|
||||
return $reader;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $sharedStrings
|
||||
* @return array<int,mixed>
|
||||
*/
|
||||
private function readRow(XMLReader $reader, array $sharedStrings): array
|
||||
{
|
||||
$values = [];
|
||||
$rowDepth = $reader->depth;
|
||||
while ($reader->read()) {
|
||||
if (
|
||||
$reader->nodeType === XMLReader::END_ELEMENT
|
||||
&& $reader->name === 'row'
|
||||
&& $reader->depth === $rowDepth
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (
|
||||
$reader->nodeType !== XMLReader::ELEMENT
|
||||
|| $reader->name !== 'c'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reference = $reader->getAttribute('r') ?? '';
|
||||
if (
|
||||
preg_match('/^([A-Z]{1,3})\d+$/i', $reference, $matches) !== 1
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
$values[$this->columnIndex($matches[1]) - 1] = $this->readCell(
|
||||
$reader,
|
||||
$sharedStrings,
|
||||
);
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/** @param list<string> $sharedStrings */
|
||||
private function readCell(XMLReader $reader, array $sharedStrings): mixed
|
||||
{
|
||||
if ($reader->isEmptyElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$type = $reader->getAttribute('t') ?? '';
|
||||
$cellDepth = $reader->depth;
|
||||
$cached = null;
|
||||
$formula = null;
|
||||
$hasFormula = false;
|
||||
$inlineParts = [];
|
||||
$phoneticDepth = null;
|
||||
|
||||
while ($reader->read()) {
|
||||
if (
|
||||
$reader->nodeType === XMLReader::END_ELEMENT
|
||||
&& $reader->name === 'c'
|
||||
&& $reader->depth === $cellDepth
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if ($reader->nodeType === XMLReader::ELEMENT) {
|
||||
if ($reader->name === 'f') {
|
||||
$hasFormula = true;
|
||||
$formula = $reader->readString();
|
||||
continue;
|
||||
}
|
||||
if ($reader->name === 'v') {
|
||||
$cached = $reader->readString();
|
||||
continue;
|
||||
}
|
||||
if ($reader->name === 'rPh') {
|
||||
$phoneticDepth = $reader->depth;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
$reader->name === 't'
|
||||
&& $phoneticDepth === null
|
||||
) {
|
||||
$inlineParts[] = $reader->readString();
|
||||
}
|
||||
}
|
||||
if (
|
||||
$reader->nodeType === XMLReader::END_ELEMENT
|
||||
&& $reader->name === 'rPh'
|
||||
&& $reader->depth === $phoneticDepth
|
||||
) {
|
||||
$phoneticDepth = null;
|
||||
}
|
||||
}
|
||||
|
||||
$value = match ($type) {
|
||||
's' => is_numeric($cached)
|
||||
? ($sharedStrings[(int) $cached] ?? null)
|
||||
: null,
|
||||
'inlineStr' => implode('', $inlineParts),
|
||||
'b' => $cached === null ? null : ($cached === '1' ? '1' : '0'),
|
||||
default => $cached ?? implode('', $inlineParts),
|
||||
};
|
||||
|
||||
if ($hasFormula) {
|
||||
return [
|
||||
'__formula' => (string) $formula,
|
||||
'__cached' => $value,
|
||||
];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function columnIndex(string $column): int
|
||||
{
|
||||
$index = 0;
|
||||
foreach (str_split(strtoupper($column)) as $letter) {
|
||||
$index = ($index * 26) + (ord($letter) - 64);
|
||||
}
|
||||
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
298
app/Business/SystemizationTodoAuditLogger.php
Normal file
298
app/Business/SystemizationTodoAuditLogger.php
Normal file
@ -0,0 +1,298 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Auth\User;
|
||||
use App\Models\Business\SystemizationTodo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class SystemizationTodoAuditLogger
|
||||
{
|
||||
public const TABLE = 'business_systemization_todo_audit_logs';
|
||||
|
||||
public const HISTORY_LIMIT = 500;
|
||||
|
||||
public const ACTION_LABELS = [
|
||||
'create' => '新增',
|
||||
'update' => '修改',
|
||||
'delete' => '删除',
|
||||
'import' => '导入',
|
||||
];
|
||||
|
||||
public const FIELD_LABELS = [
|
||||
'category' => '类型',
|
||||
'section' => '内容',
|
||||
'title' => '待办事项',
|
||||
'owner' => '总负责人',
|
||||
'assignee' => '责任人',
|
||||
'start_date' => '开始时间',
|
||||
'start_note' => '开始时间备注',
|
||||
'due_date' => '完成时间',
|
||||
'due_note' => '完成时间备注',
|
||||
'status' => '状态',
|
||||
'completed_at' => '完成日期',
|
||||
'progress' => '完成情况',
|
||||
'notes' => '备注',
|
||||
'attachment_count' => '完成截图',
|
||||
'sort_order' => '排序位置',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function snapshot(SystemizationTodo $todo, bool $withAttachments = false): array
|
||||
{
|
||||
$snapshot = [
|
||||
'id' => (int) $todo->id,
|
||||
'category' => (string) ($todo->category ?? ''),
|
||||
'section' => (string) ($todo->section ?? ''),
|
||||
'title' => (string) ($todo->title ?? ''),
|
||||
'owner' => (string) ($todo->owner ?? ''),
|
||||
'assignee' => (string) ($todo->assignee ?? ''),
|
||||
'start_date' => $todo->start_date?->format('Y-m-d') ?? '',
|
||||
'start_note' => (string) ($todo->start_note ?? ''),
|
||||
'due_date' => $todo->due_date?->format('Y-m-d') ?? '',
|
||||
'due_note' => (string) ($todo->due_note ?? ''),
|
||||
'status' => $todo->statusLabel(),
|
||||
'completed_at' => $todo->completed_at?->format('Y-m-d') ?? '',
|
||||
'progress' => (string) ($todo->progress ?? ''),
|
||||
'notes' => (string) ($todo->notes ?? ''),
|
||||
'sort_order' => (int) $todo->sort_order,
|
||||
'source_key' => (string) ($todo->source_key ?? ''),
|
||||
];
|
||||
|
||||
if ($withAttachments) {
|
||||
$snapshot['attachment_count'] = (int) $todo->attachments()->count();
|
||||
}
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
public function record(
|
||||
string $action,
|
||||
int $companyId,
|
||||
?int $todoId,
|
||||
?array $before,
|
||||
?array $after,
|
||||
?int $userId = null,
|
||||
?string $actorName = null,
|
||||
array $meta = [],
|
||||
): void {
|
||||
$this->recordMany([[
|
||||
'company_id' => $companyId,
|
||||
'todo_id' => $todoId,
|
||||
'action' => $action,
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
'user_id' => $userId,
|
||||
'actor_name' => $actorName,
|
||||
'meta' => $meta,
|
||||
]]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{
|
||||
* company_id:int,
|
||||
* todo_id:?int,
|
||||
* action:string,
|
||||
* before:?array,
|
||||
* after:?array,
|
||||
* user_id:?int,
|
||||
* actor_name?:?string,
|
||||
* meta?:array
|
||||
* }> $events
|
||||
*/
|
||||
public function recordMany(array $events): void
|
||||
{
|
||||
if ($events === []) {
|
||||
return;
|
||||
}
|
||||
if (! $this->tableReady()) {
|
||||
throw new RuntimeException('系统化待办操作记录表尚未就绪,请先完成数据库迁移。');
|
||||
}
|
||||
|
||||
$now = $this->platformNow();
|
||||
$actorNames = [];
|
||||
$rows = [];
|
||||
foreach ($events as $event) {
|
||||
$action = (string) ($event['action'] ?? '');
|
||||
if (! array_key_exists($action, self::ACTION_LABELS)) {
|
||||
throw new RuntimeException('不支持的待办操作记录类型。');
|
||||
}
|
||||
|
||||
$userId = isset($event['user_id']) ? (int) $event['user_id'] : null;
|
||||
$actorName = trim((string) ($event['actor_name'] ?? ''));
|
||||
if ($actorName === '' && $userId !== null) {
|
||||
if (! array_key_exists($userId, $actorNames)) {
|
||||
$actorNames[$userId] = (string) (User::query()->whereKey($userId)->value('name') ?? '');
|
||||
}
|
||||
$actorName = $actorNames[$userId];
|
||||
}
|
||||
|
||||
$meta = is_array($event['meta'] ?? null) ? $event['meta'] : [];
|
||||
$rows[] = [
|
||||
'company_id' => max(1, (int) ($event['company_id'] ?? 1)),
|
||||
'todo_id' => isset($event['todo_id']) ? (int) $event['todo_id'] : null,
|
||||
'action' => $action,
|
||||
'before_json' => $this->json($event['before'] ?? null),
|
||||
'after_json' => $this->json($event['after'] ?? null),
|
||||
'actor_user_id' => $userId,
|
||||
'actor_name' => mb_substr($actorName, 0, 191),
|
||||
'extra_json' => $meta === [] ? null : $this->json($meta),
|
||||
'at' => $now->toDateTimeString(),
|
||||
'created_at' => $now->toDateTimeString(),
|
||||
'updated_at' => $now->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
|
||||
DB::table(self::TABLE)->insert($rows);
|
||||
$companyIds = array_values(array_unique(array_column($rows, 'company_id')));
|
||||
foreach ($companyIds as $companyId) {
|
||||
$maxId = (int) DB::table(self::TABLE)
|
||||
->where('company_id', $companyId)
|
||||
->max('id');
|
||||
if ($maxId > self::HISTORY_LIMIT) {
|
||||
DB::table(self::TABLE)
|
||||
->where('company_id', $companyId)
|
||||
->where('id', '<=', $maxId - self::HISTORY_LIMIT)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function history(int $companyId, int $limit = self::HISTORY_LIMIT): array
|
||||
{
|
||||
if (! $this->tableReady()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table(self::TABLE)
|
||||
->where('company_id', $companyId)
|
||||
->orderByDesc('id')
|
||||
->limit(max(1, min($limit, self::HISTORY_LIMIT)))
|
||||
->get()
|
||||
->map(fn (object $row): array => $this->serializeEvent($row))
|
||||
->all();
|
||||
}
|
||||
|
||||
public function platformNow(): Carbon
|
||||
{
|
||||
$timezone = trim((string) config('app.platform_timezone', ''));
|
||||
if ($timezone === '') {
|
||||
$timezone = (string) config('app.timezone', 'UTC');
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::now($timezone);
|
||||
} catch (Throwable) {
|
||||
return Carbon::now((string) config('app.timezone', 'UTC'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serializeEvent(object $row): array
|
||||
{
|
||||
$before = $row->before_json ? json_decode((string) $row->before_json, true) : null;
|
||||
$after = $row->after_json ? json_decode((string) $row->after_json, true) : null;
|
||||
$before = is_array($before) ? $before : null;
|
||||
$after = is_array($after) ? $after : null;
|
||||
$meta = $row->extra_json ? json_decode((string) $row->extra_json, true) : [];
|
||||
$meta = is_array($meta) ? $meta : [];
|
||||
$source = $after ?? $before ?? [];
|
||||
|
||||
return [
|
||||
'id' => (int) $row->id,
|
||||
'at' => (string) $row->at,
|
||||
'action' => (string) $row->action,
|
||||
'action_label' => self::ACTION_LABELS[(string) $row->action] ?? (string) $row->action,
|
||||
'todo_id' => $row->todo_id === null ? null : (int) $row->todo_id,
|
||||
'category' => (string) ($source['category'] ?? ''),
|
||||
'section' => (string) ($source['section'] ?? ''),
|
||||
'title' => (string) ($source['title'] ?? ''),
|
||||
'actor' => (string) ($row->actor_name ?? ''),
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
'meta' => $meta,
|
||||
'changes' => $this->changes($before, $after),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function changes(?array $before, ?array $after): array
|
||||
{
|
||||
if ($after === null && $before !== null) {
|
||||
return ['删除前:' . $this->itemLabel($before)];
|
||||
}
|
||||
if ($before === null && $after !== null) {
|
||||
return ['新增:' . $this->itemLabel($after)];
|
||||
}
|
||||
if ($before === null && $after === null) {
|
||||
return ['—'];
|
||||
}
|
||||
|
||||
$changes = [];
|
||||
foreach (self::FIELD_LABELS as $field => $label) {
|
||||
$old = $this->displayValue($field, $before[$field] ?? '');
|
||||
$new = $this->displayValue($field, $after[$field] ?? '');
|
||||
if ($old !== $new) {
|
||||
$changes[] = $label . ':' . ($old !== '' ? $old : '空')
|
||||
. ' → ' . ($new !== '' ? $new : '空');
|
||||
}
|
||||
}
|
||||
|
||||
return $changes === [] ? ['内容无变化'] : $changes;
|
||||
}
|
||||
|
||||
private function itemLabel(array $row): string
|
||||
{
|
||||
$parts = array_values(array_filter([
|
||||
trim((string) ($row['category'] ?? '')),
|
||||
trim((string) ($row['section'] ?? '')),
|
||||
trim((string) ($row['title'] ?? '')),
|
||||
], static fn (string $value): bool => $value !== ''));
|
||||
|
||||
return $parts === [] ? '未命名待办' : implode(' · ', $parts);
|
||||
}
|
||||
|
||||
private function displayValue(string $field, mixed $value): string
|
||||
{
|
||||
if ($field === 'attachment_count') {
|
||||
return max(0, (int) $value) . ' 张';
|
||||
}
|
||||
|
||||
return trim(str_replace(["\r\n", "\r"], "\n", (string) ($value ?? '')));
|
||||
}
|
||||
|
||||
private function json(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
throw new RuntimeException('待办操作记录序列化失败。');
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
private function tableReady(): bool
|
||||
{
|
||||
try {
|
||||
return DB::getSchemaBuilder()->hasTable(self::TABLE);
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
784
app/Business/SystemizationTodoWorkbookImporter.php
Normal file
784
app/Business/SystemizationTodoWorkbookImporter.php
Normal file
@ -0,0 +1,784 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\SystemizationTodo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Services\PayablesWorkbookStreamReader;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use RuntimeException;
|
||||
|
||||
class SystemizationTodoWorkbookImporter
|
||||
{
|
||||
private const REQUIRED_HEADERS = [
|
||||
'category' => '类型',
|
||||
'section' => '内容',
|
||||
'title' => '待办事项',
|
||||
'owner' => '总负责人',
|
||||
'assignee' => '责任人',
|
||||
'start' => '开始时间',
|
||||
'finish' => '完成时间',
|
||||
'progress' => '完成情况',
|
||||
'notes' => '备注',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly SystemizationTodoAuditLogger $audit,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入财务系统化待办。只新增系统中不存在的来源行,不覆盖日常维护后的状态与截图。
|
||||
*
|
||||
* @return array{created:int,skipped:int,sheet:string}
|
||||
*/
|
||||
public function import(
|
||||
string $path,
|
||||
int $companyId,
|
||||
?int $userId = null,
|
||||
?string $originalName = null,
|
||||
): array
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
throw new RuntimeException('找不到待办 Excel 文件。');
|
||||
}
|
||||
|
||||
$extension = $this->extension($path, $originalName);
|
||||
if ($extension !== 'xlsx') {
|
||||
throw new RuntimeException('系统化待办仅支持 .xlsx 文件。');
|
||||
}
|
||||
|
||||
$reader = PayablesWorkbookStreamReader::forFile($path, $extension);
|
||||
foreach ($reader->sheetNames() as $sheetIndex => $sheetName) {
|
||||
$headerInfo = $this->headerMap($reader, $sheetIndex);
|
||||
if ($headerInfo === null) {
|
||||
continue;
|
||||
}
|
||||
$header = $headerInfo['columns'];
|
||||
$headerRow = $headerInfo['row'];
|
||||
|
||||
$rows = $this->workbookRows(
|
||||
$reader,
|
||||
$sheetIndex,
|
||||
$sheetName,
|
||||
$header,
|
||||
$headerRow,
|
||||
$companyId,
|
||||
);
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
DB::transaction(function () use (
|
||||
$rows,
|
||||
$sheetName,
|
||||
$companyId,
|
||||
$userId,
|
||||
&$created,
|
||||
&$skipped,
|
||||
): void {
|
||||
$auditEvents = [];
|
||||
foreach ($rows as $row) {
|
||||
$exists = SystemizationTodo::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->where('source_key', $row['data']['source_key'])
|
||||
->exists();
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$todo = SystemizationTodo::withoutGlobalScopes()->create([
|
||||
...$row['data'],
|
||||
'company_id' => $companyId,
|
||||
'imported_at' => now(),
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
$auditEvents[] = [
|
||||
'company_id' => $companyId,
|
||||
'todo_id' => (int) $todo->id,
|
||||
'action' => 'import',
|
||||
'before' => null,
|
||||
'after' => $this->audit->snapshot($todo),
|
||||
'user_id' => $userId,
|
||||
'meta' => [
|
||||
'source' => 'excel',
|
||||
'mode' => 'import',
|
||||
'sheet' => $sheetName,
|
||||
],
|
||||
];
|
||||
$created++;
|
||||
}
|
||||
$this->audit->recordMany($auditEvents);
|
||||
});
|
||||
|
||||
return ['created' => $created, 'skipped' => $skipped, 'sheet' => $sheetName];
|
||||
}
|
||||
|
||||
throw new RuntimeException(
|
||||
'未找到待办表头。请确认工作表包含:类型、内容、待办事项、总负责人、责任人、开始时间、完成时间、完成情况、备注。'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将新版工作簿同步到已有来源记录。保留截图和来源中已不在工作簿的事项,只新增和更新。
|
||||
*
|
||||
* @return array{created:int,updated:int,retained:int,sheet:string}
|
||||
*/
|
||||
public function sync(
|
||||
string $path,
|
||||
int $companyId,
|
||||
?int $userId = null,
|
||||
bool $dryRun = false,
|
||||
?string $originalName = null,
|
||||
): array
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
throw new RuntimeException('找不到待办 Excel 文件。');
|
||||
}
|
||||
|
||||
$extension = $this->extension($path, $originalName);
|
||||
if ($extension !== 'xlsx') {
|
||||
throw new RuntimeException('系统化待办仅支持 .xlsx 文件。');
|
||||
}
|
||||
|
||||
$reader = PayablesWorkbookStreamReader::forFile($path, $extension);
|
||||
foreach ($reader->sheetNames() as $sheetIndex => $sheetName) {
|
||||
$headerInfo = $this->headerMap($reader, $sheetIndex);
|
||||
if ($headerInfo === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows = $this->workbookRows(
|
||||
$reader,
|
||||
$sheetIndex,
|
||||
$sheetName,
|
||||
$headerInfo['columns'],
|
||||
$headerInfo['row'],
|
||||
$companyId,
|
||||
);
|
||||
$existing = SystemizationTodo::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('imported_at')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
[$matches, $newIndexes] = $this->matchRows($rows, $existing);
|
||||
$matchedIds = array_values($matches);
|
||||
$retained = $existing
|
||||
->filter(static fn (SystemizationTodo $todo): bool => ! in_array($todo->id, $matchedIds, true))
|
||||
->sortBy(static fn (SystemizationTodo $todo): string =>
|
||||
sprintf('%010d-%010d', (int) $todo->sort_order, (int) $todo->id)
|
||||
)
|
||||
->values();
|
||||
$retainedSortOrder = max(
|
||||
(int) $existing->max('sort_order'),
|
||||
...array_map(
|
||||
static fn (array $row): int => (int) $row['data']['sort_order'],
|
||||
$rows,
|
||||
),
|
||||
) + 1;
|
||||
$oldSourceKeys = $existing->pluck('source_key', 'id')->all();
|
||||
$updated = 0;
|
||||
foreach ($matches as $rowIndex => $todoId) {
|
||||
$todo = $existing->firstWhere('id', $todoId);
|
||||
if ($todo && $this->contentDiffers($todo, $rows[$rowIndex]['data'])) {
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
return [
|
||||
'created' => count($newIndexes),
|
||||
'updated' => $updated,
|
||||
'retained' => $retained->count(),
|
||||
'sheet' => $sheetName,
|
||||
];
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
DB::transaction(function () use (
|
||||
$rows,
|
||||
$matches,
|
||||
$newIndexes,
|
||||
$existing,
|
||||
$retained,
|
||||
$oldSourceKeys,
|
||||
$retainedSortOrder,
|
||||
$companyId,
|
||||
$userId,
|
||||
$sheetName,
|
||||
&$created,
|
||||
&$updated,
|
||||
): void {
|
||||
$auditEvents = [];
|
||||
SystemizationTodo::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('imported_at')
|
||||
->update(['source_key' => null]);
|
||||
|
||||
foreach ($matches as $rowIndex => $todoId) {
|
||||
$todo = $existing->firstWhere('id', $todoId);
|
||||
if (! $todo) {
|
||||
continue;
|
||||
}
|
||||
$before = $this->audit->snapshot($todo);
|
||||
$todo->source_key = null;
|
||||
$todo->syncOriginalAttribute('source_key');
|
||||
$todo->fill($rows[$rowIndex]['data']);
|
||||
$contentChanged = $todo->isDirty([
|
||||
'category',
|
||||
'section',
|
||||
'title',
|
||||
'owner',
|
||||
'assignee',
|
||||
'start_date',
|
||||
'start_note',
|
||||
'due_date',
|
||||
'due_note',
|
||||
'status',
|
||||
'completed_at',
|
||||
'progress',
|
||||
'notes',
|
||||
]);
|
||||
$todo->updated_by = $userId;
|
||||
$todo->save();
|
||||
if ($contentChanged) {
|
||||
$auditEvents[] = [
|
||||
'company_id' => $companyId,
|
||||
'todo_id' => (int) $todo->id,
|
||||
'action' => 'import',
|
||||
'before' => $before,
|
||||
'after' => $this->audit->snapshot($todo),
|
||||
'user_id' => $userId,
|
||||
'meta' => [
|
||||
'source' => 'excel',
|
||||
'mode' => 'sync',
|
||||
'sheet' => $sheetName,
|
||||
],
|
||||
];
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($newIndexes as $rowIndex) {
|
||||
$todo = SystemizationTodo::withoutGlobalScopes()->create([
|
||||
...$rows[$rowIndex]['data'],
|
||||
'company_id' => $companyId,
|
||||
'imported_at' => now(),
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
$auditEvents[] = [
|
||||
'company_id' => $companyId,
|
||||
'todo_id' => (int) $todo->id,
|
||||
'action' => 'import',
|
||||
'before' => null,
|
||||
'after' => $this->audit->snapshot($todo),
|
||||
'user_id' => $userId,
|
||||
'meta' => [
|
||||
'source' => 'excel',
|
||||
'mode' => 'sync',
|
||||
'sheet' => $sheetName,
|
||||
],
|
||||
];
|
||||
$created++;
|
||||
}
|
||||
|
||||
foreach ($retained as $todo) {
|
||||
SystemizationTodo::withoutGlobalScopes()
|
||||
->whereKey($todo->id)
|
||||
->update([
|
||||
'sort_order' => $retainedSortOrder++,
|
||||
'source_key' => $oldSourceKeys[$todo->id] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->audit->recordMany($auditEvents);
|
||||
});
|
||||
|
||||
return [
|
||||
'created' => $created,
|
||||
'updated' => $updated,
|
||||
'retained' => $retained->count(),
|
||||
'sheet' => $sheetName,
|
||||
];
|
||||
}
|
||||
|
||||
throw new RuntimeException(
|
||||
'未找到待办表头。请确认工作表包含:类型、内容、待办事项、总负责人、责任人、开始时间、完成时间、完成情况、备注。'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,int> $columns
|
||||
* @return array<int,array{
|
||||
* row:int,
|
||||
* title_index:int,
|
||||
* title_count:int,
|
||||
* source_title:string,
|
||||
* category:string,
|
||||
* section:string,
|
||||
* title:string,
|
||||
* data:array<string,mixed>
|
||||
* }>
|
||||
*/
|
||||
private function workbookRows(
|
||||
PayablesWorkbookStreamReader $reader,
|
||||
int $sheetIndex,
|
||||
string $sheetName,
|
||||
array $columns,
|
||||
int $headerRow,
|
||||
int $companyId,
|
||||
): array {
|
||||
$rows = [];
|
||||
$category = '';
|
||||
$section = '';
|
||||
foreach ($reader->rows($sheetIndex) as $rowNumber => $cells) {
|
||||
if ($rowNumber <= $headerRow) {
|
||||
continue;
|
||||
}
|
||||
$categoryValue = $this->cleanImportedLabel(
|
||||
$this->cell($cells, $columns['category'] ?? null)
|
||||
);
|
||||
if ($categoryValue !== '') {
|
||||
$category = $categoryValue;
|
||||
}
|
||||
$sectionValue = $this->cleanImportedLabel(
|
||||
$this->cell($cells, $columns['section'] ?? null)
|
||||
);
|
||||
if ($sectionValue !== '') {
|
||||
$section = $sectionValue;
|
||||
}
|
||||
|
||||
$sourceTitle = $this->cell($cells, $columns['title'] ?? null);
|
||||
if ($sourceTitle === '') {
|
||||
continue;
|
||||
}
|
||||
$titles = $this->titleSegments($sourceTitle);
|
||||
$titleCount = count($titles);
|
||||
$owner = $this->cell($cells, $columns['owner'] ?? null);
|
||||
$assignee = $this->cell($cells, $columns['assignee'] ?? null);
|
||||
$startText = $this->cell($cells, $columns['start'] ?? null);
|
||||
$finishText = $this->cell($cells, $columns['finish'] ?? null);
|
||||
[$startDate, $startNote] = $this->dateParts($startText);
|
||||
[$finishDate, $finishNote] = $this->dateParts($finishText);
|
||||
$progress = $this->cell($cells, $columns['progress'] ?? null);
|
||||
$notes = $this->cell($cells, $columns['notes'] ?? null);
|
||||
$status = $this->status($progress);
|
||||
|
||||
foreach ($titles as $offset => $title) {
|
||||
$titleIndex = $offset + 1;
|
||||
$sortOrder = ($rowNumber * 100) + $titleIndex;
|
||||
$rows[] = [
|
||||
'row' => $rowNumber,
|
||||
'title_index' => $titleIndex,
|
||||
'title_count' => $titleCount,
|
||||
'source_title' => $sourceTitle,
|
||||
'category' => $category,
|
||||
'section' => $section,
|
||||
'title' => $title,
|
||||
'data' => [
|
||||
'category' => $category !== '' ? $category : null,
|
||||
'section' => $section !== '' ? $section : null,
|
||||
'title' => $title,
|
||||
'owner' => $owner !== '' ? $owner : null,
|
||||
'assignee' => $assignee !== '' ? $assignee : null,
|
||||
'start_date' => $startDate,
|
||||
'start_note' => $startNote !== '' ? $startNote : null,
|
||||
'due_date' => $finishDate,
|
||||
'due_note' => $finishNote !== '' ? $finishNote : null,
|
||||
'status' => $status,
|
||||
'completed_at' => $status === SystemizationTodo::STATUS_COMPLETED
|
||||
? ($finishDate ?? now()->toDateString())
|
||||
: null,
|
||||
'progress' => $progress !== '' ? $progress : null,
|
||||
'notes' => $notes !== '' ? $notes : null,
|
||||
'sort_order' => $sortOrder,
|
||||
'source_key' => hash(
|
||||
'sha256',
|
||||
implode('|', [
|
||||
$companyId,
|
||||
$sheetName,
|
||||
$rowNumber,
|
||||
$titleIndex,
|
||||
$category,
|
||||
$section,
|
||||
$title,
|
||||
$owner,
|
||||
$assignee,
|
||||
])
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a cell into separate tasks only when it contains multiple numbered lines.
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private function titleSegments(string $value): array
|
||||
{
|
||||
$value = trim(str_replace(["\r\n", "\r"], "\n", $value));
|
||||
$marker = '/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u';
|
||||
$lines = preg_split('/\n/u', $value) ?: [];
|
||||
$numberedLines = array_filter(
|
||||
$lines,
|
||||
static fn (string $line): bool => preg_match($marker, trim($line)) === 1,
|
||||
);
|
||||
if (count($numberedLines) < 2) {
|
||||
return [$this->cleanImportedLabel($value)];
|
||||
}
|
||||
|
||||
$segments = [];
|
||||
$current = '';
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
if (preg_match($marker, $line) === 1) {
|
||||
if ($current !== '') {
|
||||
$segments[] = trim($current);
|
||||
}
|
||||
$current = trim((string) preg_replace($marker, '', $line));
|
||||
continue;
|
||||
}
|
||||
$current = $current === '' ? $line : $current . "\n" . $line;
|
||||
}
|
||||
if ($current !== '') {
|
||||
$segments[] = trim($current);
|
||||
}
|
||||
|
||||
return $segments !== []
|
||||
? $segments
|
||||
: [$this->cleanImportedLabel($value)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array{
|
||||
* row:int,
|
||||
* title_index:int,
|
||||
* title_count:int,
|
||||
* source_title:string,
|
||||
* category:string,
|
||||
* section:string,
|
||||
* title:string,
|
||||
* data:array<string,mixed>
|
||||
* }> $rows
|
||||
* @return array{0:array<int,int>,1:array<int,int>}
|
||||
*/
|
||||
private function matchRows(array $rows, \Illuminate\Support\Collection $existing): array
|
||||
{
|
||||
$matches = [];
|
||||
$usedIds = [];
|
||||
$candidates = [];
|
||||
foreach ($existing as $todo) {
|
||||
$candidates[$this->matchTitleKey($todo->title)][] = $todo;
|
||||
}
|
||||
|
||||
foreach ($rows as $rowIndex => $row) {
|
||||
if ($row['title_count'] < 2 || $row['title_index'] !== 1) {
|
||||
continue;
|
||||
}
|
||||
$sourceKey = $this->matchTitleKey($row['source_title']);
|
||||
$available = array_values(array_filter(
|
||||
$candidates[$sourceKey] ?? [],
|
||||
fn (SystemizationTodo $todo): bool =>
|
||||
! in_array($todo->id, $usedIds, true)
|
||||
&& $this->matchGroupKey($todo->category) === $this->matchGroupKey($row['category'])
|
||||
&& $this->matchGroupKey($todo->section) === $this->matchGroupKey($row['section']),
|
||||
));
|
||||
if ($available === []) {
|
||||
continue;
|
||||
}
|
||||
usort($available, static fn (SystemizationTodo $a, SystemizationTodo $b): int =>
|
||||
abs($a->sort_order - $row['data']['sort_order'])
|
||||
<=> abs($b->sort_order - $row['data']['sort_order'])
|
||||
);
|
||||
$matches[$rowIndex] = $available[0]->id;
|
||||
$usedIds[] = $available[0]->id;
|
||||
}
|
||||
|
||||
foreach ($rows as $rowIndex => $row) {
|
||||
if (isset($matches[$rowIndex])) {
|
||||
continue;
|
||||
}
|
||||
$key = $this->matchTitleKey($row['title']);
|
||||
$available = array_values(array_filter(
|
||||
$candidates[$key] ?? [],
|
||||
static fn (SystemizationTodo $todo): bool => ! in_array($todo->id, $usedIds, true),
|
||||
));
|
||||
if ($available === []) {
|
||||
continue;
|
||||
}
|
||||
usort($available, static fn (SystemizationTodo $a, SystemizationTodo $b): int =>
|
||||
abs($a->sort_order - $row['data']['sort_order'])
|
||||
<=> abs($b->sort_order - $row['data']['sort_order'])
|
||||
);
|
||||
$todo = $available[0];
|
||||
$matches[$rowIndex] = $todo->id;
|
||||
$usedIds[] = $todo->id;
|
||||
}
|
||||
|
||||
$remainingIds = $existing
|
||||
->pluck('id')
|
||||
->reject(static fn (int $id): bool => in_array($id, $usedIds, true))
|
||||
->values();
|
||||
$todoById = $existing->keyBy('id');
|
||||
$pairs = [];
|
||||
foreach ($rows as $rowIndex => $row) {
|
||||
if (isset($matches[$rowIndex])) {
|
||||
continue;
|
||||
}
|
||||
foreach ($remainingIds as $todoId) {
|
||||
$todo = $todoById->get($todoId);
|
||||
if (! $todo
|
||||
|| $this->matchGroupKey($todo->category) !== $this->matchGroupKey($row['category'])
|
||||
|| $this->matchGroupKey($todo->section) !== $this->matchGroupKey($row['section'])) {
|
||||
continue;
|
||||
}
|
||||
$similarity = $this->titleSimilarity((string) $todo->title, $row['title']);
|
||||
if ($similarity >= 0.55) {
|
||||
$pairs[] = [
|
||||
'rowIndex' => $rowIndex,
|
||||
'todoId' => $todoId,
|
||||
'score' => $similarity - abs($todo->sort_order - $row['data']['sort_order']) / 1000,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
usort($pairs, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);
|
||||
foreach ($pairs as $pair) {
|
||||
if (isset($matches[$pair['rowIndex']]) || in_array($pair['todoId'], $usedIds, true)) {
|
||||
continue;
|
||||
}
|
||||
$matches[$pair['rowIndex']] = $pair['todoId'];
|
||||
$usedIds[] = $pair['todoId'];
|
||||
}
|
||||
|
||||
return [
|
||||
$matches,
|
||||
array_values(array_filter(
|
||||
array_keys($rows),
|
||||
static fn (int $rowIndex): bool => ! isset($matches[$rowIndex]),
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
private function matchTitleKey(?string $value): string
|
||||
{
|
||||
$value = preg_replace(
|
||||
'/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u',
|
||||
'',
|
||||
(string) $value,
|
||||
) ?? '';
|
||||
|
||||
return $this->matchText($value);
|
||||
}
|
||||
|
||||
private function matchGroupKey(?string $value): string
|
||||
{
|
||||
return $this->matchTitleKey($this->cleanImportedLabel($value));
|
||||
}
|
||||
|
||||
private function cleanImportedLabel(?string $value): string
|
||||
{
|
||||
return trim((string) preg_replace(
|
||||
'/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u',
|
||||
'',
|
||||
(string) $value,
|
||||
));
|
||||
}
|
||||
|
||||
private function matchText(?string $value): string
|
||||
{
|
||||
return mb_strtolower(
|
||||
preg_replace('/[\s “”"\',。;、,.!?::;()()【】\[\]—-]+/u', '', (string) $value) ?? '',
|
||||
'UTF-8',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $data
|
||||
*/
|
||||
private function contentDiffers(SystemizationTodo $todo, array $data): bool
|
||||
{
|
||||
foreach ([
|
||||
'category',
|
||||
'section',
|
||||
'title',
|
||||
'owner',
|
||||
'assignee',
|
||||
'start_date',
|
||||
'start_note',
|
||||
'due_date',
|
||||
'due_note',
|
||||
'status',
|
||||
'completed_at',
|
||||
'progress',
|
||||
'notes',
|
||||
] as $field) {
|
||||
$current = $todo->{$field};
|
||||
$incoming = $data[$field] ?? null;
|
||||
if ($current instanceof \DateTimeInterface && $incoming !== null) {
|
||||
$current = $current->format('Y-m-d');
|
||||
}
|
||||
if ((string) ($current ?? '') !== (string) ($incoming ?? '')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function titleSimilarity(string $left, string $right): float
|
||||
{
|
||||
$left = $this->matchTitleKey($left);
|
||||
$right = $this->matchTitleKey($right);
|
||||
if ($left === '' || $right === '') {
|
||||
return 0.0;
|
||||
}
|
||||
if ($left === $right) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
$leftChars = mb_str_split($left);
|
||||
$rightChars = mb_str_split($right);
|
||||
$leftPairs = [];
|
||||
$rightPairs = [];
|
||||
for ($index = 0; $index < count($leftChars) - 1; $index++) {
|
||||
$leftPairs[$leftChars[$index] . $leftChars[$index + 1]] = true;
|
||||
}
|
||||
for ($index = 0; $index < count($rightChars) - 1; $index++) {
|
||||
$rightPairs[$rightChars[$index] . $rightChars[$index + 1]] = true;
|
||||
}
|
||||
$leftPairs = array_keys($leftPairs);
|
||||
$rightPairs = array_keys($rightPairs);
|
||||
$containment = str_contains($left, $right) || str_contains($right, $left) ? 0.95 : 0.0;
|
||||
|
||||
$dice = ($leftPairs === [] || $rightPairs === [])
|
||||
? 0.0
|
||||
: 2 * count(array_intersect($leftPairs, $rightPairs)) / (count($leftPairs) + count($rightPairs));
|
||||
|
||||
return max($containment, $dice);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{row:int,columns:array<string,int>}|null
|
||||
*/
|
||||
private function headerMap(PayablesWorkbookStreamReader $reader, int $sheetIndex): ?array
|
||||
{
|
||||
foreach ($reader->rows($sheetIndex, null, 12) as $rowNumber => $cells) {
|
||||
$normalized = [];
|
||||
foreach ($cells as $column => $value) {
|
||||
$value = $this->normalizeHeader($this->text($value));
|
||||
if ($value !== '') {
|
||||
$normalized[$value] = $column;
|
||||
}
|
||||
}
|
||||
|
||||
$mapped = [];
|
||||
foreach (self::REQUIRED_HEADERS as $field => $label) {
|
||||
$needle = $this->normalizeHeader($label);
|
||||
foreach ($normalized as $header => $column) {
|
||||
if ($header === $needle || str_starts_with($header, $needle)) {
|
||||
$mapped[$field] = $column;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($mapped['title'])
|
||||
&& isset($mapped['owner'])
|
||||
&& isset($mapped['assignee'])) {
|
||||
return ['row' => $rowNumber, 'columns' => $mapped];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeHeader(string $value): string
|
||||
{
|
||||
return preg_replace('/[\s ()()]+/u', '', $value) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $cells
|
||||
*/
|
||||
private function cell(array $cells, ?int $column): string
|
||||
{
|
||||
return $column === null ? '' : $this->text($cells[$column] ?? null);
|
||||
}
|
||||
|
||||
private function text(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
if (is_bool($value)) {
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
|
||||
return trim(str_replace("\u{00A0}", ' ', (string) $value));
|
||||
}
|
||||
|
||||
private function extension(string $path, ?string $originalName): string
|
||||
{
|
||||
$name = trim((string) $originalName);
|
||||
$extension = $name !== ''
|
||||
? pathinfo($name, PATHINFO_EXTENSION)
|
||||
: pathinfo($path, PATHINFO_EXTENSION);
|
||||
|
||||
return strtolower((string) $extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string|null,1:string}
|
||||
*/
|
||||
private function dateParts(string $value): array
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '' || $value === '已完成') {
|
||||
return [null, ''];
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
$serial = (float) $value;
|
||||
if ($serial >= 20000 && $serial <= 80000) {
|
||||
return [ExcelDate::excelToDateTimeObject($serial)->format('Y-m-d'), ''];
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['Y-m-d', 'Y/m/d', 'Y.m.d', 'Y年n月j日'] as $format) {
|
||||
try {
|
||||
$date = Carbon::createFromFormat($format, $value);
|
||||
if ($date !== false) {
|
||||
return [$date->format('Y-m-d'), ''];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// 不是标准日期,保留原值作为时间备注。
|
||||
}
|
||||
}
|
||||
|
||||
return [null, mb_substr($value, 0, 100)];
|
||||
}
|
||||
|
||||
private function status(string $progress): string
|
||||
{
|
||||
if (str_contains($progress, '已完成')) {
|
||||
return SystemizationTodo::STATUS_COMPLETED;
|
||||
}
|
||||
|
||||
return SystemizationTodo::STATUS_INCOMPLETE;
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,7 @@ use App\Models\Business\StoreChange;
|
||||
use App\Models\Business\StoreChangeLog;
|
||||
use App\Models\Business\StoreClosing;
|
||||
use App\Models\Business\StoreInformation;
|
||||
use App\Models\Business\ShopInfoFlow;
|
||||
use App\Models\Business\StoreInvoice;
|
||||
use App\Models\Business\StoreOpening;
|
||||
use App\Models\Business\StoreReceivingAccount;
|
||||
@ -171,6 +172,7 @@ class TableRegistry
|
||||
'fund-management/shared-company-accounts' => BankAccount::class, // 重用 BankAccount,仅类型过滤
|
||||
'billing-transactions/store-receiving-balance' => StoreReceivingAccount::class,
|
||||
'financial-operations/installation-details' => InstallationDetail::class,
|
||||
'financial-risk-control/process-statistics' => ShopInfoFlow::class,
|
||||
];
|
||||
}
|
||||
|
||||
@ -260,6 +262,7 @@ class TableRegistry
|
||||
'store-management/store-report',
|
||||
'trademark-management/information',
|
||||
'financial-operations/installation-details',
|
||||
'financial-risk-control/process-statistics',
|
||||
], true);
|
||||
}
|
||||
|
||||
|
||||
@ -20,10 +20,10 @@ class UpstreamBusinessInformation
|
||||
public function __construct(
|
||||
private readonly UpstreamQueryCacheFactory $caches,
|
||||
private readonly BusinessInformationMirrorRepository $mirror,
|
||||
private readonly InstallationDetailMirrorRepository $installationMirror,
|
||||
// 兼容仍按旧构造方式调用的代码;容器解析时会正常注入仓储。
|
||||
private readonly ?StoreReportRepository $storeReports = null,
|
||||
private readonly ?StoreAccountMirrorRepository $storeAccounts = null,
|
||||
private readonly ?InstallationDetailMirrorRepository $installationMirror = null,
|
||||
) {}
|
||||
|
||||
public function paginate(string $section, string $page, int $perPage = 50): LengthAwarePaginator
|
||||
@ -40,8 +40,13 @@ class UpstreamBusinessInformation
|
||||
'company-management/subject-operating-report' => $this->subjectOperatingReportPage($currentPage, $perPage),
|
||||
'trademark-management/information' => $this->trademarkPage($currentPage, $perPage),
|
||||
'financial-operations/installation-details' => $this->installationPage($currentPage, $perPage),
|
||||
'financial-risk-control/process-statistics' => $this->shopInfoFlowPage($currentPage, $perPage),
|
||||
default => ['data' => [], 'total' => 0],
|
||||
};
|
||||
request()->attributes->set(
|
||||
'business-page-summary-cards',
|
||||
app(BusinessSummaryCards::class)->forPage($key, $payload['summary'] ?? []),
|
||||
);
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
array_map(static fn (array $row) => (object) $row, $payload['data']),
|
||||
@ -55,9 +60,30 @@ class UpstreamBusinessInformation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 财务经营风险管控 › 流程统计:店铺申请流程(开店 / 变更 / 关店)只读镜像。
|
||||
*
|
||||
* @return array{data:list<array<string,mixed>>,total:int}
|
||||
*/
|
||||
private function shopInfoFlowPage(int $page, int $perPage): array
|
||||
{
|
||||
try {
|
||||
return app(\Modules\EcommerceFinance\Repositories\ShopInfoFlowMirrorRepository::class)->paginate(
|
||||
$page,
|
||||
$perPage,
|
||||
$this->pageFilters('financial-risk-control', 'process-statistics'),
|
||||
);
|
||||
} catch (\RuntimeException $exception) {
|
||||
report($exception);
|
||||
|
||||
throw new ServiceUnavailableHttpException(null, '店铺申请流程共享镜像尚未完成首次同步。');
|
||||
}
|
||||
}
|
||||
|
||||
private function installationPage(int $page, int $perPage): array
|
||||
{
|
||||
$payload = $this->installationMirror->paginate($page, $perPage, InstallationDetailMirrorRepository::requestFilters());
|
||||
$mirror = $this->installationMirror ?? app(InstallationDetailMirrorRepository::class);
|
||||
$payload = $mirror->paginate($page, $perPage, InstallationDetailMirrorRepository::requestFilters());
|
||||
if (! $payload['ready']) {
|
||||
throw new ServiceUnavailableHttpException(null, '安装明细镜像尚未完成首次同步');
|
||||
}
|
||||
@ -136,25 +162,230 @@ SQL);
|
||||
private function storePage(int $page, int $perPage): array
|
||||
{
|
||||
$filters = $this->pageFilters('store-management', 'basic-information');
|
||||
return $this->page(
|
||||
if ($this->mirror->ready('store-information.read')) {
|
||||
$storeFilters = array_diff_key(
|
||||
$filters,
|
||||
array_flip(['store_manager', 'department_manager', '_sort', '_direction']),
|
||||
);
|
||||
return $this->pageStoreInformation(
|
||||
$page,
|
||||
$perPage,
|
||||
$filters,
|
||||
$this->storesWithAccountOwnership(array_map(
|
||||
fn (array $row): array => $this->mapStore($row),
|
||||
$this->mirror->allStores($storeFilters),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
$payload = $this->page(
|
||||
'store-information.read',
|
||||
$page,
|
||||
$perPage,
|
||||
$filters,
|
||||
array_diff_key($filters, array_flip(['store_manager', 'department_manager'])),
|
||||
fn () => $this->snapshot('store-information.read', [
|
||||
'page' => $page,
|
||||
'perPage' => $perPage,
|
||||
]),
|
||||
false,
|
||||
);
|
||||
$rows = $this->storesWithAccountOwnership($payload['data']);
|
||||
$rows = array_values(array_filter(
|
||||
$rows,
|
||||
fn (array $row): bool => $this->storeOwnershipMatchesFilters($row, $filters),
|
||||
));
|
||||
|
||||
return ['data' => $rows, 'total' => count($rows)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 店铺基础信息页每次读取时把负责人字段覆盖为店铺账号镜像中的值。
|
||||
* 账号店铺名与 ERP 店铺名只差下划线/空格,因此统一归一后关联。
|
||||
*
|
||||
* @param list<array<string,mixed>> $rows
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function storesWithAccountOwnership(array $rows, bool $requireAccountOwnership = false): array
|
||||
{
|
||||
$ownership = $this->storeOwnership($requireAccountOwnership);
|
||||
foreach ($rows as &$row) {
|
||||
$key = StoreAccountMirrorRepository::storeNameKey($row['erp_store_name'] ?? '');
|
||||
$match = $ownership[$key] ?? null;
|
||||
if ($match === null) {
|
||||
continue;
|
||||
}
|
||||
foreach ([
|
||||
'store_manager' => $match['operation_owner'],
|
||||
'department_manager' => $match['operation_department_head'],
|
||||
] as $field => $value) {
|
||||
if ($value !== '') {
|
||||
$row[$field] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @param array<string,string|list<string>> $filters */
|
||||
private function pageStoreInformation(int $page, int $perPage, array $filters, array $rows): array
|
||||
{
|
||||
$rows = array_values(array_filter(
|
||||
$rows,
|
||||
fn (array $row): bool => $this->storeOwnershipMatchesFilters($row, $filters),
|
||||
));
|
||||
$summary = $this->storeInformationSummary($rows);
|
||||
$this->sortStoreInformation($rows, $filters);
|
||||
$total = count($rows);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return [
|
||||
'data' => array_slice($rows, $offset, $perPage),
|
||||
'total' => $total,
|
||||
'summary' => $summary,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $rows */
|
||||
private function storeInformationSummary(array $rows): array
|
||||
{
|
||||
$stores = [];
|
||||
$platforms = [];
|
||||
foreach ($rows as $row) {
|
||||
$key = trim((string) ($row['id'] ?? $row['erp_store_name'] ?? $row['store_name'] ?? ''));
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$stores[$key] = match ((string) ($row['store_status'] ?? '')) {
|
||||
'0', '正常' => '正常',
|
||||
'3', '关闭中' => '关闭中',
|
||||
'6', '已关闭', '关店' => '已关闭',
|
||||
default => (string) ($row['store_status'] ?? ''),
|
||||
};
|
||||
$platform = trim((string) ($row['platform'] ?? ''));
|
||||
if ($platform !== '') {
|
||||
$platforms[$platform] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$statusCounts = array_count_values($stores);
|
||||
|
||||
return [
|
||||
'total' => count($stores),
|
||||
'normal' => (int) ($statusCounts['正常'] ?? 0),
|
||||
'closing' => (int) ($statusCounts['关闭中'] ?? 0),
|
||||
'closed' => (int) ($statusCounts['已关闭'] ?? 0),
|
||||
'platforms' => count($platforms),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,string|list<string>> $filters */
|
||||
private function storeOwnershipMatchesFilters(array $row, array $filters): bool
|
||||
{
|
||||
foreach (['store_manager', 'department_manager'] as $key) {
|
||||
$expected = trim((string) ($filters[$key] ?? ''));
|
||||
if ($expected !== ''
|
||||
&& mb_stripos((string) ($row[$key] ?? ''), $expected) === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $rows @param array<string,string|list<string>> $filters */
|
||||
private function sortStoreInformation(array &$rows, array $filters): void
|
||||
{
|
||||
$key = (string) ($filters['_sort'] ?? '');
|
||||
if ($key === '') {
|
||||
return;
|
||||
}
|
||||
$direction = ($filters['_direction'] ?? 'asc') === 'desc' ? -1 : 1;
|
||||
usort($rows, static function (array $left, array $right) use ($key, $direction): int {
|
||||
$comparison = strnatcasecmp(
|
||||
(string) ($left[$key] ?? ''),
|
||||
(string) ($right[$key] ?? ''),
|
||||
) * $direction;
|
||||
|
||||
return $comparison !== 0
|
||||
? $comparison
|
||||
: ((int) ($right['id'] ?? 0) <=> (int) ($left['id'] ?? 0));
|
||||
});
|
||||
}
|
||||
|
||||
private function storeReportPage(int $page, int $perPage): array
|
||||
{
|
||||
$filters = StoreReportRepository::requestFilters();
|
||||
$rows = $this->storeReportRows($filters);
|
||||
|
||||
// 整列为 0 的月份视为没有销量,默认隐藏;仍可从「筛选字段」手动打开。
|
||||
request()->attributes->set(
|
||||
'store-report.default_hidden_columns',
|
||||
$this->columnsWithoutData($rows, 'sales_', zeroCountsAsData: false),
|
||||
);
|
||||
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return ['data' => array_slice($rows, $offset, $perPage), 'total' => count($rows)];
|
||||
return [
|
||||
'data' => array_slice($rows, $offset, $perPage),
|
||||
'total' => count($rows),
|
||||
'summary' => $this->storeReportSummary($rows),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $rows */
|
||||
private function storeReportSummary(array $rows): array
|
||||
{
|
||||
$normalStores = [];
|
||||
$previousMonth = now()->startOfMonth()->subMonthNoOverflow();
|
||||
$currentMonth = now()->startOfMonth();
|
||||
$previousMonthKey = 'sales_' . $previousMonth->format('m');
|
||||
$currentMonthKey = 'sales_' . $currentMonth->format('m');
|
||||
$previousSales = 0.0;
|
||||
$currentSales = 0.0;
|
||||
$previousStores = [];
|
||||
$currentStores = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if (trim((string) ($row['status'] ?? '')) !== '开店中') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = trim((string) ($row['erp_store_name'] ?? ''));
|
||||
if ($key === '') {
|
||||
$key = trim((string) ($row['store_id'] ?? $row['id'] ?? ''));
|
||||
}
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalStores[$key] = true;
|
||||
$previousValue = (float) ($row[$previousMonthKey] ?? 0);
|
||||
$currentValue = (float) ($row[$currentMonthKey] ?? 0);
|
||||
$previousSales += $previousValue;
|
||||
$currentSales += $currentValue;
|
||||
if ($previousValue > 0) {
|
||||
$previousStores[$key] = true;
|
||||
}
|
||||
if ($currentValue > 0) {
|
||||
$currentStores[$key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'normal_stores' => count($normalStores),
|
||||
'previous_month_sales' => round($previousSales, 2),
|
||||
'current_month_sales' => round($currentSales, 2),
|
||||
'previous_month_stores' => count($previousStores),
|
||||
'current_month_stores' => count($currentStores),
|
||||
'previous_month_key' => $previousMonthKey,
|
||||
'current_month_key' => $currentMonthKey,
|
||||
'previous_month_label' => $previousMonth->isSameYear($currentMonth)
|
||||
? '上月(' . $previousMonth->format('n月') . ')'
|
||||
: '上月(' . $previousMonth->format('Y年n月') . ')',
|
||||
'current_month_label' => '当月(' . $currentMonth->format('n月') . ')',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{data:array<int,array<string,mixed>>,total:int} */
|
||||
@ -257,6 +488,11 @@ SQL);
|
||||
private function subjectOperatingReportPage(int $page, int $perPage): array
|
||||
{
|
||||
$filters = $this->pageFilters('company-management', 'subject-operating-report');
|
||||
$companyStatusReady = $this->mirror->ready('company-information.read');
|
||||
$companyStatus = trim((string) request('company_status', ''));
|
||||
if (in_array($companyStatus, ['正常', '已注销'], true)) {
|
||||
$filters['company_status'] = $companyStatus;
|
||||
}
|
||||
$rows = $this->storeReportRows(array_intersect_key($filters, array_flip(['company_type'])));
|
||||
$entities = [];
|
||||
|
||||
@ -290,28 +526,177 @@ SQL);
|
||||
|
||||
$rows = array_values($entities);
|
||||
$details = $this->companyDetails(array_column($rows, 'entity'));
|
||||
try {
|
||||
$classifications = $this->mirror->companyClassificationsByNames(array_column($rows, 'entity'));
|
||||
} catch (Throwable) {
|
||||
$classifications = [];
|
||||
}
|
||||
foreach ($rows as &$row) {
|
||||
$row['company_details'] = $details[$row['entity']] ?? [];
|
||||
$row['city'] = $this->cityFromDetails($row['company_details']);
|
||||
$row['company_status'] = $row['company_details'][0]['company_status'] ?? null;
|
||||
$classification = $classifications[$row['entity']] ?? [];
|
||||
$taxType = $classification['tax_type']
|
||||
?? $row['company_details'][0]['taxpayer_category']
|
||||
?? null;
|
||||
$businessType = $classification['business_registration_type']
|
||||
?? $row['company_details'][0]['business_registration_type']
|
||||
?? null;
|
||||
$classificationStatus = trim((string) ($classification['status'] ?? ''));
|
||||
$retained = $classificationStatus !== ''
|
||||
? $classificationStatus === '保留'
|
||||
: $row['company_status'] === '正常';
|
||||
$row['company_status'] = $retained ? '正常' : '已注销';
|
||||
// Excel 台账明确有税务类型时始终展示;没有时再回退到上游公司资料。
|
||||
$row['tax_type'] = $this->normalizeTaxType($taxType) ?? '/';
|
||||
$businessType = trim((string) $businessType);
|
||||
$row['business_registration_type'] = $retained
|
||||
&& in_array($businessType, ['核定', '个人独资企业'], true)
|
||||
? $businessType
|
||||
: '/';
|
||||
}
|
||||
unset($row);
|
||||
|
||||
// 整列为空的月份默认隐藏;金额 0 仍视为有账单,不隐藏。
|
||||
request()->attributes->set(
|
||||
'subject-operating-report.default_hidden_columns',
|
||||
$this->columnsWithoutData($rows, 'income_', zeroCountsAsData: true),
|
||||
);
|
||||
|
||||
$rows = array_values(array_filter($rows, fn (array $row): bool => $this->subjectMatchesFilters($row, $filters)));
|
||||
$summary = $this->subjectSummary($rows, $companyStatusReady);
|
||||
$this->sortSubjectRows($rows, $filters);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$pageRows = array_slice($rows, $offset, $perPage);
|
||||
|
||||
return ['data' => $pageRows, 'total' => count($rows)];
|
||||
return ['data' => $pageRows, 'total' => count($rows), 'summary' => $summary];
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $rows */
|
||||
private function subjectSummary(array $rows, bool $companyStatusReady): array
|
||||
{
|
||||
$normal = 0;
|
||||
$deregistered = 0;
|
||||
$smallTaxpayers = 0;
|
||||
$generalTaxpayers = 0;
|
||||
$previousMonth = now()->startOfMonth()->subMonthNoOverflow();
|
||||
$currentMonth = now()->startOfMonth();
|
||||
$previousMonthKey = 'income_' . $previousMonth->format('m');
|
||||
$currentMonthKey = 'income_' . $currentMonth->format('m');
|
||||
$previousIncome = 0.0;
|
||||
$currentMonthIncome = 0.0;
|
||||
$previousIncomeEntities = 0;
|
||||
$currentIncomeEntities = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$status = trim((string) ($row['company_status'] ?? ''));
|
||||
if ($status === '正常') {
|
||||
$normal++;
|
||||
} elseif ($status === '已注销') {
|
||||
$deregistered++;
|
||||
}
|
||||
|
||||
if ($status !== '正常') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$taxType = (string) ($row['tax_type'] ?? '');
|
||||
if (str_contains($taxType, '小规模')) {
|
||||
$smallTaxpayers++;
|
||||
}
|
||||
if (str_contains($taxType, '一般纳税人')) {
|
||||
$generalTaxpayers++;
|
||||
}
|
||||
|
||||
$previousValue = (float) ($row[$previousMonthKey] ?? 0);
|
||||
$currentValue = (float) ($row[$currentMonthKey] ?? 0);
|
||||
$previousIncome += $previousValue;
|
||||
$currentMonthIncome += $currentValue;
|
||||
if ($previousValue > 0) {
|
||||
$previousIncomeEntities++;
|
||||
}
|
||||
if ($currentValue > 0) {
|
||||
$currentIncomeEntities++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => count($rows),
|
||||
'normal' => $normal,
|
||||
'deregistered' => $deregistered,
|
||||
'small_taxpayer' => $smallTaxpayers,
|
||||
'general_taxpayer' => $generalTaxpayers,
|
||||
'previous_income' => round($previousIncome, 2),
|
||||
'current_month_income' => round($currentMonthIncome, 2),
|
||||
'previous_income_entities' => $previousIncomeEntities,
|
||||
'current_income_entities' => $currentIncomeEntities,
|
||||
'previous_month_key' => $previousMonthKey,
|
||||
'current_month_key' => $currentMonthKey,
|
||||
'previous_month_label' => $previousMonth->isSameYear($currentMonth)
|
||||
? '上月(' . $previousMonth->format('n月') . ')'
|
||||
: '上月(' . $previousMonth->format('Y年n月') . ')',
|
||||
'current_month_label' => '当月(' . $currentMonth->format('n月') . ')',
|
||||
'company_status_ready' => $companyStatusReady,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeTaxType(mixed $value): ?string
|
||||
{
|
||||
$taxType = trim((string) $value);
|
||||
if ($taxType === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match ($taxType) {
|
||||
'小规模' => '小规模纳税人',
|
||||
'一般纳税人', '小规模纳税人', '个体工商户', '无税务登记' => $taxType,
|
||||
default => $taxType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回整列都没有数据的月份列;这类列只默认隐藏,字段和数据不会被删除。
|
||||
*
|
||||
* @param list<array<string,mixed>> $rows
|
||||
* @return list<string>
|
||||
*/
|
||||
private function columnsWithoutData(array $rows, string $prefix, bool $zeroCountsAsData): array
|
||||
{
|
||||
$columns = [];
|
||||
|
||||
for ($month = 1; $month <= 12; $month++) {
|
||||
$key = sprintf('%s%02d', $prefix, $month);
|
||||
foreach ($rows as $row) {
|
||||
$value = $row[$key] ?? null;
|
||||
if ($value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
if (! $zeroCountsAsData && (float) $value === 0.0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
continue 2;
|
||||
}
|
||||
|
||||
$columns[] = $key;
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row @param array<string,string> $filters */
|
||||
private function subjectMatchesFilters(array $row, array $filters): bool
|
||||
{
|
||||
$companyStatus = trim((string) ($filters['company_status'] ?? ''));
|
||||
if ($companyStatus !== '' && (string) ($row['company_status'] ?? '') !== $companyStatus) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (($filters['company_type'] ?? '') !== '' && (string) ($row['company_type'] ?? '') !== (string) $filters['company_type']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (['city', 'entity', 'plan', 'new_entity'] as $key) {
|
||||
foreach (['city', 'entity', 'tax_type', 'business_registration_type', 'plan', 'new_entity'] as $key) {
|
||||
$needle = trim((string) ($filters[$key] ?? ''));
|
||||
if ($needle !== '' && mb_stripos((string) ($row[$key] ?? ''), $needle) === false) {
|
||||
return false;
|
||||
@ -335,7 +720,7 @@ SQL);
|
||||
{
|
||||
$key = (string) ($filters['_sort'] ?? 'entity');
|
||||
$allowed = [
|
||||
'city', 'entity', 'company_type', 'rolling_12m_income',
|
||||
'city', 'entity', 'company_type', 'tax_type', 'business_registration_type', 'rolling_12m_income',
|
||||
...array_map(static fn (int $month): string => sprintf('income_%02d', $month), range(1, 12)),
|
||||
'can_change_entity', 'plan', 'new_entity',
|
||||
];
|
||||
@ -436,7 +821,8 @@ SQL);
|
||||
'company_status' => $row['companyStatus'] ?? $row['company_status'] ?? null,
|
||||
'financial_officer' => $row['financialManager'] ?? $row['financial_officer'] ?? null,
|
||||
'supervisor' => $row['supervisor'] ?? null,
|
||||
'taxpayer_category' => $row['taxCategoryAndType'] ?? $row['taxpayer_category'] ?? null,
|
||||
'taxpayer_category' => $row['finance_tax_type'] ?? $row['taxCategoryAndType'] ?? $row['taxpayer_category'] ?? null,
|
||||
'business_registration_type' => $row['business_registration_type'] ?? null,
|
||||
'shareholders' => $row['shareholders'] ?? null,
|
||||
]);
|
||||
}
|
||||
@ -450,14 +836,15 @@ SQL);
|
||||
$storeFilters = array_diff_key($filters, array_flip(['store_manager', 'department_manager', 'profit_center']));
|
||||
$stores = $this->allStores($storeFilters);
|
||||
foreach ($stores['data'] as &$store) {
|
||||
$match = $ownership[trim((string) ($store['erp_store_name'] ?? ''))] ?? null;
|
||||
$key = StoreAccountMirrorRepository::storeNameKey($store['erp_store_name'] ?? '');
|
||||
$match = $ownership[$key] ?? null;
|
||||
if ($match === null) {
|
||||
continue;
|
||||
}
|
||||
// 未匹配到账号或字段为空时保留经营分析副本库原值。
|
||||
foreach ([
|
||||
'store_manager' => $match['operation_owner'],
|
||||
'department_manager' => $match['operation_director'],
|
||||
'department_manager' => $match['operation_department_head'],
|
||||
'profit_center' => $match['profit_center'],
|
||||
] as $field => $value) {
|
||||
if ($value !== '') {
|
||||
@ -475,12 +862,15 @@ SQL);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,array{operation_owner:string,operation_director:string,profit_center:string}> */
|
||||
private function storeOwnership(): array
|
||||
/** @return array<string,array{operation_owner:string,operation_department_head:string,operation_director:string,profit_center:string}> */
|
||||
private function storeOwnership(bool $requireAccountOwnership = false): array
|
||||
{
|
||||
try {
|
||||
return ($this->storeAccounts ?? app(StoreAccountMirrorRepository::class))->ownershipByStoreName();
|
||||
} catch (\RuntimeException | \InvalidArgumentException $exception) {
|
||||
if ($requireAccountOwnership) {
|
||||
throw new \RuntimeException('店铺账号密码共享镜像尚未就绪,无法关联负责人。', 0, $exception);
|
||||
}
|
||||
// 账号镜像未登记或未就绪时保留经营分析副本库原值,不阻断店铺报表。
|
||||
return [];
|
||||
}
|
||||
@ -578,9 +968,16 @@ SQL);
|
||||
];
|
||||
}
|
||||
|
||||
private function page(string $scope, int $page, int $perPage, array $filters, callable $resolver): array
|
||||
private function page(
|
||||
string $scope,
|
||||
int $page,
|
||||
int $perPage,
|
||||
array $filters,
|
||||
callable $resolver,
|
||||
?bool $mirrorReady = null,
|
||||
): array
|
||||
{
|
||||
if ($this->mirror->ready($scope)) {
|
||||
if ($mirrorReady ?? $this->mirror->ready($scope)) {
|
||||
return $this->normalize($scope, $this->mirror->paginate($scope, $page, $perPage, $filters));
|
||||
}
|
||||
|
||||
@ -600,7 +997,7 @@ SQL);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
/** @return array<string,string|list<string>> */
|
||||
private function pageFilters(string $section, string $page): array
|
||||
{
|
||||
$fields = collect(config('business_navigation'))
|
||||
@ -615,7 +1012,20 @@ SQL);
|
||||
$filters = [];
|
||||
foreach ($allowed as $name) {
|
||||
foreach ([$name, $name . '_from', $name . '_to'] as $key) {
|
||||
$value = trim((string) request($key, ''));
|
||||
$raw = request($key, '');
|
||||
// 多选筛选(xxx[])传进来是数组:去重后原样带下去,由各自的仓库取并集。
|
||||
if (is_array($raw)) {
|
||||
$values = [];
|
||||
foreach ($raw as $item) {
|
||||
if (! is_scalar($item)) continue;
|
||||
$item = mb_substr(trim((string) $item), 0, 191);
|
||||
if ($item !== '') $values[$item] = true;
|
||||
if (count($values) >= 50) break;
|
||||
}
|
||||
if ($values !== []) $filters[$key] = array_keys($values);
|
||||
continue;
|
||||
}
|
||||
$value = trim((string) $raw);
|
||||
if ($value !== '') $filters[$key] = mb_substr($value, 0, 191);
|
||||
}
|
||||
}
|
||||
@ -641,6 +1051,7 @@ SQL);
|
||||
return [
|
||||
'data' => array_map($mapper, $payload['data']),
|
||||
'total' => (int) $payload['total'],
|
||||
'summary' => (array) ($payload['summary'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
@ -655,6 +1066,10 @@ SQL);
|
||||
'1' => '已注销',
|
||||
default => $row['company_status'] ?? null,
|
||||
};
|
||||
$businessType = trim((string) ($row['business_registration_type'] ?? ''));
|
||||
$row['business_registration_type'] = in_array($businessType, ['核定', '个人独资企业'], true)
|
||||
? $businessType
|
||||
: '/';
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
238
app/Business/WithdrawalBalanceAlertSynchronizer.php
Normal file
238
app/Business/WithdrawalBalanceAlertSynchronizer.php
Normal file
@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\StoreReceivingAccount;
|
||||
use App\Models\Business\Todo;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
|
||||
|
||||
class WithdrawalBalanceAlertSynchronizer
|
||||
{
|
||||
public const CATEGORY = '提现余额预警';
|
||||
|
||||
public const TODO_STATUS = '待处理';
|
||||
|
||||
public function __construct(
|
||||
private readonly JushuitanShopBalanceRepository $shopBalances,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the latest balance date for a company and persist one open todo
|
||||
* per store that has exceeded its reserve standard without any change for
|
||||
* three consecutive calendar days.
|
||||
*
|
||||
* @return array{balance_date:string,dates:list<string>,alerts:array<int,array<string,mixed>>,created:int,updated:int}
|
||||
*/
|
||||
public function syncLatest(int $companyId): array
|
||||
{
|
||||
$latest = $this->shopBalances->latestBalanceDate(now());
|
||||
if ($latest === null) {
|
||||
return [
|
||||
'balance_date' => '',
|
||||
'dates' => [],
|
||||
'alerts' => [],
|
||||
'created' => 0,
|
||||
'updated' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->syncForDate($companyId, $latest);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{balance_date:string,dates:list<string>,alerts:array<int,array<string,mixed>>,created:int,updated:int}
|
||||
*/
|
||||
public function syncForDate(int $companyId, string $balanceDate): array
|
||||
{
|
||||
$dates = self::datesEndingAt($balanceDate);
|
||||
$stores = StoreReceivingAccount::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
if ($stores->isEmpty()) {
|
||||
return [
|
||||
'balance_date' => $balanceDate,
|
||||
'dates' => $dates,
|
||||
'alerts' => [],
|
||||
'created' => 0,
|
||||
'updated' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$balancesByDate = $this->shopBalances->matchesForStoresOnDates($stores, $dates);
|
||||
$reserveStandards = $stores->mapWithKeys(
|
||||
static fn (StoreReceivingAccount $store): array => [
|
||||
(int) $store->id => WithdrawalReserveStandard::amountFor(
|
||||
$store->store_platform,
|
||||
$store->erp_store_name,
|
||||
),
|
||||
],
|
||||
)->all();
|
||||
$alerts = self::detect($stores, $balancesByDate, $reserveStandards, $dates);
|
||||
[$created, $updated] = DB::transaction(function () use ($companyId, $stores, &$alerts): array {
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
|
||||
foreach ($stores as $store) {
|
||||
$alert = $alerts[(int) $store->id] ?? null;
|
||||
if ($alert === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$title = $this->todoTitle($store);
|
||||
$description = $this->description($store, $alert);
|
||||
$todo = Todo::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->where('category', self::CATEGORY)
|
||||
->where('title', $title)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotIn('status', ['已完成', '已关闭'])
|
||||
->orderByDesc('id')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($todo === null) {
|
||||
$todo = Todo::withoutGlobalScopes()->create([
|
||||
'company_id' => $companyId,
|
||||
'title' => $title,
|
||||
'category' => self::CATEGORY,
|
||||
'priority' => '高',
|
||||
'status' => self::TODO_STATUS,
|
||||
'description' => $description,
|
||||
]);
|
||||
$created++;
|
||||
} else {
|
||||
$attributes = [
|
||||
'priority' => '高',
|
||||
'description' => $description,
|
||||
];
|
||||
if ($todo->status !== self::TODO_STATUS) {
|
||||
$attributes['status'] = self::TODO_STATUS;
|
||||
}
|
||||
if ($todo->fill($attributes)->isDirty()) {
|
||||
$todo->save();
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
$alerts[(int) $store->id]['todo_id'] = (int) $todo->id;
|
||||
}
|
||||
|
||||
return [$created, $updated];
|
||||
});
|
||||
|
||||
return [
|
||||
'balance_date' => $balanceDate,
|
||||
'dates' => $dates,
|
||||
'alerts' => $alerts,
|
||||
'created' => $created,
|
||||
'updated' => $updated,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<StoreReceivingAccount> $stores
|
||||
* @param array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>> $balancesByDate
|
||||
* @param array<int,int|null> $reserveStandards
|
||||
* @param list<string> $dates
|
||||
* @return array<int,array{balance:float,start_date:string,end_date:string,message:string}>
|
||||
*/
|
||||
public static function detect(
|
||||
iterable $stores,
|
||||
array $balancesByDate,
|
||||
array $reserveStandards,
|
||||
array $dates,
|
||||
): array {
|
||||
if (count($dates) !== 3) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$alerts = [];
|
||||
foreach ($stores as $store) {
|
||||
$reserve = $reserveStandards[(int) $store->id] ?? null;
|
||||
if ($reserve === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$balanceCents = null;
|
||||
$unchanged = true;
|
||||
foreach ($dates as $date) {
|
||||
$match = $balancesByDate[(int) $store->id][$date]
|
||||
?? ['status' => 'not_ready', 'balance' => null];
|
||||
if (($match['status'] ?? '') !== 'matched' || ($match['balance'] ?? null) === null) {
|
||||
$unchanged = false;
|
||||
break;
|
||||
}
|
||||
|
||||
$cents = (int) round((float) $match['balance'] * 100);
|
||||
if ($balanceCents === null) {
|
||||
$balanceCents = $cents;
|
||||
} elseif ($balanceCents !== $cents) {
|
||||
$unchanged = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $unchanged || $balanceCents === null || $balanceCents <= $reserve * 100) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$balance = $balanceCents / 100;
|
||||
$alerts[(int) $store->id] = [
|
||||
'balance' => $balance,
|
||||
'start_date' => $dates[0],
|
||||
'end_date' => $dates[2],
|
||||
'message' => sprintf(
|
||||
'余额连续 3 天保持 %s 元,高于预留标准 %s 元。',
|
||||
number_format($balance, 2),
|
||||
number_format($reserve, 2),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return $alerts;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function datesEndingAt(string $balanceDate): array
|
||||
{
|
||||
$end = CarbonImmutable::createFromFormat('!Y-m-d', $balanceDate);
|
||||
if (! $end instanceof CarbonImmutable || $end->format('Y-m-d') !== $balanceDate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
$end->subDays(2)->toDateString(),
|
||||
$end->subDay()->toDateString(),
|
||||
$end->toDateString(),
|
||||
];
|
||||
}
|
||||
|
||||
private function todoTitle(StoreReceivingAccount $store): string
|
||||
{
|
||||
return mb_substr(
|
||||
'提现余额连续3天未变动|' . (string) $store->store_platform . '|' . (string) $store->erp_store_name,
|
||||
0,
|
||||
255,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array{balance:float,start_date:string,end_date:string,message:string} $alert */
|
||||
private function description(StoreReceivingAccount $store, array $alert): string
|
||||
{
|
||||
return implode("\n", array_filter([
|
||||
$alert['message'],
|
||||
'统计期间:' . $alert['start_date'] . ' 至 ' . $alert['end_date'],
|
||||
'店铺平台:' . ($store->store_platform ?: '未配置'),
|
||||
'ERP店铺名称:' . ($store->erp_store_name ?: '未配置'),
|
||||
'店铺主体:' . ($store->store_entity_name ?: '未配置'),
|
||||
'平台店铺ID:' . ($store->store_id ?: '未配置'),
|
||||
'处理建议:核实店铺余额是否真实停滞,并检查是否应执行提现。',
|
||||
], static fn (string $line): bool => $line !== ''));
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\StoreReceivingAccount;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
|
||||
@ -17,7 +18,10 @@ class WithdrawalManagementReport
|
||||
'withdrawal_path',
|
||||
'withdrawal_account',
|
||||
'bank_card',
|
||||
'balance_two_days_ago',
|
||||
'balance_yesterday',
|
||||
'balance',
|
||||
'balance_alert',
|
||||
'reserve_standard',
|
||||
'withdrawal_amount',
|
||||
'compliance',
|
||||
@ -30,12 +34,15 @@ class WithdrawalManagementReport
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* filters:array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string},
|
||||
* filters:array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string,balanceDate:string},
|
||||
* platforms:list<string>,paths:list<string>,storeEntities:list<string>,rows:Collection<int,StoreReceivingAccount>,
|
||||
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>,
|
||||
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>,
|
||||
* balancesByDate:array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>>,
|
||||
* balanceDays:list<array{key:string,date:string,label:string}>,
|
||||
* alerts:array<int,array{balance:float,start_date:string,end_date:string,message:string}>,
|
||||
* reserveStandards:array<int,int|null>,withdrawalAmounts:array<int,float|null>,compliance:array<int,string|null>,
|
||||
* summary:array{source:int,matched:int,ambiguous:int,unmatched:int,not_ready:int},
|
||||
* balanceColumnLabel:string,balanceDate:string,sort:array{field:string,direction:string}
|
||||
* summary:array{source:int,matched:int,finance:int,ambiguous:int,unmatched:int,not_ready:int},
|
||||
* balanceColumnLabel:string,balanceDate:string,latestBalanceDate:?string,balanceDates:list<string>,sort:array{field:string,direction:string}
|
||||
* }
|
||||
*/
|
||||
public function results(Request $request): array
|
||||
@ -61,12 +68,33 @@ class WithdrawalManagementReport
|
||||
}
|
||||
|
||||
$today = now()->startOfDay();
|
||||
$balanceDate = $today->copy()->subDay()->toDateString();
|
||||
// 余额快照按日期长期保留:默认显示最新余额日期,也可指定历史日期回看。
|
||||
$latestBalanceDate = $this->shopBalances->latestBalanceDate($today);
|
||||
$balanceDate = $filters['balanceDate'] !== ''
|
||||
? $filters['balanceDate']
|
||||
: ($latestBalanceDate ?? $today->copy()->subDay()->toDateString());
|
||||
$labelDate = $filters['balanceDate'] !== ''
|
||||
? CarbonImmutable::parse($balanceDate)
|
||||
: $today;
|
||||
$rows = $query->orderByDesc('id')->get();
|
||||
$balances = $this->shopBalances->matchesForStores($rows, $balanceDate);
|
||||
$historyDates = WithdrawalBalanceAlertSynchronizer::datesEndingAt($balanceDate);
|
||||
$balancesByDate = $this->shopBalances->matchesForStoresOnDates($rows, $historyDates);
|
||||
$balances = array_map(
|
||||
static fn (array $dates): array => $dates[$balanceDate] ?? [
|
||||
'status' => 'not_ready',
|
||||
'balance' => null,
|
||||
'source_store_name' => null,
|
||||
'source_platform' => null,
|
||||
],
|
||||
$balancesByDate,
|
||||
);
|
||||
$balanceCounts = collect($balances)->countBy(
|
||||
static fn (array $match): string => $match['status'],
|
||||
);
|
||||
$financeCount = collect($balances)->filter(
|
||||
static fn (array $match): bool => ($match['data_source'] ?? null)
|
||||
=== JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE,
|
||||
)->count();
|
||||
|
||||
if ($filters['balanceStatus'] !== '') {
|
||||
$rows = $rows->filter(
|
||||
@ -82,6 +110,19 @@ class WithdrawalManagementReport
|
||||
),
|
||||
],
|
||||
)->all();
|
||||
$balanceDays = collect($historyDates)->map(
|
||||
static fn (string $date, int $index): array => [
|
||||
'key' => $index === 0 ? 'balance_two_days_ago' : ($index === 1 ? 'balance_yesterday' : 'balance'),
|
||||
'date' => $date,
|
||||
'label' => CarbonImmutable::parse($date)->format('n月j日') . '店铺余额',
|
||||
],
|
||||
)->values()->all();
|
||||
$alerts = WithdrawalBalanceAlertSynchronizer::detect(
|
||||
$rows,
|
||||
$balancesByDate,
|
||||
$reserveStandards,
|
||||
$historyDates,
|
||||
);
|
||||
$withdrawalAmounts = $rows->mapWithKeys(
|
||||
fn (StoreReceivingAccount $row): array => [
|
||||
(int) $row->id => $this->withdrawalAmountFor($row, $balanceDate),
|
||||
@ -89,7 +130,7 @@ class WithdrawalManagementReport
|
||||
)->all();
|
||||
$compliance = $rows->mapWithKeys(
|
||||
fn (StoreReceivingAccount $row): array => [
|
||||
(int) $row->id => $this->complianceFor(
|
||||
(int) $row->id => self::complianceFor(
|
||||
$balances[(int) $row->id] ?? ['status' => 'not_ready', 'balance' => null],
|
||||
$reserveStandards[(int) $row->id] ?? null,
|
||||
$withdrawalAmounts[(int) $row->id] ?? null,
|
||||
@ -98,7 +139,16 @@ class WithdrawalManagementReport
|
||||
)->all();
|
||||
$rows = $sort['field'] === ''
|
||||
? $this->sortByBalanceStatus($rows, $balances)
|
||||
: $this->sortRows($rows, $sort, $balances, $reserveStandards, $withdrawalAmounts, $compliance);
|
||||
: $this->sortRows(
|
||||
$rows,
|
||||
$sort,
|
||||
$balancesByDate,
|
||||
$balanceDays,
|
||||
$alerts,
|
||||
$reserveStandards,
|
||||
$withdrawalAmounts,
|
||||
$compliance,
|
||||
);
|
||||
|
||||
return [
|
||||
'filters' => $filters,
|
||||
@ -107,31 +157,43 @@ class WithdrawalManagementReport
|
||||
'storeEntities' => $storeEntities,
|
||||
'rows' => $rows,
|
||||
'balances' => $balances,
|
||||
'balancesByDate' => $balancesByDate,
|
||||
'balanceDays' => $balanceDays,
|
||||
'alerts' => $alerts,
|
||||
'reserveStandards' => $reserveStandards,
|
||||
'withdrawalAmounts' => $withdrawalAmounts,
|
||||
'compliance' => $compliance,
|
||||
'summary' => [
|
||||
'source' => $this->shopBalances->countForDate($balanceDate),
|
||||
'matched' => (int) $balanceCounts->get('matched', 0),
|
||||
'finance' => $financeCount,
|
||||
'ambiguous' => (int) $balanceCounts->get('ambiguous', 0),
|
||||
'unmatched' => (int) $balanceCounts->get('unmatched', 0),
|
||||
'not_ready' => (int) $balanceCounts->get('not_ready', 0),
|
||||
],
|
||||
'balanceColumnLabel' => $today->format('n月j日') . '店铺余额',
|
||||
'balanceColumnLabel' => $labelDate->format('n月j日') . '店铺余额',
|
||||
'balanceDate' => $balanceDate,
|
||||
'latestBalanceDate' => $latestBalanceDate,
|
||||
'balanceDates' => $this->shopBalances->availableDates($today),
|
||||
'sort' => $sort,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string} */
|
||||
/** @return array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string,balanceDate:string} */
|
||||
public function filters(Request $request): array
|
||||
{
|
||||
$clean = static fn (mixed $value, int $length = 80): string => mb_substr(trim((string) $value), 0, $length);
|
||||
$balanceStatus = $clean($request->query('balance_status'));
|
||||
$balanceDate = $clean($request->query('balance_date'), 10);
|
||||
|
||||
if (! in_array($balanceStatus, ['matched', 'unmatched', 'ambiguous', 'not_ready'], true)) {
|
||||
$balanceStatus = '';
|
||||
}
|
||||
$validDate = preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $balanceDate, $parts) === 1
|
||||
&& checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1]);
|
||||
if (! $validDate || $balanceDate > now()->toDateString()) {
|
||||
$balanceDate = '';
|
||||
}
|
||||
|
||||
return [
|
||||
'platform' => $clean($request->query('platform')),
|
||||
@ -139,6 +201,7 @@ class WithdrawalManagementReport
|
||||
'storeEntity' => $clean($request->query('store_entity'), 191),
|
||||
'keyword' => $clean($request->query('keyword'), 191),
|
||||
'balanceStatus' => $balanceStatus,
|
||||
'balanceDate' => $balanceDate,
|
||||
];
|
||||
}
|
||||
|
||||
@ -170,7 +233,7 @@ class WithdrawalManagementReport
|
||||
|
||||
/**
|
||||
* @param Collection<int,StoreReceivingAccount> $rows
|
||||
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
|
||||
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}> $balances
|
||||
* @return Collection<int,StoreReceivingAccount>
|
||||
*/
|
||||
private function sortByBalanceStatus(Collection $rows, array $balances): Collection
|
||||
@ -193,17 +256,35 @@ class WithdrawalManagementReport
|
||||
/**
|
||||
* @param Collection<int,StoreReceivingAccount> $rows
|
||||
* @param array{field:string,direction:string} $sort
|
||||
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
|
||||
* @param array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>> $balancesByDate
|
||||
* @param list<array{key:string,date:string,label:string}> $balanceDays
|
||||
* @param array<int,array{balance:float,start_date:string,end_date:string,message:string}> $alerts
|
||||
* @param array<int,int|null> $reserveStandards
|
||||
* @param array<int,float|null> $withdrawalAmounts
|
||||
* @param array<int,string|null> $compliance
|
||||
* @return Collection<int,StoreReceivingAccount>
|
||||
*/
|
||||
private function sortRows(Collection $rows, array $sort, array $balances, array $reserveStandards, array $withdrawalAmounts, array $compliance): Collection
|
||||
{
|
||||
return $rows->sort(function (StoreReceivingAccount $left, StoreReceivingAccount $right) use ($sort, $balances, $reserveStandards, $withdrawalAmounts, $compliance): int {
|
||||
$leftValue = $this->sortValue($left, $sort['field'], $balances, $reserveStandards, $withdrawalAmounts, $compliance);
|
||||
$rightValue = $this->sortValue($right, $sort['field'], $balances, $reserveStandards, $withdrawalAmounts, $compliance);
|
||||
private function sortRows(
|
||||
Collection $rows,
|
||||
array $sort,
|
||||
array $balancesByDate,
|
||||
array $balanceDays,
|
||||
array $alerts,
|
||||
array $reserveStandards,
|
||||
array $withdrawalAmounts,
|
||||
array $compliance,
|
||||
): Collection {
|
||||
return $rows->sort(function (StoreReceivingAccount $left, StoreReceivingAccount $right) use (
|
||||
$sort,
|
||||
$balancesByDate,
|
||||
$balanceDays,
|
||||
$alerts,
|
||||
$reserveStandards,
|
||||
$withdrawalAmounts,
|
||||
$compliance,
|
||||
): int {
|
||||
$leftValue = $this->sortValue($left, $sort['field'], $balancesByDate, $balanceDays, $alerts, $reserveStandards, $withdrawalAmounts, $compliance);
|
||||
$rightValue = $this->sortValue($right, $sort['field'], $balancesByDate, $balanceDays, $alerts, $reserveStandards, $withdrawalAmounts, $compliance);
|
||||
$comparison = $this->compareSortValues($leftValue, $rightValue, $sort['direction']);
|
||||
|
||||
return $comparison !== 0
|
||||
@ -213,13 +294,38 @@ class WithdrawalManagementReport
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
|
||||
* @param array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>> $balancesByDate
|
||||
* @param list<array{key:string,date:string,label:string}> $balanceDays
|
||||
* @param array<int,array{balance:float,start_date:string,end_date:string,message:string}> $alerts
|
||||
* @param array<int,int|null> $reserveStandards
|
||||
* @param array<int,float|null> $withdrawalAmounts
|
||||
* @param array<int,string|null> $compliance
|
||||
*/
|
||||
private function sortValue(StoreReceivingAccount $row, string $field, array $balances, array $reserveStandards, array $withdrawalAmounts, array $compliance): mixed
|
||||
{
|
||||
private function sortValue(
|
||||
StoreReceivingAccount $row,
|
||||
string $field,
|
||||
array $balancesByDate,
|
||||
array $balanceDays,
|
||||
array $alerts,
|
||||
array $reserveStandards,
|
||||
array $withdrawalAmounts,
|
||||
array $compliance,
|
||||
): mixed {
|
||||
$balanceFor = static function (string $dayKey) use ($row, $balancesByDate, $balanceDays): ?float {
|
||||
$date = null;
|
||||
foreach ($balanceDays as $day) {
|
||||
if ($day['key'] === $dayKey) {
|
||||
$date = $day['date'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
$match = $date === null
|
||||
? null
|
||||
: ($balancesByDate[(int) $row->id][$date] ?? null);
|
||||
|
||||
return ($match['status'] ?? null) === 'matched' ? (float) $match['balance'] : null;
|
||||
};
|
||||
|
||||
return match ($field) {
|
||||
'platform' => $row->store_platform,
|
||||
'erp_store_name' => $row->erp_store_name,
|
||||
@ -228,9 +334,10 @@ class WithdrawalManagementReport
|
||||
'withdrawal_path' => $row->withdrawal_path,
|
||||
'withdrawal_account' => $row->withdrawal_account_name,
|
||||
'bank_card' => $row->bank_card_last_four,
|
||||
'balance' => ($balances[(int) $row->id]['status'] ?? null) === 'matched'
|
||||
? $balances[(int) $row->id]['balance']
|
||||
: null,
|
||||
'balance_two_days_ago' => $balanceFor('balance_two_days_ago'),
|
||||
'balance_yesterday' => $balanceFor('balance_yesterday'),
|
||||
'balance' => $balanceFor('balance'),
|
||||
'balance_alert' => isset($alerts[(int) $row->id]) ? 1 : 0,
|
||||
'reserve_standard' => $reserveStandards[(int) $row->id] ?? null,
|
||||
'withdrawal_amount' => $withdrawalAmounts[(int) $row->id] ?? null,
|
||||
'compliance' => $compliance[(int) $row->id] ?? null,
|
||||
@ -248,7 +355,7 @@ class WithdrawalManagementReport
|
||||
}
|
||||
|
||||
/** @param array{status:string,balance:?float} $balance */
|
||||
private function complianceFor(array $balance, ?int $reserve, ?float $withdrawalAmount): ?string
|
||||
public static function complianceFor(array $balance, ?int $reserve, ?float $withdrawalAmount): ?string
|
||||
{
|
||||
if (($balance['status'] ?? '') !== 'matched' || $balance['balance'] === null || $reserve === null || $withdrawalAmount === null) {
|
||||
return null;
|
||||
|
||||
@ -12,7 +12,9 @@ use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataValidation;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Throwable;
|
||||
|
||||
@ -29,11 +31,30 @@ class WithdrawalManagementSpreadsheet
|
||||
];
|
||||
|
||||
private const CLEAR_VALUE = '[清空]';
|
||||
private const WITHDRAWAL_TEMPLATE_HEADERS = [
|
||||
'店铺平台',
|
||||
'ERP店铺名称',
|
||||
'平台店铺ID',
|
||||
'余额日期',
|
||||
'店铺余额',
|
||||
'预留标准',
|
||||
'提现金额',
|
||||
'提现后余额',
|
||||
'是否达标',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly JushuitanShopBalanceRepository $shopBalances,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* rows:\Illuminate\Support\Collection<int,StoreReceivingAccount>,
|
||||
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>,
|
||||
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>,
|
||||
* balancesByDate:array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>>,
|
||||
* balanceDays:list<array{key:string,date:string,label:string}>,
|
||||
* alerts:array<int,array{balance:float,start_date:string,end_date:string,message:string}>,
|
||||
* reserveStandards:array<int,int|null>,withdrawalAmounts:array<int,float|null>,compliance:array<int,string|null>,
|
||||
* balanceColumnLabel:string,balanceDate:string
|
||||
* } $report
|
||||
@ -63,7 +84,7 @@ class WithdrawalManagementSpreadsheet
|
||||
['1. 店铺平台、ERP店铺名称用于定位店铺;有平台店铺ID时优先按平台和ID匹配。'],
|
||||
['2. 店铺主体、提现路径、提现账户名称、银行卡号后四位和提现金额为可更新字段。'],
|
||||
['3. 可更新字段留空表示保持原值,填写“' . self::CLEAR_VALUE . '”表示清空原值。'],
|
||||
['4. 提现金额须为非负数,最多两位小数,只用于导入当天对应的余额数据日期;上传后先预览再确认。'],
|
||||
['4. 提现金额须为非负数,最多两位小数,只用于当前页面显示的余额数据日期(最近 5 天内最新可用日期);上传后先预览再确认。'],
|
||||
], null, 'A1');
|
||||
$instructions->getStyle('A1')->getFont()->setBold(true)->setSize(14);
|
||||
$instructions->getColumnDimension('A')->setWidth(88);
|
||||
@ -76,10 +97,113 @@ class WithdrawalManagementSpreadsheet
|
||||
return $this->download($spreadsheet, 'withdrawal_management_import_template.xlsx');
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前筛选预填店铺和余额参考值,同事只需填写提现金额。
|
||||
*
|
||||
* @param array<string,mixed> $report
|
||||
*/
|
||||
public function withdrawalTemplate(array $report): StreamedResponse
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('提现金额');
|
||||
$this->writeHeader($sheet, self::WITHDRAWAL_TEMPLATE_HEADERS);
|
||||
$this->sizeColumns($sheet, [14, 30, 20, 14, 18, 18, 18, 18, 14]);
|
||||
|
||||
$rowNumber = 2;
|
||||
foreach ($report['rows'] as $row) {
|
||||
$storeId = (int) $row->id;
|
||||
$balanceMatch = $report['balances'][$storeId] ?? [
|
||||
'status' => 'not_ready',
|
||||
'balance' => null,
|
||||
];
|
||||
$hasBalance = ($balanceMatch['status'] ?? '') === 'matched'
|
||||
&& ($balanceMatch['balance'] ?? null) !== null;
|
||||
$reserve = $report['reserveStandards'][$storeId] ?? null;
|
||||
$amount = $report['withdrawalAmounts'][$storeId] ?? null;
|
||||
|
||||
foreach ([
|
||||
'A' => (string) ($row->store_platform ?? ''),
|
||||
'B' => (string) ($row->erp_store_name ?? ''),
|
||||
'C' => (string) ($row->store_id ?? ''),
|
||||
'D' => (string) $report['balanceDate'],
|
||||
] as $column => $value) {
|
||||
$sheet->setCellValueExplicit("{$column}{$rowNumber}", $value, DataType::TYPE_STRING);
|
||||
}
|
||||
|
||||
if ($hasBalance) {
|
||||
$sheet->setCellValue("E{$rowNumber}", (float) $balanceMatch['balance']);
|
||||
} else {
|
||||
$sheet->setCellValueExplicit("E{$rowNumber}", $this->balanceStatus((string) ($balanceMatch['status'] ?? 'not_ready')), DataType::TYPE_STRING);
|
||||
}
|
||||
if ($reserve !== null) {
|
||||
$sheet->setCellValue("F{$rowNumber}", $reserve);
|
||||
} else {
|
||||
$sheet->setCellValueExplicit("F{$rowNumber}", '待配置', DataType::TYPE_STRING);
|
||||
}
|
||||
if ($amount !== null) {
|
||||
$sheet->setCellValue("G{$rowNumber}", $amount);
|
||||
}
|
||||
|
||||
$sheet->setCellValue(
|
||||
"H{$rowNumber}",
|
||||
"=IF(OR(NOT(ISNUMBER(E{$rowNumber})),G{$rowNumber}=\"\"),\"\",E{$rowNumber}-G{$rowNumber})",
|
||||
);
|
||||
$sheet->setCellValue(
|
||||
"I{$rowNumber}",
|
||||
"=IF(OR(H{$rowNumber}=\"\",NOT(ISNUMBER(F{$rowNumber}))),\"\",IF(H{$rowNumber}<=F{$rowNumber},\"是\",\"否\"))",
|
||||
);
|
||||
$rowNumber++;
|
||||
}
|
||||
|
||||
$lastDataRow = max(2, $rowNumber - 1);
|
||||
$sheet->getStyle("E2:I{$lastDataRow}")->getNumberFormat()->setFormatCode('#,##0.00');
|
||||
$sheet->getStyle("E2:I{$lastDataRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
|
||||
$sheet->getStyle("G2:G{$lastDataRow}")->getFill()
|
||||
->setFillType(Fill::FILL_SOLID)
|
||||
->getStartColor()->setARGB('FFF7F0DE');
|
||||
$sheet->getStyle('G1')->getFill()
|
||||
->setFillType(Fill::FILL_SOLID)
|
||||
->getStartColor()->setARGB('FFB9954F');
|
||||
|
||||
$validation = new DataValidation();
|
||||
$validation->setType(DataValidation::TYPE_DECIMAL)
|
||||
->setOperator(DataValidation::OPERATOR_GREATERTHANOREQUAL)
|
||||
->setFormula1('0')
|
||||
->setAllowBlank(true)
|
||||
->setShowErrorMessage(true)
|
||||
->setErrorTitle('提现金额格式错误')
|
||||
->setError('请填写非负金额,最多两位小数。');
|
||||
$sheet->setDataValidation("G2:G{$lastDataRow}", $validation);
|
||||
|
||||
$instructions = $spreadsheet->createSheet();
|
||||
$instructions->setTitle('填写说明');
|
||||
$instructions->fromArray([
|
||||
['提现金额导入说明'],
|
||||
['1. 本模板按下载时的页面筛选生成,已预填店铺标识、余额日期、店铺余额和预留标准。'],
|
||||
['2. 只需填写黄色“提现金额”列;未填写金额的店铺不会提交。填写“' . self::CLEAR_VALUE . '”可清空该店铺提现金额。'],
|
||||
['3. 店铺余额、预留标准、提现后余额和是否达标仅用于下载时预览,系统会在上传后按最新余额日期重新计算。'],
|
||||
['4. 判定规则:店铺余额 - 提现金额 ≤ 预留标准时,“是否达标”为“是”,即该笔提现按大笔提现处理。'],
|
||||
['5. 模板余额日期为 ' . $report['balanceDate'] . ';日期滚动后请重新下载模板,避免上传过期金额。'],
|
||||
], null, 'A1');
|
||||
$instructions->getStyle('A1')->getFont()->setBold(true)->setSize(14);
|
||||
$instructions->getColumnDimension('A')->setWidth(96);
|
||||
$instructions->getStyle('A1:A6')->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
|
||||
foreach (range(1, 6) as $row) {
|
||||
$instructions->getRowDimension($row)->setRowHeight($row === 1 ? 26 : 36);
|
||||
}
|
||||
$spreadsheet->setActiveSheetIndex(0);
|
||||
|
||||
return $this->download(
|
||||
$spreadsheet,
|
||||
'withdrawal_amount_import_' . str_replace('-', '', (string) $report['balanceDate']) . '.xlsx',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* total:int,changed:int,unchanged:int,
|
||||
* preview:list<array{platform:string,erp_store_name:string,store_id:string,changes:string}>,
|
||||
* preview:list<array{platform:string,erp_store_name:string,store_id:string,changes:string,withdrawal_amount:?float,post_withdrawal_balance:?float,reserve_standard:?int,compliance:?string}>,
|
||||
* plan:list<array{id:int,updated_at:string,changes:array<string,string|null>}>
|
||||
* }
|
||||
*/
|
||||
@ -95,14 +219,16 @@ class WithdrawalManagementSpreadsheet
|
||||
'import' => 'Excel 文件无法读取,请确认文件未损坏并使用最新模板。',
|
||||
]);
|
||||
}
|
||||
[$headerIndex, $headers] = $this->headers($matrix);
|
||||
[$headerIndex, $headers, $headerMode] = $this->headers($matrix);
|
||||
$indexes = array_flip($headers);
|
||||
$balanceDate = now()->subDay()->toDateString();
|
||||
$balanceDate = $this->currentBalanceDate();
|
||||
$accounts = StoreReceivingAccount::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->whereNull('deleted_at')
|
||||
->get();
|
||||
[$byId, $byName] = $this->indexes($accounts);
|
||||
$balances = $this->shopBalances->matchesForStores($accounts, $balanceDate);
|
||||
$withdrawalOnlyMode = $headerMode === 'withdrawal';
|
||||
$errors = [];
|
||||
$plan = [];
|
||||
$preview = [];
|
||||
@ -112,12 +238,15 @@ class WithdrawalManagementSpreadsheet
|
||||
|
||||
foreach (array_slice($matrix, $headerIndex + 1, null, true) as $matrixIndex => $row) {
|
||||
$values = [];
|
||||
foreach (self::CONFIG_HEADERS as $header) {
|
||||
$values[$header] = trim((string) ($row[$indexes[$header]] ?? ''));
|
||||
foreach ([...self::CONFIG_HEADERS, '提现金额', '余额日期'] as $header) {
|
||||
$values[$header] = isset($indexes[$header])
|
||||
? trim((string) ($row[$indexes[$header]] ?? ''))
|
||||
: '';
|
||||
}
|
||||
// 专用模板会预填所有店铺,只把填写了提现金额的行视为提交数据。
|
||||
if ($withdrawalOnlyMode && $values['提现金额'] === '') {
|
||||
continue;
|
||||
}
|
||||
$values['提现金额'] = isset($indexes['提现金额'])
|
||||
? trim((string) ($row[$indexes['提现金额']] ?? ''))
|
||||
: '';
|
||||
if (count(array_filter($values, static fn (string $value): bool => $value !== '')) === 0) {
|
||||
continue;
|
||||
}
|
||||
@ -131,8 +260,13 @@ class WithdrawalManagementSpreadsheet
|
||||
$erpStoreName = mb_substr($values['ERP店铺名称'], 0, 191);
|
||||
$storeId = mb_substr(ltrim($values['平台店铺ID'], "'"), 0, 191);
|
||||
|
||||
if ($platform === '' || $erpStoreName === '') {
|
||||
$errors[] = "第 {$line} 行缺少店铺平台或 ERP 店铺名称";
|
||||
if ($platform === '' || ($storeId === '' && $erpStoreName === '')) {
|
||||
$errors[] = "第 {$line} 行缺少店铺平台或店铺标识";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($withdrawalOnlyMode && $values['余额日期'] !== '' && $values['余额日期'] !== $balanceDate) {
|
||||
$errors[] = "第 {$line} 行余额日期 {$values['余额日期']} 已过期,请按最新余额日期 {$balanceDate} 重新下载模板";
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -187,11 +321,40 @@ class WithdrawalManagementSpreadsheet
|
||||
'changes' => $changes,
|
||||
];
|
||||
if (count($preview) < 20) {
|
||||
$amount = array_key_exists('withdrawal_amount', $changes)
|
||||
? ($changes['withdrawal_amount'] === null ? null : (float) $changes['withdrawal_amount'])
|
||||
: (
|
||||
$account->withdrawal_balance_date === $balanceDate
|
||||
&& $account->withdrawal_amount !== null
|
||||
&& is_numeric($account->withdrawal_amount)
|
||||
? (float) $account->withdrawal_amount
|
||||
: null
|
||||
);
|
||||
$balance = ($balances[(int) $account->id]['status'] ?? '') === 'matched'
|
||||
? (float) $balances[(int) $account->id]['balance']
|
||||
: null;
|
||||
$reserve = WithdrawalReserveStandard::amountFor(
|
||||
$account->store_platform,
|
||||
$account->erp_store_name,
|
||||
);
|
||||
$preview[] = [
|
||||
'platform' => $platform,
|
||||
'erp_store_name' => $erpStoreName,
|
||||
'store_id' => $storeId,
|
||||
'changes' => implode('、', $changedLabels),
|
||||
'withdrawal_amount' => $amount,
|
||||
'post_withdrawal_balance' => $balance !== null && $amount !== null
|
||||
? round($balance - $amount, 2)
|
||||
: null,
|
||||
'reserve_standard' => $reserve,
|
||||
'compliance' => WithdrawalManagementReport::complianceFor(
|
||||
[
|
||||
'status' => $balance === null ? 'not_ready' : 'matched',
|
||||
'balance' => $balance,
|
||||
],
|
||||
$reserve,
|
||||
$amount,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -221,6 +384,7 @@ class WithdrawalManagementSpreadsheet
|
||||
{
|
||||
return DB::transaction(function () use ($plan, $companyId): int {
|
||||
$updated = 0;
|
||||
$currentBalanceDate = null;
|
||||
|
||||
foreach ($plan as $item) {
|
||||
/** @var StoreReceivingAccount|null $account */
|
||||
@ -238,11 +402,13 @@ class WithdrawalManagementSpreadsheet
|
||||
}
|
||||
|
||||
if (isset($item['changes']['withdrawal_balance_date'])
|
||||
&& $item['changes']['withdrawal_balance_date'] !== null
|
||||
&& $item['changes']['withdrawal_balance_date'] !== now()->subDay()->toDateString()) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '余额日期已滚动,请重新上传并预览提现金额。',
|
||||
]);
|
||||
&& $item['changes']['withdrawal_balance_date'] !== null) {
|
||||
$currentBalanceDate ??= $this->currentBalanceDate();
|
||||
if ($item['changes']['withdrawal_balance_date'] !== $currentBalanceDate) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '余额日期已滚动,请重新上传并预览提现金额。',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($item['changes'] as $field => $value) {
|
||||
@ -262,19 +428,36 @@ class WithdrawalManagementSpreadsheet
|
||||
|
||||
private function workbook(array $report): Spreadsheet
|
||||
{
|
||||
$headers = [...self::CONFIG_HEADERS, $report['balanceColumnLabel'], '预留标准', '提现金额', '是否达标'];
|
||||
$balanceHeaders = array_map(
|
||||
static fn (array $day): string => (string) $day['label'],
|
||||
$report['balanceDays'],
|
||||
);
|
||||
$headers = [
|
||||
...self::CONFIG_HEADERS,
|
||||
...$balanceHeaders,
|
||||
'余额来源',
|
||||
'余额预警',
|
||||
'预留标准',
|
||||
'提现金额',
|
||||
'是否达标',
|
||||
];
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('提现管理');
|
||||
$this->writeHeader($sheet, $headers);
|
||||
$this->sizeColumns($sheet, [14, 32, 20, 32, 16, 24, 22, 20, 18, 18, 14]);
|
||||
$this->sizeColumns($sheet, [14, 32, 20, 32, 16, 24, 22, 20, 20, 20, 32, 18, 18, 18, 14]);
|
||||
|
||||
$rowNumber = 2;
|
||||
foreach ($report['rows'] as $row) {
|
||||
$match = $report['balances'][(int) $row->id] ?? ['status' => 'not_ready', 'balance' => null];
|
||||
$reserve = $report['reserveStandards'][(int) $row->id] ?? null;
|
||||
$withdrawalAmount = $report['withdrawalAmounts'][(int) $row->id] ?? null;
|
||||
$compliance = $report['compliance'][(int) $row->id] ?? null;
|
||||
$alert = $report['alerts'][(int) $row->id] ?? null;
|
||||
$hasCompleteAlertSeries = collect($report['balanceDays'])->every(
|
||||
fn (array $day): bool => (
|
||||
$report['balancesByDate'][(int) $row->id][(string) $day['date']]['status'] ?? ''
|
||||
) === 'matched',
|
||||
);
|
||||
$textValues = [
|
||||
1 => (string) ($row->store_platform ?? ''),
|
||||
2 => (string) ($row->erp_store_name ?? ''),
|
||||
@ -288,22 +471,46 @@ class WithdrawalManagementSpreadsheet
|
||||
$sheet->setCellValueExplicit([$column, $rowNumber], $value, DataType::TYPE_STRING);
|
||||
}
|
||||
|
||||
if ($match['status'] === 'matched') {
|
||||
$sheet->setCellValue([8, $rowNumber], (float) $match['balance']);
|
||||
} else {
|
||||
$sheet->setCellValueExplicit([8, $rowNumber], $this->balanceStatus($match['status']), DataType::TYPE_STRING);
|
||||
foreach ($report['balanceDays'] as $dayIndex => $day) {
|
||||
$column = 8 + $dayIndex;
|
||||
$match = $report['balancesByDate'][(int) $row->id][$day['date']]
|
||||
?? ['status' => 'not_ready', 'balance' => null];
|
||||
if ($match['status'] === 'matched') {
|
||||
$sheet->setCellValue([$column, $rowNumber], (float) $match['balance']);
|
||||
} else {
|
||||
$sheet->setCellValueExplicit([$column, $rowNumber], $this->balanceStatus($match['status']), DataType::TYPE_STRING);
|
||||
}
|
||||
}
|
||||
|
||||
$sourceParts = [];
|
||||
foreach ($report['balanceDays'] as $day) {
|
||||
$match = $report['balancesByDate'][(int) $row->id][$day['date']]
|
||||
?? ['status' => 'not_ready'];
|
||||
$sourceParts[] = str_replace('店铺余额', '', (string) $day['label']) . ':' . match ($match['status']) {
|
||||
'matched' => ($match['data_source'] ?? null) === 'finance' ? '财务系统' : '聚水潭',
|
||||
'ambiguous' => '多条匹配',
|
||||
'unmatched' => '未匹配',
|
||||
default => '待同步',
|
||||
};
|
||||
}
|
||||
$sheet->setCellValueExplicit([11, $rowNumber], implode(';', $sourceParts), DataType::TYPE_STRING);
|
||||
|
||||
$sheet->setCellValueExplicit(
|
||||
[12, $rowNumber],
|
||||
$alert !== null ? '连续3天未变动' : ($hasCompleteAlertSeries ? '未触发' : '数据不足'),
|
||||
DataType::TYPE_STRING,
|
||||
);
|
||||
if ($reserve === null) {
|
||||
$sheet->setCellValueExplicit([9, $rowNumber], '待配置', DataType::TYPE_STRING);
|
||||
$sheet->setCellValueExplicit([13, $rowNumber], '待配置', DataType::TYPE_STRING);
|
||||
} else {
|
||||
$sheet->setCellValue([9, $rowNumber], $reserve);
|
||||
$sheet->setCellValue([13, $rowNumber], $reserve);
|
||||
}
|
||||
if ($withdrawalAmount === null) {
|
||||
$sheet->setCellValueExplicit([10, $rowNumber], '待接入', DataType::TYPE_STRING);
|
||||
$sheet->setCellValueExplicit([14, $rowNumber], '待接入', DataType::TYPE_STRING);
|
||||
} else {
|
||||
$sheet->setCellValue([10, $rowNumber], $withdrawalAmount);
|
||||
$sheet->setCellValue([14, $rowNumber], $withdrawalAmount);
|
||||
}
|
||||
$sheet->setCellValueExplicit([11, $rowNumber], match ($compliance) {
|
||||
$sheet->setCellValueExplicit([15, $rowNumber], match ($compliance) {
|
||||
'yes' => '是',
|
||||
'no' => '否',
|
||||
default => '待核验',
|
||||
@ -313,7 +520,9 @@ class WithdrawalManagementSpreadsheet
|
||||
|
||||
if ($rowNumber > 2) {
|
||||
$sheet->getStyle('H2:J' . ($rowNumber - 1))->getNumberFormat()->setFormatCode('#,##0.00');
|
||||
$sheet->getStyle('M2:N' . ($rowNumber - 1))->getNumberFormat()->setFormatCode('#,##0.00');
|
||||
$sheet->getStyle('H2:J' . ($rowNumber - 1))->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
|
||||
$sheet->getStyle('M2:N' . ($rowNumber - 1))->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
|
||||
}
|
||||
$sheet->getStyle('C2:C' . max(2, $rowNumber - 1))->getNumberFormat()->setFormatCode('@');
|
||||
$sheet->getStyle('G2:G' . max(2, $rowNumber - 1))->getNumberFormat()->setFormatCode('@');
|
||||
@ -322,25 +531,47 @@ class WithdrawalManagementSpreadsheet
|
||||
return $spreadsheet;
|
||||
}
|
||||
|
||||
/** @return array{0:int,1:list<string>} */
|
||||
/** @return array{0:int,1:list<string>,2:'config'|'withdrawal'} */
|
||||
private function headers(array $matrix): array
|
||||
{
|
||||
foreach (array_slice($matrix, 0, 10, true) as $index => $row) {
|
||||
$headers = array_map(static fn (mixed $value): string => trim((string) $value), $row);
|
||||
if (in_array('店铺平台', $headers, true) && in_array('ERP店铺名称', $headers, true)) {
|
||||
if (! in_array('店铺平台', $headers, true) || ! in_array('提现金额', $headers, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->requireUniqueHeader($headers, '店铺平台');
|
||||
$this->requireUniqueHeader($headers, '提现金额');
|
||||
$fullConfigurationTemplate = collect(self::CONFIG_HEADERS)->every(
|
||||
static fn (string $header): bool => in_array($header, $headers, true),
|
||||
);
|
||||
|
||||
if ($fullConfigurationTemplate) {
|
||||
foreach (self::CONFIG_HEADERS as $required) {
|
||||
if (count(array_keys($headers, $required, true)) !== 1) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => "导入表头必须且只能包含一个“{$required}”列,请下载最新模板。",
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (count(array_keys($headers, '提现金额', true)) > 1) {
|
||||
throw ValidationException::withMessages(['import' => '导入表头只能包含一个“提现金额”列。']);
|
||||
$this->requireUniqueHeader($headers, $required);
|
||||
}
|
||||
|
||||
return [(int) $index, $headers];
|
||||
return [(int) $index, $headers, 'config'];
|
||||
}
|
||||
|
||||
if (! in_array('ERP店铺名称', $headers, true) && ! in_array('平台店铺ID', $headers, true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '提现金额模板必须包含“ERP店铺名称”或“平台店铺ID”用于定位店铺,请下载最新模板。',
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'ERP店铺名称', '平台店铺ID', '余额日期', '店铺余额', '预留标准',
|
||||
'提现后余额', '是否达标',
|
||||
] as $optional) {
|
||||
if (count(array_keys($headers, $optional, true)) > 1) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => "导入表头只能包含一个“{$optional}”列。",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return [(int) $index, $headers, 'withdrawal'];
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
@ -348,6 +579,16 @@ class WithdrawalManagementSpreadsheet
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param list<string> $headers */
|
||||
private function requireUniqueHeader(array $headers, string $header): void
|
||||
{
|
||||
if (count(array_keys($headers, $header, true)) !== 1) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => "导入表头必须且只能包含一个“{$header}”列,请下载最新模板。",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{0:array<string,list<StoreReceivingAccount>>,1:array<string,list<StoreReceivingAccount>>} */
|
||||
private function indexes($accounts): array
|
||||
{
|
||||
@ -423,6 +664,14 @@ class WithdrawalManagementSpreadsheet
|
||||
return $changes;
|
||||
}
|
||||
|
||||
private function currentBalanceDate(): string
|
||||
{
|
||||
$today = now()->startOfDay();
|
||||
|
||||
return $this->shopBalances->latestBalanceDate($today)
|
||||
?? $today->copy()->subDay()->toDateString();
|
||||
}
|
||||
|
||||
private function key(string $platform, string $identity): string
|
||||
{
|
||||
return trim($platform) . "\0" . trim($identity);
|
||||
|
||||
339
app/Business/XlsxSheetReader.php
Normal file
339
app/Business/XlsxSheetReader.php
Normal file
@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use Generator;
|
||||
use RuntimeException;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* xlsx 工作表流式读取器(按表头取列)。
|
||||
*
|
||||
* 主要给几十万行的大表用(聚水潭导出订单、快递运费明细等),直接套常规 Excel 读法会踩坑:
|
||||
* - 字符串用 inlineStr 内联存储(sharedStrings 里是空的);
|
||||
* - worksheet 的 <dimension> 只写一个 A1,靠它判断行数会把整表读成空;
|
||||
* - 一行订单一个商品明细,单文件常见上万行 × 上百列,解压后几十 MB。
|
||||
*
|
||||
* 所以这里用 ZipArchive 取压缩流 + expat 增量解析,只保留调用方点名的列,
|
||||
* 内存占用与文件大小无关(导出 20 万行也不会整表进内存)。
|
||||
*/
|
||||
class XlsxSheetReader
|
||||
{
|
||||
private const READ_CHUNK = 262144;
|
||||
|
||||
/**
|
||||
* 逐行读取导出文件(跳过表头行)。
|
||||
*
|
||||
* @param list<string|list<string>> $columns 需要取出的列(缺列会报错)。给字符串就是精确匹配表头;
|
||||
* 给数组表示同一列有多个可选表头(取第一个命中的,
|
||||
* 返回键用数组里的第一个名字)。
|
||||
* @param list<string|list<string>> $optionalColumns 可选列:文件里有就取,没有就跳过(不报错)。
|
||||
* @return Generator<int, array<string, string>>
|
||||
*/
|
||||
public function rows(string $path, array $columns, array $optionalColumns = []): Generator
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
throw new RuntimeException('订单导出文件不存在:' . $path);
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($path) !== true) {
|
||||
throw new RuntimeException('无法打开订单导出文件(不是有效的 xlsx):' . $path);
|
||||
}
|
||||
|
||||
$stream = $zip->getStream($this->worksheetPath($zip));
|
||||
if ($stream === false) {
|
||||
$zip->close();
|
||||
|
||||
throw new RuntimeException('订单导出文件里没有可读的工作表。');
|
||||
}
|
||||
|
||||
try {
|
||||
yield from $this->parseSheet($stream, $this->sharedStrings($zip), $columns, $optionalColumns);
|
||||
} finally {
|
||||
fclose($stream);
|
||||
$zip->close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param resource $stream
|
||||
* @param list<string> $shared
|
||||
* @param list<string|list<string>> $columns
|
||||
* @param list<string|list<string>> $optionalColumns
|
||||
* @return Generator<int, array<string, string>>
|
||||
*/
|
||||
private function parseSheet($stream, array $shared, array $columns, array $optionalColumns): Generator
|
||||
{
|
||||
$state = [
|
||||
'row' => [],
|
||||
'column' => -1,
|
||||
'type' => null,
|
||||
'value' => '',
|
||||
'inValue' => false,
|
||||
'rowNumber' => 0,
|
||||
'wanted' => [],
|
||||
'pending' => [],
|
||||
];
|
||||
|
||||
$parser = xml_parser_create('UTF-8');
|
||||
xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, 0);
|
||||
|
||||
xml_set_element_handler(
|
||||
$parser,
|
||||
static function ($parser, string $name, array $attributes) use (&$state): void {
|
||||
$name = self::localName($name);
|
||||
|
||||
switch ($name) {
|
||||
case 'row':
|
||||
$state['row'] = [];
|
||||
$state['column'] = -1;
|
||||
break;
|
||||
|
||||
case 'c':
|
||||
$state['column'] = self::columnIndex($attributes['r'] ?? null, $state['column'] + 1);
|
||||
$state['type'] = $attributes['t'] ?? null;
|
||||
$state['value'] = '';
|
||||
break;
|
||||
|
||||
case 'v':
|
||||
case 't':
|
||||
$state['inValue'] = true;
|
||||
break;
|
||||
}
|
||||
},
|
||||
static function ($parser, string $name) use (&$state, $shared, $columns, $optionalColumns): void {
|
||||
$name = self::localName($name);
|
||||
|
||||
switch ($name) {
|
||||
case 'v':
|
||||
case 't':
|
||||
$state['inValue'] = false;
|
||||
break;
|
||||
|
||||
case 'c':
|
||||
$state['row'][$state['column']] = self::cellValue($state['type'], $state['value'], $shared);
|
||||
break;
|
||||
|
||||
case 'row':
|
||||
$state['rowNumber']++;
|
||||
if ($state['rowNumber'] === 1) {
|
||||
$state['wanted'] = self::wantedColumns($state['row'], $columns, $optionalColumns);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$values = [];
|
||||
foreach ($state['wanted'] as $index => $column) {
|
||||
$values[$column] = $state['row'][$index] ?? '';
|
||||
}
|
||||
$state['pending'][] = $values;
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
xml_set_character_data_handler($parser, static function ($parser, string $data) use (&$state): void {
|
||||
if ($state['inValue']) {
|
||||
$state['value'] .= $data;
|
||||
}
|
||||
});
|
||||
|
||||
$first = true;
|
||||
try {
|
||||
while (! feof($stream)) {
|
||||
$chunk = fread($stream, self::READ_CHUNK);
|
||||
if ($chunk === false || $chunk === '') {
|
||||
break;
|
||||
}
|
||||
if ($first) {
|
||||
// 聚水潭导出的 XML 带 UTF-8 BOM,expat 会当成非法前缀。
|
||||
$chunk = preg_replace('/^\xEF\xBB\xBF/', '', $chunk, 1);
|
||||
$first = false;
|
||||
}
|
||||
|
||||
if (xml_parse($parser, $chunk, false) === 0) {
|
||||
throw new RuntimeException('订单导出文件解析失败:' . xml_error_string(xml_get_error_code($parser)));
|
||||
}
|
||||
|
||||
while ($state['pending'] !== []) {
|
||||
yield array_shift($state['pending']);
|
||||
}
|
||||
}
|
||||
|
||||
if (xml_parse($parser, '', true) === 0) {
|
||||
throw new RuntimeException('订单导出文件解析失败:' . xml_error_string(xml_get_error_code($parser)));
|
||||
}
|
||||
} finally {
|
||||
xml_parser_free($parser);
|
||||
}
|
||||
|
||||
while ($state['pending'] !== []) {
|
||||
yield array_shift($state['pending']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 表头行 -> [列下标 => 列名],并校验需要的列都在;可选列缺了不算错。
|
||||
*
|
||||
* @param array<int, string> $header
|
||||
* @param list<string|list<string>> $columns
|
||||
* @param list<string|list<string>> $optionalColumns
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function wantedColumns(array $header, array $columns, array $optionalColumns = []): array
|
||||
{
|
||||
$byName = [];
|
||||
foreach ($header as $index => $name) {
|
||||
$name = trim($name);
|
||||
if ($name !== '' && ! isset($byName[$name])) {
|
||||
$byName[$name] = $index;
|
||||
}
|
||||
}
|
||||
|
||||
$wanted = [];
|
||||
foreach ($columns as $column) {
|
||||
$names = is_array($column) ? array_values($column) : [$column];
|
||||
$matched = null;
|
||||
foreach ($names as $name) {
|
||||
if (isset($byName[$name])) {
|
||||
$matched = $name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($matched === null) {
|
||||
throw new RuntimeException('文件缺少列:' . implode(' / ', $names));
|
||||
}
|
||||
$wanted[$byName[$matched]] = $names[0];
|
||||
}
|
||||
|
||||
foreach ($optionalColumns as $column) {
|
||||
$names = is_array($column) ? array_values($column) : [$column];
|
||||
$matched = null;
|
||||
foreach ($names as $name) {
|
||||
if (isset($byName[$name])) {
|
||||
$matched = $name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($matched !== null && ! isset($wanted[$byName[$matched]])) {
|
||||
$wanted[$byName[$matched]] = $names[0];
|
||||
}
|
||||
}
|
||||
|
||||
return $wanted;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $shared
|
||||
*/
|
||||
private static function cellValue(?string $type, string $value, array $shared): string
|
||||
{
|
||||
if ($type === 's') {
|
||||
return $shared[(int) $value] ?? '';
|
||||
}
|
||||
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
/** 单元格引用(如 AV12)-> 0 基列下标。 */
|
||||
private static function columnIndex(?string $reference, int $fallback): int
|
||||
{
|
||||
if ($reference === null || preg_match('/^([A-Z]+)/i', $reference, $matches) !== 1) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$letters = strtoupper($matches[1]);
|
||||
$index = 0;
|
||||
for ($i = 0, $length = strlen($letters); $i < $length; $i++) {
|
||||
$index = $index * 26 + (ord($letters[$i]) - 64);
|
||||
}
|
||||
|
||||
return $index - 1;
|
||||
}
|
||||
|
||||
private function worksheetPath(ZipArchive $zip): string
|
||||
{
|
||||
$workbook = $zip->getFromName('xl/workbook.xml');
|
||||
$relationships = $zip->getFromName('xl/_rels/workbook.xml.rels');
|
||||
if (is_string($workbook) && is_string($relationships)
|
||||
&& preg_match('/<sheet[^>]*r:id="([^"]+)"/', $workbook, $sheet) === 1
|
||||
&& preg_match('/Id="' . preg_quote($sheet[1], '/') . '"[^>]*Target="([^"]+)"/', $relationships, $target) === 1) {
|
||||
$path = ltrim($target[1], '/');
|
||||
|
||||
return str_starts_with($path, 'xl/') ? $path : 'xl/' . $path;
|
||||
}
|
||||
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = (string) $zip->getNameIndex($i);
|
||||
if (preg_match('#^xl/worksheets/[^/]+\.xml$#', $name) === 1) {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
return 'xl/worksheets/sheet1.xml';
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取共享字符串表;聚水潭导出通常为空,但标准 xlsx 会用到。
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function sharedStrings(ZipArchive $zip): array
|
||||
{
|
||||
$xml = $zip->getFromName('xl/sharedStrings.xml');
|
||||
if (! is_string($xml) || $xml === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
$index = -1;
|
||||
$buffer = '';
|
||||
$inside = false;
|
||||
|
||||
$parser = xml_parser_create('UTF-8');
|
||||
xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, 0);
|
||||
xml_set_element_handler(
|
||||
$parser,
|
||||
static function ($parser, string $name) use (&$inside, &$buffer, &$index): void {
|
||||
$name = self::localName($name);
|
||||
|
||||
if ($name === 'si') {
|
||||
$index++;
|
||||
$buffer = '';
|
||||
} elseif ($name === 't') {
|
||||
$inside = true;
|
||||
}
|
||||
},
|
||||
static function ($parser, string $name) use (&$inside, &$buffer, &$out): void {
|
||||
$name = self::localName($name);
|
||||
|
||||
if ($name === 't') {
|
||||
$inside = false;
|
||||
} elseif ($name === 'si') {
|
||||
$out[] = $buffer;
|
||||
}
|
||||
}
|
||||
);
|
||||
xml_set_character_data_handler($parser, static function ($parser, string $data) use (&$inside, &$buffer): void {
|
||||
if ($inside) {
|
||||
$buffer .= $data;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
xml_parse($parser, preg_replace('/^\xEF\xBB\xBF/', '', $xml, 1), true);
|
||||
} finally {
|
||||
xml_parser_free($parser);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function localName(string $name): string
|
||||
{
|
||||
$separator = strrpos($name, ':');
|
||||
|
||||
return $separator === false ? $name : substr($name, $separator + 1);
|
||||
}
|
||||
}
|
||||
50
app/Console/Commands/ArchiveStoreInformationDaily.php
Normal file
50
app/Console/Commands/ArchiveStoreInformationDaily.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\StoreInformationDailyArchive;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class ArchiveStoreInformationDaily extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:archive-store-information
|
||||
{--date= : 快照日期,格式 YYYY-MM-DD;默认今天}';
|
||||
|
||||
protected $description = '每日保存一份店铺基础信息完整快照,便于后续复查';
|
||||
|
||||
public function handle(StoreInformationDailyArchive $archive): int
|
||||
{
|
||||
$date = trim((string) ($this->option('date') ?: now('Asia/Shanghai')->format('Y-m-d')));
|
||||
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/D', $date)) {
|
||||
$this->error('快照日期格式应为 YYYY-MM-DD。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$snapshotDate = CarbonImmutable::createFromFormat('!Y-m-d', $date, 'Asia/Shanghai');
|
||||
} catch (Throwable) {
|
||||
$snapshotDate = null;
|
||||
}
|
||||
if ($snapshotDate === null || $snapshotDate->format('Y-m-d') !== $date) {
|
||||
$this->error('快照日期不是有效日期。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$count = $archive->capture($snapshotDate);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error('店铺基础信息存档失败:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(sprintf('已保存 %s 店铺基础信息快照,共 %d 条。', $date, $count));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,246 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\ProductMonthlyWeightedAverageReporter;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 检查上游完整性后,自动生成尚未计算的最新月份商品月加权平均单价。
|
||||
*/
|
||||
class AutoComputeProductMonthlyWeightedAverage extends Command
|
||||
{
|
||||
protected $signature = 'product:monthly-weighted-average:auto
|
||||
{--month= : 指定月份;默认取最新已成功的进销存月份}
|
||||
{--result-json= : 将执行结果写入指定 JSON 文件}';
|
||||
|
||||
protected $description = '上游完整后自动计算商品月加权平均单价,并保护已计算历史月份';
|
||||
|
||||
public function handle(ProductMonthlyWeightedAverageReporter $reporter): int
|
||||
{
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$month = $this->targetMonth($connection);
|
||||
if ($month === null) {
|
||||
return $this->finish('blocked', null, [
|
||||
'message' => '未找到已成功导入的进销存月份。',
|
||||
]);
|
||||
}
|
||||
|
||||
$lock = Cache::lock('product-monthly-weighted-average-auto:' . $month->format('Y-m'), 7200);
|
||||
if (! $lock->get()) {
|
||||
return $this->finish('blocked', $month, [
|
||||
'message' => '同月自动计算任务正在执行。',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$state = DB::connection($connection)->table('product_monthly_weighted_average_states')
|
||||
->where('report_month', $month->toDateString())
|
||||
->first();
|
||||
if (($state?->status ?? null) === 'computed') {
|
||||
$report = $reporter->build($connection, $month);
|
||||
$report['calculation_status'] = $report['status'];
|
||||
$report['status'] = 'already_computed';
|
||||
|
||||
return $this->finish('already_computed', $month, $report, $reporter);
|
||||
}
|
||||
|
||||
$checks = $this->checks($connection, $month);
|
||||
if (in_array(false, array_column($checks, 'passed'), true)) {
|
||||
DB::connection($connection)->table('product_monthly_weighted_average_states')->updateOrInsert(
|
||||
['report_month' => $month->toDateString()],
|
||||
[
|
||||
'status' => 'blocked',
|
||||
'last_error' => '上游数据未通过完整性检查',
|
||||
'updated_at' => now(),
|
||||
'created_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
return $this->finish('blocked', $month, [
|
||||
'message' => '上游数据未更新或不完整,未执行计算。',
|
||||
'checks' => $checks,
|
||||
]);
|
||||
}
|
||||
|
||||
$baseExit = $this->call('product:monthly-weighted-average:base', [
|
||||
'month' => $month->format('Y-m'),
|
||||
'--from-previous' => true,
|
||||
// 自动任务只会在尚未 computed 的月份走到这里,可安全清理失败重试留下
|
||||
// 的同月基表;已 computed 的历史月份在前面的门禁直接跳过。
|
||||
'--force' => true,
|
||||
]);
|
||||
if ($baseExit !== self::SUCCESS) {
|
||||
return $this->finish('failed', $month, [
|
||||
'message' => '生成期初基数失败。',
|
||||
]);
|
||||
}
|
||||
|
||||
$computeExit = $this->call('product:monthly-weighted-average:compute', [
|
||||
'month' => $month->format('Y-m'),
|
||||
]);
|
||||
if ($computeExit !== self::SUCCESS) {
|
||||
return $this->finish('failed', $month, [
|
||||
'message' => '加权平均单价计算失败。',
|
||||
]);
|
||||
}
|
||||
|
||||
$report = $reporter->build($connection, $month);
|
||||
$report['checks'] = $checks;
|
||||
|
||||
return $this->finish(
|
||||
($report['status'] ?? null) === 'review_required' ? 'review_required' : 'success',
|
||||
$month,
|
||||
$report,
|
||||
$reporter,
|
||||
);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
private function targetMonth(string $connection): ?CarbonImmutable
|
||||
{
|
||||
$requested = trim((string) $this->option('month'));
|
||||
if ($requested !== '') {
|
||||
return CarbonImmutable::parse($requested)->startOfMonth();
|
||||
}
|
||||
|
||||
$latest = DB::connection($connection)->table('inventory_report_authoritative_months')
|
||||
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
|
||||
->where('status', 'succeeded')
|
||||
->whereNotNull('active_generation')
|
||||
->orderByDesc('source_month')
|
||||
->value('source_month');
|
||||
|
||||
return $latest === null ? null : CarbonImmutable::parse((string) $latest)->startOfMonth();
|
||||
}
|
||||
|
||||
/** @return list<array{name:string,passed:bool,actual:mixed,expected:mixed}> */
|
||||
private function checks(string $connection, CarbonImmutable $month): array
|
||||
{
|
||||
$db = DB::connection($connection);
|
||||
$monthState = $db->table('inventory_report_authoritative_months')
|
||||
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
|
||||
->where('source_month', $month->toDateString())
|
||||
->first();
|
||||
$generation = $monthState?->active_generation;
|
||||
$activeRows = $generation === null
|
||||
? 0
|
||||
: $db->table('inventory_report_authoritative_rows')
|
||||
->where('generation', (string) $generation)
|
||||
->count();
|
||||
$expectedRows = (int) ($monthState?->source_row_count ?? 0);
|
||||
|
||||
$previous = $month->subMonthNoOverflow();
|
||||
$previousState = $db->table('product_monthly_weighted_average_states')
|
||||
->where('report_month', $previous->toDateString())
|
||||
->value('status');
|
||||
$previousRows = $db->table('product_monthly_weighted_average_rows')
|
||||
->where('report_month', $previous->toDateString())
|
||||
->count();
|
||||
|
||||
$inboundRows = $db->table('inventory_inbound_detail_rows')
|
||||
->where('report_month', $month->toDateString())
|
||||
->count();
|
||||
$inboundMissingDimensions = $db->table('inventory_inbound_detail_rows')
|
||||
->where('report_month', $month->toDateString())
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('barcode')->orWhere('barcode', '')
|
||||
->orWhereNull('warehouse_short_name')->orWhere('warehouse_short_name', '');
|
||||
})
|
||||
->count();
|
||||
$inboundMissingAmounts = $db->table('inventory_inbound_detail_rows')
|
||||
->where('report_month', $month->toDateString())
|
||||
->whereNull('amount')
|
||||
->whereNull('estimated_amount')
|
||||
->count();
|
||||
$productCatalogRows = $db->table('ops_jst_sku')->whereNotNull('sku_id')->count();
|
||||
|
||||
return [
|
||||
[
|
||||
'name' => '进销存月份状态成功',
|
||||
'passed' => ($monthState?->status ?? null) === 'succeeded',
|
||||
'actual' => $monthState?->status,
|
||||
'expected' => 'succeeded',
|
||||
],
|
||||
[
|
||||
'name' => '进销存激活明细行数完整',
|
||||
'passed' => $expectedRows > 0 && $activeRows === $expectedRows,
|
||||
'actual' => $activeRows,
|
||||
'expected' => $expectedRows,
|
||||
],
|
||||
[
|
||||
'name' => '上月加权平均结果已完成',
|
||||
'passed' => $previousState === 'computed' && $previousRows > 0,
|
||||
'actual' => ['status' => $previousState, 'rows' => $previousRows],
|
||||
'expected' => ['status' => 'computed', 'rows' => '> 0'],
|
||||
],
|
||||
[
|
||||
'name' => '入库明细已更新',
|
||||
'passed' => $inboundRows > 0,
|
||||
'actual' => $inboundRows,
|
||||
'expected' => '> 0',
|
||||
],
|
||||
[
|
||||
'name' => '入库商品编码与仓库简称无缺失',
|
||||
'passed' => $inboundMissingDimensions === 0,
|
||||
'actual' => $inboundMissingDimensions,
|
||||
'expected' => 0,
|
||||
],
|
||||
[
|
||||
'name' => '入库金额至少取金额或预估金额',
|
||||
'passed' => $inboundMissingAmounts === 0,
|
||||
'actual' => $inboundMissingAmounts,
|
||||
'expected' => 0,
|
||||
],
|
||||
[
|
||||
'name' => '普通商品资料已更新',
|
||||
'passed' => $productCatalogRows > 0,
|
||||
'actual' => $productCatalogRows,
|
||||
'expected' => '> 0',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
private function finish(
|
||||
string $status,
|
||||
?CarbonImmutable $month,
|
||||
array $payload,
|
||||
?ProductMonthlyWeightedAverageReporter $reporter = null,
|
||||
): int {
|
||||
$result = ['status' => $status] + $payload;
|
||||
if (! isset($result['report_month']) && $month !== null) {
|
||||
$result['report_month'] = $month->toDateString();
|
||||
}
|
||||
|
||||
$path = trim((string) $this->option('result-json'));
|
||||
if ($path !== '') {
|
||||
File::ensureDirectoryExists(dirname($path));
|
||||
File::put(
|
||||
$path,
|
||||
json_encode(
|
||||
$result,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
|
||||
) . PHP_EOL,
|
||||
);
|
||||
}
|
||||
|
||||
if ($reporter !== null && isset($result['totals'])) {
|
||||
$this->line($reporter->brief($result));
|
||||
} elseif (isset($result['message'])) {
|
||||
$this->error((string) $result['message']);
|
||||
}
|
||||
|
||||
return in_array($status, ['blocked', 'failed'], true)
|
||||
? self::FAILURE
|
||||
: self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 固定月度顺序:先生成商品月加权平均单价,再同步商品成本表。
|
||||
*/
|
||||
class AutoMonthlyWeightedAverageAndProductCost extends Command
|
||||
{
|
||||
protected $signature = 'finance:monthly-weighted-product-cost:auto
|
||||
{--month= : 指定月份;默认取最新已成功的进销存月份}
|
||||
{--result-json= : 将两阶段结果写入指定 JSON 文件}';
|
||||
|
||||
protected $description = '先计算商品月加权平均单价,再自动同步商品成本表';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$month = $this->targetMonth($connection);
|
||||
if ($month === null) {
|
||||
return $this->writeResult([
|
||||
'status' => 'blocked',
|
||||
'message' => '未找到已成功导入的进销存月份。',
|
||||
]);
|
||||
}
|
||||
|
||||
$weightedPath = tempnam(sys_get_temp_dir(), 'weighted-result-') . '.json';
|
||||
$costPath = tempnam(sys_get_temp_dir(), 'product-cost-result-') . '.json';
|
||||
try {
|
||||
$weightedExit = $this->call('product:monthly-weighted-average:auto', [
|
||||
'--month' => $month->format('Y-m'),
|
||||
'--result-json' => $weightedPath,
|
||||
]);
|
||||
$weighted = $this->readJson($weightedPath);
|
||||
if (
|
||||
$weightedExit !== self::SUCCESS
|
||||
|| in_array($weighted['status'] ?? 'failed', ['blocked', 'failed'], true)
|
||||
) {
|
||||
return $this->writeResult([
|
||||
'status' => 'failed',
|
||||
'report_month' => $month->toDateString(),
|
||||
'message' => '商品月加权平均单价未完成,商品成本未执行。',
|
||||
'weighted_average' => $weighted,
|
||||
'product_cost' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$costExit = $this->call('store-receipt:product-cost:sync', [
|
||||
'month' => $month->format('Y-m'),
|
||||
'--result-json' => $costPath,
|
||||
]);
|
||||
$cost = $this->readJson($costPath);
|
||||
$status = $costExit !== self::SUCCESS
|
||||
? 'failed'
|
||||
: ((int) ($cost['pending_cost_rows'] ?? 0) > 0 ? 'review_required' : 'success');
|
||||
|
||||
return $this->writeResult([
|
||||
'status' => $status,
|
||||
'report_month' => $month->toDateString(),
|
||||
'message' => match ($status) {
|
||||
'success' => '月加权平均单价与商品成本已按顺序完成。',
|
||||
'review_required' => '月加权平均单价与商品成本已完成,存在待核验成本。',
|
||||
default => '商品成本同步失败。',
|
||||
},
|
||||
'weighted_average' => $weighted,
|
||||
'product_cost' => $cost,
|
||||
]);
|
||||
} finally {
|
||||
@unlink($weightedPath);
|
||||
@unlink($costPath);
|
||||
}
|
||||
}
|
||||
|
||||
private function targetMonth(string $connection): ?CarbonImmutable
|
||||
{
|
||||
$requested = trim((string) $this->option('month'));
|
||||
if ($requested !== '') {
|
||||
return CarbonImmutable::parse($requested)->startOfMonth();
|
||||
}
|
||||
|
||||
$latest = DB::connection($connection)->table('inventory_report_authoritative_months')
|
||||
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
|
||||
->where('status', 'succeeded')
|
||||
->whereNotNull('active_generation')
|
||||
->orderByDesc('source_month')
|
||||
->value('source_month');
|
||||
|
||||
return $latest === null ? null : CarbonImmutable::parse((string) $latest)->startOfMonth();
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function readJson(string $path): array
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
return ['status' => 'failed', 'message' => '子任务未生成结果文件。'];
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) file_get_contents($path), true);
|
||||
|
||||
return is_array($decoded)
|
||||
? $decoded
|
||||
: ['status' => 'failed', 'message' => '子任务结果文件无法解析。'];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $result */
|
||||
private function writeResult(array $result): int
|
||||
{
|
||||
$path = trim((string) $this->option('result-json'));
|
||||
if ($path !== '') {
|
||||
File::ensureDirectoryExists(dirname($path));
|
||||
File::put(
|
||||
$path,
|
||||
json_encode(
|
||||
$result,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
|
||||
) . PHP_EOL,
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($result['message'])) {
|
||||
$this->line((string) $result['message']);
|
||||
}
|
||||
|
||||
return in_array($result['status'] ?? 'failed', ['success', 'review_required'], true)
|
||||
? self::SUCCESS
|
||||
: self::FAILURE;
|
||||
}
|
||||
}
|
||||
29
app/Console/Commands/BackfillWorkOrderWaybillLookup.php
Normal file
29
app/Console/Commands/BackfillWorkOrderWaybillLookup.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Modules\EcommerceFinance\Services\WorkOrderWaybillLookup;
|
||||
|
||||
class BackfillWorkOrderWaybillLookup extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:index-work-order-waybills {--batch=500} {--max-batches=2} {--pause-ms=50}';
|
||||
protected $description = '按稳定游标建立查件运单小索引;生产须使用 run-finance-batch.sh 执行';
|
||||
|
||||
public function handle(WorkOrderWaybillLookup $lookup): int
|
||||
{
|
||||
$lock = Cache::store('upstream_shared')->lock('work-order-waybill-lookup:backfill', 3600);
|
||||
if (! $lock->get()) {
|
||||
$this->info('已有运单索引任务运行,本轮跳过。');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
try {
|
||||
$result = $lookup->backfill((int) $this->option('batch'), max(0, (int) $this->option('max-batches')), max(0, (int) $this->option('pause-ms')));
|
||||
$this->info(json_encode($result));
|
||||
return self::SUCCESS;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
334
app/Console/Commands/ComputeProductMonthlyWeightedAverage.php
Normal file
334
app/Console/Commands/ComputeProductMonthlyWeightedAverage.php
Normal file
@ -0,0 +1,334 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\ProductMonthlyWeightedAverageReporter;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 按取数逻辑计算某个月的更改价格、定价状态、采购单价、期初金额、入库金额和加权平均单价。
|
||||
*/
|
||||
class ComputeProductMonthlyWeightedAverage extends Command
|
||||
{
|
||||
protected $signature = 'product:monthly-weighted-average:compute
|
||||
{month : 报表月份,例如 2026-07}
|
||||
{--force : 覆盖已经计算完成的同月结果}
|
||||
{--result-json= : 将结果简报写入指定 JSON 文件}';
|
||||
|
||||
protected $description = '按取数逻辑计算商品月加权平均单价';
|
||||
|
||||
public function handle(ProductMonthlyWeightedAverageReporter $reporter): int
|
||||
{
|
||||
$month = CarbonImmutable::parse($this->argument('month'))->startOfMonth();
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$table = 'product_monthly_weighted_average_rows';
|
||||
$deadline = $month->addMonthNoOverflow()->day(2)->startOfDay();
|
||||
|
||||
$stateStatus = DB::connection($connection)
|
||||
->table('product_monthly_weighted_average_states')
|
||||
->where('report_month', $month->toDateString())
|
||||
->value('status');
|
||||
$computedRows = DB::connection($connection)->table($table)
|
||||
->where('report_month', $month->toDateString())
|
||||
->whereNotNull('computed_at')
|
||||
->count();
|
||||
if (! $this->option('force') && ($stateStatus === 'computed' || $computedRows > 0)) {
|
||||
$this->error($month->format('Y-m') . ' 已经计算完成,历史月份不自动覆盖;如需人工纠正请显式使用 --force。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$query = DB::connection($connection)->table($table)->where('report_month', $month->toDateString());
|
||||
$codes = (clone $query)->distinct()->pluck('product_code')->map(fn ($code) => trim((string) $code))->unique()->values()->all();
|
||||
$this->info('待计算 SKU:' . count($codes));
|
||||
|
||||
$cost = $this->costPrices($connection, $codes);
|
||||
$work = $this->workPlatformPrices($connection, $codes);
|
||||
$change = $this->priceChanges($connection, $codes);
|
||||
$inbound = $this->inboundAggregate($connection, $month);
|
||||
$costRecordSkus = $this->costPriceRecordSkus($connection, $codes);
|
||||
|
||||
$total = (clone $query)->count();
|
||||
$computed = 0;
|
||||
$now = now();
|
||||
|
||||
for ($offset = 0; $offset < $total; $offset += 1000) {
|
||||
$rows = (clone $query)->orderBy('row_key')->offset($offset)->limit(1000)
|
||||
->get([
|
||||
'row_key', 'barcode_warehouse', 'warehouse_short_name', 'product_code', 'base_source', 'imported_at',
|
||||
'opening_quantity', 'opening_unit_price', 'previous_pricing_status', 'purchase_quantity',
|
||||
]);
|
||||
|
||||
$updates = [];
|
||||
foreach ($rows as $row) {
|
||||
// 普通商品资料与进销存的商品编码大小写不一致,统一按大写匹配。
|
||||
$code = mb_strtoupper(trim((string) $row->product_code));
|
||||
$costPrice = is_array($cost) ? ($cost[$code] ?? null) : null;
|
||||
$workPrice = is_array($work) ? ($work[$code] ?? null) : null;
|
||||
$changePrice = is_array($change) ? ($change[$code] ?? null) : null;
|
||||
$inboundKey = $code . "\0" . trim((string) $row->warehouse_short_name);
|
||||
$payload = $this->calculate(
|
||||
$row,
|
||||
$costPrice,
|
||||
$workPrice,
|
||||
$changePrice,
|
||||
$deadline,
|
||||
$inbound[$inboundKey] ?? null,
|
||||
isset($costRecordSkus[$code]),
|
||||
);
|
||||
$updates[] = array_merge([
|
||||
'report_month' => $month->toDateString(),
|
||||
'row_key' => $row->row_key,
|
||||
'barcode_warehouse' => $row->barcode_warehouse,
|
||||
'warehouse_short_name' => $row->warehouse_short_name,
|
||||
'product_code' => $row->product_code,
|
||||
'base_source' => $row->base_source,
|
||||
'imported_at' => $row->imported_at,
|
||||
'computed_at' => $now,
|
||||
], $payload);
|
||||
$computed++;
|
||||
}
|
||||
|
||||
if ($updates !== []) {
|
||||
DB::connection($connection)->table($table)->upsert(
|
||||
$updates,
|
||||
['report_month', 'row_key'],
|
||||
['adjusted_unit_price', 'opening_amount', 'purchase_quantity', 'purchase_unit_price', 'purchase_amount', 'weighted_average_unit_price', 'pricing_status', 'computed_at'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DB::connection($connection)->table('product_monthly_weighted_average_states')->updateOrInsert(
|
||||
['report_month' => $month->toDateString()],
|
||||
['status' => 'computed', 'computed_rows' => $computed, 'last_computed_at' => $now, 'updated_at' => $now, 'created_at' => $now],
|
||||
);
|
||||
|
||||
$report = $reporter->build($connection, $month);
|
||||
$json = json_encode($report, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
if (is_string($json)) {
|
||||
$resultPath = trim((string) $this->option('result-json'));
|
||||
if ($resultPath !== '') {
|
||||
File::ensureDirectoryExists(dirname($resultPath));
|
||||
File::put($resultPath, $json . PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info($month->format('Y-m') . " 计算完成:{$computed} 行");
|
||||
$this->line($reporter->brief($report));
|
||||
if (($report['status'] ?? null) === 'review_required') {
|
||||
$this->warn('存在待复核差异;结果已生成,但不会视为完全无异常。');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,float>
|
||||
*/
|
||||
private function costPrices(string $connection, array $codes): array
|
||||
{
|
||||
$map = [];
|
||||
foreach (array_chunk($codes, 2000) as $chunk) {
|
||||
foreach (DB::connection($connection)->table('ops_jst_sku')
|
||||
->whereIn('sku_id', $chunk)->whereNotNull('cost_price')->get(['sku_id', 'cost_price']) as $row) {
|
||||
$map[mb_strtoupper(trim((string) $row->sku_id))] = (float) $row->cost_price;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,array{price:float,effect:?string}>
|
||||
*/
|
||||
private function workPlatformPrices(string $connection, array $codes): array
|
||||
{
|
||||
$map = [];
|
||||
$now = now();
|
||||
foreach (array_chunk($codes, 2000) as $chunk) {
|
||||
$rows = DB::connection($connection)->table('js_pm_price_adjustment')
|
||||
->whereIn('skuCode', $chunk)
|
||||
->whereNotNull('purchasePrice')
|
||||
->where(function ($query) use ($now): void {
|
||||
$query->whereNull('effectTime')->orWhere('effectTime', '<=', $now);
|
||||
})
|
||||
->orderBy('effectTime')
|
||||
->get(['skuCode', 'purchasePrice', 'effectTime']);
|
||||
foreach ($rows as $row) {
|
||||
$map[mb_strtoupper(trim((string) $row->skuCode))] = [
|
||||
'price' => (float) $row->purchasePrice,
|
||||
'effect' => $row->effectTime === null ? null : (string) $row->effectTime,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,array{price:float,time:?string}>
|
||||
*/
|
||||
/**
|
||||
* 入库明细汇总:按「大写条码 + 仓库简称」汇总库存变动量与总计(金额 + 预估金额)。
|
||||
*
|
||||
* @return array<string,array{quantity:float,total:float}>
|
||||
*/
|
||||
private function inboundAggregate(string $connection, CarbonImmutable $month): array
|
||||
{
|
||||
$map = [];
|
||||
if (! \Illuminate\Support\Facades\Schema::connection($connection)->hasTable('inventory_inbound_detail_rows')) {
|
||||
return $map;
|
||||
}
|
||||
|
||||
foreach (DB::connection($connection)->table('inventory_inbound_detail_rows')
|
||||
->where('report_month', $month->toDateString())
|
||||
->selectRaw('warehouse_short_name, upper(barcode) as code, sum(quantity) as quantity, sum(coalesce(amount, 0) + coalesce(estimated_amount, 0)) as total')
|
||||
->groupBy('warehouse_short_name')
|
||||
->groupByRaw('upper(barcode)')
|
||||
->get() as $row) {
|
||||
$key = mb_strtoupper(trim((string) $row->code)) . "\0" . trim((string) $row->warehouse_short_name);
|
||||
$map[$key] = ['quantity' => (float) $row->quantity, 'total' => (float) $row->total];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 有"聚水潭成本价记录"(operated_at 非空)的商品编码集合。
|
||||
*
|
||||
* @return array<string,bool>
|
||||
*/
|
||||
private function costPriceRecordSkus(string $connection, array $codes): array
|
||||
{
|
||||
$map = [];
|
||||
if (! \Illuminate\Support\Facades\Schema::connection($connection)->hasTable('jushuitan_cost_price_history_rows')) {
|
||||
return $map;
|
||||
}
|
||||
|
||||
foreach (array_chunk($codes, 2000) as $chunk) {
|
||||
$rows = DB::connection($connection)->table('jushuitan_cost_price_history_rows')
|
||||
->whereNotNull('operated_at')
|
||||
->where(function ($query) use ($chunk): void {
|
||||
$query->whereIn('sku_id', $chunk)->orWhereIn('sku_id_normalized', $chunk);
|
||||
})
|
||||
->get(['sku_id', 'sku_id_normalized']);
|
||||
foreach ($rows as $row) {
|
||||
foreach ([$row->sku_id, $row->sku_id_normalized] as $value) {
|
||||
$key = mb_strtoupper(trim((string) $value));
|
||||
if ($key !== '') {
|
||||
$map[$key] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function priceChanges(string $connection, array $codes): array
|
||||
{
|
||||
$map = [];
|
||||
foreach (array_chunk($codes, 2000) as $chunk) {
|
||||
$rows = DB::connection($connection)->table('js_pm_price_adjustment_log')
|
||||
->whereIn('skucode', $chunk)
|
||||
->whereNotNull('afterPrice')
|
||||
->orderBy('effectStartTime')
|
||||
->get(['skucode', 'afterPrice', 'originalPrice', 'effectStartTime', 'createTime']);
|
||||
foreach ($rows as $row) {
|
||||
$map[mb_strtoupper(trim((string) $row->skucode))] = [
|
||||
'price' => (float) $row->afterPrice,
|
||||
'before' => $row->originalPrice === null ? null : (float) $row->originalPrice,
|
||||
'time' => $row->effectStartTime === null ? null : (string) $row->effectStartTime,
|
||||
'created' => $row->createTime === null ? null : (string) $row->createTime,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{price:float,effect:?string}|null $work
|
||||
* @param array{price:float,time:?string}|null $change
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function calculate(object $row, ?float $cost, ?array $work, ?array $change, CarbonImmutable $deadline, ?array $inbound = null, bool $hasCostRecord = false): array
|
||||
{
|
||||
$openingQuantity = $row->opening_quantity === null ? 0.0 : (float) $row->opening_quantity;
|
||||
$purchaseQuantity = $row->purchase_quantity === null ? 0.0 : (float) $row->purchase_quantity;
|
||||
$openingUnitPrice = $row->opening_unit_price === null ? null : (float) $row->opening_unit_price;
|
||||
$previousStatus = trim((string) ($row->previous_pricing_status ?? ''));
|
||||
$changePrice = $change['price'] ?? null;
|
||||
$workPrice = $work['price'] ?? null;
|
||||
// 最终规则:生效开始日期为空 → 未匹配到工作平台价格;
|
||||
// 生效开始日期 > 本月,或记录时间 > 次月2号(记录时间为空视为 ≤ 次月2号)→ 新价当月未生效,取变价前价格。
|
||||
$changeTime = $change['time'] ?? null;
|
||||
$changeCreated = $change['created'] ?? null;
|
||||
$monthStart = $deadline->startOfMonth();
|
||||
$hasWorkPlatformRecord = $changePrice !== null && $changeTime !== null;
|
||||
$newPricePending = false;
|
||||
if ($hasWorkPlatformRecord) {
|
||||
$newPricePending = CarbonImmutable::parse($changeTime)->greaterThanOrEqualTo($monthStart)
|
||||
|| ($changeCreated !== null && CarbonImmutable::parse($changeCreated)->greaterThan($deadline));
|
||||
$changePrice = $newPricePending ? ($change['before'] ?? $changePrice) : $changePrice;
|
||||
}
|
||||
$previousStatus = in_array($previousStatus, ['预估价', '未定价'], true) ? '未定价' : $previousStatus;
|
||||
|
||||
// 更改价格 + 定价状态(表2 第10条)
|
||||
if ($openingUnitPrice !== null) {
|
||||
if ($previousStatus !== '未定价') {
|
||||
$adjusted = $openingUnitPrice;
|
||||
$status = $previousStatus !== '' ? $previousStatus : '已定价';
|
||||
} elseif ($hasWorkPlatformRecord) {
|
||||
$adjusted = $changePrice;
|
||||
$status = $newPricePending ? '未定价' : '已定价';
|
||||
} else {
|
||||
$adjusted = $openingUnitPrice;
|
||||
$status = '未定价';
|
||||
}
|
||||
} elseif ($hasWorkPlatformRecord) {
|
||||
$adjusted = $changePrice;
|
||||
$status = $newPricePending ? '未定价' : '已定价';
|
||||
} elseif ($cost !== null) {
|
||||
// 方案②:只有"聚水潭成本价记录"里有生效记录(operated_at 非空)才算有生效日期 → 已定价。
|
||||
$adjusted = $cost;
|
||||
$status = $hasCostRecord ? '已定价' : '未定价';
|
||||
} else {
|
||||
$adjusted = null;
|
||||
$status = '待人工核查';
|
||||
}
|
||||
|
||||
// 采购数量/入库金额来自入库明细(大写条码 + 仓库简称汇总),采购单价 = 入库金额 ÷ 采购数量
|
||||
$purchaseQuantity = (float) ($inbound['quantity'] ?? 0);
|
||||
// 加权平均单价按“先除再舍入”计算,因此这里保留未舍入的金额参与除法。
|
||||
$purchaseAmountRaw = $inbound === null ? 0.0 : (float) ($inbound['total'] ?? 0);
|
||||
$purchaseAmount = $inbound === null ? null : round($purchaseAmountRaw, 2);
|
||||
$purchaseUnitPrice = ($inbound === null || $purchaseQuantity == 0.0)
|
||||
? null
|
||||
: round((float) ($inbound['total'] ?? 0) / $purchaseQuantity, 2);
|
||||
|
||||
$openingAmountRaw = $adjusted === null ? 0.0 : $openingQuantity * $adjusted;
|
||||
$openingAmount = $adjusted === null ? null : round($openingAmountRaw, 2);
|
||||
$denominator = $openingQuantity + $purchaseQuantity;
|
||||
if ($denominator === 0.0) {
|
||||
// 四项全 0:加权平均单价取"更改价格"(按两位舍入后的值)。
|
||||
$weighted = $adjusted === null ? null : round($adjusted, 2);
|
||||
} else {
|
||||
// 先除再舍入:用未舍入的期初金额与入库金额相除,最后统一保留两位。
|
||||
$weighted = round(($openingAmountRaw + $purchaseAmountRaw) / $denominator, 4);
|
||||
}
|
||||
|
||||
return [
|
||||
'purchase_quantity' => $purchaseQuantity,
|
||||
'adjusted_unit_price' => $adjusted === null ? null : round($adjusted, 2),
|
||||
'opening_amount' => $openingAmount,
|
||||
'purchase_unit_price' => $purchaseUnitPrice === null ? null : round($purchaseUnitPrice, 2),
|
||||
'purchase_amount' => $purchaseAmount,
|
||||
'weighted_average_unit_price' => $weighted,
|
||||
'pricing_status' => $status,
|
||||
];
|
||||
}
|
||||
}
|
||||
164
app/Console/Commands/FollowUpstreamColumns.php
Normal file
164
app/Console/Commands/FollowUpstreamColumns.php
Normal file
@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorSchemaManager;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 让登记过的数据集自动跟随上游新增字段。
|
||||
*
|
||||
* 上游视图/表新增列时,数据字典(upstream_schema_columns)会先刷新出来,这里再把新列
|
||||
* 补进数据集定义、建好镜像列;下一次常规同步就会把数据带进来,页面也会自动多出这一列。
|
||||
* 只做"新增":不删除、不改写已有字段,敏感字段名一律跳过。
|
||||
*/
|
||||
class FollowUpstreamColumns extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:follow-upstream-columns
|
||||
{--dataset= : 只处理指定数据集}';
|
||||
|
||||
protected $description = '把上游新增字段补进登记过的镜像数据集(页面字段自动跟随)';
|
||||
|
||||
private const DENIED_KEYWORDS = ['password', 'passwd', 'secret', 'token', 'cipher', 'credential', 'private'];
|
||||
|
||||
public function handle(
|
||||
BusinessMirrorDatasetRegistry $datasets,
|
||||
BusinessMirrorSchemaManager $schemas,
|
||||
): int {
|
||||
$keys = array_values(array_filter(array_map(
|
||||
static fn (mixed $key): string => trim((string) $key),
|
||||
(array) ($this->option('dataset') ?: array_values((array) config('upstream_mirrors.auto_follow_columns', []))),
|
||||
), static fn (string $key): bool => $key !== ''));
|
||||
|
||||
if ($keys === []) {
|
||||
$this->info('没有登记需要自动跟随上游字段的数据集。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// 先刷新数据字典,否则新字段根本不在目录里。该命令自带 24 小时新鲜度判断,
|
||||
// 已经同步过会直接跳过。
|
||||
foreach (array_unique($this->sourcesOf($keys)) as $source) {
|
||||
try {
|
||||
// --due:数据字典新鲜(24 小时内同步过)就不再重复扫上游。
|
||||
Artisan::call('upstream:schema:sync', ['source' => $source, '--due' => true]);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->warn($source .':数据字典刷新失败,本轮跳过新字段检测。');
|
||||
}
|
||||
}
|
||||
|
||||
$followed = 0;
|
||||
foreach ($keys as $key) {
|
||||
try {
|
||||
$added = $this->follow($key, $datasets, $schemas);
|
||||
if ($added !== []) {
|
||||
$followed += count($added);
|
||||
$this->info($key . ':新增 ' . implode('、', $added) . ' 并已建列,下次同步即带回数据。');
|
||||
Log::info('业务镜像自动跟随上游新增字段。', ['dataset' => $key, 'columns' => $added]);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error($key . ':跟随失败 —— ' . $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->info($followed > 0 ? sprintf('共补入 %d 个新字段。', $followed) : '没有发现上游新增字段。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** @param list<string> $keys @return list<string> */
|
||||
private function sourcesOf(array $keys): array
|
||||
{
|
||||
$sources = [];
|
||||
foreach (DB::table('business_mirror_datasets')->whereIn('dataset_key', $keys)->get() as $row) {
|
||||
$definition = json_decode((string) $row->definition, true) ?: [];
|
||||
$source = trim((string) ($definition['source_key'] ?? ''));
|
||||
if ($source !== '') {
|
||||
$sources[] = $source;
|
||||
}
|
||||
}
|
||||
|
||||
return $sources;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string> 本次补入的字段
|
||||
*/
|
||||
private function follow(string $key, BusinessMirrorDatasetRegistry $datasets, BusinessMirrorSchemaManager $schemas): array
|
||||
{
|
||||
$row = DB::table('business_mirror_datasets')->where('dataset_key', $key)->first();
|
||||
if ($row === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$definition = json_decode((string) $row->definition, true) ?: [];
|
||||
$source = trim((string) ($definition['source_key'] ?? ''));
|
||||
$schema = trim((string) ($definition['schema'] ?? ''));
|
||||
$object = trim((string) ($definition['object'] ?? ''));
|
||||
$current = array_values(array_filter(array_map(
|
||||
static fn (mixed $column): string => trim((string) $column),
|
||||
(array) ($definition['columns'] ?? []),
|
||||
), static fn (string $column): bool => $column !== ''));
|
||||
|
||||
if ($source === '' || $schema === '' || $object === '' || $current === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$upstream = DB::table('upstream_schema_columns')
|
||||
->where('source_key', $source)
|
||||
->whereRaw('LOWER(schema_name) = ?', [strtolower($schema)])
|
||||
->whereRaw('LOWER(object_name) = ?', [strtolower($object)])
|
||||
->orderBy('ordinal_position')
|
||||
->pluck('column_name')
|
||||
->map(static fn (mixed $name): string => (string) $name)
|
||||
->all();
|
||||
|
||||
$added = [];
|
||||
foreach ($upstream as $column) {
|
||||
if (in_array($column, $current, true) || str_starts_with($column, '_mirror') || $this->isSensitive($column)) {
|
||||
continue;
|
||||
}
|
||||
$added[] = $column;
|
||||
}
|
||||
|
||||
if ($added === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$definition['columns'] = array_values(array_merge($current, $added));
|
||||
// 记下"跟随进来的字段":页面据此自动补列,不会把历史上刻意没展示的字段带出来。
|
||||
$definition['followed_columns'] = array_values(array_unique(array_merge(
|
||||
(array) ($definition['followed_columns'] ?? []),
|
||||
$added,
|
||||
)));
|
||||
|
||||
DB::table('business_mirror_datasets')->where('dataset_key', $key)->update([
|
||||
'definition' => json_encode($definition, JSON_THROW_ON_ERROR),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// 立刻建列,下一次同步就能写入;页面在数据到达前显示为空即可。
|
||||
$schemas->ensure($datasets->get($key));
|
||||
|
||||
return $added;
|
||||
}
|
||||
|
||||
private function isSensitive(string $column): bool
|
||||
{
|
||||
$name = strtolower($column);
|
||||
foreach (self::DENIED_KEYWORDS as $keyword) {
|
||||
if (str_contains($name, $keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
32
app/Console/Commands/ImportAccessoryReshipCourierConfigs.php
Normal file
32
app/Console/Commands/ImportAccessoryReshipCourierConfigs.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Common\Company;
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\AccessoryReshipCourierImporter;
|
||||
use Modules\EcommerceFinance\Services\AccessoryReshipCourierStore;
|
||||
|
||||
class ImportAccessoryReshipCourierConfigs extends Command
|
||||
{
|
||||
protected $signature = 'accessory-reship:import-courier-configs {file} {--company= : 导入所属公司ID} {--dry-run : 仅检查文件及输出条数,不写入}';
|
||||
protected $description = '导入用户提供的存量快递补发扣款比例配置(重复项跳过、冲突整批回滚)';
|
||||
|
||||
public function handle(AccessoryReshipCourierImporter $importer, AccessoryReshipCourierStore $store): int
|
||||
{
|
||||
$companyId = (int) $this->option('company');
|
||||
if ($companyId <= 0 || !Company::query()->where('id', $companyId)->exists()) {
|
||||
$this->error('请指定有效的 --company,公司配置不会跨公司写入。');
|
||||
return self::FAILURE;
|
||||
}
|
||||
$path = $this->argument('file');
|
||||
$rows = $importer->read($path);
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info('文件校验通过,共' . count($rows) . '条;未写入。');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
$result = $store->import($companyId, $rows, '用户授权存量配置导入', hash_file('sha256', $path));
|
||||
$this->info('导入完成:新增' . $result['added'] . '条,重复跳过' . $result['skipped'] . '条。');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
139
app/Console/Commands/ImportAccountLedgerRemarks.php
Normal file
139
app/Console/Commands/ImportAccountLedgerRemarks.php
Normal file
@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 导入「平台 | 账单项目 | 总账备注」映射表,供店铺收款账户账单的「总账备注」列使用。
|
||||
*/
|
||||
class ImportAccountLedgerRemarks extends Command
|
||||
{
|
||||
protected $signature = 'account-ledger-remarks:import {path : xlsx/csv 文件} {--sheet=1 : 工作表序号(从 1 开始)或名称} {--replace : 先清空再导入(默认按平台+账单项目覆盖)}';
|
||||
|
||||
protected $description = '导入账单项目对应的总账备注映射表';
|
||||
|
||||
public function handle(AccountLedgerRemarkMap $map): int
|
||||
{
|
||||
$path = (string) $this->argument('path');
|
||||
if (! is_file($path)) {
|
||||
$this->error('文件不存在:' . $path);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$rows = $this->readRows($path);
|
||||
} catch (Throwable $e) {
|
||||
$this->error('读取失败:' . $e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
$this->error('没有解析到有效数据(需要表头:平台 / 账单项目 / 总账备注)。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
DB::transaction(function () use ($rows, $now): void {
|
||||
if ($this->option('replace')) {
|
||||
DB::table('ec_account_ledger_remarks')->delete();
|
||||
}
|
||||
|
||||
foreach (array_chunk($rows, 500) as $chunk) {
|
||||
DB::table('ec_account_ledger_remarks')->upsert(
|
||||
array_map(static fn (array $row): array => $row + ['created_at' => $now, 'updated_at' => $now], $chunk),
|
||||
['platform', 'bill_item'],
|
||||
['ledger_remark', 'updated_at'],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
$map->forget();
|
||||
|
||||
$platforms = [];
|
||||
foreach ($rows as $row) {
|
||||
$platforms[$row['platform']] = ($platforms[$row['platform']] ?? 0) + 1;
|
||||
}
|
||||
ksort($platforms);
|
||||
|
||||
$this->info(sprintf('已导入 %d 条总账备注映射。', count($rows)));
|
||||
foreach ($platforms as $platform => $count) {
|
||||
$this->line(sprintf(' %s:%d 条', $platform, $count));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{platform:string,bill_item:string,ledger_remark:string}>
|
||||
*/
|
||||
private function readRows(string $path): array
|
||||
{
|
||||
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
$readerType = match ($extension) {
|
||||
'xlsx' => 'Xlsx',
|
||||
'xls' => 'Xls',
|
||||
'csv' => 'Csv',
|
||||
default => throw new \InvalidArgumentException('只支持 xlsx / xls / csv 文件。'),
|
||||
};
|
||||
|
||||
$reader = IOFactory::createReader($readerType);
|
||||
$reader->setReadDataOnly(true);
|
||||
if ($readerType === 'Csv') {
|
||||
$reader->setInputEncoding('UTF-8');
|
||||
}
|
||||
$book = $reader->load($path);
|
||||
$sheetOption = trim((string) $this->option('sheet'));
|
||||
if ($sheetOption !== '' && ctype_digit($sheetOption)) {
|
||||
$index = (int) $sheetOption - 1;
|
||||
if ($index < 0 || $index >= $book->getSheetCount()) {
|
||||
throw new \InvalidArgumentException('工作表序号超出范围:' . $sheetOption);
|
||||
}
|
||||
$sheet = $book->getSheet($index);
|
||||
} else {
|
||||
$sheet = $sheetOption === '' ? $book->getActiveSheet() : $book->getSheetByName($sheetOption);
|
||||
if ($sheet === null) {
|
||||
throw new \InvalidArgumentException('找不到工作表:' . $sheetOption);
|
||||
}
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$header = null;
|
||||
foreach ($sheet->getRowIterator() as $row) {
|
||||
$cells = [];
|
||||
$cellIterator = $row->getCellIterator();
|
||||
$cellIterator->setIterateOnlyExistingCells(false);
|
||||
foreach ($cellIterator as $cell) {
|
||||
$cells[] = trim((string) $cell->getValue());
|
||||
}
|
||||
|
||||
if ($header === null) {
|
||||
$cells[0] = preg_replace('/^\xEF\xBB\xBF/', '', $cells[0] ?? '');
|
||||
$header = $cells;
|
||||
continue;
|
||||
}
|
||||
|
||||
$platform = trim($cells[0] ?? '');
|
||||
$item = trim($cells[1] ?? '');
|
||||
$remark = trim($cells[2] ?? '');
|
||||
if ($platform === '' || $item === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'platform' => mb_substr($platform, 0, 32),
|
||||
'bill_item' => mb_substr($item, 0, 191),
|
||||
'ledger_remark' => mb_substr($remark, 0, 191),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
32
app/Console/Commands/ImportAccountMappingFiles.php
Normal file
32
app/Console/Commands/ImportAccountMappingFiles.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\AccountMappingStore;
|
||||
|
||||
/**
|
||||
* 把科目映射表的旧文件存储(storage/app/account-mapping/rules.json / events.json)
|
||||
* 导入共享系统数据库(ec_account_mapping_rules / ec_account_mapping_events)。
|
||||
*
|
||||
* 幂等:以文件为准覆盖数据库内容,可重复执行。
|
||||
*/
|
||||
class ImportAccountMappingFiles extends Command
|
||||
{
|
||||
protected $signature = 'account-mapping:import-files {--events : 一并导入操作记录}';
|
||||
|
||||
protected $description = '把科目映射表的旧文件存储导入共享数据库';
|
||||
|
||||
public function handle(AccountMappingStore $store): int
|
||||
{
|
||||
$result = $store->syncFromFiles((bool) $this->option('events'));
|
||||
|
||||
$this->info(
|
||||
'已导入规则 ' . $result['rules'] . ' 条'
|
||||
. ($result['events'] > 0 ? ',操作记录 ' . $result['events'] . ' 条' : '')
|
||||
. '。'
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
205
app/Console/Commands/ImportAfterSalesCostRows.php
Normal file
205
app/Console/Commands/ImportAfterSalesCostRows.php
Normal file
@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\XlsxSheetReader;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Repositories\AfterSalesCostRowRepository;
|
||||
|
||||
/**
|
||||
* 导入聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」Excel。
|
||||
*
|
||||
* 该报表已按商品编码(组合装拆成组件)给出每行和成本,导入后订单售后费用明细页面
|
||||
* 在覆盖的店铺/月份范围内直接按这些明细行展示。
|
||||
*/
|
||||
class ImportAfterSalesCostRows extends Command
|
||||
{
|
||||
protected $signature = 'order-data:import-after-sales-cost
|
||||
{file : Excel 文件路径}
|
||||
{--period= : 数据月份,格式 YYYY-MM}
|
||||
{--chunk=500 : 每批写入行数}
|
||||
{--dry-run : 只解析不入库}';
|
||||
|
||||
protected $description = '导入聚水潭「订单售后费用数据」Excel 到订单售后费用明细';
|
||||
|
||||
public function handle(XlsxSheetReader $reader): int
|
||||
{
|
||||
$path = (string) $this->argument('file');
|
||||
$period = trim((string) $this->option('period'));
|
||||
|
||||
if (! is_file($path)) {
|
||||
$this->error('文件不存在:' . $path);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($period !== '' && preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $period) !== 1) {
|
||||
$this->error('数据月份格式必须是 YYYY-MM:' . $period);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$headers = (array) config('order_after_sales_cost.headers', []);
|
||||
$numberKeys = array_flip((array) config('order_after_sales_cost.number_keys', []));
|
||||
|
||||
if ($headers === []) {
|
||||
$this->error('缺少表头映射:config/order_after_sales_cost.php');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('流式读取文件:' . $path);
|
||||
|
||||
$chunkSize = max((int) $this->option('chunk'), 100);
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$buffer = [];
|
||||
$total = 0;
|
||||
$written = 0;
|
||||
$skipped = 0;
|
||||
$shops = [];
|
||||
$mappedRows = 0;
|
||||
$minDate = null;
|
||||
$maxDate = null;
|
||||
$importedAt = now()->toDateTimeString();
|
||||
|
||||
try {
|
||||
foreach ($reader->rows($path, [], array_keys($headers)) as $row) {
|
||||
$total++;
|
||||
$payload = [];
|
||||
|
||||
foreach ($headers as $header => $key) {
|
||||
if (! array_key_exists($header, $row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = trim((string) $row[$header]);
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($numberKeys[$key])) {
|
||||
$value = is_numeric($value) ? (float) $value : 0.0;
|
||||
} elseif (self::isDateKey($key)) {
|
||||
$value = self::dateValue($value);
|
||||
}
|
||||
|
||||
$payload[$key] = $value;
|
||||
}
|
||||
|
||||
if ($payload !== []) {
|
||||
$mappedRows++;
|
||||
}
|
||||
|
||||
$shop = trim((string) ($payload['shop_name'] ?? ''));
|
||||
$productCode = trim((string) ($payload['product_code'] ?? ''));
|
||||
if ($shop === '' || $productCode === '') {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$buffer[] = AfterSalesCostRowRepository::toRecord(
|
||||
$payload,
|
||||
$importedAt,
|
||||
$period !== '' ? $period : null,
|
||||
);
|
||||
$shops[$shop] = true;
|
||||
$orderTime = $payload['order_time'] ?? null;
|
||||
|
||||
if (is_string($orderTime) && $orderTime !== '') {
|
||||
$minDate = $minDate === null || $orderTime < $minDate ? $orderTime : $minDate;
|
||||
$maxDate = $maxDate === null || $orderTime > $maxDate ? $orderTime : $maxDate;
|
||||
}
|
||||
|
||||
if (! $dryRun && count($buffer) >= $chunkSize) {
|
||||
$written += $this->flush($buffer);
|
||||
$buffer = [];
|
||||
$this->output->write("\r写入 " . $written . ' 行');
|
||||
}
|
||||
|
||||
if ($dryRun && $buffer !== []) {
|
||||
$buffer = [];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
if (! $dryRun) {
|
||||
Cache::forget('order-data.after-sales.imported-shop-options');
|
||||
}
|
||||
$this->error('读取失败:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($mappedRows === 0) {
|
||||
$this->error('表头和配置的映射对不上,请检查文件或 config/order_after_sales_cost.php');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $dryRun && $buffer !== []) {
|
||||
$written += $this->flush($buffer);
|
||||
}
|
||||
if (! $dryRun) {
|
||||
Cache::forget('order-data.after-sales.imported-shop-options');
|
||||
}
|
||||
|
||||
$this->newLine(2);
|
||||
$this->info(sprintf(
|
||||
'完成:读取 %d 行,写入 %d 行,跳过 %d 行;店铺 %d 个;数据月份 %s;订单时间 %s ~ %s;峰值内存 %.1f MB',
|
||||
$total,
|
||||
$written,
|
||||
$skipped,
|
||||
count($shops),
|
||||
$period !== '' ? $period : '-',
|
||||
$minDate ?? '-',
|
||||
$maxDate ?? '-',
|
||||
memory_get_peak_usage(true) / 1048576,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $rows */
|
||||
private function flush(array $rows): int
|
||||
{
|
||||
DB::table('ops_after_sales_cost_rows')->upsert(
|
||||
$rows,
|
||||
['row_key'],
|
||||
[
|
||||
'shop_name', 'shop_code', 'online_order_no', 'internal_order_no', 'product_code',
|
||||
'style_code', 'shop_product_code', 'shop_style_code',
|
||||
'order_time', 'source_period', 'product_quantity', 'product_amount', 'product_cost', 'cost_source',
|
||||
'combine_type', 'payload', 'imported_at', 'updated_at',
|
||||
],
|
||||
);
|
||||
|
||||
return count($rows);
|
||||
}
|
||||
|
||||
private static function isDateKey(string $key): bool
|
||||
{
|
||||
return in_array($key, [
|
||||
'pay_time', 'order_complete_time', 'send_time', 'order_time', 'registration_time',
|
||||
'confirmation_time', 'warehouse_entry_time',
|
||||
], true);
|
||||
}
|
||||
|
||||
private static function dateValue(mixed $value): ?string
|
||||
{
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
if (is_numeric($value) && (float) $value > 20000 && (float) $value < 80000) {
|
||||
$timestamp = ((float) $value - 25569) * 86400;
|
||||
|
||||
return gmdate('Y-m-d H:i:s', (int) $timestamp);
|
||||
}
|
||||
|
||||
$timestamp = strtotime((string) $value);
|
||||
|
||||
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
}
|
||||
41
app/Console/Commands/ImportCourierFreight.php
Normal file
41
app/Console/Commands/ImportCourierFreight.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\CourierFreightImporter;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class ImportCourierFreight extends Command
|
||||
{
|
||||
protected $signature = 'courier-freight:import
|
||||
{file : xlsx / csv 文件路径}
|
||||
{--company=1 : 公司 ID}
|
||||
{--replace : 先清空该公司已有的快递运费明细}';
|
||||
|
||||
protected $description = '导入「快递运费」数据(财务经营报表 › 快递运费)';
|
||||
|
||||
public function handle(CourierFreightImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('file'),
|
||||
(int) $this->option('company'),
|
||||
(bool) $this->option('replace'),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$sources = $result['sources'];
|
||||
$result['sources'] = count($sources);
|
||||
$this->info(json_encode($result, JSON_UNESCAPED_UNICODE));
|
||||
foreach ($sources as $name => $count) {
|
||||
$this->line(sprintf(' %s:%d 行', $name === '' ? '(空来源)' : $name, $count));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
280
app/Console/Commands/ImportCourierQuoteBillingRules.php
Normal file
280
app/Console/Commands/ImportCourierQuoteBillingRules.php
Normal file
@ -0,0 +1,280 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Business\CourierQuoteVersion;
|
||||
use App\Models\Common\Company;
|
||||
use App\Support\CourierQuoteStructure;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 把整理好的结构化计费规则(标准计费规则_导入用.json)写入现有报价版本,供运费核算使用。
|
||||
* 只写 billing_structure / billing_rows 两列,原始报价单元格、执行日期、状态都不动。
|
||||
*/
|
||||
class ImportCourierQuoteBillingRules extends Command
|
||||
{
|
||||
protected $signature = 'courier-quotes:import-billing-rules {file : 标准计费规则 JSON}
|
||||
{--company= : 报价所属公司ID}
|
||||
{--dry-run : 只校验并输出结果,不写入}
|
||||
{--force : 覆盖已有且内容不同的计费规则}';
|
||||
|
||||
protected $description = '把存量报价的结构化计费规则批量写入报价版本(按版本号匹配,整批事务)';
|
||||
|
||||
private const MODES = ['waybill_rate', 'first_additional', 'flat_bands', 'flat_then', CourierQuoteStructure::SEGMENTS];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$companyId = (int) $this->option('company');
|
||||
if ($companyId <= 0 || ! Company::query()->where('id', $companyId)->exists()) {
|
||||
$this->error('请指定有效的 --company。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$payload = json_decode((string) @file_get_contents((string) $this->argument('file')), true);
|
||||
$items = is_array($payload) ? ($payload['versions'] ?? null) : null;
|
||||
if (! is_array($items) || $items === []) {
|
||||
$this->error('文件不是有效的计费规则 JSON(缺少 versions)。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$plan = [];
|
||||
$report = [];
|
||||
$seen = [];
|
||||
foreach ($items as $item) {
|
||||
if (! is_array($item)) {
|
||||
$report[] = ['', '', '错误', '版本条目必须是对象'];
|
||||
continue;
|
||||
}
|
||||
$code = (string) ($item['version_id'] ?? '');
|
||||
if (isset($seen[$code])) {
|
||||
$report[] = [$code, '', '错误', '同一文件中的版本号不能重复'];
|
||||
continue;
|
||||
}
|
||||
$seen[$code] = true;
|
||||
[$status, $note, $data] = $this->prepare($companyId, $item);
|
||||
$report[] = [$code, (string) ($item['company_name'] ?? ''), $status, $note];
|
||||
if ($data !== null) {
|
||||
$plan[] = $data;
|
||||
}
|
||||
}
|
||||
|
||||
$this->table(['版本', '快递公司', '结果', '说明'], $report);
|
||||
$counts = array_count_values(array_column($report, 2));
|
||||
$summary = collect($counts)->map(fn ($n, $k) => "{$k} {$n}")->implode(',');
|
||||
|
||||
if (isset($counts['错误'])) {
|
||||
$this->error('存在错误,整批未写入:' . $summary);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info('校验通过,未写入(dry-run):' . $summary);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($plan): void {
|
||||
foreach ($plan as [$version, $structure, $rows]) {
|
||||
$current = DB::table($version->getTable())->where('id', $version->id)->lockForUpdate()->first();
|
||||
if (! $current || $current->company_name !== $version->company_name
|
||||
|| json_decode($current->billing_structure ?? 'null', true) != $version->billing_structure
|
||||
|| json_decode($current->billing_rows ?? 'null', true) != $version->billing_rows) {
|
||||
throw new RuntimeException('报价在校验后被其他操作修改,整批未写入,请重新校验。');
|
||||
}
|
||||
$version->forceFill(['billing_structure' => $structure, 'billing_rows' => $rows])->save();
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
$this->info('导入完成:' . $summary);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $item
|
||||
* @return array{0:string,1:string,2:?array}
|
||||
*/
|
||||
private function prepare(int $companyId, array $item): array
|
||||
{
|
||||
$code = (string) ($item['version_id'] ?? '');
|
||||
$version = CourierQuoteVersion::query()->allCompanies()->where('company_id', $companyId)->where('version_code', $code)->first();
|
||||
if ($version === null) {
|
||||
return ['错误', '系统中找不到该版本号', null];
|
||||
}
|
||||
if (trim((string) $version->company_name) !== trim((string) ($item['company_name'] ?? ''))) {
|
||||
return ['错误', '快递名称不一致:系统为「' . $version->company_name . '」', null];
|
||||
}
|
||||
if (($item['conversion'] ?? '') === 'manual') {
|
||||
return ['跳过', '特殊计费(' . ($item['manual_reason'] ?? '按体积/条码') . '),需人工核算', null];
|
||||
}
|
||||
if (($error = $this->inputError($item)) !== null) {
|
||||
return ['错误', $error, null];
|
||||
}
|
||||
|
||||
$source = (array) ($item['structure'] ?? []);
|
||||
$segments = ($item['conversion'] ?? '') === 'needs_segment_mode';
|
||||
$mode = $segments ? CourierQuoteStructure::SEGMENTS : (string) ($source['mode'] ?? '');
|
||||
if (! in_array($mode, self::MODES, true)) {
|
||||
return ['错误', '不支持的计费方式:' . $mode, null];
|
||||
}
|
||||
|
||||
$params = (array) ($item['params'] ?? []);
|
||||
$perTicket = 0.0;
|
||||
foreach ((array) ($params['surcharges'] ?? []) as $surcharge) {
|
||||
if (($surcharge['condition'] ?? '') === '每票' && is_numeric($surcharge['amount'] ?? null)) {
|
||||
$perTicket += (float) $surcharge['amount'];
|
||||
}
|
||||
}
|
||||
$structure = [
|
||||
'mode' => $mode,
|
||||
'flat_bands' => $segments ? [] : $this->numbers($source['flat_bands'] ?? []),
|
||||
'then_mode' => $segments ? '' : (string) ($source['then_mode'] ?? ''),
|
||||
'first_weight' => ! $segments && is_numeric($source['first_weight'] ?? null) ? (float) $source['first_weight'] : null,
|
||||
'rate_tiers' => $segments ? [] : $this->numbers($source['rate_tiers'] ?? []),
|
||||
'rounding' => in_array($source['rounding'] ?? 'none', array_keys(CourierQuoteStructure::ROUNDING), true) ? (string) ($source['rounding'] ?? 'none') : 'none',
|
||||
'volumetric_divisor' => is_numeric($params['volumetric_divisor'] ?? null) ? (int) $params['volumetric_divisor'] : null,
|
||||
'weight_limit' => is_numeric($params['weight_limit_kg'] ?? null) ? (float) $params['weight_limit_kg'] : null,
|
||||
'min_weight' => is_numeric($source['min_weight'] ?? $params['min_weight_kg'] ?? null) ? (float) ($source['min_weight'] ?? $params['min_weight_kg']) : null,
|
||||
'per_ticket_surcharge' => $perTicket > 0 ? $perTicket : null,
|
||||
'notes' => array_values(array_map('strval', (array) ($params['notes'] ?? []))),
|
||||
'imported_from' => 'stock-quote-billing-import',
|
||||
];
|
||||
|
||||
$rows = [];
|
||||
foreach ((array) ($segments ? ($item['segments_rows'] ?? []) : ($item['rows'] ?? [])) as $row) {
|
||||
$region = trim((string) ($row['region'] ?? ''));
|
||||
if ($region === '') {
|
||||
return ['错误', '存在没有地区名的行', null];
|
||||
}
|
||||
if ($segments) {
|
||||
$list = [];
|
||||
foreach ((array) ($row['segments'] ?? []) as $s) {
|
||||
$list[] = array_filter([
|
||||
'lo' => (float) ($s['lo'] ?? 0),
|
||||
'hi' => is_numeric($s['hi'] ?? null) ? (float) $s['hi'] : null,
|
||||
'type' => (string) ($s['type'] ?? ''),
|
||||
'price' => $this->number($s['price'] ?? null),
|
||||
'base' => $this->number($s['base'] ?? null),
|
||||
'rate' => $this->number($s['rate'] ?? null),
|
||||
'fw' => $this->number($s['fw'] ?? null),
|
||||
], static fn ($v, $k) => $v !== null || $k === 'hi', ARRAY_FILTER_USE_BOTH);
|
||||
}
|
||||
$rows[] = ['region' => $region, 'segments' => $list];
|
||||
} else {
|
||||
$rows[] = [
|
||||
'region' => $region,
|
||||
'flat' => array_map(fn ($v) => $this->number($v), array_values((array) ($row['flat'] ?? []))),
|
||||
'base' => $this->number($row['base'] ?? null),
|
||||
'rates' => array_map(fn ($v) => $this->number($v), array_values((array) ($row['rates'] ?? []))),
|
||||
];
|
||||
}
|
||||
}
|
||||
if ($rows === []) {
|
||||
return ['错误', '没有任何地区价格', null];
|
||||
}
|
||||
|
||||
$existing = $version->billing_structure;
|
||||
$note = CourierQuoteStructure::describe($structure) . ',' . count($rows) . ' 个地区';
|
||||
if (is_array($existing) && $existing !== []) {
|
||||
if ($existing == $structure && $version->billing_rows == $rows) {
|
||||
return ['跳过', '已导入且内容相同', null];
|
||||
}
|
||||
if (! $this->option('force')) {
|
||||
return ['错误', '已有不同的计费规则,如需覆盖请加 --force', null];
|
||||
}
|
||||
|
||||
return ['覆盖', $note, [$version, $structure, $rows]];
|
||||
}
|
||||
|
||||
return ['写入', $note, [$version, $structure, $rows]];
|
||||
}
|
||||
|
||||
private function number(mixed $value): ?float
|
||||
{
|
||||
return is_numeric($value) ? round((float) $value, 4) : null;
|
||||
}
|
||||
|
||||
/** 金额导入拒绝负数、歧义分段和不支持的附加条件,不静默改写计费含义。 */
|
||||
private function inputError(array $item): ?string
|
||||
{
|
||||
$structure = $item['structure'] ?? null;
|
||||
$params = $item['params'] ?? [];
|
||||
if (! is_array($structure) || ! is_array($params)) {
|
||||
return 'structure 和 params 必须是对象';
|
||||
}
|
||||
if (! array_key_exists($structure['rounding'] ?? 'none', CourierQuoteStructure::ROUNDING)) {
|
||||
return '不支持的重量取整方式';
|
||||
}
|
||||
$valid = static fn ($v): bool => is_numeric($v) && is_finite((float) $v) && (float) $v >= 0;
|
||||
foreach ([$params['volumetric_divisor'] ?? null, $params['weight_limit_kg'] ?? null,
|
||||
$structure['min_weight'] ?? $params['min_weight_kg'] ?? null, $structure['first_weight'] ?? null] as $v) {
|
||||
if ($v !== null && ! $valid($v)) return '重量、体积系数必须是非负有限数字';
|
||||
}
|
||||
if (isset($params['volumetric_divisor']) && (float) $params['volumetric_divisor'] <= 0) return '体积系数必须大于0';
|
||||
$minimum = (float) ($structure['min_weight'] ?? $params['min_weight_kg'] ?? 0);
|
||||
if (($params['weight_limit_kg'] ?? 0) > 0 && $minimum > $params['weight_limit_kg']) return '起算重量不能大于限重';
|
||||
foreach (['flat_bands', 'rate_tiers'] as $field) {
|
||||
$previous = 0;
|
||||
if (! is_array($structure[$field] ?? [])) return '重量分段必须是数组';
|
||||
foreach ($structure[$field] ?? [] as $edge) {
|
||||
if (! $valid($edge) || (float) $edge <= $previous) return '重量节点必须为严格递增的正数';
|
||||
$previous = (float) $edge;
|
||||
}
|
||||
}
|
||||
$mode = ($item['conversion'] ?? '') === 'needs_segment_mode' ? CourierQuoteStructure::SEGMENTS : ($structure['mode'] ?? '');
|
||||
if (in_array($mode, ['flat_bands', 'flat_then'], true) && empty($structure['flat_bands'])) return '缺少一口价重量节点';
|
||||
if ($mode === 'flat_then' && ! in_array($structure['then_mode'] ?? '', ['waybill_rate', 'first_additional'], true)) return '不支持的超重计费方式';
|
||||
if (CourierQuoteStructure::usesFirstWeight($mode, $structure['then_mode'] ?? '') && ! (($structure['first_weight'] ?? 0) > 0)) return '首重必须大于0';
|
||||
if ($mode === 'flat_then' && ! empty($structure['rate_tiers']) && min($structure['rate_tiers']) <= max($structure['flat_bands'])) return '超重费率节点必须大于一口价最后节点';
|
||||
if (! is_array($params['surcharges'] ?? [])) return '附加费必须是数组';
|
||||
foreach ($params['surcharges'] ?? [] as $fee) {
|
||||
if (! is_array($fee) || ($fee['condition'] ?? '') !== '每票' || ! $valid($fee['amount'] ?? null)) return '附加费条件不支持自动核算,需人工核对';
|
||||
}
|
||||
$rows = $item[$mode === CourierQuoteStructure::SEGMENTS ? 'segments_rows' : 'rows'] ?? null;
|
||||
if (! is_array($rows) || $rows === []) return '没有任何地区价格';
|
||||
$regions = [];
|
||||
foreach ($rows as $row) {
|
||||
if (! is_array($row) || ! is_string($row['region'] ?? null) || trim($row['region']) === '') return '地区行格式不正确';
|
||||
$region = mb_strtolower(preg_replace('/\s+/u', '', $row['region']));
|
||||
if (isset($regions[$region])) return '存在重复地区行';
|
||||
$regions[$region] = true;
|
||||
if ($mode === CourierQuoteStructure::SEGMENTS) {
|
||||
if (! is_array($row['segments'] ?? null) || $row['segments'] === []) return '缺少地区计费分段';
|
||||
$previous = 0.0;
|
||||
foreach ($row['segments'] as $segment) {
|
||||
if (! is_array($segment) || ! $valid($segment['lo'] ?? null)
|
||||
|| (float) $segment['lo'] < $previous || ! in_array($segment['type'] ?? '', ['flat', 'waybill', 'first_add'], true)) return '分段类型不支持或存在重叠';
|
||||
$hi = $segment['hi'] ?? null;
|
||||
if ($hi !== null && (! $valid($hi) || (float) $hi <= (float) $segment['lo'])) return '分段上限必须大于下限';
|
||||
$previous = $hi === null ? INF : (float) $hi;
|
||||
foreach (['price', 'base', 'rate', 'fw'] as $key) {
|
||||
if (isset($segment[$key]) && ! $valid($segment[$key])) return '分段金额和重量必须是非负有限数字';
|
||||
}
|
||||
if ($segment['type'] === 'first_add' && ! $valid($segment['fw'] ?? null)) return '分段首重缺失';
|
||||
}
|
||||
} else {
|
||||
if (! is_array($row['flat'] ?? []) || ! is_array($row['rates'] ?? [])) return '价格必须是数组';
|
||||
if (count($row['flat'] ?? []) !== count($structure['flat_bands'] ?? [])
|
||||
|| (CourierQuoteStructure::usesRates($mode) && count($row['rates'] ?? []) !== count($structure['rate_tiers'] ?? []) + 1)) return '价格列数与重量节点不匹配';
|
||||
foreach (array_merge($row['flat'] ?? [], $row['rates'] ?? [], [$row['base'] ?? null]) as $v) {
|
||||
if ($v !== null && ! $valid($v)) return '价格必须是非负有限数字或null(不发)';
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return list<float> */
|
||||
private function numbers(mixed $values): array
|
||||
{
|
||||
return array_values(array_map('floatval', array_filter((array) $values, 'is_numeric')));
|
||||
}
|
||||
}
|
||||
43
app/Console/Commands/ImportDingProcessDetails.php
Normal file
43
app/Console/Commands/ImportDingProcessDetails.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\DingProcessDetailMirrorImporter;
|
||||
use Throwable;
|
||||
|
||||
class ImportDingProcessDetails extends Command
|
||||
{
|
||||
protected $signature = 'ding:import-process-details
|
||||
{file : 15 列流程明细 CSV 文件路径}
|
||||
{--scope=personal_permission : 数据权限范围标识}
|
||||
{--replace : 导入前清空同一范围的数据}';
|
||||
|
||||
protected $description = '兼容导入旧版铺平 CSV;生产请使用 business-mirror:sync-ding-process-details';
|
||||
|
||||
public function handle(DingProcessDetailMirrorImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('file'),
|
||||
(string) $this->option('scope'),
|
||||
(bool) $this->option('replace'),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error('导入失败:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'导入完成:%d 行,跳过占位 %d 行,跳过无效 %d 行,重复 %d 行,范围 %s。',
|
||||
$result['rows'],
|
||||
$result['placeholders'],
|
||||
$result['invalid'],
|
||||
$result['duplicates'],
|
||||
$result['scope'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
49
app/Console/Commands/ImportFinanceLedgerBase.php
Normal file
49
app/Console/Commands/ImportFinanceLedgerBase.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\FinanceLedgerBaseImporter;
|
||||
use Throwable;
|
||||
|
||||
class ImportFinanceLedgerBase extends Command
|
||||
{
|
||||
protected $signature = 'finance-ledger:import-base
|
||||
{path : 《银行当前余额-*.xlsx》路径}
|
||||
{--opening-date= : 期初日期,默认读取配置}
|
||||
{--recalculate : 导入后立即重算每日流水}';
|
||||
|
||||
protected $description = '导入财务台账的账户、店铺、公司、期初余额(全量覆盖)';
|
||||
|
||||
public function handle(FinanceLedgerBaseImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('path'),
|
||||
$this->option('opening-date') ?: null,
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(['项目', '数量/金额'], [
|
||||
['账户', $result['accounts']],
|
||||
['店铺', $result['shops']],
|
||||
['公司', $result['companies']],
|
||||
['期初账户', $result['opening_rows']],
|
||||
['期初合计', number_format($result['opening_sum'], 2)],
|
||||
['账户余额合计', number_format($result['account_balance_sum'], 2)],
|
||||
['期初日期', $result['opening_date']],
|
||||
]);
|
||||
|
||||
if ($this->option('recalculate')) {
|
||||
$this->call('finance-ledger:recalculate');
|
||||
}
|
||||
|
||||
$this->info('财务台账基础数据导入完成。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
42
app/Console/Commands/ImportFinanceLedgerSnapshot.php
Normal file
42
app/Console/Commands/ImportFinanceLedgerSnapshot.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\FinanceLedgerSnapshotImporter;
|
||||
use Throwable;
|
||||
|
||||
class ImportFinanceLedgerSnapshot extends Command
|
||||
{
|
||||
protected $signature = 'finance-ledger:import-snapshot
|
||||
{path : 已核对的 ledger_raw.json 路径}
|
||||
{--no-recalculate : 只导入明细,不重算流水}';
|
||||
|
||||
protected $description = '导入财务台账动账明细快照(本地开发或首次建账使用)';
|
||||
|
||||
public function handle(FinanceLedgerSnapshotImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('path'),
|
||||
! $this->option('no-recalculate'),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(['项目', '值'], [
|
||||
['明细行数', $result['rows']],
|
||||
['收入合计', number_format($result['income'], 2)],
|
||||
['支出合计', number_format($result['expense'], 2)],
|
||||
['日期范围', $result['from'] . ' ~ ' . $result['to']],
|
||||
['每日流水行数', $result['recalculated']['flows'] ?? '-'],
|
||||
['期末余额合计', isset($result['recalculated']) ? number_format($result['recalculated']['balance_sum'], 2) : '-'],
|
||||
]);
|
||||
$this->info('财务台账动账明细导入完成。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
36
app/Console/Commands/ImportInternalFlowReport.php
Normal file
36
app/Console/Commands/ImportInternalFlowReport.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\InternalFlowWorkbookImporter;
|
||||
use Throwable;
|
||||
|
||||
class ImportInternalFlowReport extends Command
|
||||
{
|
||||
protected $signature = 'internal-flow:import
|
||||
{path : 「一般纳税人内部流转」工作簿路径(.xlsx)}';
|
||||
|
||||
protected $description = '导入内部流转报表明细,整份替换共享业务镜像中的上一版数据';
|
||||
|
||||
public function handle(InternalFlowWorkbookImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import((string) $this->argument('path'));
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error('内部流转报表导入失败:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'内部流转报表导入完成:明细 %s 行,跳过合计/空行 %s 行,来源文件 %s。',
|
||||
number_format($result['imported']),
|
||||
number_format($result['skipped']),
|
||||
$result['source_file'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
42
app/Console/Commands/ImportInventoryInboundDetail.php
Normal file
42
app/Console/Commands/ImportInventoryInboundDetail.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\InventoryInboundDetailImporter;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 进销存报表 → 入库明细数据:按原始 Excel 原样导入,仓库简称用仓库名称映射表按「仓库」唯一匹配。
|
||||
*/
|
||||
class ImportInventoryInboundDetail extends Command
|
||||
{
|
||||
protected $signature = 'inventory:inbound-detail:import {file : 入库明细表 Excel} {--month= : 统一指定月份(不传则按创建时间判断)}';
|
||||
|
||||
protected $description = '导入进销存报表的入库明细数据';
|
||||
|
||||
public function handle(InventoryInboundDetailImporter $importer): int
|
||||
{
|
||||
$file = (string) $this->argument('file');
|
||||
$forcedMonth = trim((string) ($this->option('month') ?? ''));
|
||||
|
||||
try {
|
||||
$result = $importer->import($file, $forcedMonth);
|
||||
$this->info(sprintf(
|
||||
'导入完成:%d 行,仓库简称匹配上 %d 行,跳过 %d 行;库中该表共 %d 行',
|
||||
$result['imported'],
|
||||
$result['mapped'],
|
||||
$result['skipped'],
|
||||
$result['total'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error('入库明细导入失败:' . $exception->getMessage());
|
||||
$this->warn('已保留上一版有效数据。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
146
app/Console/Commands/ImportJstOrderLabelPlantingRules.php
Normal file
146
app/Console/Commands/ImportJstOrderLabelPlantingRules.php
Normal file
@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\JstOrderLabelPlantingRules;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 导入「标签 | 是否种菜订单」映射表,供聚水潭订单标签明细的「是否种菜订单」列使用。
|
||||
*/
|
||||
class ImportJstOrderLabelPlantingRules extends Command
|
||||
{
|
||||
protected $signature = 'jst-order-labels:import-planting-rules
|
||||
{path : xlsx/csv 文件}
|
||||
{--company= : 公司ID(默认使用当前公司或 1)}
|
||||
{--sheet=1 : 工作表序号(从 1 开始)或名称}
|
||||
{--replace : 先清空该公司已有映射再导入(默认按标签覆盖)}';
|
||||
|
||||
protected $description = '导入聚水潭订单标签明细的「是否种菜订单」映射表';
|
||||
|
||||
public function handle(JstOrderLabelPlantingRules $rules): int
|
||||
{
|
||||
$path = (string) $this->argument('path');
|
||||
if (! is_file($path)) {
|
||||
$this->error('文件不存在:' . $path);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$companyId = (int) ($this->option('company') ?: (function_exists('company_id') && company_id() ? company_id() : 1));
|
||||
if ($companyId <= 0) {
|
||||
$this->error('公司ID无效,请用 --company 指定。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$rows = $this->readRows($path);
|
||||
} catch (Throwable $e) {
|
||||
$this->error('读取失败:' . $e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
$this->error('没有解析到有效数据(需要表头:标签 / 是否种菜订单)。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
DB::transaction(function () use ($rows, $now, $companyId): void {
|
||||
if ($this->option('replace')) {
|
||||
DB::table('jst_order_label_planting_rules')->where('company_id', $companyId)->delete();
|
||||
}
|
||||
|
||||
foreach (array_chunk($rows, 500) as $chunk) {
|
||||
DB::table('jst_order_label_planting_rules')->upsert(
|
||||
array_map(static fn (array $row): array => $row + [
|
||||
'company_id' => $companyId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
], $chunk),
|
||||
['company_id', 'label'],
|
||||
['is_planting', 'updated_at'],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
$rules->forget($companyId);
|
||||
|
||||
$planting = count(array_filter($rows, static fn (array $row): bool => $row['is_planting'] === '是'));
|
||||
|
||||
$this->info(sprintf('已导入 %d 条「是否种菜订单」映射(公司 %d):种菜 %d 条,非种菜 %d 条。', count($rows), $companyId, $planting, count($rows) - $planting));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{label:string,is_planting:string}>
|
||||
*/
|
||||
private function readRows(string $path): array
|
||||
{
|
||||
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
$readerType = match ($extension) {
|
||||
'xlsx' => 'Xlsx',
|
||||
'xls' => 'Xls',
|
||||
'csv' => 'Csv',
|
||||
default => throw new \InvalidArgumentException('只支持 xlsx / xls / csv 文件。'),
|
||||
};
|
||||
|
||||
$reader = IOFactory::createReader($readerType);
|
||||
$reader->setReadDataOnly(true);
|
||||
if ($readerType === 'Csv') {
|
||||
$reader->setInputEncoding('UTF-8');
|
||||
}
|
||||
|
||||
$book = $reader->load($path);
|
||||
$sheetOption = trim((string) $this->option('sheet'));
|
||||
if ($sheetOption !== '' && ctype_digit($sheetOption)) {
|
||||
$index = (int) $sheetOption - 1;
|
||||
if ($index < 0 || $index >= $book->getSheetCount()) {
|
||||
throw new \InvalidArgumentException('工作表序号超出范围:' . $sheetOption);
|
||||
}
|
||||
$sheet = $book->getSheet($index);
|
||||
} else {
|
||||
$sheet = $sheetOption === '' ? $book->getActiveSheet() : $book->getSheetByName($sheetOption);
|
||||
if ($sheet === null) {
|
||||
throw new \InvalidArgumentException('找不到工作表:' . $sheetOption);
|
||||
}
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$header = null;
|
||||
foreach ($sheet->getRowIterator() as $row) {
|
||||
$cells = [];
|
||||
$cellIterator = $row->getCellIterator();
|
||||
$cellIterator->setIterateOnlyExistingCells(false);
|
||||
foreach ($cellIterator as $cell) {
|
||||
$cells[] = trim((string) $cell->getValue());
|
||||
}
|
||||
|
||||
if ($header === null) {
|
||||
$cells[0] = preg_replace('/^\xEF\xBB\xBF/', '', $cells[0] ?? '');
|
||||
$header = $cells;
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = trim($cells[0] ?? '');
|
||||
$flag = trim($cells[1] ?? '');
|
||||
if ($label === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'label' => mb_substr($label, 0, 191),
|
||||
'is_planting' => in_array(mb_strtolower($flag), ['否', 'n', 'no', '0', 'false'], true) ? '否' : '是',
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
43
app/Console/Commands/ImportJstOrderLabels.php
Normal file
43
app/Console/Commands/ImportJstOrderLabels.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\JstOrderLabelImporter;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class ImportJstOrderLabels extends Command
|
||||
{
|
||||
protected $signature = 'jst-order-labels:import
|
||||
{--from= : 起始下单日期 YYYY-MM-DD}
|
||||
{--to= : 结束下单日期 YYYY-MM-DD}
|
||||
{--file= : 聚水潭「导出订单」Excel 路径;给了它就从文件导入(镜像没有的历史区间用)}
|
||||
{--company=1 : 公司 ID}
|
||||
{--replace : 先清空该区间已有行}';
|
||||
|
||||
protected $description = '把聚水潭订单按标签拆行导入「聚水潭订单标签明细」:默认读共享镜像,给 --file 则读聚水潭导出的订单 Excel';
|
||||
|
||||
public function handle(JstOrderLabelImporter $importer): int
|
||||
{
|
||||
$from = (string) $this->option('from');
|
||||
$to = (string) $this->option('to');
|
||||
$file = trim((string) $this->option('file'));
|
||||
|
||||
try {
|
||||
$companyId = (int) $this->option('company');
|
||||
$replace = (bool) $this->option('replace');
|
||||
|
||||
$result = $file === ''
|
||||
? $importer->import($from, $to, $companyId, $replace, 'jst-order-label-import')
|
||||
: $importer->importFromFile($file, $from ?: null, $to ?: null, $companyId, $replace);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(json_encode($result, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\WithdrawalBalanceAlertSynchronizer;
|
||||
use App\Models\Business\StoreReceivingAccount;
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\FinanceBillShopBalanceFallback;
|
||||
use Modules\EcommerceFinance\Services\JushuitanShopBalanceImporter;
|
||||
use Throwable;
|
||||
|
||||
@ -14,9 +17,13 @@ class ImportJushuitanShopBalances extends Command
|
||||
{--scope=all : 来源范围,同一日期同一范围会被原子替换}
|
||||
{--dry-run : 只校验和统计,不写入共享业务库}';
|
||||
|
||||
protected $description = '校验并导入聚水潭平台原始账单中的店铺余额快照';
|
||||
protected $description = '导入聚水潭店铺余额,并用财务账单补录缺失店铺';
|
||||
|
||||
public function handle(JushuitanShopBalanceImporter $importer): int
|
||||
public function handle(
|
||||
JushuitanShopBalanceImporter $importer,
|
||||
WithdrawalBalanceAlertSynchronizer $alertSynchronizer,
|
||||
FinanceBillShopBalanceFallback $financeFallback,
|
||||
): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import(
|
||||
@ -26,6 +33,7 @@ class ImportJushuitanShopBalances extends Command
|
||||
(bool) $this->option('dry-run'),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
@ -39,6 +47,60 @@ class ImportJushuitanShopBalances extends Command
|
||||
$result['replaced'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
$financeFallbackFailed = false;
|
||||
try {
|
||||
$fallback = $financeFallback->fillMissing(
|
||||
$result['balance_date'],
|
||||
(string) $this->option('scope'),
|
||||
(bool) $result['dry_run'],
|
||||
);
|
||||
if (! $fallback['supported']) {
|
||||
$this->warn('账单余额回填:当前来源的店铺收款账户账单没有累计账户余额字段,跳过回填。');
|
||||
} elseif ($fallback['missing'] === 0) {
|
||||
$this->info('账单余额回填:聚水潭余额没有缺失店铺。');
|
||||
} else {
|
||||
$this->info(sprintf(
|
||||
'账单余额回填:缺失 %d 家,匹配 %d 家,写入 %d 条,仍有 %d 家待补。',
|
||||
$fallback['missing'],
|
||||
$fallback['resolved'],
|
||||
$fallback['filled'],
|
||||
$fallback['unresolved'],
|
||||
));
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error('财务系统店铺收款账户账单余额回填失败,聚水潭余额已保留,财务回填未覆盖。');
|
||||
$financeFallbackFailed = true;
|
||||
}
|
||||
|
||||
if (! $result['dry_run']) {
|
||||
$companyIds = StoreReceivingAccount::withoutGlobalScopes()
|
||||
->whereNull('deleted_at')
|
||||
->distinct()
|
||||
->pluck('company_id')
|
||||
->filter()
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
foreach ($companyIds as $companyId) {
|
||||
try {
|
||||
$alerts = $alertSynchronizer->syncLatest($companyId);
|
||||
if ($alerts['created'] > 0 || $alerts['updated'] > 0) {
|
||||
$this->info(sprintf(
|
||||
'余额预警待办:公司 %d 新增 %d 条、更新 %d 条。',
|
||||
$companyId,
|
||||
$alerts['created'],
|
||||
$alerts['updated'],
|
||||
));
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->warn("余额预警待办生成失败(公司 {$companyId}),余额快照已保留。");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $financeFallbackFailed ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
38
app/Console/Commands/ImportPlantingDetails.php
Normal file
38
app/Console/Commands/ImportPlantingDetails.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\PlantingDetailImporter;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class ImportPlantingDetails extends Command
|
||||
{
|
||||
protected $signature = 'planting-details:import
|
||||
{file : xlsx / xls / csv 文件路径}
|
||||
{--company=1 : 公司 ID}
|
||||
{--replace : 先清空该公司已有的种菜明细}';
|
||||
|
||||
protected $description = '导入「种菜明细表」数据(账单流水数据 › 种菜明细表)';
|
||||
|
||||
public function handle(PlantingDetailImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('file'),
|
||||
(int) $this->option('company'),
|
||||
(bool) $this->option('replace'),
|
||||
null,
|
||||
'planting-detail-import',
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(json_encode($result, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
350
app/Console/Commands/ImportProductMonthlyWeightedAverageBase.php
Normal file
350
app/Console/Commands/ImportProductMonthlyWeightedAverageBase.php
Normal file
@ -0,0 +1,350 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Business\InventoryWarehouseNameMapping;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
/**
|
||||
* 商品月加权平均单价:导入某个月的期初基数。
|
||||
*
|
||||
* 基数来源两类:
|
||||
* 1) 首个可用月份(7月)来自财务提供的《商品月末加权平均单价》表1,包含仓库简称、商品编码和上月加权平均单价;
|
||||
* 2) 其余月份由上月算出的加权平均单价滚动生成(--from-previous)。
|
||||
* 无论哪一类,都会并入进销存总表里"代发仓"的商品(代发仓没有库存,采购数量取销售出库)。
|
||||
*/
|
||||
class ImportProductMonthlyWeightedAverageBase extends Command
|
||||
{
|
||||
protected $signature = 'product:monthly-weighted-average:base
|
||||
{month : 报表月份,例如 2026-07}
|
||||
{--file= : 期初基数 Excel(表1),首月使用}
|
||||
{--from-previous : 用上月的加权平均单价作为本月原始价格}
|
||||
{--force : 覆盖已经存在的同月基数}';
|
||||
|
||||
protected $description = '导入/生成商品月加权平均单价的期初基数';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$month = CarbonImmutable::parse($this->argument('month'))->startOfMonth();
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$rowsTable = 'product_monthly_weighted_average_rows';
|
||||
$now = now();
|
||||
|
||||
$existingRows = DB::connection($connection)->table($rowsTable)
|
||||
->where('report_month', $month->toDateString())
|
||||
->count();
|
||||
$existingState = DB::connection($connection)->table('product_monthly_weighted_average_states')
|
||||
->where('report_month', $month->toDateString())
|
||||
->value('status');
|
||||
if (! $this->option('force') && ($existingRows > 0 || $existingState !== null)) {
|
||||
$this->error($month->format('Y-m') . ' 已存在基数或计算状态,不自动覆盖;如需人工纠正请显式使用 --force。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$inventory = $this->inventoryAggregate($connection, $month);
|
||||
$jiangxiPurchase = $this->jiangxiDeliveryPurchase($connection, $month);
|
||||
$nanchangOpening = $this->nanchangOpeningQuantity($connection, $month);
|
||||
$this->info('进销存(排除不看)合计 ' . count($inventory) . ' 个 仓库简称+商品编码');
|
||||
|
||||
DB::connection($connection)->table($rowsTable)->where('report_month', $month->toDateString())->delete();
|
||||
|
||||
$payload = [];
|
||||
$baseRows = 0;
|
||||
$deliveryRows = 0;
|
||||
$mirrorRows = 0;
|
||||
|
||||
$file = (string) ($this->option('file') ?? '');
|
||||
if ($file !== '') {
|
||||
foreach ($this->readBaseFile($file) as $row) {
|
||||
$payload[$this->rowKey($month, $row['warehouse_short_name'], $row['product_code'])] = [
|
||||
'report_month' => $month->toDateString(),
|
||||
'row_key' => $this->rowKey($month, $row['warehouse_short_name'], $row['product_code']),
|
||||
'barcode_warehouse' => $row['barcode_warehouse'],
|
||||
'warehouse_short_name' => $row['warehouse_short_name'],
|
||||
'product_code' => $row['product_code'],
|
||||
'opening_unit_price' => $row['opening_unit_price'],
|
||||
'previous_pricing_status' => $row['previous_pricing_status'],
|
||||
'base_source' => 'base',
|
||||
'imported_at' => $now,
|
||||
];
|
||||
$baseRows++;
|
||||
}
|
||||
} elseif ($this->option('from-previous')) {
|
||||
$previous = $month->subMonthNoOverflow();
|
||||
foreach (DB::connection($connection)->table($rowsTable)
|
||||
->where('report_month', $previous->toDateString())
|
||||
->get(['barcode_warehouse', 'warehouse_short_name', 'product_code', 'pricing_status', 'weighted_average_unit_price']) as $row) {
|
||||
$key = $this->rowKey($month, $row->warehouse_short_name, $row->product_code);
|
||||
$payload[$key] = [
|
||||
'report_month' => $month->toDateString(),
|
||||
'row_key' => $key,
|
||||
'barcode_warehouse' => $row->barcode_warehouse,
|
||||
'warehouse_short_name' => $row->warehouse_short_name,
|
||||
'product_code' => $row->product_code,
|
||||
'opening_unit_price' => $row->weighted_average_unit_price,
|
||||
'previous_pricing_status' => $row->pricing_status,
|
||||
'base_source' => 'base',
|
||||
'imported_at' => $now,
|
||||
];
|
||||
$baseRows++;
|
||||
}
|
||||
} else {
|
||||
$this->error('请给出 --file=表1.xlsx(首月)或 --from-previous(滚动月份)。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// 代发仓:没有库存,但必须出现在这张表里,采购数量取销售出库。
|
||||
foreach ($inventory as $key => $item) {
|
||||
$rowKey = $this->rowKey($month, $item['warehouse_short_name'], $item['product_code']);
|
||||
if (isset($payload[$rowKey])) {
|
||||
continue;
|
||||
}
|
||||
$isDelivery = $this->isDeliveryWarehouse($item['warehouse_short_name']);
|
||||
$payload[$rowKey] = [
|
||||
'report_month' => $month->toDateString(),
|
||||
'row_key' => $rowKey,
|
||||
// 条码+仓库 = 商品编码 + 仓库简称
|
||||
'barcode_warehouse' => $item['product_code'] . $item['warehouse_short_name'],
|
||||
'warehouse_short_name' => $item['warehouse_short_name'],
|
||||
'product_code' => $item['product_code'],
|
||||
'opening_unit_price' => null,
|
||||
'previous_pricing_status' => null,
|
||||
'base_source' => $isDelivery ? 'delivery' : 'mirror',
|
||||
'imported_at' => $now,
|
||||
];
|
||||
$isDelivery ? $deliveryRows++ : $mirrorRows++;
|
||||
}
|
||||
|
||||
// 南昌樵舍仓的采购数量取自"简尚-江西仓送货主仓",这些条码也要一并纳入行维度,
|
||||
// 否则只存在于江西仓送货的单据会因为表里没有对应行而丢掉采购数量。
|
||||
foreach (array_keys($jiangxiPurchase) as $code) {
|
||||
$rowKey = $this->rowKey($month, '南昌樵舍仓', $code);
|
||||
if (isset($payload[$rowKey])) {
|
||||
continue;
|
||||
}
|
||||
$payload[$rowKey] = [
|
||||
'report_month' => $month->toDateString(),
|
||||
'row_key' => $rowKey,
|
||||
'barcode_warehouse' => $code . '南昌樵舍仓',
|
||||
'warehouse_short_name' => '南昌樵舍仓',
|
||||
'product_code' => $code,
|
||||
'opening_unit_price' => null,
|
||||
'previous_pricing_status' => null,
|
||||
'base_source' => 'mirror',
|
||||
'imported_at' => $now,
|
||||
];
|
||||
$mirrorRows++;
|
||||
}
|
||||
|
||||
$matched = 0;
|
||||
foreach ($payload as $key => $row) {
|
||||
$item = $inventory[$row['warehouse_short_name'] . "\0" . $row['product_code']] ?? null;
|
||||
// 代发仓判定不能只看 base_source(滚动月份会丢标记),按仓库简称再兜一层。
|
||||
$isDelivery = $row['base_source'] === 'delivery'
|
||||
|| str_contains((string) $row['warehouse_short_name'], '代发仓');
|
||||
$isNanchang = $row['warehouse_short_name'] === '南昌樵舍仓';
|
||||
// 原始数量只取"主仓"的期初数量(仓库名称含主仓字样)。
|
||||
$openingQuantity = $isDelivery ? 0.0 : (float) ($item['opening_quantity'] ?? 0);
|
||||
if ($isNanchang) {
|
||||
// 南昌樵舍仓是特殊仓库:期初数量取"三方-南昌樵舍仓主仓"对应商品的期初数量。
|
||||
$openingQuantity = (float) ($nanchangOpening[$row['product_code']] ?? 0);
|
||||
}
|
||||
$purchaseQuantity = $isDelivery
|
||||
? abs((float) ($item['sales_out'] ?? 0))
|
||||
: ($isNanchang
|
||||
// 南昌樵舍仓是特殊仓库:采购数量取"简尚-江西仓送货主仓"对应商品的采购进仓数量。
|
||||
? (float) ($jiangxiPurchase[$row['product_code']] ?? 0)
|
||||
: (float) ($item['purchase_in'] ?? 0));
|
||||
if ($item !== null) {
|
||||
$matched++;
|
||||
}
|
||||
$payload[$key]['product_name'] = $item['category'] ?? null;
|
||||
$payload[$key]['opening_quantity'] = $openingQuantity;
|
||||
$payload[$key]['purchase_quantity'] = $purchaseQuantity;
|
||||
$payload[$key]['opening_original_amount'] = $row['opening_unit_price'] === null
|
||||
? null
|
||||
: round($openingQuantity * (float) $row['opening_unit_price'], 2);
|
||||
}
|
||||
|
||||
foreach (array_chunk($payload, 1000, true) as $chunk) {
|
||||
DB::connection($connection)->table($rowsTable)->insert(array_values($chunk));
|
||||
}
|
||||
|
||||
DB::connection($connection)->table('product_monthly_weighted_average_states')->updateOrInsert(
|
||||
['report_month' => $month->toDateString()],
|
||||
[
|
||||
'status' => 'base_imported',
|
||||
'base_filename' => $file !== '' ? basename($file) : null,
|
||||
'base_rows' => $baseRows,
|
||||
'delivery_rows' => $deliveryRows,
|
||||
'matched_rows' => $matched,
|
||||
'last_error' => null,
|
||||
'updated_at' => $now,
|
||||
'created_at' => $now,
|
||||
],
|
||||
);
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s 基数导入完成:基表 %d 行 + 代发仓 %d 行,合计 %d 行,其中 %d 行在进销存总表匹配到数据。',
|
||||
$month->format('Y-m'),
|
||||
$baseRows,
|
||||
$deliveryRows,
|
||||
count($payload),
|
||||
$matched,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** @return array<string,array{warehouse_short_name:string,product_code:string,opening_quantity:string,purchase_in:string,sales_out:string,category:string}> */
|
||||
private function inventoryAggregate(string $connection, CarbonImmutable $month): array
|
||||
{
|
||||
$generation = DB::connection($connection)->table('inventory_report_authoritative_months')
|
||||
->where('source_month', $month->toDateString())
|
||||
->value('active_generation');
|
||||
if (! $generation) {
|
||||
throw new \RuntimeException($month->format('Y-m') . ' 的进销存总表尚未导入。');
|
||||
}
|
||||
|
||||
$mappingModel = new InventoryWarehouseNameMapping();
|
||||
$mappingConnection = DB::connection($mappingModel->getConnectionName());
|
||||
$mappingTable = $mappingConnection->getDriverName() === 'sqlite'
|
||||
? $mappingModel->getTable()
|
||||
: $mappingModel->getConnection()->getDatabaseName()
|
||||
. '.' . $mappingConnection->getTablePrefix()
|
||||
. $mappingModel->getTable();
|
||||
|
||||
$sql = "select m.warehouse_short_name as short_name, r.product_code as product_code,
|
||||
sum(case when r.warehouse like '%主仓%' then r.opening_quantity else 0 end) as opening_quantity,
|
||||
sum(r.purchase_in) as purchase_in,
|
||||
sum(r.sales_out) as sales_out, max(r.category) as category
|
||||
from inventory_report_authoritative_rows r
|
||||
join {$mappingTable} m
|
||||
on m.warehouse_provider = r.warehouse_provider
|
||||
and m.warehouse = r.warehouse
|
||||
and m.company_id = 1
|
||||
and m.deleted_at is null
|
||||
where r.generation = ?
|
||||
and m.warehouse_short_name not like '%不看%'
|
||||
and r.warehouse like '%主仓%'
|
||||
and m.warehouse_short_name <> '南昌樵舍仓'
|
||||
group by m.warehouse_short_name, r.product_code";
|
||||
|
||||
$aggregate = [];
|
||||
foreach (DB::connection($connection)->select($sql, [$generation]) as $row) {
|
||||
$aggregate[$row->short_name . "\0" . mb_strtoupper(trim((string) $row->product_code))] = [
|
||||
'warehouse_short_name' => (string) $row->short_name,
|
||||
// 商品编码统一按大写建索引,避免与表1/普通商品资料大小写不一致导致匹配不上。
|
||||
'product_code' => mb_strtoupper(trim((string) $row->product_code)),
|
||||
'opening_quantity' => (string) $row->opening_quantity,
|
||||
'purchase_in' => (string) $row->purchase_in,
|
||||
'sales_out' => (string) $row->sales_out,
|
||||
'category' => (string) ($row->category ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $aggregate;
|
||||
}
|
||||
|
||||
/** @return \Generator<int,array{barcode_warehouse:string,warehouse_short_name:string,product_code:string,opening_unit_price:?float}> */
|
||||
private function readBaseFile(string $file): \Generator
|
||||
{
|
||||
$reader = IOFactory::createReaderForFile($file);
|
||||
$reader->setReadDataOnly(true);
|
||||
$spreadsheet = $reader->load($file);
|
||||
$sheet = $spreadsheet->getSheetByName('7月份期初基数') ?? $spreadsheet->getSheet(0);
|
||||
|
||||
foreach ($sheet->getRowIterator(3) as $row) {
|
||||
$cells = [];
|
||||
foreach ($row->getCellIterator('A', 'P') as $cell) {
|
||||
$cells[] = $cell->getFormattedValue();
|
||||
}
|
||||
$shortName = trim((string) ($cells[2] ?? ''));
|
||||
$productCode = trim((string) ($cells[4] ?? ''));
|
||||
if ($shortName === '' || $productCode === '') {
|
||||
continue;
|
||||
}
|
||||
$price = trim((string) ($cells[7] ?? ''));
|
||||
$previousStatus = trim((string) ($cells[15] ?? ''));
|
||||
|
||||
yield [
|
||||
// 条码+仓库统一为「仓库简称 + 商品编码」,不再沿用表1 里的单元格写法。
|
||||
'barcode_warehouse' => $shortName . $productCode,
|
||||
'warehouse_short_name' => $shortName,
|
||||
'product_code' => mb_strtoupper($productCode),
|
||||
// 原始价格保留源表精度(不提前舍入),保证加权平均单价与手工表一致;页面展示仍按两位。
|
||||
'opening_unit_price' => $price === '' ? null : (float) str_replace(',', '', $price),
|
||||
'previous_pricing_status' => $previousStatus === '' ? null : $previousStatus,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function isDeliveryWarehouse(string $shortName): bool
|
||||
{
|
||||
return str_contains($shortName, '代发');
|
||||
}
|
||||
|
||||
/**
|
||||
* 南昌樵舍仓的采购数量取"简尚-江西仓送货主仓"对应商品的采购进仓数量。
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function jiangxiDeliveryPurchase(string $connection, CarbonImmutable $month): array
|
||||
{
|
||||
$generation = DB::connection($connection)->table('inventory_report_authoritative_months')
|
||||
->where('source_month', $month->toDateString())
|
||||
->value('active_generation');
|
||||
if (! $generation) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach (DB::connection($connection)->table('inventory_report_authoritative_rows')
|
||||
->where('generation', $generation)
|
||||
->where('warehouse', '简尚-江西仓送货主仓')
|
||||
->selectRaw('product_code, sum(purchase_in) as quantity')
|
||||
->groupBy('product_code')
|
||||
->get() as $row) {
|
||||
$map[mb_strtoupper(trim((string) $row->product_code))] = (string) $row->quantity;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 南昌樵舍仓的期初数量取"三方-南昌樵舍仓主仓"对应商品的期初数量。
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function nanchangOpeningQuantity(string $connection, CarbonImmutable $month): array
|
||||
{
|
||||
$generation = DB::connection($connection)->table('inventory_report_authoritative_months')
|
||||
->where('source_month', $month->toDateString())
|
||||
->value('active_generation');
|
||||
if (! $generation) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach (DB::connection($connection)->table('inventory_report_authoritative_rows')
|
||||
->where('generation', $generation)
|
||||
->where('warehouse', '三方-南昌樵舍仓主仓')
|
||||
->selectRaw('product_code, sum(opening_quantity) as quantity')
|
||||
->groupBy('product_code')
|
||||
->get() as $row) {
|
||||
$map[mb_strtoupper(trim((string) $row->product_code))] = (string) $row->quantity;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function rowKey(CarbonImmutable $month, string $shortName, string $productCode): string
|
||||
{
|
||||
return sha1($month->format('Y-m') . '|' . $shortName . '|' . $productCode);
|
||||
}
|
||||
}
|
||||
140
app/Console/Commands/ImportPromotionCostRows.php
Normal file
140
app/Console/Commands/ImportPromotionCostRows.php
Normal file
@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\XlsxSheetReader;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Repositories\PromotionCostRowRepository;
|
||||
|
||||
class ImportPromotionCostRows extends Command
|
||||
{
|
||||
protected $signature = 'order-data:import-promotion-cost
|
||||
{file : Excel 文件路径}
|
||||
{--period= : 数据月份,格式 YYYY-MM}
|
||||
{--chunk=500 : 每批写入行数}
|
||||
{--dry-run : 只解析不入库}';
|
||||
|
||||
protected $description = '导入聚水潭商品推广费用明细 Excel';
|
||||
|
||||
public function handle(XlsxSheetReader $reader): int
|
||||
{
|
||||
$path = (string) $this->argument('file');
|
||||
$period = trim((string) $this->option('period'));
|
||||
|
||||
if (! is_file($path)) {
|
||||
$this->error('文件不存在:' . $path);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $period) !== 1) {
|
||||
$this->error('数据月份格式必须是 YYYY-MM:' . ($period !== '' ? $period : '(空)'));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$headers = (array) config('order_promotion_cost.headers', []);
|
||||
if ($headers === []) {
|
||||
$this->error('缺少表头映射:config/order_promotion_cost.php');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('流式读取文件:' . $path);
|
||||
|
||||
$chunkSize = max((int) $this->option('chunk'), 100);
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$importedAt = now()->toDateTimeString();
|
||||
$buffer = [];
|
||||
$total = 0;
|
||||
$written = 0;
|
||||
$skipped = 0;
|
||||
$promotionFee = 0.0;
|
||||
$shops = [];
|
||||
|
||||
try {
|
||||
foreach ($reader->rows($path, array_keys($headers)) as $row) {
|
||||
$total++;
|
||||
$payload = [];
|
||||
|
||||
foreach ($headers as $header => $key) {
|
||||
$value = trim((string) ($row[$header] ?? ''));
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
$payload[$key] = $key === 'promotion_fee'
|
||||
? (is_numeric($value) ? (float) $value : 0.0)
|
||||
: $value;
|
||||
}
|
||||
|
||||
$shop = trim((string) ($payload['shop_name'] ?? ''));
|
||||
$product = trim((string) ($payload['product_code'] ?? ''));
|
||||
if ($shop === '' || $product === '') {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$shops[$shop] = true;
|
||||
$promotionFee += (float) ($payload['promotion_fee'] ?? 0);
|
||||
$buffer[] = PromotionCostRowRepository::toRecord($payload, $period, $importedAt);
|
||||
|
||||
if (! $dryRun && count($buffer) >= $chunkSize) {
|
||||
$written += $this->flush($buffer);
|
||||
$buffer = [];
|
||||
$this->output->write("\r写入 " . $written . ' 行');
|
||||
}
|
||||
|
||||
if ($dryRun && $buffer !== []) {
|
||||
$buffer = [];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
if (! $dryRun) {
|
||||
Cache::forget('order-data.promotion.imported-shop-options:' . $period);
|
||||
}
|
||||
$this->error('读取失败:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $dryRun && $buffer !== []) {
|
||||
$written += $this->flush($buffer);
|
||||
}
|
||||
if (! $dryRun) {
|
||||
Cache::forget('order-data.promotion.imported-shop-options:' . $period);
|
||||
}
|
||||
|
||||
$this->newLine(2);
|
||||
$this->info(sprintf(
|
||||
'完成:读取 %d 行,写入 %d 行,跳过 %d 行;店铺 %d 个;数据月份 %s;推广费合计 %.2f;峰值内存 %.1f MB',
|
||||
$total,
|
||||
$written,
|
||||
$skipped,
|
||||
count($shops),
|
||||
$period,
|
||||
$promotionFee,
|
||||
memory_get_peak_usage(true) / 1048576,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $rows */
|
||||
private function flush(array $rows): int
|
||||
{
|
||||
DB::table('ops_promotion_cost_rows')->upsert(
|
||||
$rows,
|
||||
['row_key'],
|
||||
[
|
||||
'source_period', 'shop_name', 'product_code', 'style_code',
|
||||
'shop_product_code', 'shop_style_code', 'online_color_spec',
|
||||
'promotion_fee', 'payload', 'imported_at', 'updated_at',
|
||||
],
|
||||
);
|
||||
|
||||
return count($rows);
|
||||
}
|
||||
}
|
||||
187
app/Console/Commands/ImportStoreViolationRules.php
Normal file
187
app/Console/Commands/ImportStoreViolationRules.php
Normal file
@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Repositories\StoreViolationRuleRepository;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 店铺违规信息预警:违规字典维护(Excel 导入 / 更新,按 平台+违规类型 幂等)。
|
||||
*
|
||||
* 表头(顺序不限):平台、违规分类、违规类型、匹配关键词、预警级别、是否需要提醒、
|
||||
* 建议处理动作、处理时效、提醒方式、排序、启用、备注。
|
||||
* 导入后按需执行 store-violations:recalc-alerts 让存量记录套用新字典。
|
||||
*/
|
||||
class ImportStoreViolationRules extends Command
|
||||
{
|
||||
protected $signature = 'store-violations:import-rules
|
||||
{file : xlsx / xls / csv 文件路径}
|
||||
{--dry-run : 只解析并统计,不写库}';
|
||||
|
||||
protected $description = '导入违规字典(预警级别、提醒规则等)';
|
||||
|
||||
private const HEADERS = [
|
||||
'platform' => ['平台', 'platform'],
|
||||
'violation_category' => ['违规分类', '分类', 'violation_category'],
|
||||
'violation_type' => ['违规类型', '类型', 'violation_type'],
|
||||
'match_keywords' => ['匹配关键词', '关键词', 'match_keywords'],
|
||||
'alert_level' => ['预警级别', '级别', '预警等级', 'alert_level'],
|
||||
'needs_alert' => ['是否需要提醒', '需提醒', '需要提醒', 'needs_alert'],
|
||||
'suggested_action' => ['建议处理动作', '处理建议', '建议动作', 'suggested_action'],
|
||||
'handling_hours' => ['处理时效', '时效', 'handling_hours'],
|
||||
'remind_channel' => ['提醒方式', 'remind_channel'],
|
||||
'sort' => ['排序', 'sort'],
|
||||
'enabled' => ['启用', 'enabled'],
|
||||
'remark' => ['备注', 'remark'],
|
||||
];
|
||||
|
||||
private const PLATFORM_MAP = [
|
||||
'淘系' => 'taobao', '淘宝' => 'taobao', '天猫' => 'taobao',
|
||||
'京东' => 'jd', '京喜' => 'jingxi', '抖音' => 'douyin',
|
||||
'拼多多' => 'pinduoduo', '阿里巴巴' => 'alibaba',
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$file = (string) $this->argument('file');
|
||||
if (! is_file($file)) {
|
||||
$this->error('文件不存在:' . $file);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$reader = IOFactory::createReaderForFile($file);
|
||||
if (method_exists($reader, 'setReadDataOnly')) {
|
||||
$reader->setReadDataOnly(true);
|
||||
}
|
||||
if (method_exists($reader, 'setReadFilter')) {
|
||||
// 字典文件通常只有几十行;部分导出文件带巨大空白维度,限制读取范围。
|
||||
$reader->setReadFilter(new class implements \PhpOffice\PhpSpreadsheet\Reader\IReadFilter {
|
||||
public function readCell($columnAddress, $row, $worksheetName = ''): bool
|
||||
{
|
||||
return $row <= 5000;
|
||||
}
|
||||
});
|
||||
}
|
||||
$matrix = $reader->load($file)->getActiveSheet()->toArray(null, true, true, false);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error('无法读取文件:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($matrix === []) {
|
||||
$this->error('文件为空。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ($matrix[0] as $index => $cell) {
|
||||
$label = mb_strtolower(trim((string) $cell));
|
||||
foreach (self::HEADERS as $key => $aliases) {
|
||||
foreach ($aliases as $alias) {
|
||||
if ($label === mb_strtolower($alias)) {
|
||||
$map[$key] = $index;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! isset($map['platform'], $map['violation_type'])) {
|
||||
$this->error('缺少必需表头:平台、违规类型。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
if (! app(StoreViolationRuleRepository::class)->available()) {
|
||||
$this->error('违规字典表尚未建立,请先执行数据库迁移。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
$sort = 0;
|
||||
|
||||
foreach (array_slice($matrix, 1) as $row) {
|
||||
$value = static fn (string $key): string => isset($map[$key]) ? trim((string) ($row[$map[$key]] ?? '')) : '';
|
||||
|
||||
$platform = $value('platform');
|
||||
$platform = self::PLATFORM_MAP[$platform] ?? mb_strtolower($platform);
|
||||
$type = $value('violation_type');
|
||||
if ($platform === '' || $type === '') {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$sort += 10;
|
||||
|
||||
$levelRaw = strtoupper($value('alert_level'));
|
||||
$level = in_array($levelRaw, ['P0', 'P1', 'P2'], true) ? $levelRaw : null;
|
||||
|
||||
$needsAlertRaw = mb_strtolower($value('needs_alert'));
|
||||
$needsAlert = ! in_array($needsAlertRaw, ['否', 'no', '0', 'false'], true);
|
||||
|
||||
$hoursRaw = $value('handling_hours');
|
||||
$hours = is_numeric($hoursRaw) ? max((int) $hoursRaw, 0) : null;
|
||||
|
||||
$payload = [
|
||||
'platform' => $platform,
|
||||
'violation_category' => $value('violation_category') !== '' ? mb_substr($value('violation_category'), 0, 64) : null,
|
||||
'violation_type' => mb_substr($type, 0, 500),
|
||||
'match_keywords' => $value('match_keywords') !== '' ? mb_substr($value('match_keywords'), 0, 500) : null,
|
||||
'alert_level' => $needsAlert ? $level : null,
|
||||
'needs_alert' => $needsAlert ? 1 : 0,
|
||||
'suggested_action' => $value('suggested_action') !== '' ? mb_substr($value('suggested_action'), 0, 500) : null,
|
||||
'handling_hours' => $hours,
|
||||
'remind_channel' => $value('remind_channel') !== '' ? mb_substr($value('remind_channel'), 0, 191) : null,
|
||||
'sort' => is_numeric($value('sort')) ? (int) $value('sort') : $sort,
|
||||
'enabled' => ! in_array(mb_strtolower($value('enabled')), ['否', 'no', '0', 'false'], true) ? 1 : 0,
|
||||
'remark' => $value('remark') !== '' ? mb_substr($value('remark'), 0, 191) : null,
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
$exists = $connection->table(StoreViolationRuleRepository::TABLE)
|
||||
->where('platform', $platform)
|
||||
->where('violation_type', $payload['violation_type'])
|
||||
->exists();
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$exists ? $updated++ : $created++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($exists) {
|
||||
$connection->table(StoreViolationRuleRepository::TABLE)
|
||||
->where('platform', $platform)
|
||||
->where('violation_type', $payload['violation_type'])
|
||||
->update($payload);
|
||||
$updated++;
|
||||
} else {
|
||||
$connection->table(StoreViolationRuleRepository::TABLE)->insert($payload + [
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'字典导入%s:新增 %d 条,更新 %d 条,跳过 %d 条。',
|
||||
$this->option('dry-run') ? '(试运行,未写库)' : '完成',
|
||||
$created,
|
||||
$updated,
|
||||
$skipped,
|
||||
));
|
||||
$this->line('提示:需要让存量记录套用新字典时,执行 store-violations:recalc-alerts。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
28
app/Console/Commands/ImportSupplierBookkeepingConfigs.php
Normal file
28
app/Console/Commands/ImportSupplierBookkeepingConfigs.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigImporter;
|
||||
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigStore;
|
||||
|
||||
class ImportSupplierBookkeepingConfigs extends Command
|
||||
{
|
||||
protected $signature = 'supplier-bookkeeping:import-configs {file} {--dry-run : 仅检查文件并输出条数,不写入}';
|
||||
protected $description = '导入财务提供的厂家记账配置 Excel(供应商、厂家是否记账);新增/更新/跳过,任一行无法识别整批不写入';
|
||||
|
||||
public function handle(SupplierBookkeepingConfigImporter $importer, SupplierBookkeepingConfigStore $store): int
|
||||
{
|
||||
$path = (string) $this->argument('file');
|
||||
$rows = $importer->read($path);
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info('文件校验通过,共' . count($rows) . '条;未写入。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
$result = $store->import($rows, '命令行导入', hash_file('sha256', $path));
|
||||
$this->info("导入完成:新增{$result['added']}条,更新{$result['updated']}条,未变化{$result['skipped']}条。");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
121
app/Console/Commands/ImportSupplierSummaryInbound.php
Normal file
121
app/Console/Commands/ImportSupplierSummaryInbound.php
Normal file
@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\SupplierSummaryInboundImporter;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 供应商汇总表月度增量导入:读取已解密工作簿的「明细」子表,仅追加目标月份数据。
|
||||
*/
|
||||
class ImportSupplierSummaryInbound extends Command
|
||||
{
|
||||
protected $signature = 'inventory:supplier-summary:import
|
||||
{file : 已解密的供应商汇总 XLSX 文件}
|
||||
{--month= : 目标月份 YYYY-MM,默认上一个月}
|
||||
{--result-json= : 将执行结果写入指定 JSON 文件}';
|
||||
|
||||
protected $description = '将供应商汇总表「明细」增量导入入库明细数据';
|
||||
|
||||
public function handle(SupplierSummaryInboundImporter $importer): int
|
||||
{
|
||||
$month = trim((string) $this->option('month'));
|
||||
if ($month === '') {
|
||||
$month = now('Asia/Shanghai')->subMonthNoOverflow()->format('Y-m');
|
||||
}
|
||||
|
||||
$lock = Cache::lock('inventory-supplier-summary-inbound-import', 21600);
|
||||
if (! $lock->get()) {
|
||||
return $this->finish([
|
||||
'status' => 'failed',
|
||||
'report_month' => $month,
|
||||
'source_filename' => basename((string) $this->argument('file')),
|
||||
'success' => 0,
|
||||
'duplicates' => 0,
|
||||
'failed' => 1,
|
||||
'mapped' => 0,
|
||||
'total_in_table' => 0,
|
||||
'failure_reason_count' => 1,
|
||||
'failure_reasons' => [[
|
||||
'row' => 0,
|
||||
'reason' => '已有供应商汇总导入任务正在执行',
|
||||
]],
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $importer->import((string) $this->argument('file'), $month);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$result = [
|
||||
'status' => 'failed',
|
||||
'report_month' => $month,
|
||||
'source_filename' => basename((string) $this->argument('file')),
|
||||
'success' => 0,
|
||||
'duplicates' => 0,
|
||||
'failed' => 1,
|
||||
'mapped' => 0,
|
||||
'total_in_table' => 0,
|
||||
'failure_reason_count' => 1,
|
||||
'failure_reasons' => [[
|
||||
'row' => 0,
|
||||
'reason' => $exception->getMessage(),
|
||||
]],
|
||||
];
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
|
||||
return $this->finish($result);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $result */
|
||||
private function finish(array $result): int
|
||||
{
|
||||
$reasons = is_array($result['failure_reasons'] ?? null)
|
||||
? $result['failure_reasons']
|
||||
: [];
|
||||
$result['failure_reason_count'] = count($reasons);
|
||||
|
||||
$json = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (! is_string($json)) {
|
||||
$json = '{"status":"failed","failure_reasons":[{"row":0,"reason":"结果 JSON 编码失败"}]}';
|
||||
}
|
||||
|
||||
$resultPath = trim((string) $this->option('result-json'));
|
||||
if ($resultPath !== '') {
|
||||
File::ensureDirectoryExists(dirname($resultPath));
|
||||
File::put($resultPath, $json . PHP_EOL);
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
'%s 导入状态=%s,成功=%s,重复=%s,失败=%s,目标月份总行数=%s',
|
||||
(string) ($result['report_month'] ?? ''),
|
||||
(string) ($result['status'] ?? 'failed'),
|
||||
number_format((int) ($result['success'] ?? 0)),
|
||||
number_format((int) ($result['duplicates'] ?? 0)),
|
||||
number_format((int) ($result['failed'] ?? 0)),
|
||||
number_format((int) ($result['total_in_table'] ?? 0)),
|
||||
));
|
||||
foreach (array_slice($reasons, 0, 10) as $reason) {
|
||||
$this->warn(sprintf(
|
||||
'第 %s 行:%s',
|
||||
(string) ($reason['row'] ?? ''),
|
||||
(string) ($reason['reason'] ?? '未知错误'),
|
||||
));
|
||||
}
|
||||
if (count($reasons) > 10) {
|
||||
$this->line(sprintf(
|
||||
'另有 %s 条失败原因,完整清单见结果 JSON。',
|
||||
number_format(count($reasons) - 10),
|
||||
));
|
||||
}
|
||||
|
||||
return ($result['status'] ?? 'failed') === 'success'
|
||||
? self::SUCCESS
|
||||
: self::FAILURE;
|
||||
}
|
||||
}
|
||||
57
app/Console/Commands/ImportSystemizationTodos.php
Normal file
57
app/Console/Commands/ImportSystemizationTodos.php
Normal file
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\SystemizationTodoWorkbookImporter;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class ImportSystemizationTodos extends Command
|
||||
{
|
||||
protected $signature = 'systemization-todos:import
|
||||
{file : 待办 Excel 文件路径}
|
||||
{--company=1 : 写入的 company_id}
|
||||
{--user= : 操作人用户 ID}
|
||||
{--sync : 新增并更新现有来源事项,保留截图}
|
||||
{--dry-run : 只预览同步结果,不写入数据库}';
|
||||
|
||||
protected $description = '导入或同步财务系统化待办清单';
|
||||
|
||||
public function handle(SystemizationTodoWorkbookImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$file = (string) $this->argument('file');
|
||||
$companyId = max((int) $this->option('company'), 1);
|
||||
$userId = $this->option('user') !== null ? (int) $this->option('user') : null;
|
||||
if ($this->option('sync')) {
|
||||
$result = $importer->sync($file, $companyId, $userId, (bool) $this->option('dry-run'));
|
||||
} else {
|
||||
$result = $importer->import($file, $companyId, $userId);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->option('sync')) {
|
||||
$this->info(sprintf(
|
||||
'%s工作表 %s:新增 %d 条,更新 %d 条,保留工作簿外事项 %d 条。',
|
||||
$this->option('dry-run') ? '同步预检:' : '同步完成:',
|
||||
$result['sheet'],
|
||||
$result['created'],
|
||||
$result['updated'],
|
||||
$result['retained'],
|
||||
));
|
||||
} else {
|
||||
$this->info(sprintf(
|
||||
'工作表 %s:新增 %d 条,跳过已存在 %d 条。',
|
||||
$result['sheet'],
|
||||
$result['created'],
|
||||
$result['skipped'],
|
||||
));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -27,7 +27,7 @@ class JstDump extends Command
|
||||
['actual_weight', '实称重量'], ['online_weight', '线上重量'], ['freight', '运费'],
|
||||
['ship_time', '发货时间'], ['pay_time', '付款时间'], ['product_code', '货号'],
|
||||
['receiver_state', '省'], ['receiver_city', '市'], ['receiver_district', '区'],
|
||||
['package_no', '配货单号'], ['amount', '金额'],
|
||||
['package_no', '配货单号'], ['amount', '金额'], ['express_name1', '快递名称'],
|
||||
];
|
||||
$fh = fopen('php://output', 'w');
|
||||
fwrite($fh, chr(0xEF).chr(0xBB).chr(0xBF));
|
||||
|
||||
211
app/Console/Commands/MaterializeStoreReceiptDifferences.php
Normal file
211
app/Console/Commands/MaterializeStoreReceiptDifferences.php
Normal file
@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\StoreReceiptDifferenceMaterialization as Materialization;
|
||||
use App\Http\Controllers\Business\Pages;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MaterializeStoreReceiptDifferences extends Command
|
||||
{
|
||||
protected $signature = 'store-receipt:materialize-differences
|
||||
{month? : 账期 YYYY-MM;留空则物化镜像里已有数据的全部月份}
|
||||
{--chunk=5000 : 每批更新的行数}
|
||||
{--dry-run : 只统计与比对,不写库}';
|
||||
|
||||
protected $description = '把业绩明细的差额备注分类与有效差额物化到镜像表,页面改为读列';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! Materialization::available(true)) {
|
||||
$this->components->error('物化列或状态表不存在,请先执行数据库迁移。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$months = $this->targetMonths();
|
||||
if ($months === []) {
|
||||
$this->components->warn('镜像里没有可物化的账期。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$chunk = max(200, (int) $this->option('chunk'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
foreach ($months as $month) {
|
||||
$this->line('');
|
||||
$this->components->info(($dryRun ? '[dry-run] ' : '') . '账期 ' . substr($month, 0, 7));
|
||||
|
||||
if ($dryRun) {
|
||||
$this->reportDryRun($month);
|
||||
continue;
|
||||
}
|
||||
|
||||
$started = microtime(true);
|
||||
$updated = $this->materializeMonth($month, $chunk);
|
||||
if ($updated === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Materialization::markMaterialized($month, $updated);
|
||||
$this->line(sprintf(' %s 行,用时 %.1f 秒', number_format($updated), microtime(true) - $started));
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line('');
|
||||
$this->components->info('dry-run 完成,未写入任何数据。');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function targetMonths(): array
|
||||
{
|
||||
$month = trim((string) $this->argument('month'));
|
||||
if ($month !== '') {
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $month) === 1) {
|
||||
$month .= '-01';
|
||||
}
|
||||
|
||||
// 跨月备注规则依赖相邻月份,指定月份时一起重算前后两个月。
|
||||
return array_values(array_unique(Materialization::adjacentMonths($month)));
|
||||
}
|
||||
|
||||
return DB::connection(Materialization::connection())
|
||||
->table(Materialization::table())
|
||||
->select('report_month')
|
||||
->distinct()
|
||||
->orderBy('report_month')
|
||||
->pluck('report_month')
|
||||
->map(static fn ($value): string => substr((string) $value, 0, 10))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 分批把规则结果写进物化列;返回更新行数。
|
||||
*/
|
||||
private function materializeMonth(string $month, int $chunk): ?int
|
||||
{
|
||||
$connection = Materialization::connection();
|
||||
$table = Materialization::table();
|
||||
|
||||
$exists = DB::connection($connection)->table($table)->where('report_month', $month)->exists();
|
||||
if (! $exists) {
|
||||
$this->components->warn(' 镜像里没有该账期的数据,跳过。');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$baseExpression = Pages::receiptDefaultDifferenceSql();
|
||||
$indexExpression = Pages::receiptDifferenceRemarkIndexSql();
|
||||
|
||||
$updated = 0;
|
||||
$lastKey = '';
|
||||
$batch = 0;
|
||||
|
||||
while (true) {
|
||||
$keys = DB::connection($connection)->table($table)
|
||||
->where('report_month', $month)
|
||||
->where('row_key', '>', $lastKey)
|
||||
->orderBy('row_key')
|
||||
->limit($chunk)
|
||||
->pluck('row_key')
|
||||
->all();
|
||||
|
||||
if ($keys === []) {
|
||||
break;
|
||||
}
|
||||
|
||||
$lastKey = (string) end($keys);
|
||||
$batch++;
|
||||
|
||||
// 第一遍:规则表达式只算一次,结果落列。
|
||||
DB::connection($connection)->table($table)
|
||||
->where('report_month', $month)
|
||||
->whereIn('row_key', $keys)
|
||||
->update([
|
||||
Materialization::BASE_DIFFERENCE_COLUMN => DB::raw($baseExpression),
|
||||
Materialization::REASON_INDEX_COLUMN => DB::raw($indexExpression),
|
||||
]);
|
||||
|
||||
// 第二遍:直接用刚写入的列推导「有/无差额」,避免重复执行规则链,
|
||||
// 也避免依赖 SET 子句从左到右求值的库间差异。
|
||||
DB::connection($connection)->table($table)
|
||||
->where('report_month', $month)
|
||||
->whereIn('row_key', $keys)
|
||||
->update([
|
||||
Materialization::HAS_DIFFERENCE_COLUMN => DB::raw($this->hasDifferenceExpression()),
|
||||
]);
|
||||
|
||||
$updated += count($keys);
|
||||
$this->output->write('.');
|
||||
}
|
||||
|
||||
if ($updated === 0) {
|
||||
$this->line('');
|
||||
} else {
|
||||
$this->line(' (' . $batch . ' 批)');
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于物化列的「有/无差额」推导:口径兜底行按 0 计。
|
||||
*/
|
||||
private function hasDifferenceExpression(): string
|
||||
{
|
||||
$basisMarks = implode(', ', array_map(
|
||||
static fn (string $remark): string => "'" . str_replace("'", "''", $remark) . "'",
|
||||
Pages::RECEIPT_BASIS_FALLBACK_REMARKS,
|
||||
));
|
||||
|
||||
return 'CASE WHEN ABS(CASE WHEN remark IN (' . $basisMarks . ')'
|
||||
. ' AND COALESCE(' . Materialization::REASON_INDEX_COLUMN . ', 0) = 0 THEN 0'
|
||||
. ' ELSE COALESCE(' . Materialization::BASE_DIFFERENCE_COLUMN . ', 0) END) > 0.005 THEN 1 ELSE 0 END';
|
||||
}
|
||||
|
||||
/**
|
||||
* dry-run:对比「库里的物化值」与「按当前规则重算」的差异,不写库。
|
||||
*/
|
||||
private function reportDryRun(string $month): void
|
||||
{
|
||||
$connection = Materialization::connection();
|
||||
$table = Materialization::table();
|
||||
|
||||
$total = (int) DB::connection($connection)->table($table)->where('report_month', $month)->count();
|
||||
if ($total === 0) {
|
||||
$this->components->warn(' 镜像里没有该账期的数据,跳过。');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$baseExpression = Pages::receiptDefaultDifferenceSql();
|
||||
$indexExpression = Pages::receiptDifferenceRemarkIndexSql();
|
||||
|
||||
$row = DB::connection($connection)->table($table)
|
||||
->where('report_month', $month)
|
||||
->selectRaw('COUNT(*) AS total')
|
||||
->selectRaw('SUM(CASE WHEN ' . Materialization::REASON_INDEX_COLUMN
|
||||
. ' IS NULL OR ' . Materialization::REASON_INDEX_COLUMN . ' <> (' . $indexExpression . ') THEN 1 ELSE 0 END) AS reason_mismatch')
|
||||
->selectRaw('SUM(CASE WHEN ' . Materialization::BASE_DIFFERENCE_COLUMN
|
||||
. ' IS NULL OR ABS(' . Materialization::BASE_DIFFERENCE_COLUMN . ' - (' . $baseExpression . ')) > 0.005 THEN 1 ELSE 0 END) AS base_mismatch')
|
||||
->selectRaw('SUM(CASE WHEN ' . Materialization::HAS_DIFFERENCE_COLUMN . ' IS NULL THEN 1 ELSE 0 END) AS unreasoned')
|
||||
->selectRaw('SUM(CASE WHEN ' . Materialization::HAS_DIFFERENCE_COLUMN . ' IS NOT NULL AND '
|
||||
. Materialization::HAS_DIFFERENCE_COLUMN . ' <> (' . $this->hasDifferenceExpression() . ') THEN 1 ELSE 0 END) AS state_mismatch')
|
||||
->first();
|
||||
|
||||
$this->line(sprintf(' 行数 %s|备注序号不一致 %s|差额不一致 %s|有/无不一致 %s|未物化 %s',
|
||||
number_format((int) ($row->total ?? 0)),
|
||||
number_format((int) ($row->reason_mismatch ?? 0)),
|
||||
number_format((int) ($row->base_mismatch ?? 0)),
|
||||
number_format((int) ($row->state_mismatch ?? 0)),
|
||||
number_format((int) ($row->unreasoned ?? 0)),
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\StoreReceiptDefaultCompensationSnapshotBuilder;
|
||||
use Throwable;
|
||||
|
||||
class PreviewStoreReceiptDefaultCompensation extends Command
|
||||
{
|
||||
protected $signature = 'store-receipt:default-compensation:preview
|
||||
{month : 账期 YYYY-MM}
|
||||
{mapping : 最新科目映射表 xlsx 路径}
|
||||
{--output= : 输出 JSON 路径;默认写入 storage/app/local/performance-details/default-compensation}';
|
||||
|
||||
protected $description = '人工核对:按指定科目映射表生成「违约赔付(冲减收入)」金额快照,不写数据库';
|
||||
|
||||
public function handle(StoreReceiptDefaultCompensationSnapshotBuilder $builder): int
|
||||
{
|
||||
$startedAt = hrtime(true);
|
||||
$month = trim((string) $this->argument('month'));
|
||||
$mapping = (string) $this->argument('mapping');
|
||||
|
||||
try {
|
||||
$snapshot = $builder->build($month, $mapping);
|
||||
$path = (string) ($this->option('output') ?: storage_path(
|
||||
'app/local/performance-details/default-compensation/' . $month . '.json'
|
||||
));
|
||||
$directory = dirname($path);
|
||||
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
|
||||
throw new \RuntimeException('无法创建快照目录:' . $directory);
|
||||
}
|
||||
$temporary = $path . '.tmp';
|
||||
$json = json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
if ($json === false || file_put_contents($temporary, $json, LOCK_EX) === false) {
|
||||
throw new \RuntimeException('无法写入本地快照。');
|
||||
}
|
||||
rename($temporary, $path);
|
||||
|
||||
$this->info('本地预览快照已生成:' . $path);
|
||||
$this->line('映射条数:' . $snapshot['mapping_rows']);
|
||||
$this->line('关联行数:' . count($snapshot['amounts']));
|
||||
$this->line('金额合计:' . number_format((float) $snapshot['total'], 2, '.', ''));
|
||||
$this->line('耗时:' . number_format((hrtime(true) - $startedAt) / 1e6, 0) . ' ms');
|
||||
$this->line('PHP 峰值内存:' . number_format(memory_get_peak_usage(true) / 1024 / 1024, 1) . ' MB');
|
||||
foreach ($snapshot['source_stats'] as $platform => $stats) {
|
||||
$this->line(sprintf(
|
||||
' %s:账单行 %d,金额 %s',
|
||||
$platform,
|
||||
$stats['rows'],
|
||||
number_format((float) $stats['amount'], 2, '.', ''),
|
||||
));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
683
app/Console/Commands/PullCourierSurchargeOverlength.php
Normal file
683
app/Console/Commands/PullCourierSurchargeOverlength.php
Normal file
@ -0,0 +1,683 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 按「附加费报价维护」里启用的超长费规则,从销售出库单拉取结算行。
|
||||
*
|
||||
* 口径(第一期只做超长费,且只处理“按尺寸/最长边”的规则):
|
||||
* - 规则状态必须是 active;
|
||||
* - 生效条件解析出单边阈值(如“单边超160cm”);
|
||||
* - 计价标准解析出每单金额(如“8元/单”);
|
||||
* - 出库单按 logistics_company = 快递名称 匹配,运单号取 l_id,只取已分配运单号的单;
|
||||
* - 长宽高按明细条形码 sku_id 到商品及库存管理(商品资料 jst_sku)换算,一票多件取最大边;
|
||||
* - 查件类型/查件原因/退回单号/平台商品ID 在出库单与商品资料里取不到,留空。
|
||||
*/
|
||||
class PullCourierSurchargeOverlength extends Command
|
||||
{
|
||||
protected $signature = 'courier-surcharge:pull-overlength
|
||||
{--days=90 : 默认拉取最近多少天(无 --from/--to 时生效)}
|
||||
{--from= : 出库日期起(含),Y-m-d}
|
||||
{--to= : 出库日期止(不含),Y-m-d}
|
||||
{--rule=* : 只跑指定规则 ID,可多次传入}
|
||||
{--chunk=300 : 每批处理的出库单数量}';
|
||||
|
||||
protected $description = '按启用的超长费报价规则,从销售出库单拉取附加费结算行';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$from = $this->option('from') !== null && $this->option('from') !== ''
|
||||
? Carbon::parse((string) $this->option('from'))->startOfDay()
|
||||
: now()->subDays(max((int) $this->option('days'), 1))->startOfDay();
|
||||
$to = $this->option('to') !== null && $this->option('to') !== ''
|
||||
? Carbon::parse((string) $this->option('to'))->startOfDay()
|
||||
: now()->addDay()->startOfDay();
|
||||
$onlyRules = array_filter(array_map('intval', (array) $this->option('rule')));
|
||||
$chunkSize = min(max((int) $this->option('chunk'), 50), 500);
|
||||
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$schema = DB::connection($connection)->getSchemaBuilder();
|
||||
|
||||
if (! $schema->hasTable('courier_surcharge_settlement_rows')) {
|
||||
$this->error('结算表 courier_surcharge_settlement_rows 不存在,请先执行迁移。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$rules = $this->overlengthRules($onlyRules);
|
||||
|
||||
if ($rules === []) {
|
||||
$this->warn('没有启用且可自动计算的超长费规则。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun');
|
||||
$totalWritten = 0;
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
$threshold = $rule['threshold'];
|
||||
$amount = $rule['amount'];
|
||||
|
||||
$this->info(sprintf(
|
||||
'规则 #%d %s:单边超 %scm 计 %s 元/单,取数 %s ~ %s',
|
||||
$rule['id'],
|
||||
$rule['courier'],
|
||||
$threshold,
|
||||
$amount,
|
||||
$from->toDateString(),
|
||||
$to->toDateString(),
|
||||
));
|
||||
|
||||
$orders = $this->pullOrders($upstream, $rule['courier'], $from, $to, $chunkSize);
|
||||
|
||||
if ($orders === []) {
|
||||
$this->line(' 没有运单号非空的出库单,跳过。');
|
||||
continue;
|
||||
}
|
||||
|
||||
$written = $this->writeSettlementRows($connection, $rule, $orders, $threshold, $amount, $from, $to);
|
||||
$totalWritten += $written;
|
||||
|
||||
$this->line(' 出库单 ' . count($orders) . ' 单,命中超长并写入 ' . $written . ' 行。');
|
||||
}
|
||||
|
||||
$this->info('完成,共写入 ' . $totalWritten . ' 行结算数据。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $onlyRules
|
||||
* @return list<array{id:int,courier:string,threshold:float,amount:float}>
|
||||
*/
|
||||
private function overlengthRules(array $onlyRules): array
|
||||
{
|
||||
$rules = [];
|
||||
|
||||
foreach (DB::table('business_courier_surcharge_quotes')->where('status', 'active')->orderBy('id')->get() as $rule) {
|
||||
$types = json_decode((string) $rule->types, true) ?: [];
|
||||
|
||||
if (! in_array('超长费', $types, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! str_contains((string) $rule->mode, '按尺寸') && ! str_contains((string) $rule->mode, '最长边')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($onlyRules !== [] && ! in_array((int) $rule->id, $onlyRules, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$condition = (string) $rule->condition;
|
||||
$standard = (string) $rule->standard;
|
||||
|
||||
if (preg_match('/单边超\s*(\d+(?:\.\d+)?)\s*(?:cm|厘米|CM|㎝)/u', $condition, $matched) !== 1) {
|
||||
$this->warn('规则 #' . $rule->id . ' 的生效条件解析不出单边阈值,已跳过:' . $condition);
|
||||
continue;
|
||||
}
|
||||
|
||||
$threshold = (float) $matched[1];
|
||||
|
||||
if (preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/?\s*单/u', $standard, $priceMatched) !== 1) {
|
||||
$this->warn('规则 #' . $rule->id . ' 的计价标准解析不出每单金额,已跳过:' . $standard);
|
||||
continue;
|
||||
}
|
||||
|
||||
$rules[] = [
|
||||
'id' => (int) $rule->id,
|
||||
'courier' => trim((string) $rule->courier),
|
||||
'threshold' => $threshold,
|
||||
'amount' => (float) $priceMatched[1],
|
||||
];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取出库单(只取已分配运单号的单)。
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function pullOrders(
|
||||
UpstreamReadOnlyQuery $upstream,
|
||||
string $courier,
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
int $chunkSize,
|
||||
): array {
|
||||
$orders = [];
|
||||
$lastId = 0;
|
||||
|
||||
while (true) {
|
||||
$rows = $upstream->select(
|
||||
'SELECT TOP ' . $chunkSize . ' Id, io_id, o_id, l_id, so_id, shop_name, io_date, weight, f_weight '
|
||||
. 'FROM dbo.jst_salesOutboundInfo '
|
||||
. 'WHERE logistics_company = ? AND io_date >= ? AND io_date < ? '
|
||||
. "AND LEN(ISNULL(l_id, '')) > 0 AND Id > ? "
|
||||
. 'ORDER BY Id',
|
||||
[$courier, $from->toDateTimeString(), $to->toDateTimeString(), $lastId],
|
||||
);
|
||||
|
||||
if ($rows === []) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$row = (array) $row;
|
||||
$orders[] = $row;
|
||||
$lastId = max($lastId, (int) $row['Id']);
|
||||
}
|
||||
|
||||
if (count($rows) < $chunkSize) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $orders;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $orders
|
||||
*/
|
||||
private function writeSettlementRows(
|
||||
string $connection,
|
||||
array $rule,
|
||||
array $orders,
|
||||
float $threshold,
|
||||
float $amount,
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
): int {
|
||||
$ioIds = array_values(array_filter(array_map(
|
||||
static fn (array $order): int => (int) ($order['io_id'] ?? 0),
|
||||
$orders,
|
||||
), static fn (int $value): bool => $value > 0));
|
||||
|
||||
$items = $this->pullItems($ioIds);
|
||||
$dimensions = $this->dimensionMap($items);
|
||||
$platformProductIds = $this->platformProductIds($items, $orders);
|
||||
$onlineOrderNumbers = $this->onlineOrderNumbers($orders);
|
||||
$now = now();
|
||||
$written = 0;
|
||||
$rows = [];
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$ioId = (int) ($order['io_id'] ?? 0);
|
||||
|
||||
if ($ioId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$best = null;
|
||||
|
||||
foreach ($items[$ioId] ?? [] as $item) {
|
||||
$skuId = trim((string) ($item['sku_id'] ?? ''));
|
||||
$dim = $dimensions[$skuId] ?? null;
|
||||
|
||||
if ($dim === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$maxSide = max($dim['l'], $dim['w'], $dim['h']);
|
||||
|
||||
if ($best === null || $maxSide > $best['max_side']) {
|
||||
$best = [
|
||||
'max_side' => $maxSide,
|
||||
'sku_id' => $skuId,
|
||||
'i_id' => trim((string) ($item['i_id'] ?? '')),
|
||||
'length' => $dim['l'],
|
||||
'width' => $dim['w'],
|
||||
'height' => $dim['h'],
|
||||
'platform_product_id' => $platformProductIds[$ioId][$skuId] ?? ($platformProductIds[$ioId][''] ?? null),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($best === null || $best['max_side'] <= $threshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'rule_id' => $rule['id'],
|
||||
'surcharge_type' => '超长费',
|
||||
'courier_name' => $rule['courier'],
|
||||
'waybill' => trim((string) ($order['l_id'] ?? '')),
|
||||
'outbound_io_id' => $ioId,
|
||||
'platform_product_id' => $best['platform_product_id'],
|
||||
'product_code' => $best['sku_id'] !== '' ? $best['sku_id'] : ($best['i_id'] !== '' ? $best['i_id'] : null),
|
||||
'shop_name' => mb_substr(trim((string) ($order['shop_name'] ?? '')), 0, 191) ?: null,
|
||||
'online_order_no' => $onlineOrderNumbers[(int) ($order['o_id'] ?? 0)] ?? null,
|
||||
'length' => $best['length'],
|
||||
'width' => $best['width'],
|
||||
'height' => $best['height'],
|
||||
'volume' => round($best['length'] * $best['width'] * $best['height'], 2),
|
||||
'inspection_type' => null,
|
||||
'inspection_reason' => null,
|
||||
'return_waybill_no' => null,
|
||||
'settlement_weight' => null,
|
||||
'settlement_amount' => $amount,
|
||||
'settlement_status' => '已计算',
|
||||
'evidence' => mb_substr(sprintf(
|
||||
'条形码 %s 尺寸 %s×%s×%s,最长边 %scm > %scm',
|
||||
$best['sku_id'] !== '' ? $best['sku_id'] : '(无)',
|
||||
$this->trimNumber($best['length']),
|
||||
$this->trimNumber($best['width']),
|
||||
$this->trimNumber($best['height']),
|
||||
$this->trimNumber($best['max_side']),
|
||||
$this->trimNumber($threshold),
|
||||
), 0, 500),
|
||||
'shipped_at' => $order['io_date'] ?? null,
|
||||
'pulled_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
$written++;
|
||||
}
|
||||
|
||||
$target = DB::connection($connection)->table('courier_surcharge_settlement_rows');
|
||||
|
||||
// 同一规则、同一取数窗口内重跑时先清掉旧行,避免残留历史判断。
|
||||
$target->where('rule_id', $rule['id'])
|
||||
->where('shipped_at', '>=', $from->toDateTimeString())
|
||||
->where('shipped_at', '<', $to->toDateTimeString())
|
||||
->delete();
|
||||
|
||||
foreach (array_chunk($rows, 500) as $chunk) {
|
||||
$target->insert($chunk);
|
||||
}
|
||||
|
||||
return $written;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $ioIds
|
||||
* @return array<int,list<array<string,mixed>>>
|
||||
*/
|
||||
private function pullItems(array $ioIds): array
|
||||
{
|
||||
if ($ioIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun');
|
||||
$items = [];
|
||||
|
||||
foreach (array_chunk($ioIds, 500) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
$rows = $upstream->select(
|
||||
'SELECT io_id, sku_id, i_id, name, qty FROM dbo.jst_salesOutboundItem WHERE io_id IN (' . $placeholders . ')',
|
||||
$chunk,
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$row = (array) $row;
|
||||
$items[(int) $row['io_id']][] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* 线上订单号:优先用出库单自带 so_id,为空时按 o_id 到「未发货订单」表兜底。
|
||||
*
|
||||
* 部分渠道(如阿里巴巴-常州玖阔)的出库单 so_id 为空,但发货中订单表
|
||||
* jst_nosend_orders 里仍能按 o_id 找到线上单号。
|
||||
*
|
||||
* @param list<array<string,mixed>> $orders
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private function onlineOrderNumbers(array $orders): array
|
||||
{
|
||||
$map = [];
|
||||
$missing = [];
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$oId = (int) ($order['o_id'] ?? 0);
|
||||
$soId = trim((string) ($order['so_id'] ?? ''));
|
||||
|
||||
if ($soId !== '') {
|
||||
$map[$oId] = mb_substr($soId, 0, 191);
|
||||
} elseif ($oId > 0) {
|
||||
$missing[$oId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($missing === []) {
|
||||
return $map;
|
||||
}
|
||||
|
||||
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun');
|
||||
|
||||
foreach (array_chunk(array_keys($missing), 500) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
|
||||
try {
|
||||
$rows = $upstream->select(
|
||||
"SELECT o_id, so_id FROM dbo.jst_nosend_orders WHERE o_id IN (" . $placeholders . ')'
|
||||
. " AND LEN(ISNULL(so_id, '')) > 0",
|
||||
$chunk,
|
||||
);
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$row = (array) $row;
|
||||
$map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191);
|
||||
}
|
||||
}
|
||||
|
||||
// 仍未补到的(多为已发货、已离开未发货订单表的单),到订单视图兜底;
|
||||
// 该视图覆盖部分不在 jst_Orders 主表里的店铺(如阿里巴巴-常州玖阔)。
|
||||
$remaining = array_values(array_diff(array_keys($missing), array_keys($map)));
|
||||
|
||||
foreach (array_chunk($remaining, 500) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
|
||||
try {
|
||||
$rows = $upstream->select(
|
||||
"SELECT o_id, so_id FROM dbo.jst_ordersView WHERE o_id IN (" . $placeholders . ')'
|
||||
. " AND LEN(ISNULL(so_id, '')) > 0",
|
||||
$chunk,
|
||||
);
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$row = (array) $row;
|
||||
$map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191);
|
||||
}
|
||||
}
|
||||
|
||||
// 再兜底:发货数据视图在订单离开 jst_Orders/ordersView/nosend 后仍保留 o_id → so_id,
|
||||
// 例如 2026-07-31 新佰广1688 已归档的出库单。
|
||||
$remaining = array_values(array_diff(array_keys($missing), array_keys($map)));
|
||||
|
||||
foreach (array_chunk($remaining, 200) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
|
||||
try {
|
||||
$rows = $upstream->select(
|
||||
'SELECT DISTINCT o_id, so_id FROM dbo.Jst_SendData_View WHERE o_id IN (' . $placeholders . ')'
|
||||
. ' AND LEN(so_id) > 0',
|
||||
$chunk,
|
||||
);
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$row = (array) $row;
|
||||
$map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191);
|
||||
}
|
||||
}
|
||||
|
||||
// 最后兜底:全渠道订单列表按月归档,部分天猫订单只在该表保留 o_id → so_id。
|
||||
$remaining = array_values(array_diff(array_keys($missing), array_keys($map)));
|
||||
|
||||
if ($remaining !== []) {
|
||||
$monthById = [];
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$oId = (int) ($order['o_id'] ?? 0);
|
||||
$ioDate = trim((string) ($order['io_date'] ?? ''));
|
||||
|
||||
if ($oId <= 0 || $ioDate === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$monthById[$oId] = Carbon::parse($ioDate)->format('Ym');
|
||||
} catch (Throwable) {
|
||||
// io_date 无法解析时只查当前表。
|
||||
}
|
||||
}
|
||||
|
||||
$tables = ['jst_qmcust_orderlist' => array_values($remaining)];
|
||||
|
||||
foreach ($remaining as $oId) {
|
||||
$month = $monthById[$oId] ?? null;
|
||||
|
||||
if ($month !== null) {
|
||||
$tables['jst_qmcust_orderlist_' . $month][] = $oId;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($tables as $table => $ids) {
|
||||
foreach (array_chunk($ids, 200) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
|
||||
try {
|
||||
$rows = $upstream->select(
|
||||
'SELECT o_id, so_id FROM dbo.' . $table . ' WHERE o_id IN (' . $placeholders . ')'
|
||||
. ' AND LEN(so_id) > 0',
|
||||
$chunk,
|
||||
);
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$row = (array) $row;
|
||||
$map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台商品ID:查件工单里的 platformItemId 与订单/发货明细的 shop_i_id 同源。
|
||||
* 优先按 io_id + sku_id 从发货明细视图取;缺行时按 o_id 到全渠道订单明细(当前表 + 月度归档)兜底。
|
||||
*
|
||||
* @param array<int,list<array<string,mixed>>> $items
|
||||
* @param list<array<string,mixed>> $orders
|
||||
* @return array<int,array<string,string>>
|
||||
*/
|
||||
private function platformProductIds(array $items, array $orders): array
|
||||
{
|
||||
$map = [];
|
||||
$ioIds = array_keys($items);
|
||||
|
||||
if ($ioIds === []) {
|
||||
return $map;
|
||||
}
|
||||
|
||||
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun');
|
||||
|
||||
foreach (array_chunk($ioIds, 200) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
|
||||
try {
|
||||
$rows = $upstream->select(
|
||||
'SELECT io_id, sku_id, shop_i_id FROM dbo.Jst_SendDataDetail_View WHERE io_id IN (' . $placeholders . ')'
|
||||
. ' AND LEN(shop_i_id) > 0',
|
||||
$chunk,
|
||||
);
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$row = (array) $row;
|
||||
$ioId = (int) ($row['io_id'] ?? 0);
|
||||
$skuId = trim((string) ($row['sku_id'] ?? ''));
|
||||
$shopIId = mb_substr(trim((string) ($row['shop_i_id'] ?? '')), 0, 191);
|
||||
|
||||
if ($ioId <= 0 || $shopIId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($skuId !== '') {
|
||||
$map[$ioId][$skuId] = $shopIId;
|
||||
}
|
||||
|
||||
$map[$ioId][''] ??= $shopIId;
|
||||
}
|
||||
}
|
||||
|
||||
// 剩余按 o_id 到全渠道订单明细(当前表 + 月度归档)兜底。
|
||||
$oIdByIoId = [];
|
||||
$monthByIoId = [];
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$ioId = (int) ($order['io_id'] ?? 0);
|
||||
$oId = (int) ($order['o_id'] ?? 0);
|
||||
$ioDate = trim((string) ($order['io_date'] ?? ''));
|
||||
|
||||
if ($ioId <= 0 || $oId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$oIdByIoId[$ioId] = $oId;
|
||||
|
||||
if ($ioDate === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$monthByIoId[$ioId] = Carbon::parse($ioDate)->format('Ym');
|
||||
} catch (Throwable) {
|
||||
// io_date 无法解析时只查当前表。
|
||||
}
|
||||
}
|
||||
|
||||
$missing = [];
|
||||
|
||||
foreach ($ioIds as $ioId) {
|
||||
if (($map[$ioId] ?? []) !== []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$oId = $oIdByIoId[$ioId] ?? 0;
|
||||
|
||||
if ($oId > 0) {
|
||||
$missing[$ioId] = $oId;
|
||||
}
|
||||
}
|
||||
|
||||
if ($missing === []) {
|
||||
return $map;
|
||||
}
|
||||
|
||||
$tables = ['jst_qmcust_orderlist_item' => $missing];
|
||||
|
||||
foreach ($missing as $ioId => $oId) {
|
||||
$month = $monthByIoId[$ioId] ?? null;
|
||||
|
||||
if ($month !== null) {
|
||||
$tables['jst_qmcust_orderlist_item_' . $month][$ioId] = $oId;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($tables as $table => $idsByIo) {
|
||||
$oIds = array_values(array_unique(array_values($idsByIo)));
|
||||
|
||||
foreach (array_chunk($oIds, 200) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
|
||||
try {
|
||||
$rows = $upstream->select(
|
||||
'SELECT o_id, sku_id, shop_i_id FROM dbo.' . $table . ' WHERE o_id IN (' . $placeholders . ')'
|
||||
. ' AND LEN(shop_i_id) > 0',
|
||||
$chunk,
|
||||
);
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$byOrder = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$row = (array) $row;
|
||||
$skuId = trim((string) ($row['sku_id'] ?? ''));
|
||||
$shopIId = mb_substr(trim((string) ($row['shop_i_id'] ?? '')), 0, 191);
|
||||
|
||||
if ($shopIId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$byOrder[(int) ($row['o_id'] ?? 0)][] = ['sku_id' => $skuId, 'shop_i_id' => $shopIId];
|
||||
}
|
||||
|
||||
foreach ($idsByIo as $ioId => $oId) {
|
||||
foreach ($byOrder[$oId] ?? [] as $entry) {
|
||||
if ($entry['sku_id'] !== '') {
|
||||
$map[$ioId][$entry['sku_id']] = $entry['shop_i_id'];
|
||||
}
|
||||
|
||||
$map[$ioId][''] ??= $entry['shop_i_id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,list<array<string,mixed>>> $items
|
||||
* @return array<string,array{l:float,w:float,h:float}>
|
||||
*/
|
||||
private function dimensionMap(array $items): array
|
||||
{
|
||||
$skuIds = [];
|
||||
|
||||
foreach ($items as $rows) {
|
||||
foreach ($rows as $row) {
|
||||
$skuId = trim((string) ($row['sku_id'] ?? ''));
|
||||
|
||||
if ($skuId !== '') {
|
||||
$skuIds[$skuId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$skuIds = array_keys($skuIds);
|
||||
$dimensions = [];
|
||||
|
||||
// 商品及库存管理(商品资料)已在共享镜像里,直接读镜像,不再压上游。
|
||||
foreach (array_chunk($skuIds, 500) as $chunk) {
|
||||
try {
|
||||
$rows = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
|
||||
->table('ops_jst_sku')
|
||||
->whereIn('sku_id', $chunk)
|
||||
->get(['sku_id', 'l', 'w', 'h']);
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$l = (float) $row->l;
|
||||
$w = (float) $row->w;
|
||||
$h = (float) $row->h;
|
||||
|
||||
if ($l > 0 && $w > 0 && $h > 0) {
|
||||
$dimensions[trim((string) $row->sku_id)] = ['l' => $l, 'w' => $w, 'h' => $h];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $dimensions;
|
||||
}
|
||||
|
||||
private function trimNumber(float $value): string
|
||||
{
|
||||
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
|
||||
}
|
||||
}
|
||||
727
app/Console/Commands/PullCourierSurchargeReturn.php
Normal file
727
app/Console/Commands/PullCourierSurchargeReturn.php
Normal file
@ -0,0 +1,727 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 按「附加费报价维护」里启用的退回费 / 拦截费规则,从查件扣单拉取结算行。
|
||||
*
|
||||
* 口径:
|
||||
* - 规则状态 active、生效条件含「查件扣单」;
|
||||
* - 退回费:types 含「退回费」,支持 固定单价 / 按重量;已退回(售后退货退款
|
||||
* receive_date 进仓时间 + r_qty 实退数量)按标准计价,未退回计 0;
|
||||
* 结算重量取商品资料 ops_jst_sku.weight,按实退数量汇总;未退回时按发货数量展示;
|
||||
* - 拦截费:types 含「拦截费」且模式为固定单价,查件扣单即按标准金额计价;
|
||||
* - 扣单来源:共享镜像 js_jst_work_order_info 中 title=查件、查件类型=扣单 的工单。
|
||||
*/
|
||||
class PullCourierSurchargeReturn extends Command
|
||||
{
|
||||
private const CHECK_TYPE_FIELD = 'SELECT_CASCADEField1413856620250813172526';
|
||||
|
||||
protected $signature = 'courier-surcharge:pull-return
|
||||
{--days=90 : 默认拉取最近多少天(无 --from/--to 时生效,按查件工单创建时间)}
|
||||
{--from= : 查件工单创建起(含),Y-m-d}
|
||||
{--to= : 查件工单创建止(不含),Y-m-d}
|
||||
{--rule=* : 只跑指定规则 ID,可多次传入}
|
||||
{--type=* : 只跑指定附加费类型(退回费/拦截费),可多次传入}
|
||||
{--limit=0 : 调试用,最多处理多少张扣单工单,0 表示不限}';
|
||||
|
||||
protected $description = '按启用的退回费/拦截费报价规则,从查件扣单拉取结算行';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$from = $this->option('from') !== null && $this->option('from') !== ''
|
||||
? Carbon::parse((string) $this->option('from'))->startOfDay()
|
||||
: now()->subDays(max((int) $this->option('days'), 1))->startOfDay();
|
||||
$to = $this->option('to') !== null && $this->option('to') !== ''
|
||||
? Carbon::parse((string) $this->option('to'))->startOfDay()
|
||||
: now()->addDay()->startOfDay();
|
||||
$onlyRules = array_filter(array_map('intval', (array) $this->option('rule')));
|
||||
$onlyTypes = array_values(array_filter(
|
||||
array_map(static fn ($type): string => trim((string) $type), (array) $this->option('type')),
|
||||
static fn (string $type): bool => in_array($type, ['退回费', '拦截费'], true),
|
||||
));
|
||||
$limit = max((int) $this->option('limit'), 0);
|
||||
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$schema = DB::connection($connection)->getSchemaBuilder();
|
||||
|
||||
if (! $schema->hasTable('courier_surcharge_settlement_rows')) {
|
||||
$this->error('结算表 courier_surcharge_settlement_rows 不存在,请先执行迁移。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('js_jst_work_order_info')) {
|
||||
$this->error('查件工单镜像 js_jst_work_order_info 不存在,请先完成镜像同步。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$rules = $this->rules($onlyRules, $onlyTypes);
|
||||
|
||||
if ($rules === []) {
|
||||
$this->warn('没有启用且可自动计算的退回费/拦截费规则。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$orders = $this->pullDeductOrders($connection, $from, $to, $limit);
|
||||
$this->info(sprintf('取数 %s ~ %s,扣单工单 %d 单。', $from->toDateString(), $to->toDateString(), count($orders)));
|
||||
|
||||
if ($orders === []) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$ordersByCourier = [];
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$ordersByCourier[$order['courier']][] = $order;
|
||||
}
|
||||
|
||||
$totalWritten = 0;
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
$matched = $ordersByCourier[$rule['courier']] ?? [];
|
||||
|
||||
if ($matched === []) {
|
||||
$this->line('规则 #' . $rule['id'] . ' ' . $rule['courier'] . ':没有匹配的扣单工单,跳过。');
|
||||
continue;
|
||||
}
|
||||
|
||||
$written = $this->writeSettlementRows($connection, $rule, $matched);
|
||||
$totalWritten += $written;
|
||||
$this->line('规则 #' . $rule['id'] . ' ' . $rule['courier'] . '(' . $rule['type'] . '/' . $rule['mode'] . '):扣单 ' . count($matched) . ' 单,写入 ' . $written . ' 行。');
|
||||
}
|
||||
|
||||
$this->info('完成,共写入 ' . $totalWritten . ' 行附加费结算数据。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $onlyRules
|
||||
* @param list<string> $onlyTypes
|
||||
* @return list<array{id:int,type:string,courier:string,mode:string,value:float,effective_at:?string}>
|
||||
*/
|
||||
private function rules(array $onlyRules, array $onlyTypes): array
|
||||
{
|
||||
$rules = [];
|
||||
|
||||
foreach (DB::table('business_courier_surcharge_quotes')->where('status', 'active')->orderBy('id')->get() as $rule) {
|
||||
$types = json_decode((string) $rule->types, true) ?: [];
|
||||
$candidateTypes = array_values(array_intersect(['退回费', '拦截费'], $types));
|
||||
|
||||
if ($candidateTypes === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($onlyRules !== [] && ! in_array((int) $rule->id, $onlyRules, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$condition = trim((string) $rule->condition);
|
||||
|
||||
if (! str_contains($condition, '扣单')) {
|
||||
$this->warn('规则 #' . $rule->id . ' 的生效条件不含「扣单」,已跳过:' . $condition);
|
||||
continue;
|
||||
}
|
||||
|
||||
$mode = trim((string) $rule->mode);
|
||||
$standard = trim((string) $rule->standard);
|
||||
|
||||
foreach ($candidateTypes as $type) {
|
||||
if ($onlyTypes !== [] && ! in_array($type, $onlyTypes, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === '退回费') {
|
||||
if ($mode === '固定单价') {
|
||||
$value = $this->parseFixedAmount($standard);
|
||||
} elseif ($mode === '按重量') {
|
||||
$value = $this->parseWeightRate($standard);
|
||||
} else {
|
||||
$this->line('规则 #' . $rule->id . ' ' . $rule->courier . ' 的退回费模式「' . $mode . '」暂不支持自动计算,已跳过。');
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if ($mode === '固定单价') {
|
||||
$value = $this->parseFixedAmount($standard);
|
||||
} else {
|
||||
$this->line('规则 #' . $rule->id . ' ' . $rule->courier . ' 的拦截费模式「' . $mode . '」暂不支持自动计算,已跳过。');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
$this->warn('规则 #' . $rule->id . ' 的' . $type . '计价标准解析失败,已跳过:' . $standard);
|
||||
continue;
|
||||
}
|
||||
|
||||
$rules[] = [
|
||||
'id' => (int) $rule->id,
|
||||
'type' => $type,
|
||||
'courier' => trim((string) $rule->courier),
|
||||
'mode' => $mode,
|
||||
'value' => $value,
|
||||
'effective_at' => $rule->effective_at !== null && $rule->effective_at !== ''
|
||||
? (string) $rule->effective_at
|
||||
: null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
private function parseFixedAmount(string $standard): ?float
|
||||
{
|
||||
return preg_match('/(\d+(?:\.\d+)?)\s*(?:元)?\s*\/?\s*单/u', $standard, $matched) === 1
|
||||
? (float) $matched[1]
|
||||
: null;
|
||||
}
|
||||
|
||||
private function parseWeightRate(string $standard): ?float
|
||||
{
|
||||
return preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/\s*(?:kg|KG|公斤|千克)/u', $standard, $matched) === 1
|
||||
? (float) $matched[1]
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从查件工单镜像按创建时间拉取扣单工单。
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function pullDeductOrders(string $connection, Carbon $from, Carbon $to, int $limit): array
|
||||
{
|
||||
$orders = [];
|
||||
$lastId = 0;
|
||||
$batchSize = 500;
|
||||
|
||||
while (true) {
|
||||
$rows = DB::connection($connection)->table('js_jst_work_order_info')
|
||||
->where('title', '查件')
|
||||
->where('businessFieldList', 'like', '%"value":"扣单/%')
|
||||
->where('created', '>=', $from->toDateTimeString())
|
||||
->where('created', '<', $to->toDateTimeString())
|
||||
->where('Id', '>', $lastId)
|
||||
->orderBy('Id')
|
||||
->limit($batchSize)
|
||||
->get(['Id', 'woNo', 'created', 'businessFieldList']);
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$lastId = max($lastId, (int) $row->Id);
|
||||
$order = $this->parseWorkOrder((string) $row->businessFieldList, (string) $row->woNo, (string) $row->created);
|
||||
|
||||
if ($order !== null) {
|
||||
$orders[] = $order;
|
||||
}
|
||||
|
||||
if ($limit > 0 && count($orders) >= $limit) {
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
|
||||
if ($rows->count() < $batchSize) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $orders;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function parseWorkOrder(string $json, string $woNo, string $created): ?array
|
||||
{
|
||||
$fields = json_decode($json, true);
|
||||
|
||||
if (! is_array($fields)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$flat = [];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (is_array($field) && isset($field['fieldId'])) {
|
||||
$flat[(string) $field['fieldId']] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
$waybill = trim((string) ($flat['lIdField']['value'] ?? ''));
|
||||
$oId = (int) ($flat['o_id']['value'] ?? 0);
|
||||
$courier = trim((string) ($flat['logisticsField']['value'] ?? ''));
|
||||
$cascade = trim((string) ($flat[self::CHECK_TYPE_FIELD]['value'] ?? ''));
|
||||
|
||||
if ($waybill === '' || $courier === '' || ! str_starts_with($cascade, '扣单')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$inspectionType, $inspectionReason] = array_pad(explode('/', $cascade, 2), 2, '');
|
||||
$items = [];
|
||||
|
||||
foreach (($flat['itemInfo']['childFieldList'] ?? []) as $group) {
|
||||
if (! is_array($group)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$item = [];
|
||||
|
||||
foreach ($group as $field) {
|
||||
if (is_array($field) && isset($field['fieldId'])) {
|
||||
$item[(string) $field['fieldId']] = $field['value'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
$skuId = strtoupper(trim((string) ($item['skuId'] ?? '')));
|
||||
|
||||
if ($skuId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'sku_id' => $skuId,
|
||||
'qty' => (float) ($item['qty'] ?? 0),
|
||||
'platform_product_id' => mb_substr(trim((string) ($item['platformItemId'] ?? '')), 0, 191),
|
||||
'i_id' => trim((string) ($item['iid'] ?? '')),
|
||||
'name' => mb_substr(trim((string) ($item['name'] ?? '')), 0, 191),
|
||||
];
|
||||
}
|
||||
|
||||
$createdAt = null;
|
||||
|
||||
try {
|
||||
$createdAt = Carbon::parse($created);
|
||||
} catch (Throwable) {
|
||||
// created 无法解析时仍保留工单,仅规则生效时间过滤会放宽。
|
||||
}
|
||||
|
||||
return [
|
||||
'wo_no' => $woNo,
|
||||
'waybill' => $waybill,
|
||||
'o_id' => $oId,
|
||||
'courier' => $courier,
|
||||
'online_order_no' => mb_substr(trim((string) ($flat['orderField']['value'] ?? '')), 0, 191),
|
||||
'shop_name' => mb_substr(trim((string) ($flat['shopField']['valueLabel'] ?? ($flat['shopField']['value'] ?? ''))), 0, 191),
|
||||
'inspection_type' => mb_substr(trim((string) $inspectionType), 0, 60),
|
||||
'inspection_reason' => mb_substr(trim((string) $inspectionReason), 0, 120),
|
||||
'send_time' => trim((string) ($flat['orderSendTime']['value'] ?? '')),
|
||||
'created_at' => $createdAt,
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id:int,courier:string,mode:string,value:float,effective_at:?string} $rule
|
||||
* @param list<array<string,mixed>> $orders
|
||||
*/
|
||||
private function writeSettlementRows(string $connection, array $rule, array $orders): int
|
||||
{
|
||||
$effectiveAt = null;
|
||||
|
||||
if (($rule['effective_at'] ?? null) !== null) {
|
||||
try {
|
||||
$effectiveAt = Carbon::parse((string) $rule['effective_at'])->startOfDay();
|
||||
} catch (Throwable) {
|
||||
// 规则生效时间无法解析时不做下界过滤。
|
||||
}
|
||||
}
|
||||
|
||||
if ($effectiveAt !== null) {
|
||||
$orders = array_values(array_filter(
|
||||
$orders,
|
||||
static fn (array $order): bool => ! $order['created_at'] instanceof Carbon
|
||||
|| $order['created_at']->greaterThanOrEqualTo($effectiveAt),
|
||||
));
|
||||
}
|
||||
|
||||
if ($orders === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$oIds = [];
|
||||
$waybills = [];
|
||||
$skuIds = [];
|
||||
|
||||
foreach ($orders as $order) {
|
||||
if ((int) ($order['o_id'] ?? 0) > 0) {
|
||||
$oIds[(int) $order['o_id']] = true;
|
||||
}
|
||||
|
||||
$waybills[(string) $order['waybill']] = true;
|
||||
|
||||
foreach ($order['items'] as $item) {
|
||||
$skuIds[(string) $item['sku_id']] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$type = (string) $rule['type'];
|
||||
$returns = $type === '退回费' ? $this->pullReturns($orders) : [];
|
||||
$weights = $this->pullWeights($connection, array_keys($skuIds));
|
||||
$now = now();
|
||||
$rows = [];
|
||||
$hasReturnByWaybill = [];
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$matched = [];
|
||||
$receiveDates = [];
|
||||
$returnWaybills = [];
|
||||
$hasReturn = false;
|
||||
$settlementWeight = 0.0;
|
||||
|
||||
$orderWeight = 0.0;
|
||||
|
||||
foreach ($order['items'] as $item) {
|
||||
$weight = $weights[(string) $item['sku_id']] ?? null;
|
||||
|
||||
if ($weight !== null) {
|
||||
$orderWeight += (float) $item['qty'] * $weight;
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === '拦截费') {
|
||||
// 拦截费口径:查件扣单即按固定单价结算,不要求退货进仓。
|
||||
$amount = (float) $rule['value'];
|
||||
$status = '已计算';
|
||||
} else {
|
||||
foreach ($returns[(int) ($order['o_id'] ?? 0)] ?? [] as $return) {
|
||||
$rQty = (float) ($return['r_qty'] ?? 0);
|
||||
$receiveDate = trim((string) ($return['receive_date'] ?? ''));
|
||||
|
||||
if ($rQty <= 0 || $receiveDate === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasReturn = true;
|
||||
$matched[] = $return;
|
||||
$receiveDates[] = $receiveDate;
|
||||
|
||||
$returnWaybill = trim((string) ($return['l_id'] ?? ''));
|
||||
|
||||
if ($returnWaybill !== '') {
|
||||
$returnWaybills[$returnWaybill] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$returnedWeight = 0.0;
|
||||
|
||||
foreach ($matched as $return) {
|
||||
$weight = $weights[strtoupper(trim((string) ($return['sku_id'] ?? '')))] ?? null;
|
||||
|
||||
if ($weight !== null) {
|
||||
$returnedWeight += (float) $return['r_qty'] * $weight;
|
||||
}
|
||||
}
|
||||
|
||||
$settlementWeight = $hasReturn
|
||||
? ($returnedWeight > 0 ? $returnedWeight : $orderWeight)
|
||||
: $orderWeight;
|
||||
|
||||
if ($rule['mode'] === '固定单价') {
|
||||
$amount = $hasReturn ? (float) $rule['value'] : 0.0;
|
||||
$status = '已计算';
|
||||
} elseif (! $hasReturn) {
|
||||
$amount = 0.0;
|
||||
$status = '已计算';
|
||||
} elseif ($settlementWeight > 0) {
|
||||
$amount = round($settlementWeight * (float) $rule['value'], 2);
|
||||
$status = '已计算';
|
||||
} else {
|
||||
$amount = 0.0;
|
||||
$status = '待核价';
|
||||
}
|
||||
}
|
||||
|
||||
$primary = $this->primaryItem($order['items'], $matched);
|
||||
$waybill = (string) $order['waybill'];
|
||||
|
||||
// 同一运单可能有多张扣单工单;只保留一行,优先保留有退回记录的那行。
|
||||
if (($hasReturnByWaybill[$waybill] ?? null) === true && ! $hasReturn) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[$waybill] = [
|
||||
'rule_id' => $rule['id'],
|
||||
'surcharge_type' => $type,
|
||||
'courier_name' => $rule['courier'],
|
||||
'waybill' => $waybill,
|
||||
'outbound_io_id' => null,
|
||||
'platform_product_id' => $primary['platform_product_id'] !== '' ? $primary['platform_product_id'] : null,
|
||||
'product_code' => $primary['sku_id'] !== '' ? $primary['sku_id'] : ($primary['i_id'] !== '' ? $primary['i_id'] : null),
|
||||
'shop_name' => $order['shop_name'] !== '' ? $order['shop_name'] : null,
|
||||
'online_order_no' => $order['online_order_no'] !== '' ? $order['online_order_no'] : null,
|
||||
'length' => null,
|
||||
'width' => null,
|
||||
'height' => null,
|
||||
'volume' => null,
|
||||
'inspection_type' => $order['inspection_type'],
|
||||
'inspection_reason' => $order['inspection_reason'],
|
||||
'return_waybill_no' => $returnWaybills === []
|
||||
? null
|
||||
: mb_substr(implode(',', array_keys($returnWaybills)), 0, 191),
|
||||
'settlement_weight' => $settlementWeight > 0 ? round($settlementWeight, 2) : null,
|
||||
'settlement_amount' => $amount,
|
||||
'settlement_status' => $status,
|
||||
'evidence' => mb_substr($this->evidence($rule, $order, $hasReturn, $matched, $receiveDates, $settlementWeight, $amount), 0, 500),
|
||||
'shipped_at' => $this->shippedAt($order),
|
||||
'pulled_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
$hasReturnByWaybill[$waybill] = $hasReturn;
|
||||
}
|
||||
|
||||
foreach (array_chunk(array_keys($waybills), 500) as $chunk) {
|
||||
DB::connection($connection)->table('courier_surcharge_settlement_rows')
|
||||
->where('rule_id', $rule['id'])
|
||||
->where('surcharge_type', $type)
|
||||
->whereIn('waybill', $chunk)
|
||||
->delete();
|
||||
}
|
||||
|
||||
foreach (array_chunk(array_values($rows), 500) as $chunk) {
|
||||
DB::connection($connection)->table('courier_surcharge_settlement_rows')->insert($chunk);
|
||||
}
|
||||
|
||||
return count($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 售后退货退款:进仓时间取 receive_date,实退数量取 r_qty。
|
||||
*
|
||||
* @param list<array<string,mixed>> $orders
|
||||
* @return array<int,list<array<string,mixed>>>
|
||||
*/
|
||||
private function pullReturns(array $orders): array
|
||||
{
|
||||
$map = [];
|
||||
$oIds = [];
|
||||
$oIdByWaybill = [];
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$oId = (int) ($order['o_id'] ?? 0);
|
||||
$waybill = trim((string) ($order['waybill'] ?? ''));
|
||||
|
||||
if ($oId > 0) {
|
||||
$oIds[$oId] = true;
|
||||
}
|
||||
|
||||
if ($oId > 0 && $waybill !== '') {
|
||||
$oIdByWaybill[$waybill] = $oId;
|
||||
}
|
||||
}
|
||||
|
||||
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('operations_replica');
|
||||
|
||||
foreach (array_chunk(array_keys($oIds), 200) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
|
||||
foreach ($this->selectReturns($upstream, 'a.o_id IN (' . $placeholders . ')', $chunk) as $row) {
|
||||
$oId = (int) ($row['o_id'] ?? 0);
|
||||
|
||||
if ($oId > 0) {
|
||||
$map[$oId][] = $row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 部分售后单的 o_id 与查件工单不一致,按原始运单号再兜底一次。
|
||||
$remainingWaybills = [];
|
||||
|
||||
foreach ($oIdByWaybill as $waybill => $oId) {
|
||||
if (($map[$oId] ?? []) === []) {
|
||||
$remainingWaybills[] = $waybill;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_chunk($remainingWaybills, 200) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
|
||||
foreach ($this->selectReturns($upstream, 'a.l_id IN (' . $placeholders . ')', $chunk) as $row) {
|
||||
$oId = $oIdByWaybill[trim((string) ($row['l_id'] ?? ''))] ?? 0;
|
||||
|
||||
if ($oId > 0) {
|
||||
$map[$oId][] = $row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<mixed> $bindings
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function selectReturns(UpstreamReadOnlyQuery $upstream, string $where, array $bindings): array
|
||||
{
|
||||
try {
|
||||
$rows = $upstream->select(
|
||||
'SELECT a.o_id, a.as_id, a.l_id, a.status, CONVERT(varchar(19), a.confirm_date, 120) AS confirm_date, '
|
||||
. 'ri.sku_id, ri.qty, ri.r_qty, '
|
||||
. 'CONVERT(varchar(19), ri.receive_date, 120) AS receive_date '
|
||||
. 'FROM rpt.jst_returnAndRefundInfo a '
|
||||
. 'INNER JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = a.as_id '
|
||||
. 'WHERE ' . $where,
|
||||
$bindings,
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(static fn ($row): array => (array) $row, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $skuIds
|
||||
* @return array<string,float>
|
||||
*/
|
||||
private function pullWeights(string $connection, array $skuIds): array
|
||||
{
|
||||
$map = [];
|
||||
|
||||
foreach (array_chunk($skuIds, 500) as $chunk) {
|
||||
$rows = DB::connection($connection)->table('ops_jst_sku')
|
||||
->whereIn('sku_id', $chunk)
|
||||
->get(['sku_id', 'weight']);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$skuId = strtoupper(trim((string) $row->sku_id));
|
||||
$weight = $row->weight;
|
||||
|
||||
if ($skuId === '' || $weight === null || $weight === '' || isset($map[$skuId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$map[$skuId] = (float) $weight;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $items
|
||||
* @param list<array<string,mixed>> $returns
|
||||
* @return array{sku_id:string,i_id:string,platform_product_id:string,name:string}
|
||||
*/
|
||||
private function primaryItem(array $items, array $returns): array
|
||||
{
|
||||
$returnSkus = [];
|
||||
|
||||
foreach ($returns as $return) {
|
||||
$skuId = strtoupper(trim((string) ($return['sku_id'] ?? '')));
|
||||
|
||||
if ($skuId !== '') {
|
||||
$returnSkus[$skuId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (isset($returnSkus[$item['sku_id']])) {
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
if ($item['platform_product_id'] !== '') {
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
|
||||
return $items[0] ?? ['sku_id' => '', 'i_id' => '', 'platform_product_id' => '', 'name' => ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id:int,type:string,courier:string,mode:string,value:float,effective_at:?string} $rule
|
||||
* @param array<string,mixed> $order
|
||||
* @param list<array<string,mixed>> $matched
|
||||
* @param list<string> $receiveDates
|
||||
*/
|
||||
private function evidence(
|
||||
array $rule,
|
||||
array $order,
|
||||
bool $hasReturn,
|
||||
array $matched,
|
||||
array $receiveDates,
|
||||
float $settlementWeight,
|
||||
float $amount,
|
||||
): string {
|
||||
$parts = ['查件扣单/' . $order['inspection_reason']];
|
||||
|
||||
if (($rule['type'] ?? '退回费') === '拦截费') {
|
||||
$parts[] = '拦截费规则 ' . $this->trimNumber((float) $rule['value']) . '元/单';
|
||||
$parts[] = '结算金额 ' . number_format($amount, 2, '.', '');
|
||||
|
||||
return implode(';', $parts);
|
||||
}
|
||||
|
||||
if ($hasReturn) {
|
||||
$returnQty = 0.0;
|
||||
$confirmDates = [];
|
||||
|
||||
foreach ($matched as $return) {
|
||||
$returnQty += (float) ($return['r_qty'] ?? 0);
|
||||
$confirmDate = trim((string) ($return['confirm_date'] ?? ''));
|
||||
|
||||
if ($confirmDate !== '') {
|
||||
$confirmDates[] = $confirmDate;
|
||||
}
|
||||
}
|
||||
|
||||
if ($confirmDates !== []) {
|
||||
$parts[] = '退款确认 ' . max($confirmDates);
|
||||
}
|
||||
|
||||
$parts[] = '售后退货退款已进仓 ' . ($receiveDates === [] ? '' : max($receiveDates));
|
||||
$parts[] = '实退 ' . $this->trimNumber($returnQty);
|
||||
} else {
|
||||
$parts[] = '售后退货退款无进仓/实退记录';
|
||||
}
|
||||
|
||||
$parts[] = '商品资料重量合计 ' . $this->trimNumber($settlementWeight) . 'kg';
|
||||
$parts[] = $rule['mode'] === '固定单价'
|
||||
? '规则 ' . $this->trimNumber((float) $rule['value']) . '元/单'
|
||||
: '规则 ' . $this->trimNumber((float) $rule['value']) . '元/kg';
|
||||
$parts[] = '结算金额 ' . number_format($amount, 2, '.', '');
|
||||
|
||||
return implode(';', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $order
|
||||
*/
|
||||
private function shippedAt(array $order): ?string
|
||||
{
|
||||
$sendTime = trim((string) ($order['send_time'] ?? ''));
|
||||
|
||||
if ($sendTime !== '') {
|
||||
try {
|
||||
return Carbon::parse($sendTime)->toDateTimeString();
|
||||
} catch (Throwable) {
|
||||
// 发货时间无法解析时回退到查件工单创建时间。
|
||||
}
|
||||
}
|
||||
|
||||
return $order['created_at'] instanceof Carbon ? $order['created_at']->toDateTimeString() : null;
|
||||
}
|
||||
|
||||
private function trimNumber(float $value): string
|
||||
{
|
||||
$text = rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
|
||||
|
||||
return $text === '' ? '0' : $text;
|
||||
}
|
||||
}
|
||||
41
app/Console/Commands/PushStoreViolationAlerts.php
Normal file
41
app/Console/Commands/PushStoreViolationAlerts.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\StoreViolationNotifier;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 店铺违规信息预警:P0/P1 即时推送(每天 09:00 起由生产 cron 每 5 分钟触发)。
|
||||
*/
|
||||
class PushStoreViolationAlerts extends Command
|
||||
{
|
||||
protected $signature = 'store-violations:push-instant
|
||||
{--limit=200 : 单轮最多推送条数}
|
||||
{--retry-failed : 同时重试推送失败的记录}';
|
||||
|
||||
protected $description = '推送店铺违规预警即时消息(P0/P1,含责任人@与去认领链接)';
|
||||
|
||||
public function handle(StoreViolationNotifier $notifier): int
|
||||
{
|
||||
if (! $notifier->enabled()) {
|
||||
$this->line('推送未配置,跳过。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$summary = $notifier->pushInstant(
|
||||
max((int) $this->option('limit'), 1),
|
||||
(bool) $this->option('retry-failed'),
|
||||
);
|
||||
|
||||
$this->info(sprintf(
|
||||
'即时推送:待推 %d 条,成功 %d 条,失败 %d 条。',
|
||||
$summary['attempted'],
|
||||
$summary['succeeded'],
|
||||
$summary['failed'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
38
app/Console/Commands/PushStoreViolationDigest.php
Normal file
38
app/Console/Commands/PushStoreViolationDigest.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\StoreViolationNotifier;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 店铺违规信息预警:P2 每日 10:00 汇总日报。
|
||||
*/
|
||||
class PushStoreViolationDigest extends Command
|
||||
{
|
||||
protected $signature = 'store-violations:push-digest';
|
||||
|
||||
protected $description = '推送店铺违规预警每日汇总(P2 汇总与超时未认领点名)';
|
||||
|
||||
public function handle(StoreViolationNotifier $notifier): int
|
||||
{
|
||||
if (! $notifier->enabled()) {
|
||||
$this->line('推送未配置,跳过。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$summary = $notifier->pushDigest();
|
||||
|
||||
$this->info(sprintf(
|
||||
'日报推送:%s;P0 %d / P1 %d / P2 %d,超时未认领 %d 条。',
|
||||
$summary['sent'] ? '已发送' : '发送失败',
|
||||
(int) ($summary['pending']['P0'] ?? 0),
|
||||
(int) ($summary['pending']['P1'] ?? 0),
|
||||
(int) ($summary['pending']['P2'] ?? 0),
|
||||
(int) $summary['overdue'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
79
app/Console/Commands/RecalcStoreViolationAlerts.php
Normal file
79
app/Console/Commands/RecalcStoreViolationAlerts.php
Normal file
@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\StoreViolationAlertCalculator;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository;
|
||||
|
||||
/**
|
||||
* 店铺违规信息预警:按当前字典/归属重新计算全部记录的预警派生字段。
|
||||
*
|
||||
* 用于字典维护或店铺归属调整后的校准;不触碰处理状态、忽略原因与推送状态。
|
||||
*/
|
||||
class RecalcStoreViolationAlerts extends Command
|
||||
{
|
||||
protected $signature = 'store-violations:recalc-alerts
|
||||
{--platform= : 只重算指定平台}
|
||||
{--chunk=500 : 每批更新行数}';
|
||||
|
||||
protected $description = '重算店铺违规记录的预警级别、责任人与处理截止';
|
||||
|
||||
public function handle(StoreViolationAlertCalculator $calculator, StoreViolationRecordRepository $repository): int
|
||||
{
|
||||
if (! $repository->available()) {
|
||||
$this->error('统一表 store_violation_records 尚未建立。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$chunk = max(min((int) $this->option('chunk'), 2000), 100);
|
||||
$platform = trim((string) $this->option('platform'));
|
||||
$total = 0;
|
||||
|
||||
$query = $connection->table(StoreViolationRecordRepository::TABLE)
|
||||
->select(['id', 'platform', 'violation_no', 'shop_name', 'violation_type', 'violation_reason', 'violation_date', 'appeal_deadline'])
|
||||
->orderBy('id');
|
||||
if ($platform !== '') {
|
||||
$query->where('platform', $platform);
|
||||
}
|
||||
|
||||
$query->chunk($chunk, function ($rows) use ($calculator, $connection, &$total): void {
|
||||
$updates = [];
|
||||
foreach ($rows as $row) {
|
||||
$matchType = trim((string) ($row->violation_type ?? '')) !== ''
|
||||
? (string) $row->violation_type
|
||||
: ($row->violation_reason ?? null);
|
||||
|
||||
// upsert 的插入侧会校验非空列,带上 platform / violation_no 以通过严格模式;
|
||||
// 命中已有记录时只会更新派生字段(update 列表)。
|
||||
$updates[] = [
|
||||
'id' => $row->id,
|
||||
'platform' => (string) $row->platform,
|
||||
'violation_no' => (string) ($row->violation_no ?? ''),
|
||||
] + $calculator->calculate(
|
||||
(string) $row->platform,
|
||||
$row->shop_name,
|
||||
$matchType,
|
||||
$row->violation_date ? (string) $row->violation_date : null,
|
||||
$row->appeal_deadline ? (string) $row->appeal_deadline : null,
|
||||
);
|
||||
}
|
||||
|
||||
if ($updates !== []) {
|
||||
$connection->table(StoreViolationRecordRepository::TABLE)->upsert(
|
||||
$updates,
|
||||
['id'],
|
||||
StoreViolationRecordRepository::ALERT_DERIVED_COLUMNS,
|
||||
);
|
||||
$total += count($updates);
|
||||
}
|
||||
});
|
||||
|
||||
$this->info(sprintf('预警重算完成:%d 行。', $total));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
35
app/Console/Commands/RecalculateFinanceLedger.php
Normal file
35
app/Console/Commands/RecalculateFinanceLedger.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\FinanceLedgerCalculator;
|
||||
use Throwable;
|
||||
|
||||
class RecalculateFinanceLedger extends Command
|
||||
{
|
||||
protected $signature = 'finance-ledger:recalculate';
|
||||
|
||||
protected $description = '重算财务台账的每日资金流水、账户余额和店铺余额';
|
||||
|
||||
public function handle(FinanceLedgerCalculator $calculator): int
|
||||
{
|
||||
try {
|
||||
$result = $calculator->recalculate();
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(['项目', '值'], [
|
||||
['账户数', $result['accounts']],
|
||||
['流水行数', $result['flows']],
|
||||
['期末余额合计', number_format($result['balance_sum'], 2)],
|
||||
['期初日期', $result['opening_date']],
|
||||
]);
|
||||
$this->info('财务台账流水重算完成。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
19
app/Console/Commands/RefreshStoreReportPaidSales.php
Normal file
19
app/Console/Commands/RefreshStoreReportPaidSales.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\StoreReportMirrorSynchronizer;
|
||||
|
||||
class RefreshStoreReportPaidSales extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:refresh-paid-sales {--full : 强制全量校准派生汇总}';
|
||||
protected $description = '只刷新业绩明细派生的销量汇总,不修改原始财务明细';
|
||||
|
||||
public function handle(StoreReportMirrorSynchronizer $synchronizer): int
|
||||
{
|
||||
$result = $synchronizer->syncPaidSales((bool) $this->option('full'));
|
||||
$this->info(json_encode($result));
|
||||
return $result['status'] === 'succeeded' ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
47
app/Console/Commands/ReportProductMonthlyWeightedAverage.php
Normal file
47
app/Console/Commands/ReportProductMonthlyWeightedAverage.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\ProductMonthlyWeightedAverageReporter;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class ReportProductMonthlyWeightedAverage extends Command
|
||||
{
|
||||
protected $signature = 'product:monthly-weighted-average:report
|
||||
{month : 报表月份,例如 2026-09}
|
||||
{--result-json= : 将结果写入指定 JSON 文件}';
|
||||
|
||||
protected $description = '输出商品月加权平均单价结果简报与差异分类';
|
||||
|
||||
public function handle(ProductMonthlyWeightedAverageReporter $reporter): int
|
||||
{
|
||||
$month = CarbonImmutable::parse($this->argument('month'))->startOfMonth();
|
||||
$report = $reporter->build(
|
||||
(string) config('upstream_mirrors.connection', 'business_mirror'),
|
||||
$month,
|
||||
);
|
||||
|
||||
$path = trim((string) $this->option('result-json'));
|
||||
if ($path !== '') {
|
||||
File::ensureDirectoryExists(dirname($path));
|
||||
File::put(
|
||||
$path,
|
||||
json_encode(
|
||||
$report,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
|
||||
) . PHP_EOL,
|
||||
);
|
||||
}
|
||||
|
||||
$this->line($reporter->brief($report));
|
||||
if (($report['status'] ?? null) === 'review_required') {
|
||||
$this->warn('存在待人工复核差异,完整清单已写入结果 JSON。');
|
||||
}
|
||||
|
||||
return ($report['status'] ?? null) === 'missing'
|
||||
? self::FAILURE
|
||||
: self::SUCCESS;
|
||||
}
|
||||
}
|
||||
24
app/Console/Commands/RunAccountBillsExportQueue.php
Normal file
24
app/Console/Commands/RunAccountBillsExportQueue.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\AccountBillsExportTasks;
|
||||
|
||||
class RunAccountBillsExportQueue extends Command
|
||||
{
|
||||
protected $signature = 'account-bills:exports:run {--max-jobs=1}';
|
||||
protected $description = '依次消费账单导出任务,生产从受限批容器启动';
|
||||
|
||||
public function handle(AccountBillsExportTasks $tasks): int
|
||||
{
|
||||
$limit = max(0, (int) $this->option('max-jobs'));
|
||||
for ($processed = 0; $limit === 0 || $processed < $limit; $processed++) {
|
||||
$task = $tasks->nextQueued();
|
||||
if ($task === null) break;
|
||||
$code = $this->call('account-bills:export-run', ['token' => $task['token']]);
|
||||
if ($code === 75) break;
|
||||
}
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
209
app/Console/Commands/RunAccountBillsExportTask.php
Normal file
209
app/Console/Commands/RunAccountBillsExportTask.php
Normal file
@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\AccountBillColumns;
|
||||
use Modules\EcommerceFinance\JdBillPlatform;
|
||||
use Modules\EcommerceFinance\Repositories\DewuSettlementBillRepository;
|
||||
use Modules\EcommerceFinance\Repositories\JdDailyBillRepository;
|
||||
use Modules\EcommerceFinance\Repositories\PinduoduoSettlementBillRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingDewuBillMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingJdWalletBillRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingPinduoduoBillMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\TaobaoWechatSettlementBillRepository;
|
||||
use Modules\EcommerceFinance\Services\AccountBillsExportTasks;
|
||||
use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 后台执行「店铺收款账户账单」导出任务:生成 CSV 并更新任务状态,供页面列表下载。
|
||||
*/
|
||||
class RunAccountBillsExportTask extends Command
|
||||
{
|
||||
protected $signature = 'account-bills:export-run {token : 导出任务 token}';
|
||||
|
||||
protected $description = '执行店铺收款账户账单导出任务(后台进程调用)';
|
||||
|
||||
public function handle(AccountBillsExportTasks $tasks, AccountLedgerRemarkMap $remarkMap): int
|
||||
{
|
||||
$lock = \Illuminate\Support\Facades\Cache::store('upstream_shared')->lock('account-bills:export-worker', 10800);
|
||||
if (! $lock->get()) return 75;
|
||||
try {
|
||||
return $this->runTask($tasks, $remarkMap);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
private function runTask(AccountBillsExportTasks $tasks, AccountLedgerRemarkMap $remarkMap): int
|
||||
{
|
||||
$token = (string) $this->argument('token');
|
||||
$task = $this->findTask($tasks, $token);
|
||||
if ($task === null) {
|
||||
$this->error('导出任务不存在:' . $token);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
if (($task['status'] ?? '') !== 'queued') return self::SUCCESS;
|
||||
|
||||
$task = $tasks->patch($task, ['status' => 'running', 'started_at' => AccountBillsExportTasks::beijingNow()]);
|
||||
|
||||
try {
|
||||
if (! empty($task['cancel'])) {
|
||||
$tasks->patch($task, ['status' => 'cancelled', 'finished_at' => AccountBillsExportTasks::beijingNow()]);
|
||||
$this->info('任务已取消。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
$columns = array_values((array) ($task['columns'] ?? []));
|
||||
if ($columns === []) {
|
||||
throw new \RuntimeException('导出任务没有可用字段。');
|
||||
}
|
||||
|
||||
$csv = fopen($tasks->csvPath($task), 'w');
|
||||
if ($csv === false) {
|
||||
throw new \RuntimeException('无法创建导出文件。');
|
||||
}
|
||||
|
||||
// 让「行数」列能显示真实百分比:镜像连接改为不缓冲取数,
|
||||
// 行一取到就写盘,进度按 已写行数 / 总行数 实时刷新。
|
||||
$this->streamRowsWithoutBuffering($remarkMap);
|
||||
|
||||
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
fputcsv($csv, array_map(static fn (array $column): string => (string) ($column['label'] ?? ''), $columns));
|
||||
|
||||
$written = 0;
|
||||
foreach ($this->rows($task, $remarkMap) as $row) {
|
||||
fputcsv($csv, array_map(
|
||||
static fn (array $column): string => self::cellValue($row, $column),
|
||||
$columns,
|
||||
));
|
||||
$written++;
|
||||
if ($written % 2000 === 0) {
|
||||
// 用户在弹窗里点了「取消」:停止导出并删掉半成品文件。
|
||||
$fresh = $tasks->find((int) $task['user_id'], (string) $task['token']);
|
||||
if (! empty($fresh['cancel'])) {
|
||||
fclose($csv);
|
||||
@unlink($tasks->csvPath($task));
|
||||
$tasks->patch($task, ['status' => 'cancelled', 'rows' => $written, 'finished_at' => AccountBillsExportTasks::beijingNow(), 'size' => 0]);
|
||||
$this->info('任务已取消(已写入 ' . $written . ' 行后停止)。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
$task = $tasks->patch($task, ['status' => 'running', 'rows' => $written]);
|
||||
}
|
||||
}
|
||||
fclose($csv);
|
||||
|
||||
$task = $tasks->patch($task, [
|
||||
'status' => 'done',
|
||||
'rows' => $written,
|
||||
'size' => (int) @filesize($tasks->csvPath($task)),
|
||||
'finished_at' => AccountBillsExportTasks::beijingNow(),
|
||||
'error' => null,
|
||||
]);
|
||||
$this->info('导出完成:' . $written . ' 行');
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $exception) {
|
||||
$tasks->patch($task, [
|
||||
'status' => 'failed',
|
||||
'finished_at' => AccountBillsExportTasks::beijingNow(),
|
||||
'error' => mb_substr($exception->getMessage(), 0, 200),
|
||||
]);
|
||||
$this->error('导出失败:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function findTask(AccountBillsExportTasks $tasks, string $token): ?array
|
||||
{
|
||||
$root = $tasks->rootDir();
|
||||
foreach (glob($root . '/*/' . $token . '.json') ?: [] as $path) {
|
||||
$task = json_decode((string) file_get_contents($path), true);
|
||||
if (is_array($task) && ($task['token'] ?? null) === $token) {
|
||||
return $task;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return iterable<int,array<string,mixed>> */
|
||||
private function rows(array $task, AccountLedgerRemarkMap $remarkMap): iterable
|
||||
{
|
||||
$tab = (string) ($task['tab'] ?? '');
|
||||
$filters = (array) ($task['filters'] ?? []);
|
||||
// 后台导出固定按「发生时间」取数:该列上有 (occurred_at, record_code) 联合索引,
|
||||
// 数据库能按索引顺序边扫边返回,首行几百毫秒内到达,进度可实时刷新;
|
||||
// 按「记录编码」走主键反向扫描过滤日期,要扫完整表才出首行,进度会长时间停在 0%。
|
||||
$sort = 'occurred_at';
|
||||
$dir = strtolower((string) ($filters['dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
unset($filters['sort'], $filters['dir'], $filters['export'], $filters['token'], $filters['total'], $filters['columns']);
|
||||
|
||||
$rows = match ($tab) {
|
||||
'taobao-alipay' => app(StoreReceivingAlipayBillMirrorRepository::class)->exportRows($filters, $sort, $dir),
|
||||
'taobao-wechat' => app(TaobaoWechatSettlementBillRepository::class)->exportAccountBills($filters, $sort, $dir),
|
||||
'pinduoduo' => app(PinduoduoSettlementBillRepository::class)->exportAccountBills($filters, $sort, $dir),
|
||||
'dewu' => app(DewuSettlementBillRepository::class)->exportAccountBills($filters, $sort, $dir),
|
||||
'jd-transactions', 'jingxi' => JdBillPlatform::isJdBillTab($tab)
|
||||
? app(JdDailyBillRepository::class)->forPlatform(JdBillPlatform::platformForBillType($tab))->exportRows($filters, $sort, $dir)
|
||||
: [],
|
||||
'jd-wallet' => app(StoreReceivingJdWalletBillRepository::class)->exportRows($filters, $sort, $dir),
|
||||
'douyin' => app(StoreReceivingDouyinBillMirrorRepository::class)->exportRows($filters, $sort, $dir),
|
||||
default => throw new \RuntimeException('该页签暂不支持后台导出:' . $tab),
|
||||
};
|
||||
|
||||
return $remarkMap->decorateAll($rows, $tab);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把镜像连接切成「不缓冲」并预热总账备注映射,使取数阶段逐批返回行。
|
||||
*
|
||||
* Web/CLI 默认用缓冲查询:整段结果集在数据库端算完才返回,进度会长时间停在 0%,
|
||||
* 最后一次性跳到 100%。切到不缓冲后行随查询推进到达,进度即真实百分比。
|
||||
*/
|
||||
private function streamRowsWithoutBuffering(AccountLedgerRemarkMap $remarkMap): void
|
||||
{
|
||||
try {
|
||||
// 预热:避免逐行装饰时中途再去读缓存/文件。
|
||||
$remarkMap->map();
|
||||
|
||||
$connection = app(\Illuminate\Database\DatabaseManager::class)
|
||||
->connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
if (! $connection instanceof \Illuminate\Database\MySqlConnection) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo = $connection->getPdo();
|
||||
$pdo->setAttribute(\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
|
||||
// 读连接与写连接是两套句柄,这里统一成同一个不缓冲句柄。
|
||||
$connection->setReadPdo($pdo);
|
||||
} catch (Throwable) {
|
||||
// 拿不到句柄时退回默认行为,不影响导出结果。
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row */
|
||||
private static function cellValue(array $row, array $column): string
|
||||
{
|
||||
$field = (string) ($column['field'] ?? '');
|
||||
$value = str_starts_with($field, 'payload.')
|
||||
? data_get($row['payload'] ?? [], substr($field, 8))
|
||||
: data_get($row, $field);
|
||||
|
||||
$text = trim((string) ($value ?? ''));
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
if (preg_match('/^\d{12,}$/', $text) === 1) {
|
||||
return '="' . $text . '"';
|
||||
}
|
||||
|
||||
return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text;
|
||||
}
|
||||
}
|
||||
112
app/Console/Commands/RunAccountMappingResync.php
Normal file
112
app/Console/Commands/RunAccountMappingResync.php
Normal file
@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\EcommerceFinance\Services\AccountMappingResyncDispatcher;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 科目映射表变更后的业绩明细自动重跑队列。
|
||||
*
|
||||
* 每轮按预算处理队列里的「平台 × 月份」;实际同步放在独立 PHP 子进程中,
|
||||
* 避免大平台重跑时的内存累积,并让其它同步有机会取得全局锁。
|
||||
*/
|
||||
class RunAccountMappingResync extends Command
|
||||
{
|
||||
protected $signature = 'store-receipt:auto-resync {--max-jobs=1 : 每轮最多任务数,0为不限} {--max-seconds=300 : 单个任务结束后检查本轮时间预算}';
|
||||
|
||||
protected $description = '处理科目映射表变更触发的业绩明细重跑队列';
|
||||
|
||||
public function handle(AccountMappingResyncDispatcher $dispatcher): int
|
||||
{
|
||||
// 页面(Apache)与每分钟 cron 都以 www-data 运行,使用共享数据库锁,
|
||||
// 不受 storage/framework/cache 文件属主影响,也能避免多容器重复消费。
|
||||
$lock = Cache::store('upstream_shared')->lock('store-receipt:auto-resync', 86400);
|
||||
if (! $lock->get()) {
|
||||
// 已有进程在跑;本次新入队的任务会在当前进程下一轮循环或每分钟兜底时执行。
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
// 进程崩溃遗留的 running 任务超过 60 分钟视为可重试。
|
||||
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
|
||||
->where('status', 'running')
|
||||
->where('updated_at', '<', now()->subMinutes(60))
|
||||
->update(['status' => 'pending', 'updated_at' => now()]);
|
||||
|
||||
$processed = 0;
|
||||
$maxJobs = max(0, (int) $this->option('max-jobs'));
|
||||
$deadline = microtime(true) + max(1, (int) $this->option('max-seconds'));
|
||||
while (($maxJobs === 0 || $processed < $maxJobs) && microtime(true) < $deadline) {
|
||||
$job = DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
|
||||
->where('status', 'pending')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
if ($job === null) {
|
||||
break;
|
||||
}
|
||||
$processed++;
|
||||
|
||||
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
|
||||
->where('id', $job->id)
|
||||
->where('status', 'pending')
|
||||
->update([
|
||||
'status' => 'running',
|
||||
'attempts' => DB::raw('attempts + 1'),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
try {
|
||||
[$succeeded, $output] = $this->runSync($job);
|
||||
|
||||
// 同步期间又保存了映射时,dispatchFor 已把状态改回 pending;
|
||||
// 这里只在仍为 running 时写 done,不能吞掉新请求。
|
||||
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
|
||||
->where('id', $job->id)
|
||||
->where('status', 'running')
|
||||
->update([
|
||||
'status' => $succeeded ? 'done' : 'failed',
|
||||
'message' => mb_substr(
|
||||
$output !== '' ? $output : ($succeeded ? 'OK' : '同步命令执行失败'),
|
||||
0,
|
||||
1000
|
||||
),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('科目映射表自动重跑失败:' . $exception->getMessage());
|
||||
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
|
||||
->where('id', $job->id)
|
||||
->where('status', 'running')
|
||||
->update([
|
||||
'status' => 'failed',
|
||||
'message' => mb_substr($exception->getMessage(), 0, 1000),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** @return array{bool,string} */
|
||||
protected function runSync(object $job): array
|
||||
{
|
||||
$process = new Process([
|
||||
PHP_BINARY ?: 'php', 'artisan', 'store-receipt:order-match:sync',
|
||||
(string) $job->month, '--platform=' . $job->platform,
|
||||
], base_path());
|
||||
$process->setTimeout(7200);
|
||||
$process->run();
|
||||
|
||||
return [$process->isSuccessful(), trim($process->getOutput() . "\n" . $process->getErrorOutput())];
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@ use Illuminate\Support\Facades\Log;
|
||||
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorSynchronizer;
|
||||
use Modules\EcommerceFinance\Services\DingProcessDetailMirrorSynchronizer;
|
||||
use Modules\EcommerceFinance\Services\DingPayableWorkflowMirrorSynchronizer;
|
||||
use Throwable;
|
||||
|
||||
@ -26,6 +27,7 @@ class SyncBusinessMirrors extends Command
|
||||
BusinessMirrorSynchronizer $synchronizer,
|
||||
GenericBusinessMirrorRepository $mirror,
|
||||
?DingPayableWorkflowMirrorSynchronizer $dingPayableWorkflows = null,
|
||||
?DingProcessDetailMirrorSynchronizer $dingProcessDetails = null,
|
||||
): int
|
||||
{
|
||||
if (! (bool) config('upstream_mirrors.refresh_enabled', false) && ! $this->option('full')) {
|
||||
@ -128,6 +130,24 @@ class SyncBusinessMirrors extends Command
|
||||
Log::error('钉钉线上代发价格流程镜像同步失败。', ['exception' => $exception::class]);
|
||||
$this->error('operations_replica.rpt.ding_online_daifa_price:同步失败,已保留上一版共享数据。');
|
||||
}
|
||||
|
||||
// 财务流程明细按结构化明细/付款事实同步。首次全量通过专用命令执行,
|
||||
// 日常只处理最近更新过的审批,避免每分钟重扫整批钉钉表单。
|
||||
if ($dingProcessDetails !== null) {
|
||||
try {
|
||||
$detailResult = $dingProcessDetails->sync(false);
|
||||
if ($detailResult['status'] === 'succeeded') {
|
||||
$this->info(sprintf(
|
||||
'operations_replica.rpt.ding_process_details:同步完成,本轮 %d 个审批。',
|
||||
$detailResult['workflows'],
|
||||
));
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$failed = true;
|
||||
Log::error('钉钉流程明细结构镜像同步失败。', ['exception' => $exception::class]);
|
||||
$this->error('operations_replica.rpt.ding_process_details:同步失败,已保留上一版共享数据。');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $failed ? self::FAILURE : self::SUCCESS;
|
||||
@ -155,9 +175,18 @@ class SyncBusinessMirrors extends Command
|
||||
if (($state['status'] ?? '') === 'failed' && ! empty($state['last_attempted_at'])
|
||||
&& now()->diffInSeconds(\Illuminate\Support\Carbon::parse($state['last_attempted_at'])) < 300) continue;
|
||||
|
||||
// Only explicitly opted-in small datasets receive periodic full reconciliation.
|
||||
// Keep an interrupted full run in full mode so its cursor/generation can resume.
|
||||
$hours = (int) $datasets->definitionValue($key, 'full_reconcile_hours', 0);
|
||||
$lastFull = $state['last_full_succeeded_at'] ?? null;
|
||||
$reconcile = $hours > 0 && (empty($lastFull)
|
||||
|| \Illuminate\Support\Carbon::parse($lastFull)->addHours($hours)->lte(now()));
|
||||
$resumeFull = $hours > 0 && ($state['run_mode'] ?? '') === 'full'
|
||||
&& in_array($state['status'] ?? '', ['running', 'failed'], true);
|
||||
|
||||
$scheduled[] = [
|
||||
'key' => $key,
|
||||
'full' => $initial || (bool) $this->option('full'),
|
||||
'full' => $initial || $reconcile || $resumeFull || (bool) $this->option('full'),
|
||||
'catchup' => $initial || ($state['status'] ?? '') === 'running',
|
||||
'last_attempted_at' => (string) ($state['last_attempted_at'] ?? ''),
|
||||
];
|
||||
|
||||
64
app/Console/Commands/SyncDingProcessDetailMirror.php
Normal file
64
app/Console/Commands/SyncDingProcessDetailMirror.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\DingProcessDetailMirrorSynchronizer;
|
||||
use Throwable;
|
||||
|
||||
class SyncDingProcessDetailMirror extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:sync-ding-process-details
|
||||
{--full : 从配置起始日期完整重建明细和付款事实}
|
||||
{--business-id= : 只同步指定审批编号,多个用逗号分隔(最多 50 个)}
|
||||
{--dry-run : 只读取上游并校验,不写入镜像}';
|
||||
|
||||
protected $description = '从结构化审批镜像同步流程明细,并用钉钉官方接口补取缺失付款';
|
||||
|
||||
public function handle(DingProcessDetailMirrorSynchronizer $synchronizer): int
|
||||
{
|
||||
$businessIds = array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) $this->option('business-id')),
|
||||
)));
|
||||
|
||||
try {
|
||||
if ($businessIds !== []) {
|
||||
if ((bool) $this->option('full')) {
|
||||
throw new \InvalidArgumentException('--full 不能和 --business-id 同时使用。');
|
||||
}
|
||||
$result = $synchronizer->syncBusinessIds(
|
||||
$businessIds,
|
||||
(bool) $this->option('dry-run'),
|
||||
);
|
||||
} else {
|
||||
$result = $synchronizer->sync(
|
||||
(bool) $this->option('full'),
|
||||
(bool) $this->option('dry-run'),
|
||||
);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$this->error('同步失败,已保留上一版共享数据:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($result['status'] === 'requires_full_sync') {
|
||||
$this->warn('流程明细尚未完成结构化全量同步,请使用 --full。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s:%d 个审批,%d 条明细,%d 笔付款;金额合计 %.2f,银行付款合计 %.2f。',
|
||||
$result['status'] === 'validated' ? '校验通过' : '同步完成',
|
||||
$result['workflows'],
|
||||
$result['details'],
|
||||
$result['payments'],
|
||||
$result['amount_total'],
|
||||
$result['bank_amount_total'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
60
app/Console/Commands/SyncFinanceLedgerRpt.php
Normal file
60
app/Console/Commands/SyncFinanceLedgerRpt.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\FinanceLedgerRptSynchronizer;
|
||||
use Throwable;
|
||||
|
||||
class SyncFinanceLedgerRpt extends Command
|
||||
{
|
||||
protected $signature = 'finance-ledger:sync-rpt
|
||||
{--from= : 业务日期起,默认读取配置}
|
||||
{--to= : 业务日期止,默认读取配置或当天}
|
||||
{--days= : 只拉取最近 N 天并合并到现有明细}
|
||||
{--merge : 合并到现有明细,不删除历史}
|
||||
{--no-recalculate : 只同步明细,不重算流水}';
|
||||
|
||||
protected $description = '从 AI 库 rpt 同步财务台账动账明细,并重算每日流水';
|
||||
|
||||
public function handle(FinanceLedgerRptSynchronizer $synchronizer): int
|
||||
{
|
||||
$days = (int) ($this->option('days') ?: 0);
|
||||
$from = $this->option('from') ?: null;
|
||||
$merge = (bool) $this->option('merge');
|
||||
if ($days > 0) {
|
||||
$from = now()->subDays(max(1, $days) - 1)->toDateString();
|
||||
$merge = true;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $synchronizer->sync(
|
||||
! $this->option('no-recalculate'),
|
||||
$from,
|
||||
$this->option('to') ?: null,
|
||||
$merge,
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(['项目', '值'], [
|
||||
['明细行数', $result['rows']],
|
||||
['收入合计', number_format($result['income'], 2)],
|
||||
['支出合计', number_format($result['expense'], 2)],
|
||||
['日期范围', $result['from'] . ' ~ ' . $result['to']],
|
||||
['每日流水行数', $result['recalculated']['flows'] ?? '-'],
|
||||
['期末余额合计', isset($result['recalculated']) ? number_format($result['recalculated']['balance_sum'], 2) : '-'],
|
||||
]);
|
||||
|
||||
$this->line('来源分布:');
|
||||
foreach (($result['source_counts'] ?? []) as $source => $count) {
|
||||
$this->line(' ' . $source . ': ' . $count);
|
||||
}
|
||||
$this->info('财务台账 rpt 同步完成。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
62
app/Console/Commands/SyncFinanceLedgerShopData.php
Normal file
62
app/Console/Commands/SyncFinanceLedgerShopData.php
Normal file
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\FinanceLedgerShopDataSynchronizer;
|
||||
use Throwable;
|
||||
|
||||
class SyncFinanceLedgerShopData extends Command
|
||||
{
|
||||
protected $signature = 'finance-ledger:sync-shop-data
|
||||
{--from= : 提现业务日期起,默认从台账期初日}
|
||||
{--to= : 提现业务日期止,最晚只同步到前一晚}
|
||||
{--days= : 只同步最近 N 天}
|
||||
{--rebuild : 先用最新余额快照清空并重建店铺台账}
|
||||
{--no-recalculate : 只同步数据,不重算流水}';
|
||||
|
||||
protected $description = '同步上一晚店铺余额,并把平台账单中的提现对应到财务账户收入';
|
||||
|
||||
public function handle(FinanceLedgerShopDataSynchronizer $synchronizer): int
|
||||
{
|
||||
$days = (int) ($this->option('days') ?: 0);
|
||||
$from = $this->option('from') ?: null;
|
||||
if ($days > 0) {
|
||||
$from = now()->subDays(max(1, $days) - 1)->toDateString();
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $synchronizer->sync(
|
||||
$from,
|
||||
$this->option('to') ?: null,
|
||||
! $this->option('no-recalculate'),
|
||||
(bool) $this->option('rebuild'),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(['项目', '值'], [
|
||||
['店铺余额日期', $result['balance']['date'] ?: '-'],
|
||||
['余额源记录', $result['balance']['source']],
|
||||
['补充确认店铺', $result['balance']['added']],
|
||||
['重建店铺台账', $result['balance']['rebuilt']],
|
||||
['已更新店铺余额', $result['balance']['matched']],
|
||||
['店铺余额合计', number_format((float) $result['balance']['amount'], 2)],
|
||||
['提现处理笔数', $result['withdrawals']['processed']],
|
||||
['提现新增或更新', $result['withdrawals']['written']],
|
||||
['上游已存在提现', $result['withdrawals']['upstream_duplicates']],
|
||||
['店铺未匹配', $result['withdrawals']['unmatched']],
|
||||
['账户缺失', $result['withdrawals']['account_missing']],
|
||||
['提现收入合计', number_format((float) $result['withdrawals']['income'], 2)],
|
||||
['提现冲回支出', number_format((float) $result['withdrawals']['expense'], 2)],
|
||||
['每日流水行数', $result['recalculated']['flows'] ?? '-'],
|
||||
['PHP 峰值内存', number_format(memory_get_peak_usage(true) / 1048576, 1) . ' MiB'],
|
||||
]);
|
||||
$this->info('财务台账店铺余额与提现同步完成。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
182
app/Console/Commands/SyncJstOrderLabelItemCodes.php
Normal file
182
app/Console/Commands/SyncJstOrderLabelItemCodes.php
Normal file
@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\JstOrderLabelItemCodes;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 把聚水潭商品明细(共享镜像)里的商品编码回填到「聚水潭订单标签明细」的货号列。
|
||||
*
|
||||
* 两个来源:
|
||||
* - 主镜像 ops_jst_orders + ops_jst_order_item;
|
||||
* - 全渠道镜像 ops_jst_qmcust_orders + ops_jst_qmcust_order_items。
|
||||
*
|
||||
* 页面/命令导入(jst-order-labels:import)已自带货号;本命令用于历史数据回填与修复,可反复执行。
|
||||
*/
|
||||
class SyncJstOrderLabelItemCodes extends Command
|
||||
{
|
||||
protected $signature = 'jst-order-labels:sync-item-codes
|
||||
{--from= : 起始下单日期 YYYY-MM-DD(默认取标签明细里最早的下单日期)}
|
||||
{--to= : 结束下单日期 YYYY-MM-DD(默认取标签明细里最晚的下单日期)}
|
||||
{--company=1 : 公司 ID}
|
||||
{--chunk=800 : 每批处理的订单数}';
|
||||
|
||||
protected $description = '按订单号把聚水潭商品明细里的货号回填到聚水潭订单标签明细';
|
||||
|
||||
/** 订单镜像 → 商品明细镜像。 */
|
||||
private const SOURCES = [
|
||||
['orders' => 'ops_jst_orders', 'items' => 'ops_jst_order_item'],
|
||||
['orders' => 'ops_jst_qmcust_orders', 'items' => 'ops_jst_qmcust_order_items'],
|
||||
];
|
||||
|
||||
public function handle(DatabaseManager $databases, JstOrderLabelItemCodes $itemCodes): int
|
||||
{
|
||||
$companyId = (int) $this->option('company');
|
||||
$chunk = max((int) $this->option('chunk'), 100);
|
||||
$prefix = $databases->connection()->getTablePrefix();
|
||||
|
||||
try {
|
||||
[$from, $to] = $this->resolveWindow($databases, $companyId);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$start = $from . ' 00:00:00';
|
||||
$end = date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00';
|
||||
$mirror = $databases->connection(config('upstream_mirrors.connection', 'business_mirror'));
|
||||
|
||||
$this->info(sprintf('同步货号:%s ~ %s(公司 %d)', $from, $to, $companyId));
|
||||
|
||||
$startedAt = microtime(true);
|
||||
$totalUpdated = 0;
|
||||
|
||||
foreach (self::SOURCES as $source) {
|
||||
if (! $mirror->getSchemaBuilder()->hasTable($source['orders']) || ! $mirror->getSchemaBuilder()->hasTable($source['items'])) {
|
||||
$this->warn(sprintf('跳过 %s:镜像表不存在。', $source['orders']));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$scanned = 0;
|
||||
$matched = 0;
|
||||
$updated = 0;
|
||||
$chunks = 0;
|
||||
$mirrorName = $source['orders'];
|
||||
|
||||
try {
|
||||
$mirror->table($source['orders'])
|
||||
->select(['Id', 'o_id', 'so_id'])
|
||||
->where('order_date', '>=', $start)
|
||||
->where('order_date', '<', $end)
|
||||
->orderBy('Id')
|
||||
->chunkById($chunk, function ($records) use ($mirror, $itemCodes, $source, $prefix, $companyId, $start, $end, $mirrorName, &$scanned, &$matched, &$updated, &$chunks): void {
|
||||
$chunks++;
|
||||
$scanned += count($records);
|
||||
|
||||
$orderCodes = $itemCodes->forOrderIds($mirror, $source['items'], $records->pluck('o_id')->all());
|
||||
|
||||
$pairs = [];
|
||||
foreach ($records as $record) {
|
||||
$orderNo = trim((string) $record->so_id);
|
||||
$codes = $orderCodes[(int) $record->o_id] ?? null;
|
||||
if ($orderNo !== '' && $codes !== null && $codes !== '') {
|
||||
$pairs[] = [$orderNo, $codes];
|
||||
}
|
||||
}
|
||||
|
||||
$matched += count($pairs);
|
||||
if ($pairs !== []) {
|
||||
$updated += $this->applyMap($prefix, $pairs, $companyId, $start, $end);
|
||||
}
|
||||
|
||||
if ($chunks % 20 === 0) {
|
||||
$this->line(sprintf(' …%s:已扫描 %d 单,匹配 %d,更新行 %d', $mirrorName, $scanned, $matched, $updated));
|
||||
}
|
||||
}, 'Id');
|
||||
} catch (Throwable $exception) {
|
||||
$this->error(sprintf('%s 同步失败:%s', $mirrorName, $exception->getMessage()));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$totalUpdated += $updated;
|
||||
$this->info(sprintf('%s:扫描 %d 单,匹配 %d,更新标签行 %d', $mirrorName, $scanned, $matched, $updated));
|
||||
}
|
||||
|
||||
$this->info(sprintf('完成:共更新标签行 %d,耗时 %.0f 秒。', $totalUpdated, microtime(true) - $startedAt));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一批订单的货号按订单号写回标签表(CASE 更新,命中 order_no 索引)。
|
||||
*
|
||||
* 订单号必须全程按字符串绑定:纯数字订单号一旦被 PHP 数组键或 PDO 当成整数,
|
||||
* MariaDB 会把字符列 order_no 强转成数字比较,带「-」的订单号直接报 1292。
|
||||
*
|
||||
* @param list<array{0:string,1:string}> $pairs [[线上订单号, 货号], ...]
|
||||
*/
|
||||
private function applyMap(string $prefix, array $pairs, int $companyId, string $start, string $end): int
|
||||
{
|
||||
$cases = [];
|
||||
$bindings = [];
|
||||
$orderNos = [];
|
||||
foreach ($pairs as [$orderNo, $codes]) {
|
||||
$orderNo = (string) $orderNo;
|
||||
$cases[] = 'WHEN ? THEN ?';
|
||||
$bindings[] = $orderNo;
|
||||
$bindings[] = $codes;
|
||||
$orderNos[] = $orderNo;
|
||||
}
|
||||
|
||||
$sql = 'UPDATE ' . $prefix . 'jst_order_labels SET item_code = CASE order_no ' . implode(' ', $cases) . ' END '
|
||||
. 'WHERE company_id = ? AND order_no IN (' . implode(',', array_fill(0, count($orderNos), '?')) . ') '
|
||||
. 'AND ordered_at >= ? AND ordered_at < ?';
|
||||
|
||||
$params = array_merge($bindings, [$companyId], $orderNos, [$start, $end]);
|
||||
|
||||
$updated = DB::connection()->update($sql, $params);
|
||||
if ($updated > 0) {
|
||||
app(\App\Business\PlantingOrderLabelLookup::class)->forget($companyId);
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string} 起止日期(Y-m-d) */
|
||||
private function resolveWindow(DatabaseManager $databases, int $companyId): array
|
||||
{
|
||||
$from = trim((string) $this->option('from'));
|
||||
$to = trim((string) $this->option('to'));
|
||||
|
||||
if ($from === '' || $to === '') {
|
||||
$row = $databases->table('jst_order_labels')
|
||||
->where('company_id', $companyId)
|
||||
->selectRaw('MIN(ordered_at) AS min_at, MAX(ordered_at) AS max_at')
|
||||
->first();
|
||||
if ($row === null || $row->min_at === null || $row->max_at === null) {
|
||||
throw new RuntimeException('标签明细里没有数据,无法推断同步区间,请用 --from/--to 指定。');
|
||||
}
|
||||
$from = $from === '' ? substr((string) $row->min_at, 0, 10) : $from;
|
||||
$to = $to === '' ? substr((string) $row->max_at, 0, 10) : $to;
|
||||
}
|
||||
|
||||
foreach (['起始' => $from, '结束' => $to] as $label => $value) {
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
|
||||
throw new RuntimeException($label . '日期必须是 YYYY-MM-DD:' . $value);
|
||||
}
|
||||
}
|
||||
if ($from > $to) {
|
||||
throw new RuntimeException('起始日期不能晚于结束日期。');
|
||||
}
|
||||
|
||||
return [$from, $to];
|
||||
}
|
||||
}
|
||||
@ -6,12 +6,14 @@ use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\EcommerceFinance\Repositories\JushuitanCostPriceHistoryMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\JushuitanCostPriceHistorySynchronizer;
|
||||
use Modules\EcommerceFinance\Services\JushuitanCostPriceSnapshotDiffer;
|
||||
use Throwable;
|
||||
|
||||
class SyncJushuitanCostPriceHistory extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:sync-jushuitan-cost-price-history
|
||||
{--full : 强制启动或继续首次全量同步}
|
||||
{--source=open_api : open_api 为正式操作历史;snapshot 为独立的镜像价格观测}
|
||||
{--page-size= : 聚水潭接口每页读取条数,最大 50}
|
||||
{--max-pages= : 本轮最多读取页数,0 表示直到完成}';
|
||||
|
||||
@ -25,6 +27,25 @@ class SyncJushuitanCostPriceHistory extends Command
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($this->option('source') === 'snapshot') {
|
||||
try {
|
||||
$result = app(JushuitanCostPriceSnapshotDiffer::class)->sync(
|
||||
$this->option('page-size') === null ? 500 : (int) $this->option('page-size'),
|
||||
$this->option('max-pages') === null ? 20 : (int) $this->option('max-pages'),
|
||||
);
|
||||
$this->info(sprintf('镜像价格观测:%s,本轮读取 %d 条,发现 %d 次价格变化;不代表完整操作历史。', $result['status'], $result['rows'], $result['changes']));
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('镜像价格观测失败。', ['exception' => $exception::class]);
|
||||
$this->error('镜像价格观测失败,已保留基线、记录和续传游标。');
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
if ($this->option('source') !== 'open_api') {
|
||||
$this->error('source 仅支持 open_api 或 snapshot。');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$state = $mirror->state();
|
||||
$full = $this->shouldRunFull($state);
|
||||
|
||||
78
app/Console/Commands/SyncPlantingDetailsFromWorkOrders.php
Normal file
78
app/Console/Commands/SyncPlantingDetailsFromWorkOrders.php
Normal file
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\PlantingWorkOrderSynchronizer;
|
||||
use Illuminate\Console\Command;
|
||||
use InvalidArgumentException;
|
||||
use Throwable;
|
||||
|
||||
class SyncPlantingDetailsFromWorkOrders extends Command
|
||||
{
|
||||
protected $signature = 'planting-details:sync-work-orders
|
||||
{--month= : 月份 YYYY-MM(与 --from/--to 二选一)}
|
||||
{--from= : 起始日期 YYYY-MM-DD}
|
||||
{--to= : 结束日期 YYYY-MM-DD}
|
||||
{--company=1 : 公司 ID}
|
||||
{--dry-run : 只统计不写库}
|
||||
{--force-clear : 整月覆盖(连没有对应工单的人工行一起清掉)}';
|
||||
|
||||
protected $description = '把聚水潭「种菜报销」工单同步成种菜明细表(日期 = 工单完成日 − 1;按月重复执行不会重复写)';
|
||||
|
||||
public function handle(PlantingWorkOrderSynchronizer $synchronizer): int
|
||||
{
|
||||
try {
|
||||
[$from, $to] = $this->window();
|
||||
|
||||
$result = $synchronizer->sync(
|
||||
$from,
|
||||
$to,
|
||||
(int) $this->option('company'),
|
||||
(bool) $this->option('dry-run'),
|
||||
(bool) $this->option('force-clear'),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$unmatched = $result['unmatched_stores'];
|
||||
$result['unmatched_stores'] = count($unmatched);
|
||||
$this->info(json_encode($result, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
foreach (array_slice($unmatched, 0, 20, true) as $store => $count) {
|
||||
$this->warn(sprintf('未匹配利润中心:%s(%d 行)', $store, $count));
|
||||
}
|
||||
if (count($unmatched) > 20) {
|
||||
$this->warn('……还有 ' . (count($unmatched) - 20) . ' 家店铺未匹配到利润中心。');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string} */
|
||||
private function window(): array
|
||||
{
|
||||
$from = trim((string) $this->option('from'));
|
||||
$to = trim((string) $this->option('to'));
|
||||
$month = trim((string) $this->option('month'));
|
||||
|
||||
if ($month !== '') {
|
||||
if ($from !== '' || $to !== '') {
|
||||
throw new InvalidArgumentException('--month 与 --from/--to 只能给一个。');
|
||||
}
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
|
||||
throw new InvalidArgumentException('--month 必须是 YYYY-MM。');
|
||||
}
|
||||
|
||||
return [$month . '-01', date('Y-m-t', strtotime($month . '-01'))];
|
||||
}
|
||||
|
||||
if ($from === '' || $to === '') {
|
||||
throw new InvalidArgumentException('请给 --month=YYYY-MM,或同时给 --from 与 --to。');
|
||||
}
|
||||
|
||||
return [$from, $to];
|
||||
}
|
||||
}
|
||||
2150
app/Console/Commands/SyncStoreReceiptOrderMatches.php
Normal file
2150
app/Console/Commands/SyncStoreReceiptOrderMatches.php
Normal file
File diff suppressed because it is too large
Load Diff
3844
app/Console/Commands/SyncStoreReceiptProductCost.php
Normal file
3844
app/Console/Commands/SyncStoreReceiptProductCost.php
Normal file
File diff suppressed because it is too large
Load Diff
@ -38,13 +38,18 @@ class SyncStoreReportMirror extends Command
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'店铺报表镜像:生命周期 %s/%d 行,账单 %s/%d 行,日汇总 %s/%d 行。',
|
||||
'店铺报表镜像:流程期间 %s/%d 行,主体变更 %s(检查 %d 店,新增 %d 条),账单 %s/%d 行,日汇总 %s/%d 行,销量汇总 %s/%d 行。',
|
||||
$result['lifecycles']['status'],
|
||||
$result['lifecycles']['source_rows'],
|
||||
$result['period_changes']['status'],
|
||||
$result['period_changes']['checked'],
|
||||
$result['period_changes']['created'],
|
||||
$result['bills']['status'],
|
||||
$result['bills']['source_rows'],
|
||||
$result['daily']['status'],
|
||||
$result['daily']['source_rows'],
|
||||
$result['paid_sales']['status'],
|
||||
$result['paid_sales']['stored_rows'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
|
||||
51
app/Console/Commands/SyncStoreReportPaidSales.php
Normal file
51
app/Console/Commands/SyncStoreReportPaidSales.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\StoreReportMirrorSynchronizer;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 店铺维度报表的「销量」汇总:业绩明细「收入金额 + 优惠金额 + 冲减金额 + 退款金额」
|
||||
* 按「店铺名称 + 发生日期」预汇总。
|
||||
*
|
||||
* 业绩明细同步(store-receipt:order-match:sync)结束后会自动刷新一次;
|
||||
* 这个命令用于单独重跑(例如补数据或按最新代码强制重建)。
|
||||
*/
|
||||
class SyncStoreReportPaidSales extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:sync-store-report-paid-sales
|
||||
{--force : 忽略来源指纹,强制重建销量汇总}';
|
||||
|
||||
protected $description = '把业绩明细的收入/优惠/冲减/退款金额按店铺和发生日期汇总,供店铺维度报表销量列读取';
|
||||
|
||||
public function handle(StoreReportMirrorSynchronizer $synchronizer): int
|
||||
{
|
||||
try {
|
||||
$result = $synchronizer->syncPaidSales((bool) $this->option('force'));
|
||||
} catch (Throwable $exception) {
|
||||
$this->error('店铺维度报表销量汇总失败:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($result['status'] === 'skipped') {
|
||||
$this->warn('镜像里还没有业绩明细表,销量汇总未执行。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($result['status'] !== 'succeeded') {
|
||||
$this->error('店铺维度报表销量汇总失败,页面继续读取上一版数据。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info($result['changed']
|
||||
? sprintf('销量汇总已重建:业绩明细 %d 行 → 汇总 %d 行。', $result['source_rows'], $result['stored_rows'])
|
||||
: sprintf('业绩明细没有变化,沿用当前销量汇总(%d 行)。', $result['stored_rows']));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
212
app/Console/Commands/SyncStoreViolations.php
Normal file
212
app/Console/Commands/SyncStoreViolations.php
Normal file
@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\StoreViolationAlertCalculator;
|
||||
use App\Business\StoreViolationMapper;
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository;
|
||||
use Modules\EcommerceFinance\Services\UpstreamOdbcConnection;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 店铺违规信息预警:从经营分析副本库 rpt.crawl_shop_violation 同步到统一表。
|
||||
*
|
||||
* 上游只读;按 id 游标分批读取,幂等写入 store_violation_records。
|
||||
* 本地补充字段(扣分、处罚日期、处理状态)不会被覆盖。
|
||||
*/
|
||||
class SyncStoreViolations extends Command
|
||||
{
|
||||
protected $signature = 'store-violations:sync
|
||||
{--platform= : 只同步指定平台(taobao/alibaba/jd/jingxi/pinduoduo/douyin)}
|
||||
{--batch=1000 : 每批读取行数}';
|
||||
|
||||
protected $description = '同步店铺违规信息(operations_replica.rpt.crawl_shop_violation → store_violation_records)';
|
||||
|
||||
public function handle(
|
||||
StoreViolationMapper $mapper,
|
||||
StoreViolationRecordRepository $repository,
|
||||
StoreViolationAlertCalculator $calculator,
|
||||
): int
|
||||
{
|
||||
if (! $repository->available()) {
|
||||
$this->error('统一表 store_violation_records 尚未建立,请先执行数据库迁移。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$platform = trim((string) $this->option('platform'));
|
||||
$platforms = (array) config('store_violations.platforms', []);
|
||||
if ($platform !== '' && ! array_key_exists($platform, $platforms)) {
|
||||
$this->error('未知平台:' . $platform);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$sourceValues = [];
|
||||
foreach ($platforms as $slug => $definition) {
|
||||
if ($platform !== '' && $slug !== $platform) {
|
||||
continue;
|
||||
}
|
||||
foreach ($definition['source_values'] ?? [] as $value) {
|
||||
$sourceValues[] = (string) $value;
|
||||
}
|
||||
}
|
||||
$sourceValues = array_values(array_unique($sourceValues));
|
||||
|
||||
$batchSize = max(min((int) $this->option('batch'), 5000), 100);
|
||||
|
||||
try {
|
||||
$pdo = UpstreamOdbcConnection::create('operations_replica');
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$repository->recordSyncState('failed', $repository->totalRows(), 'connect_failed');
|
||||
$this->error('无法连接经营分析副本库:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$lastId = 0;
|
||||
$upserted = 0;
|
||||
$skipped = 0;
|
||||
$read = 0;
|
||||
|
||||
// 违规原因/处置方式:上游视图补齐后自动参与同步;未提供时不触碰这两列,
|
||||
// 避免每日同步把导入补充的数据清空。
|
||||
$extraColumns = $this->availableExtraColumns($pdo);
|
||||
$updateColumns = $extraColumns === []
|
||||
? StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS
|
||||
: array_merge(StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS, array_keys($extraColumns));
|
||||
// 预警派生字段(级别/责任人/处理截止等)每次都随同步重算写入。
|
||||
$updateColumns = array_values(array_unique(array_merge(
|
||||
$updateColumns,
|
||||
StoreViolationRecordRepository::ALERT_DERIVED_COLUMNS,
|
||||
)));
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
// FreeTDS 可能按连接区域返回 date(例如 "Oct 4 2026 12:00:00:AM"),
|
||||
// 与镜像同步器保持一致:在源端转成 ISO 字符串再交给 PHP 解析。
|
||||
$selectedColumns = ' id, shop_name, violation_no,'
|
||||
. ' CONVERT(varchar(10), violation_date, 23) AS violation_date,'
|
||||
. ' CONVERT(varchar(10), deadline, 23) AS deadline,'
|
||||
. ' violation_content, status, window_name, platform,'
|
||||
. ' CONVERT(varchar(10), crawl_date, 23) AS crawl_date';
|
||||
foreach ($extraColumns as $target => $sourceColumn) {
|
||||
$selectedColumns .= ', ' . $sourceColumn . ' AS ' . $target;
|
||||
}
|
||||
|
||||
$sql = 'SELECT TOP ' . $batchSize . $selectedColumns
|
||||
. ' FROM rpt.crawl_shop_violation WHERE id > ?';
|
||||
$params = [$lastId];
|
||||
|
||||
if ($sourceValues !== []) {
|
||||
$sql .= ' AND platform IN (' . implode(', ', array_fill(0, count($sourceValues), '?')) . ')';
|
||||
$params = array_merge($params, $sourceValues);
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY id ASC';
|
||||
|
||||
$statement = $pdo->prepare($sql);
|
||||
$statement->execute($params);
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
$statement->closeCursor();
|
||||
|
||||
if ($rows === []) {
|
||||
break;
|
||||
}
|
||||
|
||||
$payload = [];
|
||||
foreach ($rows as $row) {
|
||||
$read++;
|
||||
$lastId = max($lastId, (int) ($row['id'] ?? 0));
|
||||
$mapped = $mapper->mapCrawlRow($row);
|
||||
if ($mapped === null || $mapped['violation_no'] === '') {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$mapped = array_merge($mapped, $calculator->calculate(
|
||||
(string) $mapped['platform'],
|
||||
$mapped['shop_name'],
|
||||
$mapped['violation_type'],
|
||||
$mapped['violation_date'],
|
||||
$mapped['appeal_deadline'],
|
||||
));
|
||||
|
||||
$payload[] = $mapped + [
|
||||
'synced_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
$upserted += $repository->upsertRows($payload, $updateColumns);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$repository->recordSyncState('failed', $repository->totalRows(), 'sync_failed');
|
||||
$this->error('同步失败:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$total = $repository->totalRows();
|
||||
$repository->recordSyncState('succeeded', $total);
|
||||
|
||||
$this->info(sprintf(
|
||||
'同步完成:读取 %d 行,写入/更新 %d 行,跳过 %d 行,统一表共 %d 行。',
|
||||
$read,
|
||||
$upserted,
|
||||
$skipped,
|
||||
$total,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** @return array<string,string> 统一表字段 => 上游视图列名 */
|
||||
private function availableExtraColumns(PDO $pdo): array
|
||||
{
|
||||
try {
|
||||
$statement = $pdo->query(
|
||||
"SELECT name FROM sys.columns WHERE object_id = OBJECT_ID('rpt.crawl_shop_violation')"
|
||||
);
|
||||
$names = $statement->fetchAll(PDO::FETCH_COLUMN);
|
||||
$statement->closeCursor();
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::pickExtraColumns(array_map('strval', $names));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从上游可用列名中挑选可同步的扩展列;列名以数据平台实际提供的为准。
|
||||
*
|
||||
* @param list<string> $availableColumns
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public static function pickExtraColumns(array $availableColumns): array
|
||||
{
|
||||
$candidates = [
|
||||
'violation_reason' => ['violation_reason', 'reason'],
|
||||
'disposition' => ['disposition', 'handling_method', 'disposal'],
|
||||
];
|
||||
|
||||
$available = array_map(static fn (string $name): string => strtolower($name), $availableColumns);
|
||||
$selected = [];
|
||||
foreach ($candidates as $target => $names) {
|
||||
foreach ($names as $name) {
|
||||
if (in_array($name, $available, true)) {
|
||||
$selected[$target] = $name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $selected;
|
||||
}
|
||||
}
|
||||
49
app/Console/Commands/SyncSupplierManagementMirror.php
Normal file
49
app/Console/Commands/SyncSupplierManagementMirror.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorSynchronizer;
|
||||
use Throwable;
|
||||
|
||||
class SyncSupplierManagementMirror extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:sync-supplier-management';
|
||||
|
||||
protected $description = '完整校准供应商管理的档案、合同、联系人和打款资料共享镜像';
|
||||
|
||||
public function handle(BusinessMirrorDatasetRegistry $datasets, BusinessMirrorSynchronizer $synchronizer): int
|
||||
{
|
||||
if (! (bool) config('upstream_mirrors.refresh_enabled', false)) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$failed = false;
|
||||
foreach ([SupplierManagementMirrorRepository::DATASET, ...array_values(SupplierManagementMirrorRepository::RELATED)] as $key) {
|
||||
if (! $datasets->scheduled($key)) {
|
||||
$this->warn($key . ':未启用后台同步,已跳过。');
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// 四个已接入的小数据集顺序校准,每批沿用现有大小,直到完整结束。
|
||||
$result = $synchronizer->sync($key, true, null, 0);
|
||||
if ($result['status'] !== 'succeeded') {
|
||||
$failed = true;
|
||||
$this->error($key . ':同步未完成,请检查同步状态。');
|
||||
continue;
|
||||
}
|
||||
$this->info(sprintf('%s:同步完成,本轮 %d 批,累计处理 %d 行。', $key, $result['batches'], $result['rows']));
|
||||
} catch (Throwable $exception) {
|
||||
$failed = true;
|
||||
Log::error('供应商管理每日镜像同步失败。', ['dataset' => $key, 'exception' => $exception::class]);
|
||||
$this->error($key . ':同步失败,请检查同步状态。');
|
||||
}
|
||||
}
|
||||
|
||||
return $failed ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
172
app/Console/Commands/VerifyErpDailyBillCoverage.php
Normal file
172
app/Console/Commands/VerifyErpDailyBillCoverage.php
Normal file
@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class VerifyErpDailyBillCoverage extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:verify-erp-daily-coverage
|
||||
{--from=2026-07-01 : 起始日期 YYYY-MM-DD}
|
||||
{--to= : 截止日期 YYYY-MM-DD,默认昨日}
|
||||
{--json : 输出 JSON}';
|
||||
|
||||
protected $description = '核对京东日账单、京东钱包、头条放心购的月份与截止日覆盖是否完整';
|
||||
|
||||
private const TARGETS = [
|
||||
'jd-daily' => [
|
||||
'label' => '京东日账单',
|
||||
'periods' => 'jd_daily_bill_periods',
|
||||
'rows' => 'jd_daily_bill_rows',
|
||||
],
|
||||
'jd-wallet' => [
|
||||
'label' => '京东钱包',
|
||||
'periods' => 'jd_wallet_bill_periods',
|
||||
'rows' => 'jd_wallet_bill_rows',
|
||||
],
|
||||
'douyin' => [
|
||||
'label' => '头条放心购',
|
||||
'periods' => 'douyin_authoritative_periods',
|
||||
'rows' => 'douyin_authoritative_bills',
|
||||
],
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$fromText = (string) $this->option('from');
|
||||
$toText = (string) ($this->option('to') ?: now('Asia/Shanghai')->subDay()->toDateString());
|
||||
$from = $this->date($fromText);
|
||||
$to = $this->date($toText);
|
||||
|
||||
if ($from === null || $to === null || $from->gt($to)) {
|
||||
$this->error('日期范围无效,应为 from <= to,格式 YYYY-MM-DD。');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$db = DB::connection(config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$results = [];
|
||||
$failed = false;
|
||||
|
||||
foreach (self::TARGETS as $key => $target) {
|
||||
if (! $schema->hasTable($target['periods']) || ! $schema->hasTable($target['rows'])) {
|
||||
$results[] = [
|
||||
'dataset' => $key,
|
||||
'label' => $target['label'],
|
||||
'month' => null,
|
||||
'required_start' => $from->toDateString(),
|
||||
'required_through' => $to->toDateString(),
|
||||
'imported_start' => null,
|
||||
'imported_end' => null,
|
||||
'row_count' => 0,
|
||||
'registered_rows' => null,
|
||||
'income' => null,
|
||||
'expense' => null,
|
||||
'status' => 'missing_table',
|
||||
'issue' => '镜像表不存在',
|
||||
];
|
||||
$failed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
$month = $from->copy()->startOfMonth();
|
||||
while ($month->lte($to)) {
|
||||
$monthEnd = $month->copy()->endOfMonth();
|
||||
$requiredStart = ($month->lt($from) ? $from->copy() : $month->copy())->startOfDay();
|
||||
$requiredThrough = ($monthEnd->gt($to) ? $to->copy() : $monthEnd->copy())->startOfDay();
|
||||
$requiredEndExclusive = $requiredThrough->copy()->addDay();
|
||||
$period = $db->table($target['periods'])
|
||||
->where('period_start', $month->toDateString())
|
||||
->first();
|
||||
|
||||
$status = 'ok';
|
||||
$rowCount = 0;
|
||||
$issue = null;
|
||||
$summary = null;
|
||||
|
||||
if ($period === null) {
|
||||
$status = 'missing_month';
|
||||
$issue = '缺少该月份导入记录';
|
||||
} elseif (Carbon::parse((string) $period->period_end)->lt($requiredEndExclusive)) {
|
||||
$status = 'partial';
|
||||
$issue = '导入截止日早于要求日期';
|
||||
} else {
|
||||
$summary = $db->table($target['rows'])
|
||||
->where('generation', $period->active_generation)
|
||||
->selectRaw('COUNT(*) AS rows_count, COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense')
|
||||
->first();
|
||||
$rowCount = (int) ($summary->rows_count ?? 0);
|
||||
if ($rowCount === 0) {
|
||||
$status = 'empty';
|
||||
$issue = '有效版本没有明细';
|
||||
} elseif ($rowCount !== (int) $period->row_count) {
|
||||
$status = 'row_count_mismatch';
|
||||
$issue = '有效版本实际行数与期间登记不一致';
|
||||
} elseif (abs(round((float) $summary->income, 2) - (float) $period->income) > 0.005
|
||||
|| abs(round((float) $summary->expense, 2) - (float) $period->expense) > 0.005) {
|
||||
$status = 'amount_mismatch';
|
||||
$issue = '有效版本收支合计与期间登记不一致';
|
||||
}
|
||||
}
|
||||
|
||||
if ($status !== 'ok') {
|
||||
$failed = true;
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'dataset' => $key,
|
||||
'label' => $target['label'],
|
||||
'month' => $month->format('Y-m'),
|
||||
'required_start' => $requiredStart->toDateString(),
|
||||
'required_through' => $requiredThrough->toDateString(),
|
||||
'imported_start' => $period?->period_start,
|
||||
'imported_end' => $period?->period_end,
|
||||
'row_count' => $rowCount,
|
||||
'registered_rows' => $period === null ? null : (int) $period->row_count,
|
||||
'income' => isset($summary) ? round((float) $summary->income, 2) : null,
|
||||
'expense' => isset($summary) ? round((float) $summary->expense, 2) : null,
|
||||
'status' => $status,
|
||||
'issue' => $issue,
|
||||
];
|
||||
|
||||
unset($summary);
|
||||
$month->addMonthNoOverflow()->startOfMonth();
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line(json_encode($results, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
} else {
|
||||
$this->table(
|
||||
['数据集', '月份', '要求覆盖', '导入覆盖', '实际行数', '登记行数', '收入', '支出', '状态', '说明'],
|
||||
array_map(static fn (array $row): array => [
|
||||
$row['label'],
|
||||
$row['month'] ?? '—',
|
||||
($row['required_start'] ?? '—') . ' ~ ' . ($row['required_through'] ?? '—'),
|
||||
($row['imported_start'] ?? '—') . ' ~ ' . ($row['imported_end'] ?? '—'),
|
||||
number_format((int) $row['row_count']),
|
||||
$row['registered_rows'] === null ? '—' : number_format((int) $row['registered_rows']),
|
||||
$row['income'] === null ? '—' : number_format((float) $row['income'], 2),
|
||||
$row['expense'] === null ? '—' : number_format((float) $row['expense'], 2),
|
||||
$row['status'],
|
||||
$row['issue'] ?? '',
|
||||
], $results),
|
||||
);
|
||||
}
|
||||
|
||||
return $failed ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
private function date(string $value): ?Carbon
|
||||
{
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$date = Carbon::createFromFormat('!Y-m-d', $value, 'Asia/Shanghai');
|
||||
return $date !== false && $date->format('Y-m-d') === $value ? $date : null;
|
||||
}
|
||||
}
|
||||
70
app/Console/Commands/VerifyFinanceLedger.php
Normal file
70
app/Console/Commands/VerifyFinanceLedger.php
Normal file
@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class VerifyFinanceLedger extends Command
|
||||
{
|
||||
protected $signature = 'finance-ledger:verify';
|
||||
|
||||
protected $description = '校验财务台账数量、金额勾稽和流水平衡(只读)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$db = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$accounts = (int) $db->table('fin_accounts')->count();
|
||||
$shops = (int) $db->table('fin_shops')->count();
|
||||
$companies = (int) $db->table('fin_companies')->count();
|
||||
$openingRows = (int) $db->table('fin_account_opening')->count();
|
||||
$transactions = (int) $db->table('fin_transactions')->count();
|
||||
$flows = (int) $db->table('fin_daily_flows')->count();
|
||||
$maps = (int) $db->table('fin_account_map')->count();
|
||||
$mapped = (int) $db->table('fin_account_map')->whereNotNull('account_short')->where('account_short', '<>', '')->count();
|
||||
|
||||
$accountBalance = round((float) $db->table('fin_accounts')->sum('current_balance'), 2);
|
||||
$openingSum = round((float) $db->table('fin_account_opening')->sum('opening_balance'), 2);
|
||||
$income = round((float) $db->table('fin_transactions')->sum('income'), 2);
|
||||
$expense = round((float) $db->table('fin_transactions')->sum('expense'), 2);
|
||||
$flowOpening = round((float) $db->table('fin_daily_flows')->sum('opening_balance'), 2);
|
||||
$flowInflow = round((float) $db->table('fin_daily_flows')->sum('inflow'), 2);
|
||||
$flowOutflow = round((float) $db->table('fin_daily_flows')->sum('outflow'), 2);
|
||||
$flowClosing = round((float) $db->table('fin_daily_flows')->sum('closing_balance'), 2);
|
||||
$diff = round($flowOpening + $flowInflow - $flowOutflow - $flowClosing, 2);
|
||||
|
||||
$this->table(['项目', '值'], [
|
||||
['账户', $accounts],
|
||||
['店铺', $shops],
|
||||
['公司', $companies],
|
||||
['期初账户', $openingRows],
|
||||
['期初合计', number_format($openingSum, 2)],
|
||||
['账户余额合计', number_format($accountBalance, 2)],
|
||||
['动账明细', $transactions],
|
||||
['收入合计', number_format($income, 2)],
|
||||
['支出合计', number_format($expense, 2)],
|
||||
['每日流水', $flows],
|
||||
['流水期末合计', number_format($flowClosing, 2)],
|
||||
['流水勾稽差异', number_format($diff, 2)],
|
||||
['映射记录', $maps],
|
||||
['已映射', $mapped],
|
||||
]);
|
||||
|
||||
$errors = [];
|
||||
if (abs($openingSum - $accountBalance) > 0.01) {
|
||||
$errors[] = '期初合计与账户余额合计不一致。';
|
||||
}
|
||||
if (abs($diff) > 0.01) {
|
||||
$errors[] = '每日流水期末不等于期初+入账-出账。';
|
||||
}
|
||||
if ($openingRows !== $flows) {
|
||||
$errors[] = '期初账户数与当前流水行数不一致(期初日应一账户一行)。';
|
||||
}
|
||||
|
||||
foreach ($errors as $error) {
|
||||
$this->error($error);
|
||||
}
|
||||
|
||||
return $errors === [] ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
30
app/Console/Commands/WarmOrderDataShopCandidates.php
Normal file
30
app/Console/Commands/WarmOrderDataShopCandidates.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\EcommerceFinance\Repositories\OrderDataMirrorRepository;
|
||||
use Throwable;
|
||||
|
||||
class WarmOrderDataShopCandidates extends Command
|
||||
{
|
||||
protected $signature = 'order-data:warm-shop-candidates {--full : 全量核对店铺候选(夜间维护)}';
|
||||
|
||||
protected $description = '增量预热订单售后费用明细的店铺候选,夜间使用 --full 全量核对';
|
||||
|
||||
public function handle(OrderDataMirrorRepository $orders): int
|
||||
{
|
||||
try {
|
||||
$names = $orders->refreshShopNamesCache((bool) $this->option('full'));
|
||||
$this->info(sprintf('店铺候选已预热,共 %d 个店铺。', count($names)));
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('店铺候选预热失败。', ['exception' => $exception::class]);
|
||||
$this->error('店铺候选预热失败:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -53,6 +53,44 @@ class Kernel extends ConsoleKernel
|
||||
->withoutOverlapping(60)
|
||||
->runInBackground();
|
||||
|
||||
// 上游店铺档案每日刷新后留存一份完整基础信息,方便按日期复查负责人等字段。
|
||||
$schedule->command('business-mirror:archive-store-information')
|
||||
->dailyAt('07:00')
|
||||
->timezone('Asia/Shanghai')
|
||||
->withoutOverlapping(120)
|
||||
->runInBackground();
|
||||
|
||||
$schedule->command('business-mirror:sync-supplier-management')
|
||||
->dailyAt('08:15')
|
||||
->timezone('Asia/Shanghai')
|
||||
->withoutOverlapping(60)
|
||||
->runInBackground();
|
||||
|
||||
// 店铺违规信息预警:每天同步副本库抓取结果到统一表(本地补充字段不覆盖)。
|
||||
$schedule->command('store-violations:sync')
|
||||
->dailyAt('05:30')
|
||||
->withoutOverlapping(60)
|
||||
->runInBackground();
|
||||
|
||||
// 店铺违规预警推送:P0/P1 每天 09:00 起每 5 分钟检查;P2 每日 10:00 汇总。
|
||||
$schedule->command('store-violations:push-instant')
|
||||
->cron('*/5 9-21 * * *')
|
||||
->withoutOverlapping(5)
|
||||
->runInBackground();
|
||||
|
||||
$schedule->command('store-violations:push-digest')
|
||||
->dailyAt('10:00')
|
||||
->withoutOverlapping(30)
|
||||
->runInBackground();
|
||||
|
||||
// 财务台账:从 AI 库 rpt 同步动账明细,按日重算账户流水。
|
||||
// 生产宿主机 cron 仍需配置对应的 finance-ledger:sync-rpt 入口。
|
||||
$schedule->command('finance-ledger:sync-rpt --days=7 --merge')
|
||||
->dailyAt('02:30')
|
||||
->timezone('Asia/Shanghai')
|
||||
->withoutOverlapping(120)
|
||||
->runInBackground();
|
||||
|
||||
$schedule->command('business-mirror:sync-third-party-warehouse-storage --max-batches=0')
|
||||
->dailyAt('00:00')
|
||||
->withoutOverlapping(180)
|
||||
@ -65,11 +103,18 @@ class Kernel extends ConsoleKernel
|
||||
->runInBackground();
|
||||
}
|
||||
|
||||
// 聚水潭发货数据每日重拉最近三天,覆盖副本库迟到更新。
|
||||
// 聚水潭发货数据:凌晨重拉最近 40 天。聚水潭常在发货后数天到数周内换单/改快递公司/作废采购退货出库,
|
||||
// 只拉最近几天会让上月数据停在旧值(快递名称不一致、已作废的单仍在);40 天覆盖上月结算期。
|
||||
// 默认关闭(JST_SYNC_ENABLED=false);填好凭证并完成迁移后再打开。
|
||||
if ((bool) env('JST_SYNC_ENABLED', false)) {
|
||||
$schedule->command('jushuitan:sync-shipping --days=3')
|
||||
$schedule->command('jushuitan:sync-shipping --days=40')
|
||||
->dailyAt('01:28')
|
||||
->withoutOverlapping(300)
|
||||
->runInBackground();
|
||||
// 凌晨副本库最近一两天的订单/商品明细常未到齐(视图、订单商品表滞后),
|
||||
// 早上再拉一次最近 3 天,补齐凌晨只写入单号、缺商品明细的包裹。
|
||||
$schedule->command('jushuitan:sync-shipping --days=3')
|
||||
->dailyAt('07:28')
|
||||
->withoutOverlapping(120)
|
||||
->runInBackground();
|
||||
}
|
||||
|
||||
@ -100,6 +100,12 @@ class Handler extends ExceptionHandler
|
||||
*/
|
||||
public function render($request, Throwable $exception)
|
||||
{
|
||||
// 快递扫码填报页:手机原图超过上传上限时,回到填报页给出能看懂的提示,而不是报错页。
|
||||
if ($exception instanceof \Illuminate\Http\Exceptions\PostTooLargeException
|
||||
&& $request->is('onboarding/courier/*')) {
|
||||
return redirect()->to($request->url() . '?too_large=1');
|
||||
}
|
||||
|
||||
if (request_is_api($request)) {
|
||||
return $this->handleApiExceptions($request, $exception);
|
||||
}
|
||||
|
||||
@ -157,7 +157,10 @@ class DingTalkLogin extends Controller
|
||||
|
||||
$url = $this->applicationRoute($user->landing_page, ['company_id' => $company->id]);
|
||||
|
||||
return redirect()->intended($url);
|
||||
// 免登后固定回到首页(财务驾驶舱),不再回跳上次打开的业务页面。
|
||||
$request->session()->forget('url.intended');
|
||||
|
||||
return redirect()->to($url);
|
||||
}
|
||||
|
||||
protected function failed(): RedirectResponse
|
||||
|
||||
@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Abstracts\Http\Controller;
|
||||
use App\Http\Requests\Auth\Login as Request;
|
||||
use App\Models\Common\Contact;
|
||||
use Illuminate\Foundation\Auth\AuthenticatesUsers;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
@ -79,10 +80,14 @@ class Login extends Controller
|
||||
// Redirect to portal if is customer
|
||||
if ($user->isCustomer()) {
|
||||
$path = session('url.intended', '');
|
||||
$portalHome = $user->contact instanceof Contact
|
||||
&& $user->contact->type === Contact::VENDOR_TYPE
|
||||
? route('portal.supplier-reconciliation.index', ['company_id' => $company->id])
|
||||
: route('portal.dashboard', ['company_id' => $company->id]);
|
||||
|
||||
// Path must start with company id and 'portal' prefix
|
||||
if (!Str::startsWith($path, $company->id . '/portal')) {
|
||||
$path = route('portal.dashboard', ['company_id' => $company->id]);
|
||||
$path = $portalHome;
|
||||
}
|
||||
|
||||
if (! $request->expectsJson()) {
|
||||
@ -102,8 +107,11 @@ class Login extends Controller
|
||||
// Redirect to landing page if is user
|
||||
$url = route($user->landing_page, ['company_id' => $company->id]);
|
||||
|
||||
// 登录后固定回到首页(财务驾驶舱),不再回跳上次打开/被拦截的业务页面。
|
||||
$request->session()->forget('url.intended');
|
||||
|
||||
if (! $request->expectsJson()) {
|
||||
return redirect()->intended($url);
|
||||
return redirect()->to($url);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
@ -112,7 +120,7 @@ class Login extends Controller
|
||||
'error' => false,
|
||||
'message' => trans('auth.login_redirect'),
|
||||
'data' => null,
|
||||
'redirect' => redirect()->intended($url)->getTargetUrl(),
|
||||
'redirect' => url($url),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user