Compare commits
606 Commits
demo-setti
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
777ae82833 | ||
|
|
2bdad21810 | ||
|
|
42ec18188e | ||
|
|
c4d34926a6 | ||
|
|
acb888c2d1 | ||
|
|
168a990a7e | ||
|
|
07684bbb90 | ||
|
|
3e2069e099 | ||
|
|
34b0a7faf6 | ||
|
|
4494dc60fb | ||
|
|
83e1beb7e2 | ||
|
|
09f39af923 | ||
|
|
650e2412da | ||
|
|
1d745c5722 | ||
|
|
c08fa55e05 | ||
|
|
cb8a805a21 | ||
|
|
d8f549d390 | ||
|
|
d1a82a40fa | ||
|
|
c2d289c92a | ||
|
|
09ebf911ed | ||
|
|
be73e7c7a0 | ||
|
|
dc9421e482 | ||
| 1602895d77 | |||
| 6ed11bf858 | |||
| e2281fa425 | |||
| ce9e52efd9 | |||
| d4d61461fe | |||
| d90621f8c7 | |||
| 330b81ab7b | |||
| 3785026749 | |||
| 179348ad66 | |||
| 9dbde50f56 | |||
| 6bb08a13ce | |||
|
|
65f8ce2d37 | ||
|
|
1ace7da140 | ||
|
|
2dd14fd1b4 | ||
|
|
1404d40f71 | ||
|
|
0c29cdae97 | ||
|
|
aa4244b16d | ||
|
|
ef1bdfe1d5 | ||
|
|
8a92e1c1ff | ||
|
|
7499c42878 | ||
|
|
55cf6b8331 | ||
|
|
5a4d681d13 | ||
|
|
1722a192a8 | ||
|
|
1aef703882 | ||
|
|
5e0bfeb544 | ||
|
|
be9c70d524 | ||
|
|
146dac4493 | ||
|
|
697c52eba3 | ||
|
|
201663741d | ||
|
|
b77165d7b3 | ||
|
|
41e9b0d57c | ||
|
|
ee22ff9e6d | ||
|
|
7ba6d9ebe6 | ||
|
|
16a8ee7986 | ||
|
|
06d68a2783 | ||
|
|
7739d6016f | ||
|
|
f571f99084 | ||
|
|
a5d9aace56 | ||
|
|
fb71ecddaf | ||
|
|
2736c382a2 | ||
|
|
de2f098309 | ||
|
|
7044d4cefd | ||
|
|
a65f4cd45c | ||
|
|
4e9d21d1d6 | ||
|
|
38f5071975 | ||
|
|
6e5307933f | ||
|
|
61825e706a | ||
|
|
1bd8971310 | ||
|
|
7f4e67129f | ||
|
|
6ce4c2c8ed | ||
|
|
3007bfb254 | ||
|
|
af78c48d8e | ||
|
|
aecb2f17d1 | ||
|
|
f8c6da3acc | ||
|
|
9a6036d184 | ||
|
|
1a1518c611 | ||
|
|
47073f0a96 | ||
|
|
bec02ac546 | ||
|
|
a2faace4bd | ||
|
|
fcff9a0fc6 | ||
|
|
3293a76c91 | ||
|
|
d1c7940db9 | ||
|
|
e0532a539f | ||
|
|
94a941e100 | ||
|
|
fcd0588686 | ||
|
|
c5a3f99113 | ||
|
|
f37f41c5c3 | ||
|
|
8d951a64e6 | ||
|
|
cfbe16c519 | ||
|
|
3c1d5b73ed | ||
|
|
5621cad1bf | ||
|
|
3a793978b4 | ||
|
|
1685b858f9 | ||
|
|
0da3887aa6 | ||
|
|
8e0a30eb98 | ||
|
|
db9769b209 | ||
|
|
62d3242db5 | ||
|
|
5a501eb199 | ||
|
|
9e9ed4bc9e | ||
|
|
ecfbc904a7 | ||
|
|
871b223440 | ||
|
|
9856a68d84 | ||
|
|
f5cb9a8f7d | ||
|
|
eb5b75cf78 | ||
|
|
6b430bdd88 | ||
|
|
a5291545a5 | ||
|
|
7b6dc3cb8e | ||
|
|
76409c5f21 | ||
|
|
f415e138da | ||
|
|
ca3d3df398 | ||
|
|
09d669cc7c | ||
|
|
99debbf259 | ||
|
|
2cecdba73a | ||
|
|
003ba9b1ad | ||
|
|
e3593a4ba8 | ||
|
|
d8495766e1 | ||
|
|
ae4556584f | ||
|
|
8a7cb4fcf7 | ||
|
|
eeb9a67577 | ||
|
|
b7e8401a99 | ||
|
|
ddac862cee | ||
|
|
95ac9cc10d | ||
|
|
e7acea8ffc | ||
|
|
fd954c80a0 | ||
|
|
df3c72e927 | ||
|
|
1ec149a624 | ||
|
|
840052dd5a | ||
|
|
69a1d02fc7 | ||
|
|
f8a56f2b4e | ||
|
|
ae4c9fb93a | ||
|
|
80300dfc67 | ||
|
|
4766ec6939 | ||
|
|
f01bffecd9 | ||
|
|
3dc5bd6ee1 | ||
|
|
bfaf0e7317 | ||
|
|
159fa38fc0 | ||
|
|
338bd2914f | ||
|
|
63219a31fb | ||
|
|
04195caf9b | ||
|
|
cbc4ae3749 | ||
|
|
83015b841b | ||
|
|
7a90e8c6de | ||
|
|
33d3eba998 | ||
|
|
f1115825b2 | ||
|
|
db0099f8c9 | ||
|
|
5875fe380e | ||
|
|
4a86be6cbf | ||
|
|
372f2fc55d | ||
|
|
02e4e99308 | ||
|
|
8256fcb623 | ||
|
|
e81756833c | ||
|
|
c0f6cb5ab6 | ||
|
|
d3e455fc82 | ||
|
|
07e8a6887c | ||
|
|
a32572facb | ||
|
|
b506d64dd3 | ||
|
|
d9edeb0d5d | ||
|
|
b8135acca9 | ||
|
|
3d35218d6b | ||
|
|
a44b981059 | ||
|
|
9511ebe71f | ||
|
|
d84b2de1b7 | ||
|
|
7f700df418 | ||
|
|
7a97792b67 | ||
|
|
a6caf0ded5 | ||
|
|
6fe2ef4349 | ||
|
|
1f1eb7714e | ||
|
|
779c59138f | ||
|
|
e0c4da0933 | ||
|
|
a5e5390cd1 | ||
|
|
a969d2c4cc | ||
|
|
6ca3fa8a6b | ||
|
|
4e5f952f28 | ||
|
|
231cff9574 | ||
|
|
339b33d09b | ||
|
|
a1695789dd | ||
|
|
2b25c5bebf | ||
|
|
cfa824f905 | ||
|
|
378332be25 | ||
|
|
1a004a4d08 | ||
|
|
ed79a6ab32 | ||
|
|
722993a078 | ||
|
|
14cef19d7d | ||
|
|
74c5570661 | ||
|
|
17f565bded | ||
|
|
fa50d39020 | ||
|
|
4e0f4dd747 | ||
|
|
f34a7bf9bc | ||
|
|
964c62efb6 | ||
|
|
af6c234cdc | ||
|
|
4efff257eb | ||
|
|
954e2ceedd | ||
|
|
b8fea39e2b | ||
|
|
b6c7142677 | ||
|
|
c9fa5ea5e2 | ||
|
|
e29a01f611 | ||
|
|
36c0763106 | ||
|
|
01984139c5 | ||
|
|
6173851920 | ||
|
|
098ab23e47 | ||
|
|
86564b5add | ||
|
|
66416b14ed | ||
|
|
d457e2095d | ||
|
|
deee087f6f | ||
|
|
eab9b90b88 | ||
|
|
d326697417 | ||
|
|
acd2041043 | ||
|
|
5673856ca3 | ||
|
|
6a600bb009 | ||
|
|
8f2dd1375e | ||
|
|
c04159405f | ||
|
|
d7486a38f6 | ||
|
|
857198bbba | ||
|
|
e414396b0c | ||
|
|
81aae1261c | ||
|
|
5b5e0133e0 | ||
|
|
9a59186cd4 | ||
|
|
a39db64eac | ||
|
|
0964981097 | ||
|
|
750108fa83 | ||
|
|
11eca730f7 | ||
|
|
c6ce58f14a | ||
|
|
5124217636 | ||
|
|
06d93889b3 | ||
|
|
a12b6aac19 | ||
|
|
7ceb7644f0 | ||
|
|
9286d4a387 | ||
|
|
7579a0b5be | ||
|
|
ccdf2b4070 | ||
|
|
cacae84a85 | ||
|
|
025432eefd | ||
|
|
953581db38 | ||
|
|
9185382565 | ||
|
|
8d504c8b33 | ||
|
|
6fb9c9c7ec | ||
|
|
68242fd22a | ||
|
|
6a251df81b | ||
|
|
3b6b999d75 | ||
|
|
1864df0fc2 | ||
|
|
f8594fede2 | ||
|
|
d5a138f6c2 | ||
|
|
e55fade6d7 | ||
|
|
7f9958ad7e | ||
|
|
8bd9096bc4 | ||
|
|
2cad7290e5 | ||
|
|
97fb828a9b | ||
|
|
c5238ccfbc | ||
|
|
de8477bc7e | ||
|
|
e42aec1354 | ||
|
|
9de3044e46 | ||
|
|
7485b36b2f | ||
|
|
9e3eb8944b | ||
|
|
dcf6494e82 | ||
|
|
fba0dc6bf9 | ||
|
|
791c326edb | ||
|
|
5dd3b0a005 | ||
|
|
821957c363 | ||
|
|
7ea0858375 | ||
|
|
b1bebf6ae4 | ||
|
|
e4287ec5f7 | ||
|
|
a5ed5ca098 | ||
|
|
5db796bf64 | ||
|
|
611553ecb3 | ||
|
|
6885b1a99f | ||
|
|
610899d898 | ||
|
|
077baf785f | ||
|
|
d2b40dae3b | ||
|
|
a5ead47874 | ||
|
|
d889a7e075 | ||
|
|
d9e9c5e5db | ||
|
|
0555d0c6ba | ||
|
|
89f29e6d52 | ||
|
|
23b3ea16ae | ||
|
|
52da77a88f | ||
|
|
b4a9bfae86 | ||
|
|
e01e8077f9 | ||
|
|
19ad440d34 | ||
|
|
8d380e4902 | ||
|
|
577a0b739e | ||
|
|
cd8e1d638a | ||
|
|
6daeadc452 | ||
|
|
a2a9f9e0c1 | ||
|
|
b5636e7b99 | ||
|
|
ba609719cc | ||
|
|
fce59eb19b | ||
|
|
642e9dad97 | ||
|
|
fb1bcf49e1 | ||
|
|
ea23feb45b | ||
|
|
d9bf2bbe98 | ||
|
|
3edcbc58c0 | ||
|
|
7766fce1d4 | ||
|
|
da11241f72 | ||
|
|
0994b6dd6c | ||
|
|
7d8b342a85 | ||
|
|
1d0181ff95 | ||
|
|
55494fe5ba | ||
|
|
5c66a01261 | ||
|
|
ea8c277ee9 | ||
|
|
978faf004c | ||
|
|
d1a14ebee6 | ||
|
|
acde99e383 | ||
|
|
73936a5c5f | ||
|
|
dcdfe1382e | ||
|
|
db4ad9de4d | ||
|
|
3de99322bc | ||
|
|
c3e60b8746 | ||
|
|
ca2003155e | ||
|
|
b0fc0e6520 | ||
|
|
9f39240b56 | ||
|
|
dd5e78a9ff | ||
|
|
dae4c292f8 | ||
|
|
72f166eb14 | ||
|
|
51d007d84e | ||
|
|
3746eae199 | ||
|
|
62f54499ce | ||
|
|
8aae09d083 | ||
|
|
1cfc4092d2 | ||
|
|
0a7922301e | ||
|
|
93845c990e | ||
|
|
c9faaa5744 | ||
|
|
7dee52a00a | ||
|
|
55179b1066 | ||
|
|
1d54aefcb5 | ||
|
|
47b553ae8c | ||
|
|
5c970f4134 | ||
|
|
145c515292 | ||
|
|
9b439814bd | ||
|
|
f59efc6dc7 | ||
|
|
cdbf5b51bd | ||
|
|
016cb571c4 | ||
|
|
2a3d59054f | ||
|
|
e724ec05eb | ||
|
|
51f489c496 | ||
|
|
44d0499b53 | ||
|
|
4dad775568 | ||
|
|
178c0d8ac9 | ||
|
|
a76f6323a2 | ||
|
|
b6e5b825a5 | ||
|
|
43989ada2d | ||
|
|
e88d68d3b1 | ||
|
|
18162dfa4c | ||
|
|
90192dbed9 | ||
|
|
3db5a7aeec | ||
|
|
905d2bf459 | ||
|
|
ac48546c76 | ||
|
|
07e6d56fd3 | ||
|
|
95ed1494e2 | ||
|
|
4ac25f476b | ||
|
|
a3a00b1403 | ||
|
|
14ecfdcc8d | ||
|
|
88a58d33c0 | ||
|
|
99e6e37953 | ||
|
|
dac00291a7 | ||
|
|
205da14e94 | ||
|
|
2e3d6e5572 | ||
|
|
cde2718d2a | ||
|
|
b3d00a9bef | ||
|
|
c8577ee377 | ||
|
|
059b25f438 | ||
|
|
1678fd1775 | ||
|
|
d505ecfbae | ||
|
|
55f5a4f72a | ||
|
|
42c882aeb8 | ||
|
|
a92a2500c4 | ||
|
|
d15bf133ce | ||
|
|
775d7bc967 | ||
|
|
76ec9be6ef | ||
|
|
be127bab76 | ||
|
|
0f020afa6c | ||
|
|
8b290ff297 | ||
|
|
39ea74a52e | ||
|
|
4b6b431a91 | ||
|
|
3d1a876c60 | ||
|
|
1ee57f3e88 | ||
|
|
b3dcdb3104 | ||
|
|
bd7682c418 | ||
|
|
1aa5aed72e | ||
|
|
8170234d70 | ||
|
|
a8d93293dd | ||
|
|
b5259a5b28 | ||
|
|
5d7d094974 | ||
|
|
2c9b75c871 | ||
|
|
1778d48a00 | ||
|
|
cfbe4655da | ||
|
|
5a0c2953aa | ||
|
|
a4d681e266 | ||
|
|
4a48b8c806 | ||
|
|
6cddc5b1b8 | ||
|
|
99f49ced0c | ||
|
|
d9423a3fb1 | ||
|
|
307b98f86e | ||
|
|
433714d9b0 | ||
|
|
5c3e4538ee | ||
|
|
8725c276cb | ||
|
|
916d0891f1 | ||
|
|
ecb3750abe | ||
|
|
7991a9b662 | ||
|
|
4c2b1e7a54 | ||
|
|
cc8a594f1b | ||
|
|
91021021d9 | ||
|
|
f375d86189 | ||
|
|
fb1bf68cc5 | ||
|
|
46e8bdfaae | ||
|
|
bb675a538a | ||
|
|
0cef0133a9 | ||
|
|
676e91fd66 | ||
|
|
e4c63bbf38 | ||
|
|
90a77404fe | ||
|
|
1dc79f2cec | ||
|
|
154a2e0553 | ||
|
|
115a4f0263 | ||
|
|
7b6c6f0998 | ||
|
|
17c7a26ef4 | ||
|
|
f374f4a6d6 | ||
|
|
ae2b089422 | ||
|
|
e29826a0a6 | ||
|
|
973330a816 | ||
|
|
4600912051 | ||
|
|
7bf8dc173d | ||
|
|
47b942ce06 | ||
|
|
a40aa7977c | ||
|
|
86e895a4ae | ||
|
|
da7fd0799b | ||
|
|
33e3dbe3bf | ||
|
|
536d10234d | ||
|
|
0067c5c706 | ||
|
|
84015414b3 | ||
|
|
223fcddbc5 | ||
|
|
ed1560f216 | ||
|
|
4e65aca18e | ||
|
|
d48bb6f32b | ||
|
|
eb11ff0a93 | ||
|
|
1b63ec6c56 | ||
|
|
3857abe2e5 | ||
|
|
a77236bda8 | ||
|
|
b4c6ddc225 | ||
|
|
9eb314f0e2 | ||
|
|
b8aeef4262 | ||
|
|
ea438d55d1 | ||
|
|
d94bb6d0f5 | ||
|
|
b33f91896f | ||
|
|
cf2c2f6e39 | ||
|
|
cbc477d95a | ||
|
|
0adef38523 | ||
|
|
88f522c60d | ||
|
|
aa154fb78e | ||
|
|
1017a5cc8c | ||
|
|
c014d7431c | ||
|
|
33e0c0cbef | ||
|
|
f6a406459a | ||
|
|
080412d2b3 | ||
|
|
bca22ffb99 | ||
|
|
903f471d51 | ||
|
|
9fce030f5c | ||
|
|
51d5ac87c7 | ||
|
|
6fcb197eb5 | ||
|
|
8fa2421978 | ||
|
|
5574f845ab | ||
|
|
9bf670b951 | ||
|
|
7b80400a09 | ||
|
|
e9f04958af | ||
|
|
88dde0dd25 | ||
|
|
f04d9ba31f | ||
|
|
d1c7b48ce6 | ||
|
|
450e6dbe67 | ||
|
|
bfd12b9ca8 | ||
|
|
ea4dbbdedf | ||
|
|
2f550839cc | ||
|
|
cfaa8dfe54 | ||
|
|
21d1a0e3c1 | ||
|
|
9471332d49 | ||
|
|
c412b9c43d | ||
|
|
74de9abdac | ||
|
|
bc7c94ca0d | ||
|
|
b2e6e2567a | ||
|
|
8262637454 | ||
|
|
928a1a8370 | ||
|
|
8998de9179 | ||
|
|
7662b20598 | ||
|
|
cec0ed6394 | ||
|
|
46dbeb59fb | ||
|
|
c15f28eaa7 | ||
|
|
b40257f676 | ||
|
|
c705e01f4f | ||
|
|
0e2f2d8ed4 | ||
|
|
239ab7fafd | ||
|
|
320af5c0c6 | ||
|
|
ff6cb13308 | ||
|
|
2290622765 | ||
|
|
871e7b0b8b | ||
|
|
143050a5c6 | ||
|
|
313ab72056 | ||
|
|
1d07a90e51 | ||
|
|
0685c71141 | ||
|
|
12226dd7d0 | ||
|
|
cb9b8b072d | ||
|
|
c92ced4150 | ||
|
|
e96e9dcb93 | ||
|
|
0a1109ec34 | ||
|
|
24d7602f5a | ||
|
|
1401912059 | ||
|
|
9031f5fd4e | ||
|
|
66bb6006e6 | ||
|
|
0278438241 | ||
|
|
3db353a9f0 | ||
|
|
e004ecbc43 | ||
|
|
752d149940 | ||
|
|
dd641a2fc1 | ||
|
|
2f455cbd45 | ||
|
|
a16b0363a8 | ||
|
|
db2fd734ee | ||
|
|
a684628dad | ||
|
|
3d1ddd5b91 | ||
|
|
61b5e422ee | ||
|
|
11671f3a2b | ||
|
|
cc03572dd9 | ||
|
|
87c4c281a1 | ||
|
|
aedc0e3921 | ||
|
|
b87fae8eb1 | ||
|
|
256d996918 | ||
|
|
87d3369792 | ||
|
|
a9d44de98b | ||
|
|
9ff9abef89 | ||
|
|
2c8a2de81c | ||
|
|
f9d3217099 | ||
|
|
c482fb3c79 | ||
|
|
69e825aeb3 | ||
|
|
0e6f5b703c | ||
|
|
ce87f453a2 | ||
|
|
7d3e25d090 | ||
|
|
2fd6419129 | ||
|
|
426253b1c3 | ||
|
|
ad921011f0 | ||
|
|
029cdb1d26 | ||
|
|
a290d5b306 | ||
|
|
2cf33cfbb4 | ||
|
|
83f4178790 | ||
|
|
3bb5d8cfcc | ||
|
|
53ced5ca5b | ||
|
|
34f1e823f0 | ||
|
|
ee47f65523 | ||
|
|
ce09120483 | ||
|
|
350cc46824 | ||
|
|
0d8e81fb80 | ||
|
|
284d56a544 | ||
|
|
c774d46243 | ||
|
|
41b31ffb77 | ||
|
|
82d5350153 | ||
|
|
9648e7741f | ||
|
|
9c94552268 | ||
|
|
2e181886aa | ||
|
|
e1cac003f0 | ||
|
|
6070cdd7b8 | ||
|
|
6decba6cb6 | ||
|
|
1739dc6625 | ||
|
|
f06353d542 | ||
|
|
053c41d642 | ||
|
|
eb55a25ca8 | ||
|
|
e735e5a8c5 | ||
|
|
9c6f5923f3 | ||
|
|
bb955fa3de | ||
|
|
c52cf2900f | ||
|
|
577bf136de | ||
|
|
4bf53db3be | ||
|
|
09bbd61c89 | ||
|
|
938aabc6c1 | ||
|
|
86449ea36a | ||
|
|
fa6406b9ad | ||
|
|
d740fc5ab8 | ||
|
|
5f102fbd7a | ||
|
|
4348a88697 | ||
|
|
5e63370983 | ||
|
|
cab30b1683 | ||
|
|
41c75b16e8 | ||
|
|
ad80fa8121 | ||
|
|
4295257e5f | ||
|
|
fc93a0fb8c | ||
|
|
15b975efac | ||
|
|
81314c20b3 | ||
|
|
023f639fb4 | ||
|
|
e779880f9b | ||
|
|
8feca33f13 | ||
|
|
cb5f8f8336 | ||
|
|
820e1c87db | ||
|
|
84bf7de750 | ||
|
|
b42695c4b8 | ||
|
|
7356304d6f | ||
|
|
6b9b577978 | ||
|
|
78321ad693 | ||
|
|
0332eff9bc | ||
|
|
d00444840f | ||
|
|
9686f37fbd | ||
|
|
10bbfef2a4 | ||
|
|
c1fdf6bc9f | ||
|
|
772c7a62e8 | ||
|
|
9d7fdc9c7f | ||
|
|
de538a0162 | ||
|
|
8e854841d3 | ||
|
|
9017869d0f | ||
|
|
e9dd1a28ef | ||
|
|
aa656feecf | ||
|
|
2012c5a321 | ||
|
|
b0e523067f | ||
|
|
4bf4898e6a |
@ -72,8 +72,8 @@ trigger: always_on
|
||||
### 5.1 MQTT 通訊端點 (高頻與事件驅動)
|
||||
以下高頻或即時事件,未來將**全面改採 MQTT 協議**,透過 EMQX 與 Go Gateway 橋接:
|
||||
1. **心跳事件**:機台發布至 `machine/{serial_no}/heartbeat`。
|
||||
2. **B013 (錯誤與狀態)**:機台發布至 `machine/{serial_no}/error`。
|
||||
3. **B600 / B602 (交易紀錄)**:機台發布至 `machine/{serial_no}/transaction`。
|
||||
2. **異常與狀態 (B013)**:機台發布至 `machine/{serial_no}/error`。
|
||||
3. **交易紀錄 (B600/B602)**:機台發布至 `machine/{serial_no}/transaction`,推薦使用 `action: finalize` 整合上報。
|
||||
|
||||
處理管線:
|
||||
`機台 ➜ EMQX ➜ Go Gateway ➜ Redis List (mqtt_incoming_jobs) ➜ Laravel daemon (mqtt:listen) ➜ Job 異步寫入 DB`
|
||||
|
||||
@ -38,7 +38,7 @@ trigger: always_on
|
||||
* **樣式與組件**:全面使用 Tailwind CSS utility classes,遵循 Preline UI 的 HTML 結構進行開發。**避免撰寫自訂 CSS**。
|
||||
* **互動邏輯**:優先使用輕量級 **Alpine.js** (`x-data`, `x-show`, `@click`) 於 Blade 內完成,**禁用 React / Vue / Inertia.js**。
|
||||
* **【警告:Preline 冗餘】**:Preline 官方範例常有多餘控制項。必須確保介面佈局極簡,嚴格遵守「極簡奢華風 UI 實作規範 (SKILL.md)」。
|
||||
* **多語系 (I18n)**:所有 UI 可見文字必須使用 `@lang('key')` 或 `__('key')` 處理。Key 統一採用**英文原始詞彙**,並同步更新 `zh_TW.json`, `en.json`, `ja.json`。
|
||||
* **多語系 (I18n)**:所有 UI 可見文字必須使用 `@lang('key')` 或 `__('key')` 處理。Key 統一採用**英文原始詞彙**,並同步更新 `zh_TW.json`, `en.json`, `ja.json`。必須嚴格遵守「多語系管理與自動對齊規範 (SKILL.md)」,以 `zh_TW.json` 為標準對齊三語系,所有 JSON 檔案須按字母排序 (ksort),且斜線一律使用 `JSON_UNESCAPED_SLASHES` 還原為 `/`,嚴禁逸出為 `\/`。
|
||||
|
||||
## 5. 運行與測試機制
|
||||
* **啟動環境**:`./vendor/bin/sail up -d`
|
||||
|
||||
@ -19,6 +19,7 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
|
||||
| 介面, UI, 設計, 佈局, CSS, Tailwind, 奢華, 深色模式, Light Mode, Dark Mode, Blade, 樣式, 間距, 陰影, 動畫, 畫面, 頁面 | **極簡奢華風 UI 實作規範** | `.agents/skills/ui-minimal-luxury/SKILL.md` |
|
||||
| 查詢、撈資料、Query、Controller、下拉選單、Eloquent、N+1、`->get()`、select、交易、Transaction、Bulk、分頁、索引 | **資料庫與 ORM 最佳實踐規範** | `/home/mama/.gemini/antigravity/global_skills/database-best-practices/SKILL.md` |
|
||||
| RBAC, 權限, 角色, 租戶, Tenant, Company, Access Control, 多租戶, 權限控管 | **多租戶與權限架構實作規範** | `.agents/rules/rbac-rules.md` |
|
||||
| 多語系, 翻譯, 語系檔, lang, json, zh_TW, en, ja, I18n | **多語系管理與自動對齊規範** | `.agents/skills/multilingual-specs/SKILL.md` |
|
||||
|
||||
---
|
||||
|
||||
@ -42,4 +43,8 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
|
||||
### 🔴 新增或修改 API 與 Controller 撈取資料庫邏輯時
|
||||
必須讀取:
|
||||
1. **database-best-practices** — 確認查詢優化、交易安全、批量寫入與索引規範
|
||||
2. **rbac-rules** — 確保 `company_id` 隔離邏輯正確套用
|
||||
2. **rbac-rules** — 確保 `company_id` 隔離邏輯正確套用
|
||||
|
||||
### 🔴 新增或修改語系 JSON 檔案(lang/*.json)或執行多語系任務時
|
||||
必須讀取:
|
||||
1. **multilingual-specs** — 確保遵守三語系對齊、字母排序、斜線不逸出規範
|
||||
@ -16,7 +16,6 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
|
||||
### 2.1 維運人員認證 (User Authentication)
|
||||
- **核發端點**:B000 (登入)。
|
||||
- **使用端點**:B014 (參數下載)。
|
||||
- **方式**:使用 Laravel Sanctum 核發之 **User Token**。
|
||||
- **Header**:`Authorization: Bearer <user_token>`。
|
||||
|
||||
@ -30,27 +29,30 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
## 3. 機台核心 API (IoT Endpoints)
|
||||
|
||||
### 3.1 B000: 機台本地管理員同步登入
|
||||
用於機台 Android 端維護人員登入與進入設定頁。此 API 無狀態,且為例外不強制檢查 Bearer Token 的端點。
|
||||
用於機台 Android 端維護人員登入與進入設定頁。此 API 需帶入機台 API Token 進行認證。
|
||||
|
||||
- **URL**: POST /api/v1/app/admin/login/B000
|
||||
- **Authentication**: Bearer Token (Header)
|
||||
- **Headers**:
|
||||
- `Content-Type: application/json`
|
||||
- `Authorization: Bearer <api_token>`
|
||||
- **Request Body:**
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| machine | String | 是 | 機台編號 (serial_no) | M-001 |
|
||||
| Su_Account | String | 是 | 系統管理員或公司管理員帳號 | admin |
|
||||
| Su_Password | String | 是 | 密碼 | password123 |
|
||||
| ip | String | 否 | 用戶端 IP (相容舊版) | 192.168.1.100 |
|
||||
| type | String | 否 | 裝置類型代碼 (相容舊版) | 2 |
|
||||
| ip | String | 否 | 機台本地 IP | 192.168.1.100 |
|
||||
|
||||
- **Response Body:**
|
||||
> [!IMPORTANT]
|
||||
> 為了相容 Java APP 現有邏輯,這裡嚴格規定成功必須回傳字串 Success。
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| success | Boolean | 請求是否成功 | true |
|
||||
| code | Integer | 業務狀態碼 | 200 |
|
||||
| message | String | 驗證結果 (Success 或 Failed) | Success |
|
||||
| token | String | **臨時身份認證 Token** (用於 B014) | 1|abcdefg... |
|
||||
| identity | String | **登入者身分**:`system` 系統登入者 / `company` 公司帳號 / `staff` 一般人員。僅驗證成功時回傳 | system |
|
||||
| token | String | **臨時身份認證 Token** (可選,供未來擴充使用) | 1\|abcdefg... |
|
||||
|
||||
---
|
||||
|
||||
@ -80,21 +82,20 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
### 3.3 B009: 貨道庫存即時回報 (Supplementary Report)
|
||||
當維修或補貨人員在機台端完成操作後,將目前的貨道實體狀態同步回雲端。
|
||||
|
||||
|
||||
|
||||
- **URL**: PUT /api/v1/app/products/supplementary/B009
|
||||
- **Authentication**: Bearer Token (Header)
|
||||
- **Request Body:** 無 (由 Token 自動識別機台)
|
||||
- **Request Body:**
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| data | Array | 是 | 貨道數據陣列 | [{"tid":"1", "t060v00":"1", "num":"10"}] |
|
||||
| account | String | 是 | 執行補貨的人員帳號 (Username 或 Email),用於權限驗證 | admin |
|
||||
| data | Array/Object | 是 | 貨道數據陣列 (若傳單一物件會自動相容為陣列) | [{"tid": "1", "t060v00": "1", "num": 10, "type": 1}] |
|
||||
|
||||
- **data 陣列內部欄位:**
|
||||
|
||||
| 欄位 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| tid | Integer | 貨道編號 (Slot No) | 1 |
|
||||
| tid | String | 貨道編號 (Slot No) | "1" |
|
||||
| t060v00 | String | 商品資料庫 ID (或是 Barcode) | "1" |
|
||||
| num | Integer | 實體剩餘庫存數量 | 10 |
|
||||
| type | Integer | 貨道物理類型 (1: 履帶, 2: 彈簧)。若未提供,預設為 1。 | 1 |
|
||||
@ -110,7 +111,14 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
| success | Boolean | 同步是否成功 | true |
|
||||
| code | Integer | 內部業務狀態碼 | 200 |
|
||||
| message | String | 回應訊息 | Slot report synchronized success |
|
||||
| status | String | 固定回傳 49 代表同步完成 | "49" |
|
||||
|
||||
- **Response Body (Failed 403 - 權限不足):**
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| success | Boolean | 請求是否成功 | false |
|
||||
| code | Integer | HTTP 狀態碼 | 403 |
|
||||
| message | String | 失敗原因 | Unauthorized: Account not found |
|
||||
|
||||
---
|
||||
|
||||
@ -129,10 +137,12 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
| 欄位 | 型別 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| t060v00 | String | 商品資料庫 ID | "1" |
|
||||
| t060v01 | String | 商品名稱 | 可口可樂 330ml |
|
||||
| t060v01_en | String | 英文名稱 | Coca Cola |
|
||||
| t060v01_jp | String | 日文名稱 | コカコーラ |
|
||||
| t060v03 | String | 商品規格/簡述 | Cold Drink |
|
||||
| t060v01 | String | 商品名稱(預設語系 zh_TW) | 可口可樂 330ml |
|
||||
| t060v01_en | String | 英文名稱(**既有欄位,保留相容**) | Coca Cola |
|
||||
| t060v01_jp | String | 日文名稱(**既有欄位,保留相容**) | コカコーラ |
|
||||
| t060v01_i18n | Object | **【新增】名稱多語系 map**(locale → 名稱),key 集合 = 公司機台語系聯集;缺翻譯回退 zh_TW | {"zh_TW":"可口可樂","en":"Coca Cola","ja":"コカコーラ"} |
|
||||
| t060v03 | String | 商品規格/簡述(預設語系 zh_TW) | Cold Drink |
|
||||
| t060v03_i18n | Object | **【新增】規格多語系 map**(locale → 規格),同 `t060v01_i18n` 邏輯 | {"zh_TW":"330ml","en":"330ml","ja":"330ml"} |
|
||||
| t060v06 | String | 圖片 URL | https://.../coke.png |
|
||||
| t060v09 | Float | 標準零售價 | 25.0 |
|
||||
| t060v11 | Integer | **貨道庫存上限** (預設履帶) | 10 |
|
||||
@ -143,6 +153,12 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
| spring_limit | Integer | **彈簧貨道上限** (建議使用此欄位) | 10 |
|
||||
| track_limit | Integer | **履帶貨道上限** (建議使用此欄位) | 15 |
|
||||
|
||||
> **多語系 (i18n) 說明**:
|
||||
> - **向下相容鐵則**:`t060v01` / `t060v01_en` / `t060v01_jp` / `t060v03` 等既有欄位一律**保留**,本次僅**新增** `t060v01_i18n`、`t060v03_i18n` 兩個 map。線上舊 App 忽略新欄位、照舊運作。
|
||||
> - **`*_i18n` 的 key 集合 = 公司所有機台已開語系的聯集**(後台於機台系統設定勾選,最多 5 種/台;可選池子見 `config/locales.php`)。缺翻譯時回退 zh_TW,不下發空字串。
|
||||
> - **App 改版方向**:新版 App 改讀 `t060v01_i18n`/`t060v03_i18n` 以支援多語切換,並依 B014 的 `LangSet` 決定要顯示哪些語系。改版前現行 App 僅顯示 zh_TW/en/ja。
|
||||
> - 完整設計見 [商品多語系與機台顯示語系技術規範](file:///home/mama/projects/star-cloud/docs/API/product_multilingual_spec.md)。
|
||||
|
||||
---
|
||||
|
||||
### 3.6 B013: [DEPRECATED / REMOVED] 機台故障與異常狀態上報
|
||||
@ -150,118 +166,226 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
> **此 REST API 已廢棄並移除**。機台異常上報已全面遷移至 **MQTT** 即時通訊架構。
|
||||
> 詳細錯誤代碼請參閱:[MQTT 通訊規範 - B013 硬體代碼對照表](file:///home/mama/projects/star-cloud/.agents/skills/mqtt-communication-specs/SKILL.md#32-異常與錯誤上報-b013)
|
||||
|
||||
### 3.12 B010: [DEPRECATED / REMOVED] 心跳上報與狀態同步
|
||||
> [!WARNING]
|
||||
> **此 REST API 已廢棄並移除**。機台心跳上報與指令下發已全面遷移至 **MQTT** 架構。
|
||||
> (註:溫度數據已標準化為 **Integer**,且雲端採「變動即記錄」規則)。
|
||||
> 詳細格式請參閱:[MQTT 通訊規範 - B010 心跳上報](file:///home/mama/projects/star-cloud/.agents/skills/mqtt-communication-specs/SKILL.md#31-心跳上報-b010)
|
||||
|
||||
---
|
||||
|
||||
### 3.7 B014: 機台參數與金鑰下載 (Config Download)
|
||||
用於機台引導階段 (Provisioning),向雲端請求支付金鑰、發票設定及機台正式 API Token。
|
||||
|
||||
- **URL**: GET /api/v1/app/machine/setting/B014
|
||||
- **Authentication**: **User Token** (Sanctum Header)
|
||||
- **Request Body:** 無 (由 Query String 帶入 `machine` 參數)
|
||||
- **Authentication**: None (由 Query String 帶入 `machine` 參數識別)
|
||||
- **Request Body:** 無
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| machine | String | 是 | 機台編號 (serial_no) | M-001 |
|
||||
|
||||
- **Response Body (Success 200):**
|
||||
- **資料來源**: 所有金流密鑰與電子發票設定,均取自機台關聯的「金流配置 (`payment_configs.settings`)」巢狀結構;機台須先在後台關聯一組金流配置 (`machines.payment_config_id`),未設值欄位回傳空字串 `""`。
|
||||
- **Response Body (Success 200 - Object):**
|
||||
|
||||
| 欄位 (Key) | 說明 | 備註 |
|
||||
| 欄位 (Key) | 說明 | 來源 (payment_configs.settings) |
|
||||
| :--- | :--- | :--- |
|
||||
| **t050v01** | 機台序號 | 即 machine_id |
|
||||
| **api_token** | **機台正式 Token** | 初始化後應存於本地,後續 API 認證用 |
|
||||
| **t050v41** | 玉山特店編號 | ESUN Merchant ID |
|
||||
| **t050v42** | 玉山終端編號 | ESUN Terminal ID |
|
||||
| **t050v43** | 玉山 Hash Key | ESUN Hash |
|
||||
| **t050v34** | 發票特店 ID | Invoice Merchant ID |
|
||||
| **t050v35** | 發票 Hash Key | Invoice Key |
|
||||
| **t050v36** | 發票 Hash IV | Invoice IV |
|
||||
| **TP_APP_ID** | 趨勢支付 AppID | TrendPay ID |
|
||||
| **TP_APP_KEY** | 趨勢支付 Key | TrendPay Key |
|
||||
| **t050v01** | 機台序號 | machines.serial_no |
|
||||
| **api_token** | **機台正式 Token** | machines.api_token(初始化後存本地,後續認證用)|
|
||||
| **t050v41** | 玉山特店編號 | `esun_scan.store_id` |
|
||||
| **t050v42** | 玉山終端編號 | `esun_scan.term_id` |
|
||||
| **t050v43** | 玉山 Hash Key | `esun_scan.key` |
|
||||
| **t050v34** | 發票特店 ID | `ecpay_invoice.store_id` |
|
||||
| **t050v35** | 發票 Hash Key | `ecpay_invoice.hash_key` |
|
||||
| **t050v36** | 發票 Hash IV | `ecpay_invoice.hash_iv` |
|
||||
| **t050v38** | 發票通知 Email | `ecpay_invoice.email` |
|
||||
| **TP_APP_ID** | 趨勢/TapPay AppID | `tappay.app_id` |
|
||||
| **TP_APP_KEY** | 趨勢/TapPay Key | `tappay.app_key` |
|
||||
| **TP_PARTNER_KEY** | TapPay Partner Key | `tappay.partner_key` |
|
||||
| **TP_LINE_MERCHANT_ID** | LINE Pay 特店 ID | `tappay.line_merchant_id` |
|
||||
| **TP_JKO_MERCHANT_ID** | 街口支付 特店 ID | `tappay.jko_merchant_id` |
|
||||
| **TP_PI_MERCHANT_ID** | Pi 拍錢包 特店 ID | `tappay.pi_merchant_id` |
|
||||
| **TP_PS_MERCHANT_ID** | 全盈+Pay 特店 ID | `tappay.ps_merchant_id` |
|
||||
| **TP_EASY_MERCHANT_ID** | 悠遊付 特店 ID | `tappay.easy_merchant_id` |
|
||||
| **DevSet** | 支付旗標物件(命名沿用機台 `DevSetStructure`)| 機台既有:`ShoppingCar`(shopping_cart_enabled)/`Invoice`(tax_invoice_enabled)/`DevNFCPay`(card_terminal_enabled)/`DevEsunPay`(scan_pay_esun_enabled)/`DevTapPay`(scan_pay_tappay_enabled)/`DevCash`(cash_module_enabled)/`DevLinePay`(scan_pay_linepay_enabled LINE Pay 官方直連)/`TapPay30`(tappay_linepay TapPay 底下 LINE Pay)/`TapPay31`(tappay_jkopay 街口)/`TapPay32`(tappay_easywallet 悠遊付)/`TapPay33`(tappay_pipay)/`TapPay34`(tappay_pluspay 全盈+)。**新定義**(機台待新增欄位):`DevCreditCard`(credit_card_enabled)/`DevMobilePay`(mobile_pay_enabled)/`DevCardPay`(card_pay_enabled)/`DevScanPay`(scan_pay_enabled)。`VMC`/`Electic` 雲端不下發。註:LINE Pay 有兩條路線 —— `DevLinePay`=官方直連、`TapPay30`=經 TapPay,兩者來源不同|
|
||||
| **CashSet** | 現金面額旗標物件(對齊 `CashSetStructure`)| machines.settings → `BillF1000`/`BillE500`/`BillD100`/`CoinF50`/`CoinE10`/`CoinD5`/`CoinC1` |
|
||||
| **FunctionSet** | 非支付功能模組旗標物件(**新定義**,機台待實作)| machines.settings → `PickupModule`(pickup_module_enabled)/`PickupCode`(pickup_code_enabled)/`PassCode`(pass_code_enabled)/`WelcomeGift`(welcome_gift_enabled)/`MemberSystem`(member_system_enabled)/`AmbientTemp`(ambient_temp_monitoring_enabled) |
|
||||
| **ShoppingMode** | 購物方式(字串,**新定義**)| machines.settings.shopping_mode:`basic` / `employee_card` / `pickup_sheet` |
|
||||
| **LangSet** | 機台顯示語系(**新定義**,最多 5 種)| machines.settings.languages → `Languages`(有序語系陣列,如 `["zh_TW","en","ja"]`,順序即切換順序)/`Default`(預設語系=清單第一個)。未設定時退化為 `[fallback]`。機台據此渲染語系切換 UI。可選語系池子見 `config/locales.php` |
|
||||
| **OperationSet** | 運作參數(**新定義**,來源 machines 實體欄位)| `CardReaderSeconds`(card_reader_seconds 整數)/`PaymentBufferSeconds`(payment_buffer_seconds 整數)/`CheckoutTime1`(card_reader_checkout_time_1 時間字串)/`CheckoutTime2`(card_reader_checkout_time_2)/`HeatingStartTime`(heating_start_time)/`HeatingEndTime`(heating_end_time) |
|
||||
| **HardwareSet** | 硬體與貨道(**新定義**,來源 machines 實體欄位)| `CardReaderNo`(card_reader_no 字串)/`SpringSlot1_10`~`SpringSlot51_60`(is_spring_slot_* 布林,true=彈簧 / false=履帶) |
|
||||
|
||||
> [!CAUTION]
|
||||
> **安全性規範**:B014 會回傳敏感金鑰與正式 Token,背景必須強制進行 RBAC 校驗。只有當前登入的人員具備該機台管理權限時,後端才允許發放資料。
|
||||
> 註:
|
||||
> - 金流密鑰/發票來源已由「公司設定 (companies.settings)」改為「金流配置 (payment_configs.settings)」,並新增 `t050v38` 發票通知 Email。
|
||||
> - 機台系統設定全部以機台端 `*Set` + PascalCase 風格下發(`DevSet`/`CashSet`/`FunctionSet`/`ShoppingMode`/`OperationSet`/`HardwareSet`),不再有原始 key 雜燴物件。`DevSet`/`CashSet` 對齊機台既有結構;其餘(含 `DevCreditCard/DevMobilePay/DevCardPay/DevScanPay`、`FunctionSet`、`ShoppingMode`、`OperationSet`、`HardwareSet`)為新定義,機台 App 端需另案新增對應結構與消費邏輯。旗標/設定來源為 `machines.settings`,運作參數與硬體貨道來源為 machines 實體欄位。
|
||||
|
||||
---
|
||||
|
||||
### 3.8 B017: 貨道庫存同步 (Slot Synchronization)
|
||||
用於機台端**主動**向遠端後台拉取目前所有貨道的最新庫存、效期與配置狀態。通常發生於機台開機初始化、補貨/維護模式結束,或是機台需要確保本地端庫存資料與雲端一致時主動請求。
|
||||
### 3.8 B016: 機台系統設定回寫 (Settings Write-back)
|
||||
機台主控台由**系統方**設定硬體貨道類型後,將貨道開關狀態回寫雲端。為 B014 下載的反向操作。
|
||||
|
||||
- **URL**: GET /api/v1/app/machine/reload_msg/B017
|
||||
- **URL**: PATCH /api/v1/app/machine/setting/B016
|
||||
- **Authentication**: **User Token (Sanctum)** — 帶入 B000 核發之 `Authorization: Bearer <user_token>`,且**僅系統管理員 (`identity=system`)** 可操作;非系統方一律 403。
|
||||
- **Headers**:
|
||||
- `Content-Type: application/json`
|
||||
- `Authorization: Bearer <user_token>`
|
||||
- **Request Body:**
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| machine | String | 是 | 機台序號 (serial_no) | SN202604130001 |
|
||||
| settings | Object | 是 | 貨道類型鍵值物件,鍵名採**後端原始 snake_case**。只覆寫有送來的鍵,其餘保留 | {"is_spring_slot_1_10": true} |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **僅硬體貨道**:機台端 B016 現在**只回寫硬體貨道類型** (`is_spring_slot_*`)。支付旗標、現金面額、功能模組等系統設定純由**後台單向決定**,B016 **不再接收**,未列入下列白名單者一律忽略。
|
||||
|
||||
- **可回寫鍵(settings 之下,皆為布林):**
|
||||
- 貨道類型(machines 實體欄位):`is_spring_slot_1_10`、`is_spring_slot_11_20`、`is_spring_slot_21_30`、`is_spring_slot_31_40`、`is_spring_slot_41_50`、`is_spring_slot_51_60`(true=彈簧 / false=履帶)
|
||||
|
||||
- **Response Body (Success 200):**
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| success | Boolean | 是否成功 | true |
|
||||
| code | Integer | 業務狀態碼 | 200 |
|
||||
| message | String | 回應訊息 | Settings updated successfully. |
|
||||
|
||||
- **錯誤回應:**
|
||||
|
||||
| HTTP | 情境 | message |
|
||||
| :--- | :--- | :--- |
|
||||
| 403 | 非系統管理員 / 未登入 | Forbidden |
|
||||
| 404 | 查無該序號機台 | Machine not found |
|
||||
| 422 | `settings` 非物件/陣列,或未含任何有效貨道鍵 | Invalid settings payload |
|
||||
|
||||
> 註:寫回時更新 `is_spring_slot_*` 實體欄位並記錄 `updater_id`。為 system-admin 專用,套用 `withoutGlobalScopes` 跨租戶定位機台。
|
||||
|
||||
---
|
||||
|
||||
### 3.9 B660: 取貨碼驗證 (Pickup Code)
|
||||
處理機台端的 8 位數代碼取貨流程。
|
||||
|
||||
- **URL**: POST /api/v1/app/machine/pickup/verify/B660
|
||||
- **Authentication**: Bearer Token (Header)
|
||||
- **Request Body:** 無 (由 Token 自動識別機台)
|
||||
- **運作模式**:
|
||||
- **POST (驗證)**: 提交 `code` 驗證。成功後回傳 `pickup_code_id` 與建議 `slot_no`。
|
||||
- **消耗機制**: 出貨完成後,由機台發送 MQTT `finalize` 訊息,並在 `payment_type` 帶入 `6` (取貨碼) 完成扣減。
|
||||
|
||||
- **Response Body:**
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| code | String | 8 位數取貨碼 | "12345678" |
|
||||
|
||||
---
|
||||
|
||||
### 3.10 B670: 通行碼驗證 (Pass Code)
|
||||
專供維修或工程人員現場測試使用的通行碼。僅做身分確認,不涉及庫存消耗。
|
||||
|
||||
- **URL**: POST /api/v1/app/machine/passcode/verify/B670
|
||||
- **Authentication**: Bearer Token (Header)
|
||||
- **Request Body:**
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| code | String | 是 | 8 位數通行碼 | "88888888" |
|
||||
|
||||
**Response (Success 200):**
|
||||
- `name`: 持有者姓名 (如:維護部 A 員)。
|
||||
- `status`: "active"。
|
||||
|
||||
---
|
||||
|
||||
### 3.11 B680: 員工卡身分驗證 (Staff Card Verification)
|
||||
機台端感應員工卡 (RFID/NFC) 後驗證卡片 UID。
|
||||
|
||||
- **URL**: POST /api/v1/app/machine/staff/verify/B680
|
||||
- **Authentication**: Bearer Token (Header)
|
||||
- **Request Body:**
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| code | String | 是 | 卡片 UID 碼 | "A1B2C3D4" |
|
||||
|
||||
**Response (Success 200):**
|
||||
- `name`: 員工姓名。
|
||||
- `code_id`: 員工卡 ID (用於 MQTT 交易關聯)。
|
||||
- `status`: "active"。
|
||||
|
||||
> [!WARNING]
|
||||
> **鎖定機制**:B660, B670, B680 與 B690 若連續錯誤 5 次,該機台將被鎖定驗證功能 1 分鐘。
|
||||
|
||||
---
|
||||
|
||||
### 3.12 B690: 來店禮驗證 (Welcome Gift Verify)
|
||||
處理機台端的 8 位數來店禮代碼驗證。成功後回傳折扣資訊供機台端套用折扣。
|
||||
|
||||
- **URL**: POST /api/v1/app/machine/welcome-gift/verify/B690
|
||||
- **Authentication**: Bearer Token (Header)
|
||||
- **Request Body:**
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| code | String | 是 | 8 位數來店禮代碼 | "12345678" |
|
||||
|
||||
**Response (Success 200):**
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| success | Boolean | 請求是否成功 | true |
|
||||
| code | Integer | 200 | 200 |
|
||||
| data | Array | 貨道數據陣列 (依 slot_no 排序) | 見下表 |
|
||||
| code | Integer | 業務狀態碼 | 200 |
|
||||
| data.name | String | 來店禮名稱 | 新客八五折 |
|
||||
| data.code_id | Integer | 來店禮 ID (用於 MQTT 交易關聯) | 12 |
|
||||
| data.discount_type | String | 折扣類型 (percentage / amount) | percentage |
|
||||
| data.discount_value | Integer | 折扣趴數 (如 15 代表 15% off) 或折扣金額 | 15 |
|
||||
| data.discount_label | String | 台灣中文折數標籤 (如「八五折」、「折 50 元」) | 八五折 |
|
||||
| data.usage_type | String | 使用類型 (once / unlimited) | once |
|
||||
| data.status | String | 狀態 | active |
|
||||
|
||||
**data 陣列內部欄位:**
|
||||
|
||||
| 欄位 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| tid | String | 貨道編號 | "1" |
|
||||
| num | Integer | 當前庫存數量 | 10 |
|
||||
| expiry_date | String | 商品效期 | "2026-12-31" |
|
||||
| batch_no | String | 批號 | "B202604" |
|
||||
| product_id | Integer | 商品 ID | 1 |
|
||||
| capacity | Integer | 貨道容量上限 | 15 |
|
||||
| status | String | 貨道狀態 ("1": 啟用, "0": 停用) | "1" |
|
||||
|
||||
---
|
||||
|
||||
### 3.9 B024: 取貨碼/通行碼驗證與消耗回報 (Access Code Verify & Report)
|
||||
用於處理機台端的代碼取貨流程。包含驗證代碼有效性(驗證階段)與確認出貨完成後的狀態消耗(回報階段)。
|
||||
|
||||
- **URL**: POST|PUT /api/v1/app/sell/access-code/B024
|
||||
- **Authentication**: Bearer Token (Header)
|
||||
- **Request Body (POST - 驗證階段):**
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| passCode | String | 是 | 使用者輸入的取貨碼/通行碼 | "12345678" |
|
||||
|
||||
- **Response Body (POST - 驗證成功 200):**
|
||||
雲端將回傳該碼對應的權限或待出貨商品資訊。
|
||||
**Response (Failed 404):**
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| res1 | String | 該代碼在雲端的關聯 ID | "99" |
|
||||
| res2 | String | 操作模式 (1: 出貨, 2: 僅驗證) | "1" |
|
||||
| res3 | String | 預計出貨商品 ID | "5" |
|
||||
| res4 | String | 折扣金額或活動標籤 | "0.0" |
|
||||
| success | Boolean | 請求是否成功 | false |
|
||||
| message | String | 失敗原因 | Invalid or expired code |
|
||||
| code | Integer | 業務狀態碼 | 404 |
|
||||
| remaining_attempts | Integer | 剩餘嘗試次數 | 3 |
|
||||
|
||||
- **Request Body (PUT - 回報階段):**
|
||||
當機台端確認實體出貨成功後,必須發送此請求以耗刷該代碼。
|
||||
**Response (Failed 400 — 格式錯誤):**
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| success | Boolean | 請求是否成功 | false |
|
||||
| message | String | 失敗原因 (code 非 8 位數) | Invalid format |
|
||||
| code | Integer | 業務狀態碼 | 400 |
|
||||
|
||||
**Response (Failed 429 — 已鎖定):**
|
||||
|
||||
連續錯誤達 5 次後,該機台於鎖定期間 (60 秒) 內的所有驗證請求皆回傳此回應。
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| success | Boolean | 請求是否成功 | false |
|
||||
| message | String | 失敗原因 | Too many attempts. Locked. |
|
||||
| code | Integer | 業務狀態碼 | 429 |
|
||||
|
||||
- **消耗機制**: 出貨完成後,由機台發送 MQTT `finalize` 訊息,並在 `payment_type` 帶入 `7` (來店禮) 完成扣減。
|
||||
|
||||
---
|
||||
|
||||
### 3.14 B027: 贈品碼/優惠券驗證 (Free Gift Verify)
|
||||
用於處理行銷贈品券、0 元購活動或特定的優惠券核銷流程。
|
||||
|
||||
- **URL**: POST /api/v1/app/sell/free-gift/B027
|
||||
- **Authentication**: Bearer Token (Header)
|
||||
- **運作模式**:
|
||||
- **POST**: 提交 `passCode` 驗證。成功後雲端會回傳對應活動 ID 與商品配置。
|
||||
- **消耗機制**: 出貨完成後,由機台發送 MQTT `finalize` 訊息進行消耗。
|
||||
|
||||
---
|
||||
|
||||
### 3.15 B650: 會員身分驗證 (Member Verification)
|
||||
機台端掃碼後驗證會員身分。
|
||||
|
||||
- **URL**: POST /api/v1/app/machine/member/verify/B650
|
||||
- **Authentication**: Bearer Token (Header)
|
||||
- **Request Body:**
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| accessCodeId | String | 是 | 驗證階段取得的 res1 (ID) | "99" |
|
||||
| status | String | 是 | 出貨結果 (1: 成功, 0: 失敗) | "1" |
|
||||
| code | String | 是 | 會員代碼 | "MEMBER12345" |
|
||||
|
||||
---
|
||||
|
||||
### 3.10 B027: 贈品碼/優惠券驗證與消耗回報 (Free Gift Verify & Report)
|
||||
用於處理行銷贈品券、0 元購活動或特定的優惠券核銷流程。
|
||||
|
||||
- **URL**: POST|PUT /api/v1/app/sell/free-gift/B027
|
||||
- **Authentication**: Bearer Token (Header)
|
||||
- **運作模式**:
|
||||
- **POST**: 提交 `passCode` 驗證該贈品券是否有效。成功後雲端會回傳對應活動 ID 與商品配置。
|
||||
- **PUT**: 當該贈品(0 元商品)出貨完成後,向雲端回報消耗,確保優惠券不會重複核銷。
|
||||
|
||||
> [!NOTE]
|
||||
> B027 與 B024 的邏輯具備高度對稱性,區別在於 B027 通常綁定的是特定的行銷活動 (Campaign) 與 0 元出貨邏輯。
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 4. 已遷移至 MQTT 的端點索引
|
||||
@ -270,9 +394,47 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
|
||||
| 原 API 代碼 | 名稱 | MQTT Topic | 備註 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| B010 | 心跳上報 | `machine/{serial_no}/heartbeat` | [已廢棄](#312-b010-deprecated--removed-心跳上報與狀態同步) |
|
||||
| B013 | 異常上報 | `machine/{serial_no}/error` | [已廢棄](#36-b013-deprecated--removed-機台故障與異常狀態上報) |
|
||||
| B010 | 心跳上報 | `machine/{serial_no}/heartbeat` | [已遷移](#312-b010-deprecated--removed-心跳上報與狀態同步) |
|
||||
| B013 | 異常上報 | `machine/{serial_no}/error` | [已遷移](#36-b013-deprecated--removed-機台故障與異常狀態上報) |
|
||||
| B600 | 交易建立 | `machine/{serial_no}/transaction` | `action: create` |
|
||||
| B601 | 發票紀錄 | `machine/{serial_no}/transaction` | `action: invoice` |
|
||||
| B602 | 出貨結果 | `machine/{serial_no}/transaction` | `action: dispense` |
|
||||
| B600+ | 交易完成(統整) | `machine/{serial_no}/transaction` | `action: finalize` |
|
||||
| [New] | 商品同步指令 | `machine/{serial_no}/command` | `command: update_products` |
|
||||
| B055 (PUT) | 遠端出貨回報 | `machine/{serial_no}/command/ack` | GET 查詢路由保留 |
|
||||
|
||||
---
|
||||
|
||||
## 附錄 (Appendix)
|
||||
|
||||
### Appendix A: B600 支付類型對照表 (`payment_type`)
|
||||
|
||||
此對照表依據機台 B600 規格定義,用於解析 `order.payment_type`:
|
||||
|
||||
| 代碼 | 支付方式 | 備註 / 說明 |
|
||||
| :--- | :--- | :--- |
|
||||
| `1` | 信用卡 | 一般實體信用卡刷卡 |
|
||||
| `2` | 電子票證 | 包含悠遊卡、一卡通、iCash 等(與信用卡、手機支付共用同一台實體刷卡機)|
|
||||
| `3` | 掃碼支付 | 一般掃碼支付 |
|
||||
| `4` | 投幣機支付 | 實體紙鈔機 |
|
||||
| `5` | 通行碼 | 現場維護人員測試使用 |
|
||||
| `6` | 取貨碼 | 包含 8 位數取貨碼與 0 元贈品碼 |
|
||||
| `7` | 來店禮 | |
|
||||
| `8` | 問卷 | |
|
||||
| `9` | 零錢 | 硬幣投幣機 |
|
||||
| `10` | 手機支付 | NFC 手機感應(行動支付,走信用卡軌道,依使用者按鈕分類) |
|
||||
| `21` ~ `25` | 線下付款 + X | `21`=線下+1, `22`=線下+2... `25`=線下+9 |
|
||||
| `30` | TapPay - LINE Pay | TapPay 底下的 LINE Pay |
|
||||
| `31` | TapPay - 街口支付 | |
|
||||
| `32` | TapPay - 悠遊付 | |
|
||||
| `33` | TapPay - Pi 拍錢包 | |
|
||||
| `34` | TapPay - 全盈+PAY | |
|
||||
| `40` | 會員驗證取貨商品 | |
|
||||
| `41` | 員工卡 | RFID / NFC 刷卡 |
|
||||
| `42` | 取物單 | |
|
||||
| `50` ~ `54` | 線下付款 + TapPay | `50`=線下+30, `51`=線下+31... `54`=線下+34 |
|
||||
| `70` | LINE Pay(官方直連) | LINE Pay 官方直連,非 TapPay 底下的 `30` |
|
||||
| `60` | 點數 / 優惠卷全額折抵 | 點數全額折抵出貨 或 優惠卷全額折抵出貨 |
|
||||
| `61` ~ `69` | 會員 + X | `61`=會員+1, `62`=會員+2... `69`=會員+9 |
|
||||
| `90` ~ `94` | 會員 + TapPay | `90`=會員+30, `91`=會員+31... `94`=會員+34 |
|
||||
| `100` | 遠端出貨 | 後台遠端觸發出貨 |
|
||||
|
||||
@ -19,7 +19,7 @@ description: 規範智能販賣機與 Cloud 平台間的高頻通訊處理流程
|
||||
4. **Model (儲存層)**:執行資料存取。
|
||||
|
||||
### 1.2 MQTT 管線 (高頻/即時操作)
|
||||
適用於 B010 (心跳), B013 (異常), B600/B601/B602 (交易生命週期) 等高頻或即時性端點:
|
||||
適用於 `heartbeat` (心跳), `error` (異常), `transaction` (交易生命週期: `create`, `finalize` 等) 等高頻或即時性端點:
|
||||
1. **Go Gateway (接收層)**:訂閱 EMQX Topic,提取 `serial_no`,包裝成標準 JSON。
|
||||
2. **Redis List (橋接層)**:Go 執行 `RPUSH starcloud_database_mqtt_incoming_jobs {json}`。
|
||||
3. **Laravel `mqtt:listen` (消費層)**:常駐指令 `BLPOP` 取出 JSON,根據 `type` 分派至對應 Job。
|
||||
@ -90,9 +90,10 @@ public function handle(MachineService $service): void
|
||||
|
||||
### 常見端點處理模式
|
||||
1. **$SYS Events**:由 Broker 自動觸發,走 **MQTT 管線**。用於毫秒級更新機台的 `online/offline` 狀態與 `last_heartbeat_at`。
|
||||
2. **B010 (心跳)**:高頻點,走 **MQTT 管線** (`machine/+/heartbeat`)。更新 `last_heartbeat_at` 與感測器快照。
|
||||
3. **B013 (異常)**:事件驅動點,走 **MQTT 管線** (`machine/+/error`)。寫入 `machine_logs` 並觸發告警。
|
||||
4. **B600/B601/B602 (交易生命週期)**:高價值點,走 **MQTT 管線** (`machine/+/transaction`)。透過 `action` 欄位區分 `create` (交易建立) / `invoice` (發票) / `dispense` (出貨結果)。
|
||||
2. **heartbeat (心跳)**:原 B010。高頻點,走 **MQTT 管線** (`machine/+/heartbeat`)。更新 `last_heartbeat_at` 與感測器快照。
|
||||
3. **error (異常)**:原 B013。事件驅動點,走 **MQTT 管線** (`machine/+/error`)。寫入 `machine_logs` 並觸發告警。
|
||||
4. **transaction (交易生命週期)**:原 B600/B601/B602。高價值點,走 **MQTT 管線** (`machine/+/transaction`)。透過 `action` 欄位區分 `create` (建立) / `finalize` (完成統整) / `dispense` (出貨結果)。
|
||||
- **員工卡/通行碼**:應透過 `action: finalize` 且 `amount: 0` 並帶入對應 `payment_type` (如 41: 員工卡) 進行交易錄入。
|
||||
5. **B012 (商品同步)**:大資料量,走 **HTTP 管線**。應確保 Service 層具備緩存 (Cache) 機制。
|
||||
6. **B055 (遠端出貨)**:雲端下發指令,走 **MQTT 下行管線** (`machine/{id}/command`)。
|
||||
|
||||
|
||||
@ -26,9 +26,7 @@ description: 定義 Star Cloud 與終端機台 (IoT) 之間的 MQTT 全域通訊
|
||||
機台連線時必須提供以下憑據:
|
||||
- **Username**:機台編號 (`serial_no`),例如 `SN00001`。
|
||||
- **Password**:機台 `api_token` 的 SHA256 雜湊值,由 B014 API 取得。
|
||||
- **Client ID**:格式建議為 **`SC_{serial_no}_{random_suffix}`**。
|
||||
- **解析邏輯**:系統接收連線事件時,會自動移除 `SC_` 前綴,並移除第一個底線後的所有隨機字串,還原出純淨的 `serial_no`。
|
||||
- **範例**:`SC_M0408_pndk` ➜ 解析為 `M0408`。
|
||||
- **Client ID**:格式建議為 **`SC_{serial_no}`**(確保同一機台僅能有一個連線,新連線將踢除舊連線)。
|
||||
|
||||
**EMQX 認證機制**:
|
||||
- **資料結構**:Redis Hash (`HSET`)。Key 格式為 `{prefix}machine_auth:{username}`,包含 `password` 欄位。
|
||||
@ -38,14 +36,7 @@ description: 定義 Star Cloud 與終端機台 (IoT) 之間的 MQTT 全域通訊
|
||||
> [!CAUTION]
|
||||
> **嚴禁使用 Redis String (`SET`) 儲存認證資料**。EMQX 5.x 的 Redis Auth Plugin 僅支援 `HMGET` 讀取 Hash 結構。若誤用 String,認證將永遠失敗。
|
||||
|
||||
### 1.3 連線狀態即時同步 ($SYS Events)
|
||||
系統透過訂閱 EMQX 的系統主題來實現「毫秒級」的連線狀態更新,不需等待心跳包:
|
||||
- **訂閱主題**:
|
||||
- `$SYS/brokers/+/clients/+/connected` ➜ 標記為 `online`
|
||||
- `$SYS/brokers/+/clients/+/disconnected` ➜ 標記為 `offline`
|
||||
- **處理流程**:Go Gateway 接收 ➜ 解析 Client ID 為 Serial No ➜ 推送 `type: status` 至 Redis ➜ Laravel `ProcessStatus` Job 更新資料庫與 `last_heartbeat_at`。
|
||||
|
||||
### 1.4 Go Gateway 認證
|
||||
### 1.3 Go Gateway 認證
|
||||
- **Username**:`star-cloud-gateway` (固定值)。
|
||||
- **Password**:與 Redis Hash `machine_auth:star-cloud-gateway` 中的 `password` 欄位對應。
|
||||
- Go Gateway 的認證資料須透過 Laravel 側寫入 Redis。
|
||||
@ -74,23 +65,22 @@ App (Android) 透過 **WSS** 穿越公網連線 Broker。路徑:`App → Cloud
|
||||
```java
|
||||
Mqtt3Client client = MqttClient.builder()
|
||||
.useMqttVersion3()
|
||||
.identifier("SC_" + machineID + "_" + randomSuffix)
|
||||
.identifier("SC_" + machineID)
|
||||
.serverHost("mqtt.setdomains.work")
|
||||
.serverPort(443)
|
||||
.sslWithDefaultConfig()
|
||||
.webSocketConfig().serverPath("/mqtt").applyWebSocketConfig()
|
||||
.simpleAuth().username(machineID).password(apiToken.getBytes()).applySimpleAuth()
|
||||
.willPublish()
|
||||
.topic("machine/" + machineID + "/heartbeat")
|
||||
.topic("machine/" + machineID + "/status")
|
||||
.payload("{\"status\":\"offline\"}".getBytes())
|
||||
.applyWillPublish()
|
||||
.buildBlocking();
|
||||
```
|
||||
|
||||
### Fallback 策略
|
||||
- **指數退避重連**:1s → 2s → 4s → ... → 最大 60s。
|
||||
- WSS 斷線超過 **3 分鐘** → 退回 HTTP 輪詢 (B010),背景每 60 秒嘗試重連 WSS。
|
||||
- WSS 恢復 → 立即切回 MQTT,停止 HTTP 輪詢。
|
||||
### 重連策略 (Reconnection Strategy)
|
||||
- **指數退避重連**:斷線後採指數退避(1s → 2s → 4s → ... → 最大 60s)嘗試重連 WSS。
|
||||
- **無 HTTP Fallback**:不再退回 HTTP 輪詢 (B010),斷線期間的狀態與交易將依賴 App 本地暫存(詳見第 6 節「斷線保障機制」)。
|
||||
|
||||
---
|
||||
|
||||
@ -123,7 +113,7 @@ Mqtt3Client client = MqttClient.builder()
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> 為了降低傳輸開銷,目前僅要求上報 **韌體版本** 與 **溫度** (整數型態)。其餘狀態如連線/斷線已透過 LWT 與 $SYS 事件處理。
|
||||
> 為了降低傳輸開銷,目前僅要求上報 **韌體版本** 與 **溫度** (整數型態)。其餘狀態如連線/斷線已透過 LWT 機制處理。
|
||||
> 雲端對於溫度的日誌記錄規則:**只要有變動 (current !== last) 即寫入日誌**,並維持每 4 小時一次的保底記錄。
|
||||
|
||||
### 3.2 異常與錯誤上報 (B013)
|
||||
@ -139,17 +129,90 @@ Mqtt3Client client = MqttClient.builder()
|
||||
```
|
||||
#### B013 硬體代碼對照表 (由 MachineService 自動翻譯)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **出貨結果判定**:僅 `0402` (出貨成功) 與 `0424` (購買成功) 視為成功,其餘出貨結果碼均應觸發退款。
|
||||
> 完整代碼定義於 `MachineService::ERROR_CODE_MAP`。
|
||||
|
||||
**基本出貨流程:**
|
||||
|
||||
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **0402** | Dispense successful | info | 出貨成功 |
|
||||
| **0401** | Dispensing in progress | info | 正在出貨 |
|
||||
| **0402** | Dispense successful | info | ✅ 出貨成功 |
|
||||
| **0403** | Slot jammed | error | **貨道卡貨** (重大異常) |
|
||||
| **0202** | Product empty | warning | 貨道缺貨 |
|
||||
| **0412** | Elevator rise error | error | 昇降機上升異常 |
|
||||
| **0415** | Pickup door error | error | 取貨門異常 |
|
||||
| **5402** | Pickup door not closed | warning | **取貨門未關** (警告) |
|
||||
| **5403** | Elevator failure | error | 昇降系統故障 |
|
||||
| **0404** | Motor not stopped | warning | 電機未正常停止 |
|
||||
| **0405** | Motor not found | error | 電機不存在 |
|
||||
| **0406** | Motor not found (0406) | error | 電機不存在 (同 0405) |
|
||||
| **0407** | Elevator failure | error | 昇降機故障 |
|
||||
|
||||
*(註:`error_code` 應遵循此表定義,雲端將根據 `i18n` 自動轉換。`tid` 為貨道或任務 ID。)*
|
||||
**昇降機系統:**
|
||||
|
||||
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **040A** | Elevator belt self-check error | error | 昇降機皮帶自檢錯誤 |
|
||||
| **0410** | Elevator rising | info | 昇降機正在上升 |
|
||||
| **0411** | Elevator descending | info | 昇降機正在下降 |
|
||||
| **0412** | Elevator rise error | error | 昇降機上升錯誤 |
|
||||
| **0413** | Elevator descent error | error | 昇降機下降錯誤 |
|
||||
| **0425** | Elevator support rod return error | error | 昇降台撐桿回位錯誤 |
|
||||
| **0428** | Elevator support rod push error | error | 昇降台撐桿推出錯誤 |
|
||||
| **0429** | Elevator enter microwave error | error | 昇降台進微波爐錯誤 |
|
||||
| **0430** | Elevator exit microwave error | error | 昇降台出微波爐錯誤 |
|
||||
|
||||
**微波爐取貨口門:**
|
||||
|
||||
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **0414** | Microwave pickup door closing | info | 正在關閉微波爐取貨口門 |
|
||||
| **0415** | Microwave pickup door close error | error | 微波爐取貨口門關閉錯誤 |
|
||||
| **0426** | Microwave pickup door opening | info | 正在打開微波爐取貨口門 |
|
||||
| **0427** | Microwave pickup door open error | error | 微波爐取貨口門打開錯誤 |
|
||||
|
||||
**微波爐進貨口門:**
|
||||
|
||||
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **0416** | Microwave delivery door opening | info | 正在打開微波爐進貨口門 |
|
||||
| **0417** | Microwave delivery door open error | error | 微波爐進貨口門打開錯誤 |
|
||||
| **0418** | Pushing product into microwave | info | 正在將商品推入微波爐 |
|
||||
| **0419** | Microwave delivery door closing | info | 正在關閉微波爐進貨口門 |
|
||||
| **0420** | Microwave delivery door close error | error | 微波爐進貨口門關閉錯誤 |
|
||||
|
||||
**微波爐內部:**
|
||||
|
||||
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **0421** | Product not detected in microwave | error | 微波爐內未檢測到商品 |
|
||||
| **0422** | Product heating | info | 商品正在加熱 |
|
||||
| **0423** | Product heating remaining time | info | 商品加熱剩餘時間秒 |
|
||||
| **0424** | Purchase successful | info | ✅ 購買成功 |
|
||||
| **0431** | Microwave push rod push error | error | 微波爐內推桿推出錯誤 |
|
||||
| **0432** | Microwave push rod retract error | error | 微波爐內推桿收回錯誤 |
|
||||
|
||||
**鮮奶機專用:**
|
||||
|
||||
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **040B** | Milk dispense timeout | error | 按鍵出奶超時 |
|
||||
| **040C** | Cup dropper failure | error | 漏杯器漏杯失敗 |
|
||||
| **040D** | Cup drop not detected | error | 未檢測到杯子掉落 |
|
||||
|
||||
**手環機專用:**
|
||||
|
||||
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **040E** | Bracelet scan failed | error | 沒掃碼到手環 |
|
||||
| **040F** | Bracelet drop not detected | error | 沒檢測到手環掉入取貨斗 |
|
||||
|
||||
**取貨口門 / 購物車模式 / 液體機:**
|
||||
|
||||
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **0433** | Pickup door opened (cart mode) | info | 取貨口門打開成功 (購物車) |
|
||||
| **0434** | Pickup door closed (cart mode) | info | 取貨口門關閉成功 (購物車) |
|
||||
| **0435** | Liquid dispense paused | warning | 暫停出液 (液體機) |
|
||||
| **0441** | Pickup door unlocked | info | 取貨口門開鎖上報 |
|
||||
| **04FF** | Purchase terminated | error | 購買終止 |
|
||||
|
||||
### 3.3 交易生命週期上報 (Transaction) - `machine/{serial_no}/transaction`
|
||||
|
||||
@ -167,7 +230,6 @@ Mqtt3Client client = MqttClient.builder()
|
||||
"items": [
|
||||
{
|
||||
"product_id": "5",
|
||||
"product_name": "Coca Cola",
|
||||
"price": 25.0,
|
||||
"quantity": 1
|
||||
}
|
||||
@ -182,6 +244,7 @@ Mqtt3Client client = MqttClient.builder()
|
||||
"flow_id": "T202604140001",
|
||||
"invoice_no": "AB12345678",
|
||||
"invoice_date": "2026-04-22",
|
||||
"machine_time": "2026-04-22 14:30:15",
|
||||
"amount": 25.0,
|
||||
"random_no": "1234",
|
||||
"love_code": ""
|
||||
@ -202,9 +265,69 @@ Mqtt3Client client = MqttClient.builder()
|
||||
}
|
||||
```
|
||||
|
||||
#### B600+ 交易完成統整 (`action: finalize`)
|
||||
這是目前推薦的標準流程,將交易、發票與出貨結果一次性上報,確保資料完整性。
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "finalize",
|
||||
"order": {
|
||||
"order_no": "T202508080001",
|
||||
"flow_id": "CLIENT_UID_123",
|
||||
"total_amount": 100,
|
||||
"pay_amount": 100,
|
||||
"original_amount": 100,
|
||||
"discount_amount": 0,
|
||||
"change_amount": 0,
|
||||
"payment_type": 1,
|
||||
"payment_status": 1,
|
||||
"code_id": "對應指令的ID或取貨碼(如適用)",
|
||||
"payment_request": "{}",
|
||||
"payment_response": "{}",
|
||||
"member_barcode": "MBR999",
|
||||
"invoice_info": "12345678-0912345678",
|
||||
"machine_time": "2025-08-08 10:00:00",
|
||||
"points_used": 0,
|
||||
"items": [
|
||||
{
|
||||
"product_id": 1,
|
||||
"price": 100,
|
||||
"quantity": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"invoice": {
|
||||
"invoice_no": "AB12345678",
|
||||
"invoice_date": "2025-08-08",
|
||||
"machine_time": "2025-08-08 10:01:30",
|
||||
"amount": 100,
|
||||
"random_number": "1234",
|
||||
"love_code": "",
|
||||
"carrier_id": "/ABC1234",
|
||||
"carrier_type": "3J0002",
|
||||
"business_tax_id": "12345678",
|
||||
"rtn_code": "1",
|
||||
"rtn_msg": "成功"
|
||||
},
|
||||
"dispense": [
|
||||
{
|
||||
"slot_no": "1",
|
||||
"product_id": 1,
|
||||
"amount": 100,
|
||||
"dispense_status": 1,
|
||||
"remaining_stock": 9,
|
||||
"member_barcode": "MBR999",
|
||||
"machine_time": "2025-08-08 10:01:00",
|
||||
"points_used": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> - `serial_no` 不需放在 Payload 中,Go Gateway 自動從 Topic 路徑提取並注入。
|
||||
> - 若 Payload 不含 `action` 欄位,系統預設視為 `create`(向後相容既有 B600 格式)。
|
||||
> - **閉環勾稽參數**:當使用特定支付方式 (如 `payment_type: 6`=取貨碼, `5`=通行碼, `41`=員工卡) 時,請務必在 `order` 中帶入 `code_id` 欄位。後端會根據此 ID 完成對應的狀態更新與核銷。
|
||||
|
||||
### 3.4 雲端指令 (Downstream Commands) - `machine/{serial_no}/command`
|
||||
這是雲端主動下發給機台的訊息,取代原本 B010 Response 的輪詢等待。
|
||||
@ -229,6 +352,8 @@ Mqtt3Client client = MqttClient.builder()
|
||||
- `change`: 遠端找零(夾帶 amount 參數)。
|
||||
- `update_inventory`: 遠端即時修改庫存。
|
||||
- `dispense`: 遠端出貨指令。
|
||||
- `update_ads`: 遠端廣告同步指令。
|
||||
- `update_products`: 遠端商品資料同步指令(觸發機台調用 B012)。
|
||||
|
||||
**`update_inventory` Payload 範例:**
|
||||
```json
|
||||
@ -236,14 +361,21 @@ Mqtt3Client client = MqttClient.builder()
|
||||
"command": "update_inventory",
|
||||
"command_id": "3",
|
||||
"payload": {
|
||||
"data": [
|
||||
{
|
||||
"slot_no": "3",
|
||||
"stock": 6,
|
||||
"expiry_date": null,
|
||||
"batch_no": "B2026042201"
|
||||
}
|
||||
]
|
||||
"slot_no": "3",
|
||||
"stock": 6,
|
||||
"expiry_date": null,
|
||||
"batch_no": "B2026042201"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`update_products` Payload 範例:**
|
||||
```json
|
||||
{
|
||||
"command": "update_products",
|
||||
"command_id": "123",
|
||||
"payload": {
|
||||
"remark": "手動同步商品資料"
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -294,7 +426,7 @@ Mqtt3Client client = MqttClient.builder()
|
||||
- **QoS 0**:適用於高頻率心跳,即使掉一兩次包也不影響系統判斷。
|
||||
- **QoS 1**:適用於交易與指令,確保「至少送達一次」。App 端收到指令後應回覆回執。
|
||||
3. **遺囑訊息 (Last Will and Testament)**:
|
||||
機台 Connect 時應設定 Last Will 於 `machine/{serial_no}/heartbeat`,Payload 為 `{"status": "offline"}`。當連線異常中斷時,雲端能立刻得知。
|
||||
機台 Connect 時應設定 Last Will 於 `machine/{serial_no}/status`,Payload 為 `{"status": "offline"}`。當連線異常中斷時,雲端能立刻得知。
|
||||
4. **傳輸加密**:
|
||||
- **App 端 (公網)**:強制使用 WSS (`wss://`),TLS 由 NPM / Cloudflare 終止。
|
||||
- **Go Gateway (內網)**:預設走 TCP (`tcp://emqx:1883`),區網環境可免 TLS。正式環境建議升級至 `ssl://emqx:8883`。
|
||||
|
||||
161
.agents/skills/multilingual-specs/SKILL.md
Normal file
161
.agents/skills/multilingual-specs/SKILL.md
Normal file
@ -0,0 +1,161 @@
|
||||
# 多語系管理與自動對齊規範 (Multilingual Specs)
|
||||
|
||||
本技能規範定義了 Star Cloud 系統中所有多語系 JSON 檔案(位於 `lang/`)的維護標準。當專案需要新增、修改或校對多語系時,必須嚴格遵守此規範。
|
||||
|
||||
---
|
||||
|
||||
## 核心三大原則 (CRITICAL RULES)
|
||||
|
||||
### 1. 三語系完美對齊 (Master-Align)
|
||||
- **以 `zh_TW.json` 為唯一標準**:不論任何時候新增或修改翻譯 Key,都必須以 `lang/zh_TW.json` 的鍵值(Keys)集合為絕對標準。
|
||||
- **三檔對齊**:`lang/en.json` 與 `lang/ja.json` 的鍵值集合必須與 `zh_TW.json` 百分之百相同(行數、數量與 Key 的拼寫必須完全一致)。
|
||||
|
||||
### 2. 字母順序排序 (ksort)
|
||||
- 為避免 Git 版本控制產生大範圍的亂序 Diff,三個 JSON 檔案中的所有鍵值對,必須一律按 **Key 的英文字母升冪順序** 進行排序(類似 PHP 的 `ksort()`)。
|
||||
|
||||
### 3. 斜線不逸出與美化 (No Escaped Slashes)
|
||||
- **禁止使用預設 `json_encode` 逸出斜線**:JSON 檔案中的網址或路徑斜線一律使用 `/`,嚴禁產生 `\/`。
|
||||
- **儲存格式化要求**:
|
||||
在寫入語系 JSON 檔案時,必須使用以下 JSON 編碼參數:
|
||||
```php
|
||||
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 自動化對齊工具 (I18n Alignment Tool)
|
||||
|
||||
當處理語系變更時,AI 助手應在專案的 `scratch/` 目錄中建立一個臨時 PHP 腳本(例如 `scratch/align_lang.php`),執行對齊並排序後,**立即將其刪除**。
|
||||
|
||||
### 🔧 臨時對齊腳本範本 (`scratch/align_lang.php`)
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* Star Cloud - 多語系自動對齊與排序工具
|
||||
* 執行指令:php scratch/align_lang.php
|
||||
*/
|
||||
|
||||
$langDir = __DIR__ . '/../lang/';
|
||||
$zhPath = $langDir . 'zh_TW.json';
|
||||
$enPath = $langDir . 'en.json';
|
||||
$jaPath = $langDir . 'ja.json';
|
||||
|
||||
if (!file_exists($zhPath)) {
|
||||
die("❌ 找不到 zh_TW.json 主語系檔!\n");
|
||||
}
|
||||
|
||||
$zhData = json_decode(file_get_contents($zhPath), true);
|
||||
$enData = file_exists($enPath) ? json_decode(file_get_contents($enPath), true) : [];
|
||||
$jaData = file_exists($jaPath) ? json_decode(file_get_contents($jaPath), true) : [];
|
||||
|
||||
// 1. 精準翻譯對照字典 (用於自動補齊常見系統/機台特有詞彙)
|
||||
$translationDict = [
|
||||
// 通行碼/機台/權限相關
|
||||
"Channel Limits Config" => [
|
||||
"en" => "Channel Limits Config",
|
||||
"ja" => "チャンネル制限設定"
|
||||
],
|
||||
"Connection error" => [
|
||||
"en" => "Connection error",
|
||||
"ja" => "接続エラー"
|
||||
],
|
||||
"Passcode settings" => [
|
||||
"en" => "Passcode settings",
|
||||
"ja" => "パスコード設定"
|
||||
],
|
||||
"Reset Passcode" => [
|
||||
"en" => "Reset Passcode",
|
||||
"ja" => "パスコードをリセット"
|
||||
],
|
||||
"Update Passcode" => [
|
||||
"en" => "Update Passcode",
|
||||
"ja" => "パスコードを更新"
|
||||
],
|
||||
"Machine Authorization" => [
|
||||
"en" => "Machine Authorization",
|
||||
"ja" => "デバイス授権"
|
||||
],
|
||||
"Status" => [
|
||||
"en" => "Status",
|
||||
"ja" => "ステータス"
|
||||
],
|
||||
"Actions" => [
|
||||
"en" => "Actions",
|
||||
"ja" => "操作"
|
||||
]
|
||||
];
|
||||
|
||||
$missingEnCount = 0;
|
||||
$missingJaCount = 0;
|
||||
|
||||
$newEnData = [];
|
||||
$newJaData = [];
|
||||
|
||||
// 2. 以 zh_TW 的 keys 為基準對齊 en 與 ja
|
||||
foreach ($zhData as $key => $zhVal) {
|
||||
// 處理英文
|
||||
if (isset($enData[$key]) && trim($enData[$key]) !== '') {
|
||||
$newEnData[$key] = $enData[$key];
|
||||
} else {
|
||||
// 嘗試從精準字典查找
|
||||
if (isset($translationDict[$key]["en"])) {
|
||||
$newEnData[$key] = $translationDict[$key]["en"];
|
||||
} else {
|
||||
// fallback: 使用 key 原始名稱作為英文
|
||||
$newEnData[$key] = $key;
|
||||
}
|
||||
$missingEnCount++;
|
||||
}
|
||||
|
||||
// 處理日文
|
||||
if (isset($jaData[$key]) && trim($jaData[$key]) !== '') {
|
||||
$newJaData[$key] = $jaData[$key];
|
||||
} else {
|
||||
// 嘗試從精準字典查找
|
||||
if (isset($translationDict[$key]["ja"])) {
|
||||
$newJaData[$key] = $translationDict[$key]["ja"];
|
||||
} else {
|
||||
// fallback: 如果 key 與英文相同,且非中文,可先 fallback 英文,後續再由 LLM 補譯
|
||||
$newJaData[$key] = $key;
|
||||
}
|
||||
$missingJaCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 字母排序 (ksort)
|
||||
ksort($zhData);
|
||||
ksort($newEnData);
|
||||
ksort($newJaData);
|
||||
|
||||
// 4. 寫回檔案 (反逸出斜線 + Unicode)
|
||||
$jsonFlags = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
|
||||
|
||||
file_put_contents($zhPath, json_encode($zhData, $jsonFlags) . "\n");
|
||||
file_put_contents($enPath, json_encode($newEnData, $jsonFlags) . "\n");
|
||||
file_put_contents($jaPath, json_encode($newJaData, $jsonFlags) . "\n");
|
||||
|
||||
echo "✅ 多語系處理完畢!\n";
|
||||
echo "📊 統計結果:\n";
|
||||
echo " - 總共鍵數: " . count($zhData) . " 個\n";
|
||||
echo " - 補齊英文空缺: {$missingEnCount} 個\n";
|
||||
echo " - 補齊日文空缺: {$missingJaCount} 個\n";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 AI 助手處理語系之 SOP 流程
|
||||
|
||||
當使用者要求修改或補齊語系時,AI 必須:
|
||||
|
||||
1. **同步更新/新增 `zh_TW.json`**:將新的詞彙加入 `zh_TW.json` 中。
|
||||
2. **利用 LLM 翻譯補足空缺**:
|
||||
- 針對新加入的詞彙,由 AI 運用其 LLM 能力將高品質的英文(`en`)與日文(`ja`)翻譯出來。
|
||||
3. **擴充對齊腳本的暫存字典**:
|
||||
- 將這些新翻譯的詞彙暫時寫入臨時對齊腳本 `scratch/align_lang.php` 中的 `$translationDict` 中。
|
||||
4. **執行一鍵對齊與字母排序**:
|
||||
- 在終端機執行 `php scratch/align_lang.php`,確保三個語系 JSON 檔案行數百分之百對齊、全部排序、斜線清空。
|
||||
5. **清理暫存**:
|
||||
- 刪除 `scratch/align_lang.php`。
|
||||
6. **Git 檢查與提交**:
|
||||
- 執行 `git diff` 確認修改範疇僅限於新增的語系,無亂序與無關的語系被刪除。
|
||||
@ -5,367 +5,483 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範
|
||||
|
||||
# 極簡奢華風 UI 實作規範 (Minimal Luxury UI)
|
||||
|
||||
本文件定義了 Star Cloud 專案的核心視覺語言。所有新頁面與組件開發必須嚴格遵守此規範。
|
||||
> [!IMPORTANT]
|
||||
> **開始寫任何頁面前,先對齊本規範。所有規範以此文件為最終依據。**
|
||||
> 黃金範本頁面:Type A → `admin/machines/index.blade.php`|Type B → `admin/products/index.blade.php`
|
||||
|
||||
## 1. 核心設計令牌 (Design Tokens)
|
||||
---
|
||||
|
||||
### 色彩系統 (CSS Variables)
|
||||
位於 `resources/css/app.css`:
|
||||
- `--color-luxury-deep`: `#0f172a` (深色背景)
|
||||
- `--color-luxury-card`: `#1e293b` (卡片背景)
|
||||
- `--color-accent`: `#06b6d4` (青色點綴,適用於按鈕與標示)
|
||||
## 1. 全局速查卡 (Quick Reference)
|
||||
|
||||
### 字體 (Typography)
|
||||
- **內文字體**: `Plus Jakarta Sans`
|
||||
- **標題/顯示字體**: `Outfit`
|
||||
- **特性**: 標題需帶有 `letter-spacing: -0.02em` 以增強精密感。
|
||||
### 1.1 字型規則
|
||||
|
||||
## 2. 核心組件樣式
|
||||
| 使用場景 | Tailwind Class |
|
||||
|---------|---------------|
|
||||
| 頁面主標題 (H1)、大數字 | `font-display`(Outfit) |
|
||||
| 所有內文、按鈕、標籤 | `font-sans`(Plus Jakarta Sans,預設不需加) |
|
||||
| SN 碼、版本號、Barcode、時間戳 | `font-mono`(僅限純英數代碼) |
|
||||
|
||||
### 豪華卡片 (Luxury Card)
|
||||
```html
|
||||
<div class="luxury-card p-6 rounded-2xl animate-luxury-in">
|
||||
</div>
|
||||
```
|
||||
- **特效**: 懸停時帶有 Y 軸平移與深度投影。
|
||||
### 1.2 文字大小階層
|
||||
|
||||
### 側邊導覽項 (Luxury Nav Item)
|
||||
```html
|
||||
<a href="#" class="luxury-nav-item active">
|
||||
<i class="lucide-icon"></i>
|
||||
<span>節點名稱</span>
|
||||
</a>
|
||||
```
|
||||
- **啟用狀態**: 左側帶有圓角直條指示器,並輔以青色發光陰影。
|
||||
| 元素 | 大小 | 字重 | 顏色 | 其他 |
|
||||
|------|------|------|------|------|
|
||||
| 頁面 H1 | `text-2xl sm:text-3xl` | `font-black` | `slate-800 dark:white` | `tracking-tight font-display` |
|
||||
| 頁面副標題 | `text-sm` | `font-bold` | `slate-500 dark:slate-400` | `uppercase tracking-widest mt-1` |
|
||||
| 表格標頭 (th) | `text-xs` | `font-bold` | `slate-500 dark:slate-400` | `uppercase tracking-[0.15em]` |
|
||||
| 表格主要資料 | `text-base` | `font-extrabold` | `slate-800 dark:slate-100` | `tracking-tight` |
|
||||
| 表格次要/描述 | `text-xs` | `font-bold` | `slate-500 dark:slate-400` | `tracking-wide` |
|
||||
| 技術碼(SN/ID/條碼)| `text-xs` | `font-black` | `slate-400 dark:slate-500` | `font-mono uppercase tracking-widest` |
|
||||
| 時間戳 | `text-xs` | `font-black` | `slate-400` | `font-mono tracking-widest` |
|
||||
| 狀態 Badge | `text-[10px]` | `font-black` | 依狀態色系 | `uppercase tracking-widest` |
|
||||
| Modal 標題 | `text-xl sm:text-2xl` | `font-black` | `slate-800 dark:white` | `font-display tracking-tight` |
|
||||
| Mobile Card 標籤 | `text-[10px]` | `font-black` | `slate-400 dark:slate-500` | `uppercase tracking-widest mb-1` |
|
||||
| Mobile Card 主標題 | `text-base` | `font-extrabold` | `slate-800 dark:slate-100` | `tracking-tight` |
|
||||
| Mobile Card 副標(技術碼)| `text-xs` | `font-bold font-mono` | `slate-400 dark:slate-500` | `uppercase tracking-widest` |
|
||||
|
||||
### 按鈕組件 (Buttons)
|
||||
- **Primary**: `.btn-luxury-primary` (青色漸層,適用於建立、儲存)
|
||||
- **Secondary**: `.btn-luxury-secondary` (白色/深色背景,帶邊框,適用於編輯、篩選)
|
||||
- **Ghost**: `.btn-luxury-ghost` (無背景,適用於取消、查看更多)
|
||||
> ❌ 嚴禁使用 `italic`|❌ Mobile Card 標題禁用 `text-lg`
|
||||
|
||||
### 1.3 顏色速查
|
||||
|
||||
| 用途 | Class |
|
||||
|------|-------|
|
||||
| 品牌強調/按鈕 | `cyan-500` / `bg-cyan-500` |
|
||||
| 成功/上線 | `emerald-500` / `bg-emerald-500/10` |
|
||||
| 警告/等待 | `amber-500` / `bg-amber-500/10` |
|
||||
| 錯誤/危險 | `rose-500` / `bg-rose-500/10` |
|
||||
| 進階/系統 | `indigo-500` / `bg-indigo-500/10` |
|
||||
| 停用/次要 | `slate-400` / `bg-slate-500/10` |
|
||||
|
||||
> Badge 背景:`bg-{color}/10 border border-{color}/20 text-{color}-500`
|
||||
|
||||
### 1.4 互動效果
|
||||
|
||||
| 元素 | Hover | Transition |
|
||||
|------|-------|-----------|
|
||||
| Primary 按鈕 | `hover:bg-cyan-600` + `active:scale-95` | `transition-all duration-300` |
|
||||
| 表格列 (tr) | `hover:bg-slate-50/50 dark:hover:bg-white/[0.02]` | `transition-colors duration-200` |
|
||||
| 表格操作按鈕 | `hover:bg-cyan-500/5 hover:text-cyan-500` | `transition-all duration-200` |
|
||||
| 危險操作按鈕 | `hover:bg-rose-500/5 hover:text-rose-500` | `transition-all duration-200` |
|
||||
| Mobile Card 操作 | `hover:bg-cyan-500 hover:text-white` | `transition-all duration-300` |
|
||||
|
||||
### 1.5 圓角規則
|
||||
|
||||
| 元素 | Class |
|
||||
|------|-------|
|
||||
| 主要內容卡片(桌面)| `rounded-3xl` |
|
||||
| Mobile Card | `rounded-[2rem]`(比桌面更大)|
|
||||
| 按鈕、輸入框 | `rounded-xl` |
|
||||
| 狀態 Badge | `rounded-full` |
|
||||
| 縮圖卡片、圖示框 | `rounded-2xl` |
|
||||
|
||||
### 1.6 進場動畫
|
||||
|
||||
| 場景 | 做法 |
|
||||
|------|------|
|
||||
| 頁面卡片、主內容區 | `animate-luxury-in` |
|
||||
| 多卡片依序 | `style="animation-delay: 100ms"` 遞增 |
|
||||
| Loading Overlay | `<x-luxury-spinner>`(禁止手寫)|
|
||||
| Slide-over 面板 | `translate-x-full → translate-x-0` (x-transition) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 強制使用的組件
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 以下 HTML 結構**嚴禁手寫**,一律使用組件:
|
||||
|
||||
| 禁止手寫 | 改用組件 |
|
||||
|---------|---------|
|
||||
| `<h1>` 標題 + 副標題 + Action 按鈕 | `<x-page-header>` |
|
||||
| Tab 導覽列 | `<x-tab-nav>` + `<x-tab-nav-item>` |
|
||||
| Loading Overlay | `<x-luxury-spinner>` |
|
||||
| 搜尋欄 + 重置按鈕(Type A 純列表) | `<x-search-bar>` |
|
||||
| 狀態標籤 | `<x-status-badge>` |
|
||||
| 統計卡片 | `<x-stat-card>` |
|
||||
| 空狀態提示 | `<x-empty-state>` |
|
||||
| 刪除確認 | `<x-delete-confirm-modal>` |
|
||||
|
||||
### x-luxury-spinner 傳參規則
|
||||
|
||||
> [!CAUTION]
|
||||
> - ✅ 正確:`show="isLoadingTable"`
|
||||
> - ✅ 正確:`show="tabLoading === 'products'"`
|
||||
> - ❌ 錯誤:`:show="\"tabLoading === 'products'\""` → **Blade ParseError!**
|
||||
> - ❌ 錯誤:`:show="'isLoadingTable'"` → 語意不清
|
||||
|
||||
### x-status-badge 用法
|
||||
|
||||
```html
|
||||
<button class="btn-luxury-primary">
|
||||
<i class="lucide-plus w-4 h-4"></i>
|
||||
<span>建立新機台</span>
|
||||
</button>
|
||||
{{-- 靜態 --}}
|
||||
<x-status-badge :status="$machine->is_online ? 'online' : 'offline'" />
|
||||
|
||||
<button class="btn-luxury-ghost">取消</button>
|
||||
{{-- Mobile Card 用小尺寸 --}}
|
||||
<x-status-badge :status="$item->status" size="sm" />
|
||||
```
|
||||
|
||||
## 3. 動畫與互動
|
||||
內建狀態:`active`, `inactive`, `online`, `offline`, `enabled`, `disabled`, `pending`, `error`
|
||||
|
||||
### 進場動畫
|
||||
- **`.animate-luxury-in`**: 所有的主內容區域或卡片在頁面載入時,應具備由下而上的淡入效果。
|
||||
### x-empty-state 用法
|
||||
|
||||
### 互動過渡 (Transitions)
|
||||
- **標準時間**: 所有的懸停、色彩變換等過渡效果,統一建議使用 **`duration-300`** (300ms)。
|
||||
- **例外**: 極其細微的透明度變化可縮短至 `150ms`,但涉及背景色與位移的互動一律以 `300ms` 為準。
|
||||
```html
|
||||
{{-- 表格 @empty 中 --}}
|
||||
<x-empty-state mode="table" :colspan="6" :message="__('No data found')" />
|
||||
|
||||
### Alpine.js 互動模式 (以時間選擇器為例)
|
||||
- **互動原則**: 點擊觸發下拉選單時,必須使用 `x-transition` 且帶有 `scale` 偏移。
|
||||
- **樣式要求**: 選單背景需使用玻璃擬態 (Glassmorphism) 或帶透明度的深色背景。
|
||||
{{-- Card Grid @empty 中 --}}
|
||||
<x-empty-state :message="__('No records found')" />
|
||||
```
|
||||
|
||||
## 4. 實作檢查清單 (Checklist)
|
||||
### x-stat-card 用法
|
||||
|
||||
- [x] **列表佈局**: 是否採用「整合式卡片」結構且內距設為 `p-8`?
|
||||
- [ ] **分頁與總數**: 列表底部是否正確召喚 `vendor.pagination.luxury`?
|
||||
- [ ] **刪除動作**: 是否已全面使用 `<x-delete-confirm-modal />` 封裝執行路徑?
|
||||
- [ ] **文字色階**: 符合標題 `slate-900/white` 與標籤 `slate-500` 的對比度。
|
||||
- [ ] **可讀性檢查**: 二級資訊是否達到 `text-xs` (12px) 且權重不超過 `font-bold`?
|
||||
```html
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<x-stat-card :label="__('Total')" :value="$stats['total']" />
|
||||
<x-stat-card :label="__('Online')" :value="$stats['online']" color="cyan" delay="100ms" />
|
||||
<x-stat-card :label="__('Error')" :value="$stats['error']" color="rose" delay="200ms" />
|
||||
<x-stat-card :label="__('Idle')" :value="$stats['idle']" color="amber" delay="300ms" />
|
||||
</div>
|
||||
```
|
||||
|
||||
## 5. 開發注意事項 (Important Notes)
|
||||
---
|
||||
|
||||
### 技術限制備忘
|
||||
- **CSS 編譯**: 複雜的 `box-shadow` 或漸層應直接寫原生 CSS 屬性,避免在 `@apply` 中使用帶空格的數值導致編譯失敗。
|
||||
- **深色模式**: 互動式按鈕在深色模式下必須強化文字亮度(`dark:text-white`),並輔以青色發光效果。
|
||||
## 3. 頁面佈局範本 (Page Templates)
|
||||
|
||||
### 即時動態呈現規範
|
||||
- **格式**: `#機台編號 動作內容` (例如 `#V-001 執行出貨`)。
|
||||
- **脈絡**: 必須呈現相對時間與機台位置。
|
||||
|
||||
## 6. 頁面佈局規範 (Page Layout)
|
||||
|
||||
### 佈局決策規則 (Layout Decision Rules)
|
||||
|
||||
根據篩選條件的複雜程度,選擇適當的清單頁面佈局:
|
||||
|
||||
#### 1. 整合式佈局 (Integrated Layout) - 【預設推薦】
|
||||
- **適用場景**: 絕大多數 CRUD 列表。
|
||||
- **實作方式**: 篩選器、工具列與資料表格全部封裝在同一個 `luxury-card` 中。
|
||||
- **內距規範**: 強制使用 `p-8` 以獲得最佳空氣感。
|
||||
- **元件間距**: 篩選區與表格之間固定使用 `mb-10`。
|
||||
- **範例**: 帳號管理、角色設定、機台日誌。
|
||||
|
||||
#### 2. 分離式佈局 (Split Layout)
|
||||
- **適用場景**: 複雜查詢 (Filtered Fields >= 3 或多行篩選)。
|
||||
- **實作方式**: 篩選區獨立為一個 `luxury-card`,下方間隔 `mb-6` 後再放置資料清單卡片。
|
||||
- **樣式規範**: 篩選卡片通常使用 `p-6`(緊湊式),清單卡片使用 `p-8`(寬鬆式)。
|
||||
- **範例**: 交易紀錄、機台日誌。
|
||||
|
||||
### 標準寬版佈局 (Wide Layout)
|
||||
### Type A — 純列表(黃金範本:machines/index.blade.php)
|
||||
|
||||
```html
|
||||
@section('content')
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Title') }}</h1>
|
||||
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Subtitle') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="btn-luxury-primary">
|
||||
<i class="lucide-plus w-4 h-4"></i>
|
||||
<span>新增</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4 pb-20" x-data="machineApp()">
|
||||
|
||||
<div class="luxury-card rounded-3xl p-8 animate-luxury-in">
|
||||
<div class="flex items-center justify-between mb-10">
|
||||
<form class="relative group">
|
||||
<input type="text" class="luxury-input pl-12 pr-6 w-64" placeholder="{{ __('Search...') }}">
|
||||
</form>
|
||||
</div>
|
||||
<x-page-header
|
||||
:title="__('Module Name')"
|
||||
:subtitle="__('Description')"
|
||||
>
|
||||
<button @click="openCreateModal()" class="btn-luxury-primary flex items-center gap-2">
|
||||
<svg class="w-4 h-4">...</svg>
|
||||
<span class="text-sm sm:text-base">{{ __('Add') }}</span>
|
||||
</button>
|
||||
</x-page-header>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-separate border-spacing-y-0">
|
||||
<thead>
|
||||
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
||||
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">{{ __('Name') }}</th>
|
||||
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Action') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
||||
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
||||
<td class="px-6 py-6 font-extrabold text-slate-800 dark:text-slate-100">Example Name</td>
|
||||
<td class="px-6 py-6 text-right"> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative overflow-hidden">
|
||||
<x-luxury-spinner show="isLoadingTable" />
|
||||
<div id="ajax-content-container" :class="{ 'opacity-30 pointer-events-none': isLoadingTable }">
|
||||
|
||||
<x-search-bar
|
||||
:action="route('admin.xxx.index')"
|
||||
:value="request('search')"
|
||||
:placeholder="__('Search...')"
|
||||
/>
|
||||
|
||||
{{-- 桌面表格 --}}
|
||||
<div class="hidden xl:block overflow-x-auto">
|
||||
<table class="w-full text-left border-separate border-spacing-y-0">
|
||||
<thead>
|
||||
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
|
||||
{{ __('Column') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
||||
@forelse($items as $item)
|
||||
<tr class="group hover:bg-slate-50/50 dark:hover:bg-white/[0.02] transition-colors duration-200">
|
||||
<td class="px-6 py-6">...</td>
|
||||
</tr>
|
||||
@empty
|
||||
<x-empty-state mode="table" :colspan="5" :message="__('No records found')" />
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- Mobile/Tablet Card Grid --}}
|
||||
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@foreach($items as $item)
|
||||
{{-- 見 Section 4:Mobile Card 黃金範本 --}}
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- 分頁 --}}
|
||||
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
|
||||
{{ $items->appends(request()->query())->links('vendor.pagination.luxury') }}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
|
||||
{{ $items->links('vendor.pagination.luxury') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
```
|
||||
|
||||
### 佈局核心原則:
|
||||
1. **移除重複內距**: 根容器 `div` 應**禁止**使用 `p-6` 或 `p-10`,因為佈局基底已提供基礎間距。
|
||||
2. **區塊間隙**: 建議使用 `space-y-6` 或 `space-y-8` 以獲得最佳空氣感。但在「高密度資料管理」或使用者有特殊緊湊需求的情境下,容許縮減至 **`space-y-2`**。
|
||||
3. **主容器樣式**: 強制對齊為 `luxury-card rounded-3xl p-8`。
|
||||
3. **標題排版**:
|
||||
- 主標題需應用 `font-display` (Outfit)。
|
||||
- 描述文字需應用 `uppercase tracking-widest font-bold` 以呈現高級設計感。
|
||||
|
||||
## 7. 表單元件規範 (Form Elements)
|
||||
|
||||
針對輸入框與下拉選單,強制使用以下類別以確保深色模式質感。
|
||||
|
||||
### 輸入框與選單
|
||||
- **類別**: `.luxury-input`, `.luxury-select`
|
||||
- **特性**:
|
||||
- 深色模式下具備半透明背景與背景模糊效果。
|
||||
- 統一的 `rounded-xl` 圓角與 `font-bold` 字體。
|
||||
- 聚焦時帶有青色 (`Cyan`) 發光邊框。
|
||||
|
||||
```html
|
||||
<input type="text" class="luxury-input" placeholder="請輸入內容">
|
||||
|
||||
<select class="luxury-select">
|
||||
<option value="1">啟用</option>
|
||||
<option value="0">禁用</option>
|
||||
</select>
|
||||
|
||||
### 搜尋式下拉選單 (Searchable Select) - 【進階推薦】
|
||||
- **組件**: `<x-searchable-select />`
|
||||
- **適用場景**: 選項大於 10 筆或具備層級關聯的篩選器(如:公司名稱、機台編號)。
|
||||
- **奢華特徵**:
|
||||
- **動態旋轉箭頭**: 透過 `::after` 偽元素實作,選單展開時箭頭執行 `300ms` 的 180 度旋轉動畫。
|
||||
- **即時過濾**: 輸入關鍵字即時隱藏不匹配項。
|
||||
- **選取標示**: 選取的項目右側帶有青色 (`Cyan`) 的勾選小圖標。
|
||||
- **全部選項修復 (Space Fix)**: 若用於篩選(如公司篩選),組件內部已實作「空格佔位符」機制。若選單中的「全部」選項在選取後消失,請確保該選項的值為單個空格 (`value=" "`)。這能繞過 Preline 對空標記的隱藏邏輯,並同步觸發 Laravel 的 `blank()` 判定。
|
||||
|
||||
```html
|
||||
<x-searchable-select
|
||||
name="company_id"
|
||||
:options="$companies"
|
||||
:selected="request('company_id')"
|
||||
:placeholder="__('All Companies')"
|
||||
onchange="this.form.submit()"
|
||||
/>
|
||||
```
|
||||
```
|
||||
|
||||
## 8. 編輯與詳情頁規範 (Detail & Edit Views)
|
||||
|
||||
為了讓分層資訊更具視覺引導,各個區塊 (Section) 的圖示應採用不同的顏色意象。
|
||||
|
||||
### 區塊圖示色彩意象 (Section Icon Palette)
|
||||
- **基本資訊 (Basic Info)**: **翠綠色 (`Emerald`)**。代表核心、穩定與起點。
|
||||
- 樣式: `bg-emerald-500/10 text-emerald-500`
|
||||
- **硬體/插槽設定**: **琥珀色 (`Amber/Orange`)**。代表動作、物理連接與硬體警告。
|
||||
- 樣式: `bg-amber-500/10 text-amber-500`
|
||||
- **系統/進階設定**: **靛藍色 (`Indigo`)**。代表邏輯、權限與深層配置。
|
||||
- 樣式: `bg-indigo-500/10 text-indigo-500`
|
||||
- **危險/移除動作**: **玫瑰紅 (`Rose`)**。代表破壞性操作。
|
||||
- 樣式: `bg-rose-500/10 text-rose-500`
|
||||
|
||||
## 9. 資料表格規範 (Data Tables)
|
||||
|
||||
為了確保管理後台資料的可讀性與精密感,表格內的所有文字級別必須對齊以下規範:
|
||||
|
||||
### 文字大小與權重 (Typography Hierarchy)
|
||||
- **表頭 (Table Header)**:
|
||||
- 類別: `text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em]`
|
||||
- 作用: 提供清晰的欄位定義而不奪取資料視覺焦點。具備足夠對比度。
|
||||
- **主標題 (Primary Item)**:
|
||||
- 類別: `text-base font-extrabold text-slate-800 dark:text-slate-100`
|
||||
- 範例: 公司名稱、機體名稱。
|
||||
- **次要資訊 (Secondary Info)**:
|
||||
- 類別: `text-xs font-bold text-slate-500 dark:text-slate-400 tracking-wide`
|
||||
- 範例: 使用者帳號、備註、權限名稱。
|
||||
- **狀態標籤 (Status Badge)**:
|
||||
- 範例: 啟用 (`emerald`)、禁用 (`rose`) / 角色名稱 (`sky`/`indigo`)。
|
||||
- 特性: `px-2.5 py-1 rounded-lg text-xs font-bold border tracking-wider`
|
||||
|
||||
### 空間與反應 (Spacing & Interaction)
|
||||
- **單元格內距**: 統一使用 `px-6 py-6`。
|
||||
- **懸停反應**: 必須在 `tr` 套用 `group` 且子<E4B894>### 9.4 標竿刪除確認模式 (Luxury Delete Modal Pattern)
|
||||
當執行刪除或具備破壞性的操作時,**禁止**使用瀏覽器原生 `confirm()` 或簡易的 `x-modal`。全站統一使用 **`<x-delete-confirm-modal />`** Blade 組件進行二次確認。
|
||||
|
||||
1. **參數配置**:
|
||||
- `title`: (選填) 預設為「確認刪除」。
|
||||
- `message`: (選填) 定義具體的刪除警告訊息(例如「您確定要永久刪除此帳號嗎?」)。
|
||||
2. **視覺特徵**:
|
||||
- **背景**: `bg-slate-900/60 backdrop-blur-sm`。
|
||||
- **容器**: `rounded-3xl shadow-2xl animate-luxury-in`。
|
||||
- **圖示**: 警告圖示使用 `bg-amber-100/10 text-amber-600`。
|
||||
- **按鈕**: 刪除按鈕使用 `bg-rose-500` 搭配 `shadow-rose-200` 投影,取消按鈕使用 `bg-slate-100`。
|
||||
3. **交互規範**:
|
||||
- **禁止斜體 (No Italics)**: 彈窗標題與按鈕文字嚴禁使用 `italic`,保持直挺專業感。
|
||||
|
||||
```html
|
||||
<!-- 使用範例 -->
|
||||
<x-delete-confirm-modal :message="__('Are you sure you want to delete this account?')" />
|
||||
```
|
||||
|
||||
## 10. 系統兼容性與標準化 (Compatibility & Standardization)
|
||||
|
||||
為了確保在不同版本的開發環境中(如目前專案使用的 Tailwind CSS v3.1)UI 都能正確呈現,並維持全站操作感一致,必須遵守以下額外規範。
|
||||
|
||||
### Tailwind CSS 版本兼容性 (v3.1)
|
||||
- **禁止使用 `size-` 屬性**: 舊版不支援 `size-4` 等語法,請一律分拆寫作 `w-4 h-4`。
|
||||
- **避免非標準間距**: 避免使用 `4.5` (`18px`) 等任意值,優先使用標準等級如 `4` (`16px`) 或 `5` (`20px`)。
|
||||
|
||||
## 11. 字體與技術資訊規範 (Typography & Technical Data)
|
||||
|
||||
為了確保全站「次要資訊」具備極一致的高級感,必須遵守以下「機台標竿」規範:
|
||||
|
||||
### 核心樣式級別 (Core Typography Scale)
|
||||
| 資訊類型 | 客戶/配置名稱 (標題) | 技術代碼 (ID, SN, Code) | 清單時間 (Timestamps) | 分隔符號 (•) |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **字體族** | `font-sans` (Plus Jakarta Sans) | `font-mono` (微縮型單雙格) | `font-mono` (或 `sans` 視場景) | `font-sans` |
|
||||
| **尺寸** | `text-base` | `text-xs` (不可使用 10px) | `text-xs` | `text-xs` |
|
||||
| **字重** | `font-extrabold` (800) | `font-bold` (700) | `font-black` (900) | `font-bold` |
|
||||
| **字距** | `tracking-tight` (-0.02em) | `tracking-widest` (最寬) | `tracking-widest` | `tracking-normal` |
|
||||
| **格式** | 保持原始名稱 | `uppercase` (強制大寫) | `uppercase` | N/A |
|
||||
| **色彩** | `slate-900` / `slate-100` | `slate-500` / `slate-400` | `slate-400` / `slate-400/80` | `slate-300` / `slate-700` |
|
||||
|
||||
### 實作禁忌與準則
|
||||
- **禁止斜體 (No Italics)**: 名稱欄位嚴禁附帶 `italic`(特別是標題或配置名稱),保持直挺專業感。
|
||||
- **作用範圍 (Mono Scoping)**: `font-mono` 僅限作用於「純英文/數字」的代碼。Email 或分隔點必須回歸 `font-sans` 以確保圓潤。
|
||||
- **權重載入 (Font Weights)**: 確保 HTML Header 載入了 `800` 與 `900` 權重,避免瀏覽器模擬出的假粗體。
|
||||
- **清單資訊密度**: 對於高密度清單中的時間資訊,應優先使用 `font-black` 與 `tracking-widest` 來建立明確的「標籤感」,而非僅僅是「微縮文字」。
|
||||
|
||||
---
|
||||
## 12. 提示與告警規範 (Alerts & Notifications)
|
||||
|
||||
為了確保全站操作回饋的一致性與專業感,所有系統內部的提示(成功、錯誤、警告)必須遵循以下規範。
|
||||
|
||||
### 1. 懸浮式自動消失提示 (Auto-hiding Toasts)
|
||||
- **視覺樣式**:
|
||||
- 位置: 固定於畫面上方中央 (`fixed top-8 left-1/2 -translate-x-1/2`)。
|
||||
- 特效: 毛玻璃背景 (`backdrop-blur-xl`)、圓角 (`rounded-2xl`)、軟陰影。
|
||||
- 動畫: 滑入 (`translate-y-0`) / 滑出 (`-translate-y-4`),配合 `opacity` 變化。
|
||||
- **型態定義**:
|
||||
- **Success (成功)**: 使用 `emerald` 色系。
|
||||
- **Error (錯誤)**: 使用 `rose` 色系。
|
||||
- **時長規範**:
|
||||
- 成功提示: 3 秒後消失。
|
||||
- 錯誤提示: 5 秒後消失(提供使用者更多閱讀錯誤原因的時間)。
|
||||
- **組件實作**: 統一調用 `<x-toast />`。
|
||||
|
||||
### 2. 視窗內操作警告 (Inline Action Warnings)
|
||||
- **適用場景**: 在 Modal 或編輯頁面中,提示可能導致風險的操作(如編輯自身角色)。
|
||||
- **視覺樣式**:
|
||||
- 背景: `bg-amber-500/10` (琥珀色)。
|
||||
- 邊框: `border-amber-500/20`。
|
||||
- 進場動畫: `animate-luxury-in`。
|
||||
- **實作範例**:
|
||||
```html
|
||||
<div class="p-5 bg-amber-500/10 border border-amber-500/20 text-amber-600 rounded-2xl flex items-start gap-4 animate-luxury-in font-bold">
|
||||
<!-- Icon & Text -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### 3. 通用豪華確認與告警視窗 (General Luxury Modals)
|
||||
**統一準則**: 所有的系統確認 (Confirm) 或重要告警 (Alert/Warning) **必須** 捨棄 `x-modal` 組件,改用 Section 9.4 定義的自定義 Div 結構。
|
||||
|
||||
- **警告模式 (Warning/Alert)**:
|
||||
- 僅提供「關閉/確定」一個按鈕。
|
||||
- 樣式同 9.4,但隱藏刪除 Form 與相關色彩。
|
||||
- **確認模式 (Confirm)**:
|
||||
- 提供「取消」與「執行」兩個按鈕。
|
||||
- 執行按鈕顏色視操作性質而定 (Delete: `rose`, Save/Action: `cyan`)。
|
||||
|
||||
---
|
||||
> [!IMPORTANT]
|
||||
> **開發新功能前,必須確認 `app.css` 中的 `.btn-luxury-*` 系列組件是否滿足需求。**
|
||||
> 嚴禁在 Blade 中寫入大量重複的 `bg-indigo-600` 等舊式類別。
|
||||
|
||||
---
|
||||
> [!TIP]
|
||||
> 當遇到未定義的 UI 區塊時,優先參考 `admin.dashboard.blade.php` 的卡片與即時動態實作方式進行衍生。
|
||||
|
||||
## 13. AJAX 交互與加載規範 (AJAX Interactions & Loaders)
|
||||
|
||||
為了提供無刷新的 SPA 式流暢體驗,所有管理後台模組必須遵循統一的 AJAX 交互模式。
|
||||
|
||||
### 13.1 極奢華多環加載器 (Premium Multi-Ring Loader)
|
||||
用於表格遮罩、局部更新或狀態切換。具備雙環旋轉與脈衝核心圖示。
|
||||
### Type B — Tab + 列表(黃金範本:products/index.blade.php)
|
||||
|
||||
```html
|
||||
<!-- Loading Overlay Pattern -->
|
||||
<div x-show="isLoadingTable"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-300"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="absolute inset-0 z-50 flex flex-col items-center justify-center bg-white/40 dark:bg-slate-900/40 backdrop-blur-[1px] rounded-3xl" x-cloak>
|
||||
|
||||
<div class="relative w-16 h-16 mb-4 flex items-center justify-center">
|
||||
<!-- 外圈:快速旋轉 -->
|
||||
<div class="absolute inset-0 rounded-full border-2 border-transparent border-t-cyan-500 border-r-cyan-500/30 animate-spin"></div>
|
||||
<!-- 內圈:反向慢速旋轉 -->
|
||||
<div class="absolute inset-2 rounded-full border border-cyan-500/10 animate-spin" style="animation-duration: 3s; direction: reverse;"></div>
|
||||
<!-- 核心:脈衝圖示 -->
|
||||
<div class="relative w-8 h-8 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-cyan-500 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
@section('content')
|
||||
<div class="space-y-2 pb-20" x-data="{ activeTab: 'main', tabLoading: null }">
|
||||
|
||||
<x-page-header :title="__('Module Name')" :subtitle="__('Description')">
|
||||
<template x-if="activeTab === 'main'">
|
||||
<a href="..." class="btn-luxury-primary flex items-center gap-2">...</a>
|
||||
</template>
|
||||
</x-page-header>
|
||||
|
||||
<x-tab-nav>
|
||||
<x-tab-nav-item value="main" :label="__('Main List')" />
|
||||
<x-tab-nav-item value="secondary" :label="__('Secondary')" />
|
||||
</x-tab-nav>
|
||||
|
||||
<div x-show="activeTab === 'main'" class="luxury-card rounded-3xl p-6 animate-luxury-in relative min-h-[300px]" x-cloak>
|
||||
<x-luxury-spinner show="tabLoading === 'main'" z-index="z-20" />
|
||||
<div id="tab-main-container">
|
||||
@include('admin.xxx.partials.tab-main')
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.4em] animate-pulse">{{ __('Loading Data') }}...</p>
|
||||
|
||||
<div x-show="activeTab === 'secondary'" class="luxury-card rounded-3xl p-6 animate-luxury-in relative min-h-[300px]" x-cloak>
|
||||
<x-luxury-spinner show="tabLoading === 'secondary'" z-index="z-20" />
|
||||
<div id="tab-secondary-container">
|
||||
@include('admin.xxx.partials.tab-secondary')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
```
|
||||
|
||||
> ⚠️ **Tab Partial 搜尋欄**:Tab 頁面的 `@submit.prevent` 需呼叫 `handleFilterSubmit('tab_name')`,而非 `fetchPage()`。因此 Tab Partial **不使用** `<x-search-bar>`,須自行手寫 `<form @submit.prevent="handleFilterSubmit('...')">` 結構。
|
||||
|
||||
---
|
||||
|
||||
## 4. Mobile Card 黃金範本
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 所有 Mobile Card 必須嚴格遵守以下三區結構。禁止自行發明新結構。
|
||||
|
||||
### 卡片容器
|
||||
```html
|
||||
<div class="luxury-card p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800
|
||||
bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group">
|
||||
```
|
||||
|
||||
### 區域一:Card Header
|
||||
```html
|
||||
<div class="flex items-start justify-between gap-4 mb-6">
|
||||
|
||||
<div class="flex items-center gap-4 min-w-0">
|
||||
{{-- 圖示 56x56,hover 變 cyan --}}
|
||||
<div class="w-14 h-14 rounded-2xl bg-slate-100 dark:bg-slate-800
|
||||
flex items-center justify-center text-slate-400
|
||||
border border-slate-200 dark:border-slate-700
|
||||
group-hover:bg-cyan-500 group-hover:text-white
|
||||
transition-all duration-300 overflow-hidden shadow-sm shrink-0">
|
||||
{{-- img or SVG icon --}}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
{{-- 主標題:text-base font-extrabold,絕對不用 text-lg --}}
|
||||
<h3 class="text-base font-extrabold text-slate-800 dark:text-slate-100
|
||||
truncate hover:text-cyan-600 dark:hover:text-cyan-400
|
||||
transition-colors tracking-tight cursor-pointer">
|
||||
主要名稱
|
||||
</h3>
|
||||
{{-- 副標(SN/Barcode)--}}
|
||||
<p class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500
|
||||
uppercase tracking-widest truncate">
|
||||
副標
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 右側:強制用 x-status-badge,禁止用小圓點 --}}
|
||||
<x-status-badge :status="$item->status" size="sm" />
|
||||
|
||||
</div>
|
||||
```
|
||||
|
||||
### 13.2 標準 fetchPage 實作模式
|
||||
所有清單頁面應在 `x-data` 中實作 `fetchPage` 方法,並將動態內容包裹於 `#ajax-content-container` 中。
|
||||
### 區域二:Info Grid
|
||||
```html
|
||||
<div class="grid grid-cols-2 gap-y-4 mb-6 border-y border-slate-100 dark:border-slate-800/50 py-4">
|
||||
|
||||
<div>
|
||||
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
|
||||
{{ __('Label') }}
|
||||
</p>
|
||||
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">值</p>
|
||||
</div>
|
||||
|
||||
{{-- 跨欄(如時間戳)--}}
|
||||
<div class="col-span-2">
|
||||
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
|
||||
{{ __('Last Updated') }}
|
||||
</p>
|
||||
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 font-mono">
|
||||
{{ $item->updated_at->format('Y-m-d H:i:s') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
```
|
||||
|
||||
### 區域三:Action Buttons
|
||||
```html
|
||||
<div class="flex items-center gap-3">
|
||||
{{-- 一般動作 --}}
|
||||
<button type="button" @click="..."
|
||||
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl
|
||||
bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400
|
||||
font-bold text-xs hover:bg-cyan-500 hover:text-white
|
||||
transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50">
|
||||
<svg class="w-3.5 h-3.5">...</svg>
|
||||
{{ __('Edit') }}
|
||||
</button>
|
||||
|
||||
{{-- 危險動作 --}}
|
||||
<button type="button" @click="..."
|
||||
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl
|
||||
bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400
|
||||
font-bold text-xs hover:bg-rose-500 hover:text-white
|
||||
transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50">
|
||||
<svg class="w-3.5 h-3.5">...</svg>
|
||||
{{ __('Delete') }}
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. RWD 斷點規則
|
||||
|
||||
| 元素 | 規則 |
|
||||
|------|------|
|
||||
| 桌面表格顯示 | `hidden xl:block` |
|
||||
| Mobile Card 顯示 | `xl:hidden` |
|
||||
| Card Grid 欄數 | `grid-cols-1 md:grid-cols-2 gap-6` |
|
||||
| 切換點 | 統一 `xl`(1280px)**禁止用 `lg` 或 `2xl`** |
|
||||
| Page Header 排列 | 全斷點橫向排列 (flex-row) | 組件已處理,標題與按鈕並列 |
|
||||
| H1 字大小 | `text-2xl sm:text-3xl` | 組件已處理,帶有 truncate 避免擠壓 |
|
||||
| Tab 列寬 | `w-full sm:w-fit`(組件已處理)|
|
||||
| Tab 按鈕 | `flex-1 sm:flex-none`(組件已處理)|
|
||||
|
||||
### RWD 禁止事項
|
||||
|
||||
> [!CAUTION]
|
||||
> - ❌ Mobile Card 標題用 `text-lg`(必須 `text-base font-extrabold`)
|
||||
> - ❌ Mobile Card 右上角用小圓點代替 `x-status-badge`
|
||||
> - ❌ 操作按鈕不加 `flex-1`
|
||||
> - ❌ Mobile Card 圓角用 `rounded-3xl`(應為 `rounded-[2rem]`)
|
||||
> - ❌ Info Grid 欄位標籤用 `text-xs`(應為 `text-[10px]`)
|
||||
> - ❌ 桌面/手機切換用 `lg` 斷點
|
||||
|
||||
---
|
||||
|
||||
## 6. 表單元件
|
||||
|
||||
```html
|
||||
{{-- 輸入框 --}}
|
||||
<input type="text" class="luxury-input py-2.5 pl-11 pr-4 block w-full sm:w-72 text-sm">
|
||||
|
||||
{{-- 可搜尋下拉(選項 > 10 筆時)--}}
|
||||
<x-searchable-select
|
||||
name="company_id"
|
||||
:options="$companies"
|
||||
:selected="request('company_id')"
|
||||
:placeholder="__('All Companies')"
|
||||
/>
|
||||
|
||||
{{-- 下拉選單遮擋處理範例 --}}
|
||||
<div class="relative focus-within:z-20">
|
||||
<label>...</label>
|
||||
<x-searchable-select ... />
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 提示與告警
|
||||
|
||||
| 類型 | 組件 |
|
||||
|------|------|
|
||||
| 成功/錯誤 Toast | `<x-toast />` |
|
||||
| 刪除確認 Modal | `<x-delete-confirm-modal />` |
|
||||
| 頁面內警告(琥珀色)| `<div class="p-5 bg-amber-500/10 border border-amber-500/20 text-amber-600 rounded-2xl flex items-start gap-4 font-bold">` |
|
||||
|
||||
### 7.1 Modal 實作規範(強制)
|
||||
|
||||
> [!CAUTION]
|
||||
> **嚴禁使用 Preline 的 `hs-overlay` 系統**來實作 Modal。
|
||||
> 本專案全部 Modal 統一使用 **Alpine.js** (`x-show` + `x-transition`) 實作。
|
||||
> 使用 `hs-overlay` class 會導致 Modal 出現但完全透明(`opacity: 0`),因為 Preline overlay 的動畫 class 在此專案中未被正確初始化。
|
||||
|
||||
**正確:Alpine.js Modal 標準結構(參考 `modal-replenishment-create.blade.php`)**
|
||||
|
||||
```html
|
||||
{{-- 1. 觸發按鈕:用 $dispatch 發送 window 事件 --}}
|
||||
<button type="button" @click="$dispatch('open-xxx-modal')">開啟</button>
|
||||
|
||||
{{-- 2. Modal 主體:放在 layout 底部,x-data 宣告 Alpine state --}}
|
||||
<div x-data="{ showModal: false }" @open-xxx-modal.window="showModal = true" x-cloak>
|
||||
<div x-show="showModal" class="fixed inset-0 z-[110] overflow-y-auto" style="display: none;">
|
||||
<div class="flex items-center justify-center min-h-screen px-4 ...">
|
||||
|
||||
{{-- Backdrop --}}
|
||||
<div x-show="showModal"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm"
|
||||
@click="showModal = false"></div>
|
||||
|
||||
{{-- Modal 內容 --}}
|
||||
<div x-show="showModal"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-4 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 translate-y-4 sm:scale-95"
|
||||
class="relative inline-flex flex-col align-bottom bg-white dark:bg-slate-900
|
||||
rounded-[2.5rem] text-left shadow-2xl transform
|
||||
sm:my-8 sm:align-middle sm:max-w-lg w-full
|
||||
border border-slate-100 dark:border-slate-800 z-10"
|
||||
@click.stop>
|
||||
|
||||
{{-- Header --}}
|
||||
<div class="px-10 py-8 pb-4 flex items-center justify-between">
|
||||
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight">
|
||||
標題
|
||||
</h3>
|
||||
<button @click="showModal = false"
|
||||
class="p-2.5 rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-slate-600 transition-all border border-slate-100 dark:border-slate-700">
|
||||
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Body / Footer --}}
|
||||
<div class="px-10 py-6">...</div>
|
||||
<div class="px-10 py-6 border-t border-slate-100 dark:border-slate-800/50 flex justify-end gap-4">
|
||||
<button @click="showModal = false" class="btn-luxury-ghost px-8">取消</button>
|
||||
<button type="submit" class="btn-luxury-primary px-12">確認</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**跨元件開啟 Modal(dropdown 內的按鈕):**
|
||||
- 按鈕在 Alpine.js dropdown 內時,需先關閉 dropdown 再發送事件:
|
||||
```html
|
||||
<button @click="open = false; $dispatch('open-xxx-modal')">切換帳號</button>
|
||||
```
|
||||
- Modal 透過 `@open-xxx-modal.window` 接收(`window` 層級才能跨 Alpine scope 傳遞)
|
||||
|
||||
|
||||
|
||||
## 8. AJAX fetchPage 標準實作
|
||||
|
||||
```javascript
|
||||
async fetchPage(url) {
|
||||
@ -374,12 +490,12 @@ async fetchPage(url) {
|
||||
try {
|
||||
const res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
const html = await res.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const newContent = doc.querySelector('#ajax-content-container');
|
||||
if (newContent) {
|
||||
document.querySelector('#ajax-content-container').innerHTML = newContent.innerHTML;
|
||||
window.history.pushState({}, '', url);
|
||||
if (window.HSStaticMethods?.autoInit) window.HSStaticMethods.autoInit();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('AJAX Load Failed:', e);
|
||||
@ -389,49 +505,46 @@ async fetchPage(url) {
|
||||
}
|
||||
```
|
||||
|
||||
### 13.3 觸發 AJAX 導航
|
||||
- **搜尋表單**: 使用 `@submit.prevent="fetchPage($el.action + '?' + new URLSearchParams(new FormData($el)).toString())"`。
|
||||
- **下拉選單**: 使用 `onchange="this.dispatchEvent(new CustomEvent('ajax:navigate', { detail: { url: ... }, bubbles: true }))"`。
|
||||
- **分頁/連結**: 在容器上使用代理監聽,或直接調用 `fetchPage(url)`。
|
||||
> **分頁**:`{{ $items->appends(request()->query())->links('vendor.pagination.luxury') }}`
|
||||
|
||||
## 14. 頁面佈局與頁籤間距規範 (Page Layout & Tab Spacing)
|
||||
---
|
||||
|
||||
為了確保管理後台全站視覺的「高度整合感」與「緊湊性」,頁面頂部結構與頁籤導覽必須遵循以下間距規範。
|
||||
## 9. 區塊圖示色彩(詳情/編輯頁)
|
||||
|
||||
### 14.1 核心佈局間距 (Core Spacing Scale)
|
||||
- **根容器間距 (Root Space)**: 頂部 Header、Tabs 與內容區域之間的垂直間距統一使用 **`space-y-2`**。
|
||||
- **標題與描述 (Title & Subtitle)**: 描述文字應緊貼標題,使用 `mt-1`。
|
||||
- **頁籤外層間距 (Tabs Outer)**: 頁籤容器與 Header 之間應由根容器的 `space-y-2` 自然推開,不額外加 `mt`。
|
||||
| 區塊 | 色系 | Class |
|
||||
|------|------|-------|
|
||||
| 基本資訊 | 翠綠 Emerald | `bg-emerald-500/10 text-emerald-500` |
|
||||
| 硬體/插槽 | 琥珀 Amber | `bg-amber-500/10 text-amber-500` |
|
||||
| 系統/進階 | 靛藍 Indigo | `bg-indigo-500/10 text-indigo-500` |
|
||||
| 危險/刪除 | 玫瑰 Rose | `bg-rose-500/10 text-rose-500` |
|
||||
---
|
||||
|
||||
### 14.2 頁籤導覽樣式 (Tab Navigation Styles)
|
||||
- **容器內距**: 使用 `p-1.5` 的圓角膠囊背景。
|
||||
- **按鈕內距**: 頁籤按鈕統一使用 **`px-8 py-3`**。
|
||||
- **內容間隙**: 頁籤下方內容區與頁籤列之間,建議不額外添加 `mt-6` 等大間距。若內容區域內部需要層次感,使用 `space-y-3` 即可。
|
||||
## 10. Z-Index 堆疊與遮擋處理 (Z-Index Stacking)
|
||||
|
||||
### 14.3 實作範例結構 (Reference Structure)
|
||||
> [!IMPORTANT]
|
||||
> 當頁面或 Modal 中存在多個垂直排列的 x-searchable-select 時,為防止選單開展時被下一個欄位遮擋,嚴禁使用固定的 z-index。
|
||||
|
||||
### 10.1 動態層級提升模式
|
||||
必須在欄位容器上使用 focus-within:z-[index],確保正在操作的欄位永遠處於堆疊最前方。
|
||||
|
||||
| 使用場景 | 推薦 Class |
|
||||
|---------|------------|
|
||||
| 一般表單頁面 | relative focus-within:z-[20] |
|
||||
| 模態框 (Modal) 內 | relative focus-within:z-[60] |
|
||||
|
||||
### 10.2 代碼範例
|
||||
```html
|
||||
<div class="space-y-2">
|
||||
<!-- Header Area -->
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-black">標題</h1>
|
||||
<p class="text-sm mt-1">描述文字</p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<!-- 型號選單 -->
|
||||
<div class="relative focus-within:z-[60]">
|
||||
<label>型號</label>
|
||||
<x-searchable-select ... />
|
||||
</div>
|
||||
|
||||
<!-- Tabs Container -->
|
||||
<div class="flex items-center p-1.5 bg-slate-100 dark:bg-slate-900/40 rounded-2xl w-fit border border-slate-200/50 dark:border-white/5">
|
||||
<button class="px-8 py-3 rounded-xl text-sm font-black active">Tab A</button>
|
||||
<button class="px-8 py-3 rounded-xl text-sm font-black">Tab B</button>
|
||||
</div>
|
||||
|
||||
<!-- Content Area (AJAX Container) -->
|
||||
<div class="space-y-3">
|
||||
<!-- 內容卡片 -->
|
||||
<!-- 公司選單 -->
|
||||
<div class="relative focus-within:z-[60]">
|
||||
<label>公司</label>
|
||||
<x-searchable-select ... />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 14.4 統計卡片間距 (Stats Grid Spacing)
|
||||
- **網格間距**: 頂部統計卡片網格 (Grid) 建議使用 **`gap-4`** (16px),避免使用過大的 `gap-6` 或 `gap-8` 導致版面鬆散。
|
||||
- **卡片內距**: 統計卡片內部統一使用 `p-6`。
|
||||
@ -84,4 +84,5 @@ MQTT_OUTGOING_QUEUE=mqtt_outgoing_commands
|
||||
|
||||
# EMQX Ports (for local dev)
|
||||
MQTT_PORT=1883
|
||||
MQTT_SSL_PORT=8883
|
||||
EMQX_DASHBOARD_PORT=18083
|
||||
|
||||
@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy-demo:
|
||||
runs-on: ubuntu-latest # 使用機器人預設宣告的標籤
|
||||
runs-on: [ubuntu-latest, demo]
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
@ -34,7 +34,6 @@ jobs:
|
||||
docker run --rm -v /workspace/mama/star-cloud:/target busybox mkdir -p /target/docker/emqx
|
||||
cat docker/emqx/acl.conf | docker run -i --rm -v /workspace/mama/star-cloud:/target busybox sh -c "cat > /target/docker/emqx/acl.conf"
|
||||
|
||||
|
||||
- name: Step 1 - Build & Deploy Containers
|
||||
run: |
|
||||
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml up -d --build --wait
|
||||
@ -81,4 +80,4 @@ jobs:
|
||||
docker restart star-cloud-mqtt-worker
|
||||
|
||||
- name: Step 5 - Cleanup
|
||||
run: docker image prune -f
|
||||
run: docker image prune -f --filter "until=168h"
|
||||
|
||||
@ -7,15 +7,120 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [ubuntu-latest, prod]
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
github-server-url: https://gitea.setdomains.work
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
- name: Step 1 - Push Code to Production
|
||||
- name: Step 0 - Prepare Environment & Sync Configs
|
||||
run: |
|
||||
echo "Production deployment is currently in preparation..."
|
||||
# 待正式環境資料確定後,再補上 rsync 與 SSH 邏輯
|
||||
# 1. 確保結構正確
|
||||
mkdir -p docker/emqx
|
||||
|
||||
# 2. 檢查並清理宿主機 (Host) 上被 Docker 誤建為目錄的檔案
|
||||
# 生產環境路徑為 ~/star-cloud (對應絕對路徑 /home/twsystem1003/star-cloud)
|
||||
docker run --rm -v /home/twsystem1003/star-cloud:/target busybox sh -c "
|
||||
if [ -d /target/.env ]; then echo 'Removing .env directory on host'; rm -rf /target/.env; fi
|
||||
if [ -d /target/docker/emqx/acl.conf ]; then echo 'Removing acl.conf directory on host'; rm -rf /target/docker/emqx/acl.conf; fi
|
||||
"
|
||||
|
||||
# 3. 從宿主機載入持久化的 .env 檔案
|
||||
docker run --rm -v /home/twsystem1003/star-cloud:/data:ro busybox cat /data/.env > .env || cp .env.example .env
|
||||
|
||||
# 4. 將 Runner 最新 Checkout 的設定檔同步至宿主機持久區
|
||||
docker run --rm -v /home/twsystem1003/star-cloud:/target busybox mkdir -p /target/docker/emqx
|
||||
cat docker/emqx/acl.conf | docker run -i --rm -v /home/twsystem1003/star-cloud:/target busybox sh -c "cat > /target/docker/emqx/acl.conf"
|
||||
|
||||
- name: Step 1 - Ensure Existing Containers Are Running
|
||||
run: |
|
||||
for container in \
|
||||
star-cloud-mysql \
|
||||
star-cloud-redis \
|
||||
star-cloud-emqx
|
||||
do
|
||||
if ! docker inspect "${container}" >/dev/null 2>&1; then
|
||||
echo ">>> Required container is missing: ${container}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ">>> Ensuring ${container} is running..."
|
||||
docker start "${container}" >/dev/null
|
||||
done
|
||||
|
||||
for container in star-cloud-mysql star-cloud-redis star-cloud-emqx; do
|
||||
echo ">>> Waiting for ${container} to become healthy..."
|
||||
|
||||
for i in $(seq 1 60); do
|
||||
status=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${container}" 2>/dev/null || echo "missing")
|
||||
|
||||
if [ "${status}" = "healthy" ]; then
|
||||
echo ">>> ${container} is healthy."
|
||||
break
|
||||
fi
|
||||
|
||||
if [ "${i}" -eq 60 ]; then
|
||||
echo ">>> ${container} did not become healthy. Current status: ${status}"
|
||||
docker logs --tail 80 "${container}" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
done
|
||||
|
||||
for container in \
|
||||
star-cloud-laravel \
|
||||
star-cloud-queue \
|
||||
star-cloud-mqtt-worker \
|
||||
star-cloud-mqtt-gateway
|
||||
do
|
||||
if ! docker inspect "${container}" >/dev/null 2>&1; then
|
||||
echo ">>> Required container is missing: ${container}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ">>> Ensuring ${container} is running..."
|
||||
docker start "${container}" >/dev/null
|
||||
done
|
||||
|
||||
- name: Step 2 - Deploy Code
|
||||
run: |
|
||||
echo ">>> Syncing code to star-cloud-laravel..."
|
||||
tar --exclude='.git' --exclude='node_modules' --exclude='vendor' \
|
||||
--exclude='storage' --exclude='bootstrap/cache' \
|
||||
-cf /tmp/deploy.tar . || [ $? -le 1 ]
|
||||
|
||||
docker exec -i star-cloud-laravel tar -xf - -C /var/www/html/ < /tmp/deploy.tar
|
||||
rm -f /tmp/deploy.tar
|
||||
|
||||
# 生產環境使用 1001:1001
|
||||
docker exec star-cloud-laravel sh -c "
|
||||
chown -R 1001:1001 /var/www/html &&
|
||||
mkdir -p /.npm && chown -R 1001:1001 /.npm &&
|
||||
rm -rf /var/www/html/node_modules
|
||||
"
|
||||
|
||||
- name: Step 3 - Composer & NPM & Initialization
|
||||
run: |
|
||||
docker exec -u 1001:1001 -w /var/www/html star-cloud-laravel sh -c "
|
||||
composer install --no-dev --optimize-autoloader --no-interaction &&
|
||||
npm install &&
|
||||
npm run build &&
|
||||
php artisan migrate --force &&
|
||||
php artisan storage:link &&
|
||||
php artisan optimize:clear &&
|
||||
php artisan optimize &&
|
||||
php artisan view:cache &&
|
||||
php artisan queue:restart &&
|
||||
php artisan db:seed --class=RoleSeeder --force &&
|
||||
php artisan db:seed --class=AdminUserSeeder --force
|
||||
"
|
||||
docker exec star-cloud-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
|
||||
|
||||
- name: Step 4 - Restart Long-running Workers
|
||||
run: |
|
||||
docker restart star-cloud-mqtt-worker
|
||||
echo "✅ MQTT Worker 已重啟"
|
||||
|
||||
- name: Step 5 - Cleanup
|
||||
run: docker image prune -f --filter "until=168h"
|
||||
|
||||
@ -1,114 +0,0 @@
|
||||
name: star-cloud-deploy-demo
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy-demo:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
github-server-url: https://gitea.setdomains.work
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
- name: Step 1 - Push Code to Demo
|
||||
run: |
|
||||
apt-get update && apt-get install -y rsync openssh-client
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEMO_SSH_KEY }}" > ~/.ssh/id_rsa_demo
|
||||
chmod 600 ~/.ssh/id_rsa_demo
|
||||
rsync -avz --delete \
|
||||
--exclude='/.git' \
|
||||
--exclude='/node_modules' \
|
||||
--exclude='/vendor' \
|
||||
--exclude='/storage' \
|
||||
--exclude='/.env' \
|
||||
--exclude='/public/build' \
|
||||
-e "ssh -p 22 -i ~/.ssh/id_rsa_demo -o StrictHostKeyChecking=no" \
|
||||
./ mama@demo-sc-103:/workspace/mama/star-cloud/
|
||||
rm ~/.ssh/id_rsa_demo
|
||||
|
||||
- name: Step 2 - Check if Rebuild Needed
|
||||
id: check_rebuild
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: demo-sc-103
|
||||
port: 22
|
||||
username: mama
|
||||
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||
script: |
|
||||
cd /workspace/mama/star-cloud
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|compose\.demo\.yaml|docker-compose\.yaml)'; then
|
||||
echo "REBUILD_NEEDED=true"
|
||||
else
|
||||
echo "REBUILD_NEEDED=false"
|
||||
fi
|
||||
|
||||
- name: Step 3 - Container Up & Health Check
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: demo-sc-103
|
||||
port: 22
|
||||
username: mama
|
||||
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||
script: |
|
||||
cd /workspace/mama/star-cloud
|
||||
chown -R 1000:1000 .
|
||||
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|compose\.demo\.yaml|docker-compose\.yaml)'; then
|
||||
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
|
||||
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.demo.yaml up -d --build --wait
|
||||
else
|
||||
echo "⚡ 無 Docker 檔案變更,僅重載服務..."
|
||||
if ! docker ps --format '{{.Names}}' | grep -q 'star-cloud-demo-laravel'; then
|
||||
echo "容器未運行,正在啟動..."
|
||||
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.demo.yaml up -d --wait
|
||||
else
|
||||
echo "容器已運行,跳過 docker compose,直接進行程式碼部署..."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "容器狀態:" && docker ps --filter "name=star-cloud-demo"
|
||||
|
||||
- name: Step 4 - Composer & NPM Build
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: demo-sc-103
|
||||
port: 22
|
||||
username: mama
|
||||
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||
script: |
|
||||
docker exec -u 1000:1000 -w /var/www/html star-cloud-demo-laravel sh -c "
|
||||
# 1. 後端依賴
|
||||
composer install --no-dev --optimize-autoloader --no-interaction &&
|
||||
|
||||
# 2. 前端編譯
|
||||
npm install &&
|
||||
npm run build &&
|
||||
|
||||
# 3. Laravel 初始化與優化
|
||||
php artisan migrate --force &&
|
||||
php artisan storage:link &&
|
||||
php artisan optimize:clear &&
|
||||
php artisan optimize &&
|
||||
php artisan view:cache &&
|
||||
php artisan queue:restart &&
|
||||
php artisan db:seed --class=RoleSeeder --force &&
|
||||
php artisan db:seed --class=AdminUserSeeder --force
|
||||
"
|
||||
docker exec star-cloud-demo-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
|
||||
|
||||
- name: Step 5 - Auto Sync workflows to main
|
||||
run: |
|
||||
git config --global user.email "bot@setdomains.work"
|
||||
git config --global user.name "CICD Bot"
|
||||
git fetch origin main
|
||||
git checkout main
|
||||
git checkout ${{ github.ref_name }} -- .gitea/workflows/
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "[AUTO] Sync workflows from ${{ github.ref_name }} to main"
|
||||
GIT_SSH_COMMAND="ssh -p 3222 -o StrictHostKeyChecking=no" git push origin main
|
||||
fi
|
||||
git checkout ${{ github.ref_name }}
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -20,3 +20,9 @@ yarn-error.log
|
||||
/docs/API
|
||||
/docs/*.xlsx
|
||||
/docs/pptx
|
||||
/scratch
|
||||
/.antigravitycli
|
||||
|
||||
# Claude Code 個人設定(權限授權等,不進版控)
|
||||
/.claude/settings.json
|
||||
/.claude/settings.local.json
|
||||
|
||||
16
CLAUDE.md
Normal file
16
CLAUDE.md
Normal file
@ -0,0 +1,16 @@
|
||||
# star-cloud — 專案開發規範(Claude Code)
|
||||
|
||||
> 本專案的開發規範與 Antigravity 共用**同一份來源**:`.agents/rules/`。
|
||||
> 下方以 `@` import 載入,避免重複維護。**要修改規範請直接編輯 `.agents/rules/` 內的檔案**,不要改這裡。
|
||||
|
||||
## 一律遵循的專案規範(always-on)
|
||||
|
||||
@.agents/rules/framework.md
|
||||
@.agents/rules/api-rules.md
|
||||
@.agents/rules/rbac-rules.md
|
||||
@.agents/rules/skill-trigger.md
|
||||
|
||||
## Claude 專屬補充
|
||||
|
||||
- `.agents/rules/` 與 `skill-trigger.md` 原為 Antigravity 撰寫,其中提到的 `view_file` 工具,在 Claude 對應為 `Read`;`.agents/skills/` 的 SKILL.md 可用 `Read` 讀取後依其指引作業。
|
||||
- 提交訊息規範(繁體中文、`[FEAT]/[FIX]/[DOCS]/[STYLE]/[REFACTOR]` 前綴 + 數字列表)與環境晉升(dev → demo → main)流程,已封裝為使用者層自訂指令 `/now-push`、`/start-task`、`/promote-env`、`/hotfix-prod`,需要時可直接呼叫。
|
||||
@ -9,6 +9,7 @@ use App\Jobs\Machine\ProcessHeartbeat;
|
||||
use App\Jobs\Machine\ProcessMachineError;
|
||||
use App\Jobs\Machine\ProcessTransaction;
|
||||
use App\Jobs\Machine\ProcessStatus;
|
||||
use App\Jobs\Machine\ProcessMachineEvent;
|
||||
use App\Jobs\Transaction\ProcessInvoice;
|
||||
use App\Jobs\Transaction\ProcessDispenseRecord;
|
||||
use App\Jobs\Machine\ProcessCommandAck;
|
||||
@ -71,6 +72,14 @@ class ListenMqttQueue extends Command
|
||||
$serialNo = $data['serial_no'] ?? '';
|
||||
$payload = $data['payload'] ?? [];
|
||||
|
||||
// 如果 payload 是 JSON 字串,則解析它
|
||||
if (is_string($payload)) {
|
||||
$decoded = json_decode($payload, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$payload = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($serialNo)) {
|
||||
Log::warning("MQTT Listen: Missing serialNo in message", [
|
||||
'type' => $type,
|
||||
@ -86,9 +95,16 @@ class ListenMqttQueue extends Command
|
||||
$finalPayload = is_array($payload) ? $payload : ['raw_data' => $payload];
|
||||
ProcessHeartbeat::dispatch($serialNo, $finalPayload);
|
||||
break;
|
||||
case 'ambient_temp':
|
||||
$finalPayload = is_array($payload) ? $payload : ['temperature' => $payload];
|
||||
\App\Jobs\Machine\ProcessAmbientTemp::dispatch($serialNo, $finalPayload);
|
||||
break;
|
||||
case 'status':
|
||||
ProcessStatus::dispatch($serialNo, $payload);
|
||||
break;
|
||||
case 'event':
|
||||
ProcessMachineEvent::dispatch($serialNo, $payload);
|
||||
break;
|
||||
case 'error':
|
||||
ProcessMachineError::dispatch($serialNo, $payload);
|
||||
break;
|
||||
@ -113,7 +129,22 @@ class ListenMqttQueue extends Command
|
||||
*/
|
||||
protected function dispatchTransactionByAction(string $serialNo, array $payload): void
|
||||
{
|
||||
$action = $payload['action'] ?? 'create';
|
||||
Log::debug("MQTT Transaction incoming payload", [
|
||||
'serial_no' => $serialNo,
|
||||
'payload' => $payload
|
||||
]);
|
||||
|
||||
$rawAction = $payload['action'] ?? 'create';
|
||||
$action = strtolower(trim($rawAction));
|
||||
|
||||
// 超級偵錯:檢查 action 字串的每一個位元組
|
||||
$hex = bin2hex($rawAction);
|
||||
Log::debug("MQTT Action Debug", [
|
||||
'raw' => $rawAction,
|
||||
'hex' => $hex,
|
||||
'processed' => $action,
|
||||
'match_finalize' => ($action === 'finalize' ? 'YES' : 'NO')
|
||||
]);
|
||||
|
||||
switch ($action) {
|
||||
case 'invoice':
|
||||
@ -130,6 +161,12 @@ class ListenMqttQueue extends Command
|
||||
Log::info("MQTT Transaction [dispense] dispatched", ['serial_no' => $serialNo]);
|
||||
break;
|
||||
|
||||
case 'finalize':
|
||||
// 新增:B600/B601/B602 統整上報
|
||||
\App\Jobs\Transaction\ProcessTransactionFinalized::dispatch($serialNo, $payload);
|
||||
Log::info("MQTT Transaction [finalize] dispatched", ['serial_no' => $serialNo]);
|
||||
break;
|
||||
|
||||
case 'create':
|
||||
default:
|
||||
// B600: 交易建立(既有邏輯,向後相容無 action 欄位的舊格式)
|
||||
|
||||
119
app/Console/Commands/Machine/ProcessOtaSchedules.php
Normal file
119
app/Console/Commands/Machine/ProcessOtaSchedules.php
Normal file
@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Machine;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\Machine\OtaSchedule;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\RemoteCommand;
|
||||
use App\Services\Machine\MqttService;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProcessOtaSchedules extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'ota:process-schedules';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Process scheduled OTA firmware updates and send MQTT commands to target machines';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(MqttService $mqttService)
|
||||
{
|
||||
$this->info('[' . now()->toDateTimeString() . '] Starting to process scheduled OTA updates...');
|
||||
|
||||
// 撈取狀態為待發送且時間已到的排程
|
||||
$schedules = OtaSchedule::where('status', 'pending')
|
||||
->where('scheduled_at', '<=', now())
|
||||
->with('apkVersion')
|
||||
->get();
|
||||
|
||||
if ($schedules->isEmpty()) {
|
||||
$this->info('No pending OTA schedules to process.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info("Found {$schedules->count()} pending OTA schedule(s) to process.");
|
||||
|
||||
foreach ($schedules as $schedule) {
|
||||
$this->info("Processing OTA Schedule ID: {$schedule->id} (APK: {$schedule->apkVersion->version_name})");
|
||||
|
||||
// 標記為處理中,防止重複執行
|
||||
$schedule->update(['status' => 'processing']);
|
||||
$apkVersion = $schedule->apkVersion;
|
||||
|
||||
try {
|
||||
// 根據 target_type 撈取目標機台
|
||||
if ($schedule->target_type === 'all') {
|
||||
$machines = Machine::all();
|
||||
} else {
|
||||
$machineIds = $schedule->target_value ?? [];
|
||||
$machines = Machine::whereIn('id', $machineIds)->get();
|
||||
}
|
||||
|
||||
if ($machines->isEmpty()) {
|
||||
$this->warn("No target machines found for OTA Schedule ID: {$schedule->id}. Skipping.");
|
||||
$schedule->update(['status' => 'completed']);
|
||||
continue;
|
||||
}
|
||||
|
||||
$successCount = 0;
|
||||
|
||||
foreach ($machines as $machine) {
|
||||
// 1. 先建立 RemoteCommand 紀錄
|
||||
$command = RemoteCommand::create([
|
||||
'machine_id' => $machine->id,
|
||||
'user_id' => $schedule->user_id,
|
||||
'command_type' => 'update_app',
|
||||
'status' => 'pending',
|
||||
'payload' => [
|
||||
'url' => $apkVersion->url,
|
||||
'version' => $apkVersion->version_name,
|
||||
'version_code' => $apkVersion->version_code,
|
||||
'is_forced' => $apkVersion->is_forced,
|
||||
],
|
||||
]);
|
||||
|
||||
// 2. 推播 MQTT 更新指令
|
||||
$pushed = $mqttService->pushCommand(
|
||||
$machine->serial_no,
|
||||
'update_app',
|
||||
$command->payload,
|
||||
(string) $command->id
|
||||
);
|
||||
|
||||
if ($pushed) {
|
||||
$successCount++;
|
||||
} else {
|
||||
$command->update(['status' => 'failed']);
|
||||
$this->error("Failed to push MQTT OTA command to machine: {$machine->serial_no}");
|
||||
}
|
||||
}
|
||||
|
||||
$schedule->update(['status' => 'completed']);
|
||||
$this->info("Successfully processed OTA Schedule ID: {$schedule->id}. MQTT deployed to {$successCount}/{$machines->count()} machines.");
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error("Error processing OTA Schedule ID {$schedule->id}: " . $e->getMessage(), [
|
||||
'exception' => $e
|
||||
]);
|
||||
$schedule->update(['status' => 'failed']);
|
||||
$this->error("Error processing OTA Schedule ID {$schedule->id}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('OTA schedules processing completed.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
113
app/Console/Commands/ReconcileInvoicesCommand.php
Normal file
113
app/Console/Commands/ReconcileInvoicesCommand.php
Normal file
@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Transaction\Invoice;
|
||||
use App\Services\Invoice\EcpayInvoiceService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 電子發票對帳:掃 pending 發票,去綠界 GetIssue 查真實狀態。
|
||||
*
|
||||
* 機台開票回應掉包時 status 會停在 pending(不能當失敗,否則重開會重複)。
|
||||
* 本命令以 RelateNumber 向綠界查詢確認:已開→補登 issued;確認未開→標 failed(待補開)。
|
||||
* 僅處理機台有綠界發票設定者;未設定者一律略過,未開發票的機台零接觸。
|
||||
*/
|
||||
class ReconcileInvoicesCommand extends Command
|
||||
{
|
||||
protected $signature = 'invoices:reconcile {--limit=100 : 單次處理筆數上限} {--max-retry=12 : 單張最多查詢次數}';
|
||||
|
||||
protected $description = '對 pending 電子發票向綠界 GetIssue 查詢並補登/標記狀態';
|
||||
|
||||
public function handle(EcpayInvoiceService $ecpay): int
|
||||
{
|
||||
$limit = (int) $this->option('limit');
|
||||
$maxRetry = (int) $this->option('max-retry');
|
||||
|
||||
$invoices = Invoice::with(['machine.paymentConfig', 'order'])
|
||||
->where('status', Invoice::STATUS_PENDING)
|
||||
->whereNotNull('relate_number')
|
||||
->where('retry_count', '<', $maxRetry)
|
||||
->orderBy('id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
if ($invoices->isEmpty()) {
|
||||
$this->info('No pending invoices to reconcile.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$issued = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
$machine = $invoice->machine;
|
||||
if (!$machine || !$ecpay->configForMachine($machine)) {
|
||||
// 機台無綠界設定 → 略過,不動狀態(不可能誤觸未開發票機台)
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $ecpay->getIssue($machine, $invoice->relate_number);
|
||||
|
||||
// 連線/解密失敗 → 本輪略過,僅累加重試次數待下輪
|
||||
if ($result === null) {
|
||||
$invoice->update([
|
||||
'retry_count' => $invoice->retry_count + 1,
|
||||
'last_checked_at' => now(),
|
||||
]);
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$rtnCode = (string) ($result['RtnCode'] ?? '');
|
||||
$invoiceNo = $result['IIS_Number'] ?? ($result['InvoiceNumber'] ?? '');
|
||||
|
||||
if ($rtnCode === '1' && !empty($invoiceNo)) {
|
||||
// 綠界確認已開立 → 補登
|
||||
$invoice->update([
|
||||
'status' => Invoice::STATUS_ISSUED,
|
||||
'invoice_no' => $invoiceNo,
|
||||
'invoice_date' => $this->parseDate($result['IIS_Create_Date'] ?? null),
|
||||
'random_number' => $result['IIS_Random_Number'] ?? $invoice->random_number,
|
||||
'rtn_code' => $rtnCode,
|
||||
'rtn_msg' => $result['RtnMsg'] ?? $invoice->rtn_msg,
|
||||
'retry_count' => $invoice->retry_count + 1,
|
||||
'last_checked_at' => now(),
|
||||
]);
|
||||
$issued++;
|
||||
Log::info('Invoice reconciled as issued', [
|
||||
'invoice_id' => $invoice->id,
|
||||
'invoice_no' => $invoiceNo,
|
||||
]);
|
||||
} else {
|
||||
// 綠界查無此發票 → 確認未開立,標 failed 待補開
|
||||
$invoice->update([
|
||||
'status' => Invoice::STATUS_FAILED,
|
||||
'rtn_code' => $rtnCode ?: $invoice->rtn_code,
|
||||
'rtn_msg' => $result['RtnMsg'] ?? $invoice->rtn_msg,
|
||||
'retry_count' => $invoice->retry_count + 1,
|
||||
'last_checked_at' => now(),
|
||||
]);
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Reconcile done. issued={$issued} failed={$failed} skipped={$skipped}");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function parseDate(?string $raw): ?string
|
||||
{
|
||||
if (empty($raw)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return \Carbon\Carbon::parse($raw)->toDateString();
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
54
app/Console/Commands/SweepAbandonedOrdersCommand.php
Normal file
54
app/Console/Commands/SweepAbandonedOrdersCommand.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Transaction\Order;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 把「停留在 pending 太久」的交易標記為 abandoned(感應前取消 / 付款未完成)。
|
||||
*
|
||||
* 設計重點(過渡期安全):
|
||||
* - 只動 status = 'pending' 的單。線上 main 機台(晟崴/中國醫)從不送 pending,
|
||||
* 後台也就不會有它們的 pending 單 → 本命令永遠掃不到它們,零接觸。
|
||||
* - 付款對話框逾時約 40 秒,預設門檻給到 15 分鐘,遠大於正常付款耗時;
|
||||
* 超過此門檻仍 pending,代表機台沒回成功/失敗(取消、斷線、App 重啟),視為 abandoned。
|
||||
* - 不碰庫存、不碰金流:pending 本來就還沒出貨/扣庫存。
|
||||
*/
|
||||
class SweepAbandonedOrdersCommand extends Command
|
||||
{
|
||||
protected $signature = 'orders:sweep-abandoned {--minutes=15 : 停留在 pending 超過幾分鐘視為 abandoned} {--limit=500 : 單次處理筆數上限}';
|
||||
|
||||
protected $description = '將逾時停留在 pending 的交易標記為 abandoned';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$minutes = max(1, (int) $this->option('minutes'));
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
$threshold = now()->subMinutes($minutes);
|
||||
|
||||
$orders = Order::withoutGlobalScopes()
|
||||
->where('status', Order::STATUS_PENDING)
|
||||
->where('created_at', '<', $threshold)
|
||||
->orderBy('id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
if ($orders->isEmpty()) {
|
||||
$this->info('No stale pending orders to sweep.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$swept = 0;
|
||||
foreach ($orders as $order) {
|
||||
$order->update(['status' => Order::STATUS_ABANDONED]);
|
||||
$swept++;
|
||||
}
|
||||
|
||||
Log::info('Swept abandoned orders', ['count' => $swept, 'older_than_minutes' => $minutes]);
|
||||
$this->info("Marked {$swept} pending order(s) as abandoned (older than {$minutes} min).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
62
app/Console/Commands/WarmProductsCacheCommand.php
Normal file
62
app/Console/Commands/WarmProductsCacheCommand.php
Normal file
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\System\Company;
|
||||
use App\Services\Product\ProductCatalogService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class WarmProductsCacheCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'products:warm-cache {--company= : ID of the specific company to warm cache for}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Pre-warm the Redis cache for product catalogs of all or a specific company.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @param ProductCatalogService $catalogService
|
||||
* @return int
|
||||
*/
|
||||
public function handle(ProductCatalogService $catalogService): int
|
||||
{
|
||||
$companyId = $this->option('company');
|
||||
|
||||
if ($companyId) {
|
||||
$companies = Company::where('id', $companyId)->pluck('id');
|
||||
} else {
|
||||
// Only warm companies that actually have products to avoid empty cache entries
|
||||
$companies = Company::whereHas('products')->pluck('id');
|
||||
}
|
||||
|
||||
if ($companies->isEmpty()) {
|
||||
$this->warn('No companies found with products to warm cache for.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Starting to warm product catalog cache for {$companies->count()} companies...");
|
||||
$bar = $this->output->createProgressBar($companies->count());
|
||||
$bar->start();
|
||||
|
||||
foreach ($companies as $id) {
|
||||
$catalogService->rebuildCache($id);
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
$this->info('✅ Product catalog cache warming completed!');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,11 @@ class Kernel extends ConsoleKernel
|
||||
protected function schedule(Schedule $schedule): void
|
||||
{
|
||||
// $schedule->command('inspire')->hourly();
|
||||
$schedule->command('ota:process-schedules')->everyMinute();
|
||||
// 電子發票對帳:對 pending 發票去綠界查證(補登 issued / 標 failed 待補開)
|
||||
$schedule->command('invoices:reconcile')->everyFiveMinutes()->withoutOverlapping();
|
||||
// 交易結案掃描:把逾時停留在 pending 的單標記為 abandoned(只動 pending,碰不到線上 main 機台)
|
||||
$schedule->command('orders:sweep-abandoned')->everyFiveMinutes()->withoutOverlapping();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -6,6 +6,7 @@ use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MachineAdvertisement;
|
||||
use App\Models\System\Advertisement;
|
||||
use App\Models\System\Company;
|
||||
use App\Models\System\SystemOperationLog;
|
||||
use App\Traits\ImageHandler;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@ -18,6 +19,9 @@ class AdvertisementController extends AdminController
|
||||
{
|
||||
$user = auth()->user();
|
||||
$tab = $request->input('tab', 'list');
|
||||
$perPage = in_array((int) $request->input('per_page', 10), [10, 25, 50, 100], true)
|
||||
? (int) $request->input('per_page', 10)
|
||||
: 10;
|
||||
|
||||
// Tab 1: 廣告列表
|
||||
$query = Advertisement::with('company');
|
||||
@ -35,7 +39,7 @@ class AdvertisementController extends AdminController
|
||||
$query->where('company_id', $request->company_id);
|
||||
}
|
||||
|
||||
$advertisements = $query->latest()->paginate(10);
|
||||
$advertisements = $query->latest()->paginate($perPage)->withQueryString();
|
||||
|
||||
// Tab 2: 機台廣告設置 (所需資料) - 隱藏已過期的廣告
|
||||
$allAds = Advertisement::playing()->get();
|
||||
@ -75,41 +79,60 @@ class AdvertisementController extends AdminController
|
||||
'end_at' => 'nullable|date|after_or_equal:start_at',
|
||||
]);
|
||||
|
||||
$user = auth()->user();
|
||||
$file = $request->file('file');
|
||||
|
||||
if ($request->type === 'image') {
|
||||
$path = $this->storeAsWebp($file, 'ads');
|
||||
} else {
|
||||
$path = $file->store('ads', 'public');
|
||||
}
|
||||
try {
|
||||
$user = auth()->user();
|
||||
$file = $request->file('file');
|
||||
|
||||
if ($request->type === 'image') {
|
||||
$path = $this->storeAsWebp($file, 'ads');
|
||||
} else {
|
||||
$path = $file->store('ads', 'public');
|
||||
}
|
||||
|
||||
if ($user->isSystemAdmin()) {
|
||||
$companyId = $request->filled('company_id') ? $request->company_id : null;
|
||||
} else {
|
||||
$companyId = $user->company_id;
|
||||
}
|
||||
if ($user->isSystemAdmin()) {
|
||||
$companyId = $request->filled('company_id') ? $request->company_id : null;
|
||||
} else {
|
||||
$companyId = $user->company_id;
|
||||
}
|
||||
|
||||
$advertisement = Advertisement::create([
|
||||
'company_id' => $companyId,
|
||||
'name' => $request->name,
|
||||
'type' => $request->type,
|
||||
'duration' => (int) $request->duration,
|
||||
'url' => Storage::disk('public')->url($path),
|
||||
'is_active' => true,
|
||||
'start_at' => $request->start_at,
|
||||
'end_at' => $request->end_at,
|
||||
]);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Advertisement created successfully.'),
|
||||
'data' => $advertisement
|
||||
$advertisement = Advertisement::create([
|
||||
'company_id' => $companyId,
|
||||
'name' => $request->name,
|
||||
'type' => $request->type,
|
||||
'duration' => (int) $request->duration,
|
||||
'url' => Storage::disk('public')->url($path),
|
||||
'is_active' => true,
|
||||
'start_at' => $request->start_at,
|
||||
'end_at' => $request->end_at,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Advertisement created successfully.'));
|
||||
if ($request->wantsJson()) {
|
||||
session()->flash('success', __('Advertisement created successfully.'));
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Advertisement created successfully.'),
|
||||
'data' => $advertisement
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Advertisement created successfully.'));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Advertisement Upload Error: ' . $e->getMessage(), [
|
||||
'user_id' => auth()->id(),
|
||||
'file_name' => $request->file('file')?->getClientOriginalName(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('Failed to process advertisement file: ') . $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('error', __('Failed to process advertisement file.'));
|
||||
}
|
||||
}
|
||||
|
||||
public function update(Request $request, Advertisement $advertisement)
|
||||
@ -163,6 +186,7 @@ class AdvertisementController extends AdminController
|
||||
$advertisement->update($data);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
session()->flash('success', __('Advertisement updated successfully.'));
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Advertisement updated successfully.'),
|
||||
@ -186,6 +210,7 @@ class AdvertisementController extends AdminController
|
||||
|
||||
$advertisement->delete();
|
||||
if ($request->wantsJson()) {
|
||||
session()->flash('success', __('Advertisement deleted successfully.'));
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Advertisement deleted successfully.')
|
||||
@ -282,4 +307,108 @@ class AdvertisementController extends AdminController
|
||||
'message' => __('Assignment removed successfully.')
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步廣告至機台 (透過 MQTT 發送 update_ads 指令)
|
||||
*/
|
||||
public function syncToMachine(Request $request, Machine $machine, \App\Services\Machine\MqttService $mqttService)
|
||||
{
|
||||
// 併行檢查:若有 pending 指令,超過 1 分鐘視為逾時可覆蓋
|
||||
$pendingCommand = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
|
||||
->where('command_type', 'update_ads')
|
||||
->where('status', 'pending')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if ($pendingCommand) {
|
||||
$isTimedOut = $pendingCommand->created_at->lt(now()->subMinute());
|
||||
|
||||
if (!$isTimedOut) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('This machine has a pending command. Please wait.')
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 超過 1 分鐘:視為逾時,自動標記舊指令
|
||||
$pendingCommand->update([
|
||||
'status' => 'timeout',
|
||||
'note' => __('Superseded by new command (Timeout)'),
|
||||
]);
|
||||
}
|
||||
|
||||
$command = \App\Models\Machine\RemoteCommand::create([
|
||||
'machine_id' => $machine->id,
|
||||
'user_id' => auth()->id(),
|
||||
'command_type' => 'update_ads',
|
||||
'payload' => [],
|
||||
'status' => 'pending',
|
||||
'remark' => $request->filled('note') ? $request->input('note') : __('Manual Sync Ads'),
|
||||
]);
|
||||
|
||||
$success = $mqttService->pushCommand(
|
||||
$machine->serial_no,
|
||||
'update_ads',
|
||||
[],
|
||||
(string) $command->id
|
||||
);
|
||||
|
||||
if ($success) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Sync command sent successfully.')
|
||||
]);
|
||||
}
|
||||
|
||||
$command->update(['status' => 'failed', 'note' => 'MQTT push failed']);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('Failed to send sync command.')
|
||||
], 500);
|
||||
}
|
||||
|
||||
public function toggleStatus($id)
|
||||
{
|
||||
try {
|
||||
$advertisement = Advertisement::findOrFail($id);
|
||||
$oldValues = $advertisement->toArray();
|
||||
$advertisement->is_active = !$advertisement->is_active;
|
||||
$advertisement->save();
|
||||
$newValues = $advertisement->fresh()->toArray();
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $advertisement->company_id,
|
||||
'user_id' => auth()->id(),
|
||||
'module' => 'advertisement',
|
||||
'action' => 'update',
|
||||
'target_id' => $advertisement->id,
|
||||
'target_type' => Advertisement::class,
|
||||
'note' => 'advertisement_status_toggled',
|
||||
'old_values' => $oldValues,
|
||||
'new_values' => $newValues,
|
||||
]);
|
||||
|
||||
$status = $advertisement->is_active ? __('Enabled') : __('Disabled');
|
||||
|
||||
if (request()->ajax()) {
|
||||
session()->flash('success', __('Advertisement status updated to :status', ['status' => $status]));
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Advertisement status updated to :status', ['status' => $status]),
|
||||
'is_active' => $advertisement->is_active
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Advertisement status updated to :status', ['status' => $status]));
|
||||
} catch (\Exception $e) {
|
||||
if (request()->ajax()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,21 +16,634 @@ class AnalysisController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
// 機台報表分析
|
||||
public function machineReports()
|
||||
public function machineReports(Request $request)
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
$user = auth()->user();
|
||||
$companyId = $user->company_id;
|
||||
|
||||
// 1. 取得篩選參數
|
||||
$selectedCompanyId = $request->input('company_id');
|
||||
$selectedMachineId = $request->input('machine_id', 'ALL');
|
||||
$selectedProductId = $request->input('product_id', 'ALL');
|
||||
$paymentTypes = $request->input('payment_types', []);
|
||||
$dateRange = $request->input('date_range');
|
||||
|
||||
if (!is_array($paymentTypes)) {
|
||||
$paymentTypes = $paymentTypes ? explode(',', $paymentTypes) : [];
|
||||
}
|
||||
$paymentTypes = array_filter(array_map('intval', $paymentTypes));
|
||||
|
||||
// 2. 解析日期區間
|
||||
if ($dateRange) {
|
||||
$dates = explode(' to ', $dateRange);
|
||||
if (count($dates) === 2) {
|
||||
$startDate = \Illuminate\Support\Carbon::parse($dates[0])->startOfDay();
|
||||
$endDate = \Illuminate\Support\Carbon::parse($dates[1])->endOfDay();
|
||||
} else {
|
||||
$startDate = \Illuminate\Support\Carbon::parse($dates[0])->startOfDay();
|
||||
$endDate = \Illuminate\Support\Carbon::parse($dates[0])->endOfDay();
|
||||
}
|
||||
} else {
|
||||
// 預設為最近 7 天 (含今日)
|
||||
$startDate = now()->subDays(6)->startOfDay();
|
||||
$endDate = now()->endOfDay();
|
||||
$dateRange = $startDate->format('Y-m-d') . ' to ' . $endDate->format('Y-m-d');
|
||||
}
|
||||
|
||||
// 3. 租戶隔離:非系統管理員強制限制為其所屬公司
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$effectiveCompanyId = $companyId;
|
||||
} else {
|
||||
$effectiveCompanyId = $selectedCompanyId;
|
||||
}
|
||||
|
||||
// 取得使用者授權的機台 ID 範圍 (一般用戶)
|
||||
$allowedMachineIds = null;
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$allowedMachineIds = \App\Models\Machine\Machine::pluck('id')->toArray();
|
||||
}
|
||||
|
||||
// 4. 加載下拉選單選項
|
||||
// 公司選單 (僅限 Super Admin)
|
||||
$companies = [];
|
||||
if ($user->isSystemAdmin()) {
|
||||
$companies = \App\Models\System\Company::active()->get(['id', 'name']);
|
||||
}
|
||||
|
||||
// 機台選單 (自動套用多租戶與授權隔離)
|
||||
$machinesQuery = \App\Models\Machine\Machine::query();
|
||||
if ($effectiveCompanyId) {
|
||||
$machinesQuery->where('company_id', $effectiveCompanyId);
|
||||
}
|
||||
$machines = $machinesQuery->get(['id', 'name', 'serial_no']);
|
||||
|
||||
// 商品選單
|
||||
$productsQuery = \App\Models\Product\Product::query();
|
||||
if ($effectiveCompanyId) {
|
||||
$productsQuery->where('company_id', $effectiveCompanyId);
|
||||
}
|
||||
$products = $productsQuery->active()->get(['id', 'name', 'barcode']);
|
||||
|
||||
// 5. 支付工具選項 (使用者指定之 11 種)
|
||||
$availablePaymentTypes = [
|
||||
1 => __('Credit Card'),
|
||||
2 => __('E-Ticket (EasyCard/iPass)'),
|
||||
3 => __('QR Code Payment - E.SUN'), // 掃碼支付-玉山
|
||||
4 => __('Bill Acceptor'),
|
||||
5 => __('Pass Code'),
|
||||
6 => __('Pickup Code'),
|
||||
7 => __('Welcome Gift'),
|
||||
8 => __('Questionnaire'),
|
||||
9 => __('Coin Acceptor'),
|
||||
10 => __('Mobile Pay'), // NFC 手機感應(手機支付)
|
||||
11 => __('QR Code Payment - TayPay'), // 掃碼支付-TayPay
|
||||
41 => __('Staff Card'),
|
||||
42 => __('Pickup Voucher'),
|
||||
];
|
||||
|
||||
// 子查詢:取得每個 order_id + product_id 的銷售單價
|
||||
$itemPriceQuery = \Illuminate\Support\Facades\DB::table('order_items')
|
||||
->select([
|
||||
'order_id',
|
||||
'product_id',
|
||||
\Illuminate\Support\Facades\DB::raw('MIN(price) as unit_price')
|
||||
])
|
||||
->groupBy('order_id', 'product_id');
|
||||
|
||||
// 6. 執行主報表統計 SQL (按機台分組)
|
||||
// 子查詢 A:營業額 (計算機台實收)
|
||||
$salesQuery = \Illuminate\Support\Facades\DB::table('orders as o')
|
||||
->select([
|
||||
'o.machine_id',
|
||||
\Illuminate\Support\Facades\DB::raw('SUM(o.pay_amount) as total_sales')
|
||||
])
|
||||
->where('o.payment_status', \App\Models\Transaction\Order::PAYMENT_STATUS_SUCCESS)
|
||||
->whereNull('o.deleted_at')
|
||||
->whereBetween('o.created_at', [$startDate, $endDate]);
|
||||
|
||||
// 套用篩選條件到營業額子查詢
|
||||
if ($effectiveCompanyId) {
|
||||
$salesQuery->where('o.company_id', $effectiveCompanyId);
|
||||
}
|
||||
if ($allowedMachineIds !== null) {
|
||||
if (empty($allowedMachineIds)) {
|
||||
$salesQuery->whereRaw('1 = 0');
|
||||
} else {
|
||||
$salesQuery->whereIn('o.machine_id', $allowedMachineIds);
|
||||
}
|
||||
}
|
||||
if ($selectedMachineId && $selectedMachineId !== 'ALL') {
|
||||
$salesQuery->where('o.machine_id', $selectedMachineId);
|
||||
}
|
||||
if (!empty($paymentTypes)) {
|
||||
$salesQuery->whereIn('o.payment_type', $paymentTypes);
|
||||
}
|
||||
|
||||
// 如果篩選特定商品,營業額只計算有成功出貨的該商品金額
|
||||
if ($selectedProductId && $selectedProductId !== 'ALL') {
|
||||
$salesQuery->join('dispense_records as dr', function($join) {
|
||||
$join->on('dr.order_id', '=', 'o.id')
|
||||
->where('dr.dispense_status', '=', 1);
|
||||
})
|
||||
->leftJoinSub($itemPriceQuery, 'oi', function($join) {
|
||||
$join->on('oi.order_id', '=', 'dr.order_id')
|
||||
->on('oi.product_id', '=', 'dr.product_id');
|
||||
})
|
||||
->where('dr.product_id', $selectedProductId)
|
||||
->select([
|
||||
'o.machine_id',
|
||||
\Illuminate\Support\Facades\DB::raw('SUM(COALESCE(oi.unit_price, 0)) as total_sales')
|
||||
]);
|
||||
}
|
||||
$salesQuery->groupBy('o.machine_id');
|
||||
|
||||
// 子查詢 B:實際出貨件數與成本 (僅統計出貨成功且在範圍內)
|
||||
$dispenseQuery = \Illuminate\Support\Facades\DB::table('dispense_records as dr')
|
||||
->join('orders as o', 'o.id', '=', 'dr.order_id')
|
||||
->leftJoin('products as p', 'p.id', '=', 'dr.product_id')
|
||||
->select([
|
||||
'dr.machine_id',
|
||||
\Illuminate\Support\Facades\DB::raw('COUNT(dr.id) as total_quantity'),
|
||||
\Illuminate\Support\Facades\DB::raw('SUM(COALESCE(p.cost, 0)) as total_cost')
|
||||
])
|
||||
->where('dr.dispense_status', 1)
|
||||
->whereNull('o.deleted_at')
|
||||
->whereBetween('o.created_at', [$startDate, $endDate]);
|
||||
|
||||
// 套用篩選條件到出貨子查詢
|
||||
if ($effectiveCompanyId) {
|
||||
$dispenseQuery->where('o.company_id', $effectiveCompanyId);
|
||||
}
|
||||
if ($allowedMachineIds !== null) {
|
||||
if (empty($allowedMachineIds)) {
|
||||
$dispenseQuery->whereRaw('1 = 0');
|
||||
} else {
|
||||
$dispenseQuery->whereIn('o.machine_id', $allowedMachineIds);
|
||||
}
|
||||
}
|
||||
if ($selectedMachineId && $selectedMachineId !== 'ALL') {
|
||||
$dispenseQuery->where('o.machine_id', $selectedMachineId);
|
||||
}
|
||||
if ($selectedProductId && $selectedProductId !== 'ALL') {
|
||||
$dispenseQuery->where('dr.product_id', $selectedProductId);
|
||||
}
|
||||
if (!empty($paymentTypes)) {
|
||||
$dispenseQuery->whereIn('o.payment_type', $paymentTypes);
|
||||
}
|
||||
$dispenseQuery->groupBy('dr.machine_id');
|
||||
|
||||
// 聯結機台與子查詢,取得最終統計資料
|
||||
$mainQuery = \Illuminate\Support\Facades\DB::table('machines as m')
|
||||
->leftJoinSub($salesQuery, 'sq', 'sq.machine_id', '=', 'm.id')
|
||||
->leftJoinSub($dispenseQuery, 'dq', 'dq.machine_id', '=', 'm.id')
|
||||
->select([
|
||||
'm.id as machine_id',
|
||||
\Illuminate\Support\Facades\DB::raw('COALESCE(m.serial_no, "UNKNOWN") as serial_no'),
|
||||
\Illuminate\Support\Facades\DB::raw('COALESCE(m.name, "未知機台") as name'),
|
||||
\Illuminate\Support\Facades\DB::raw('CAST(COALESCE(dq.total_quantity, 0) as SIGNED) as total_quantity'),
|
||||
\Illuminate\Support\Facades\DB::raw('CAST(COALESCE(sq.total_sales, 0) as DECIMAL(10,2)) as total_sales'),
|
||||
\Illuminate\Support\Facades\DB::raw('CAST(COALESCE(dq.total_cost, 0) as DECIMAL(10,2)) as total_cost'),
|
||||
\Illuminate\Support\Facades\DB::raw('CAST((COALESCE(sq.total_sales, 0) - COALESCE(dq.total_cost, 0)) as DECIMAL(10,2)) as total_profit')
|
||||
])
|
||||
->where(function($query) {
|
||||
$query->whereNotNull('sq.total_sales')
|
||||
->orWhereNotNull('dq.total_quantity');
|
||||
});
|
||||
|
||||
if ($effectiveCompanyId) {
|
||||
$mainQuery->where('m.company_id', $effectiveCompanyId);
|
||||
}
|
||||
if ($allowedMachineIds !== null) {
|
||||
if (empty($allowedMachineIds)) {
|
||||
$mainQuery->whereRaw('1 = 0');
|
||||
} else {
|
||||
$mainQuery->whereIn('m.id', $allowedMachineIds);
|
||||
}
|
||||
}
|
||||
if ($selectedMachineId && $selectedMachineId !== 'ALL') {
|
||||
$mainQuery->where('m.id', $selectedMachineId);
|
||||
}
|
||||
|
||||
$tableData = $mainQuery->get();
|
||||
|
||||
// 7. 計算總計 Summary
|
||||
$totalQuantity = 0;
|
||||
$totalSales = 0.0;
|
||||
$totalCost = 0.0;
|
||||
$totalProfit = 0.0;
|
||||
|
||||
foreach ($tableData as $row) {
|
||||
$row->total_quantity = (int)$row->total_quantity;
|
||||
$row->total_sales = (float)$row->total_sales;
|
||||
$row->total_cost = (float)$row->total_cost;
|
||||
$row->total_profit = (float)$row->total_profit;
|
||||
|
||||
$totalQuantity += $row->total_quantity;
|
||||
$totalSales += $row->total_sales;
|
||||
$totalCost += $row->total_cost;
|
||||
$totalProfit += $row->total_profit;
|
||||
}
|
||||
|
||||
$summary = [
|
||||
'total_quantity' => $totalQuantity,
|
||||
'total_sales' => round($totalSales, 2),
|
||||
'total_cost' => round($totalCost, 2),
|
||||
'total_profit' => round($totalProfit, 2),
|
||||
];
|
||||
|
||||
// 8. 圖表日走勢統計
|
||||
// 每日營業額
|
||||
$trendSalesQuery = \Illuminate\Support\Facades\DB::table('orders as o')
|
||||
->select([
|
||||
\Illuminate\Support\Facades\DB::raw("DATE_FORMAT(o.created_at, '%Y-%m-%d') as date"),
|
||||
\Illuminate\Support\Facades\DB::raw('SUM(o.pay_amount) as total_sales')
|
||||
])
|
||||
->where('o.payment_status', \App\Models\Transaction\Order::PAYMENT_STATUS_SUCCESS)
|
||||
->whereNull('o.deleted_at')
|
||||
->whereBetween('o.created_at', [$startDate, $endDate]);
|
||||
|
||||
// 套用篩選到走勢營業額
|
||||
if ($effectiveCompanyId) {
|
||||
$trendSalesQuery->where('o.company_id', $effectiveCompanyId);
|
||||
}
|
||||
if ($allowedMachineIds !== null) {
|
||||
if (empty($allowedMachineIds)) {
|
||||
$trendSalesQuery->whereRaw('1 = 0');
|
||||
} else {
|
||||
$trendSalesQuery->whereIn('o.machine_id', $allowedMachineIds);
|
||||
}
|
||||
}
|
||||
if ($selectedMachineId && $selectedMachineId !== 'ALL') {
|
||||
$trendSalesQuery->where('o.machine_id', $selectedMachineId);
|
||||
}
|
||||
if (!empty($paymentTypes)) {
|
||||
$trendSalesQuery->whereIn('o.payment_type', $paymentTypes);
|
||||
}
|
||||
|
||||
// 如果篩選特定商品,營業額只計算有成功出貨的商品價格
|
||||
if ($selectedProductId && $selectedProductId !== 'ALL') {
|
||||
$trendSalesQuery->join('dispense_records as dr', function($join) {
|
||||
$join->on('dr.order_id', '=', 'o.id')
|
||||
->where('dr.dispense_status', '=', 1);
|
||||
})
|
||||
->leftJoinSub($itemPriceQuery, 'oi', function($join) {
|
||||
$join->on('oi.order_id', '=', 'dr.order_id')
|
||||
->on('oi.product_id', '=', 'dr.product_id');
|
||||
})
|
||||
->where('dr.product_id', $selectedProductId)
|
||||
->select([
|
||||
\Illuminate\Support\Facades\DB::raw("DATE_FORMAT(o.created_at, '%Y-%m-%d') as date"),
|
||||
\Illuminate\Support\Facades\DB::raw('SUM(COALESCE(oi.unit_price, 0)) as total_sales')
|
||||
]);
|
||||
}
|
||||
$trendSalesQuery->groupBy('date');
|
||||
$trendSalesData = $trendSalesQuery->get();
|
||||
|
||||
// 每日出貨件數與成本
|
||||
$trendDispenseQuery = \Illuminate\Support\Facades\DB::table('dispense_records as dr')
|
||||
->join('orders as o', 'o.id', '=', 'dr.order_id')
|
||||
->leftJoin('products as p', 'p.id', '=', 'dr.product_id')
|
||||
->select([
|
||||
\Illuminate\Support\Facades\DB::raw("DATE_FORMAT(o.created_at, '%Y-%m-%d') as date"),
|
||||
\Illuminate\Support\Facades\DB::raw('COUNT(dr.id) as total_quantity'),
|
||||
\Illuminate\Support\Facades\DB::raw('SUM(COALESCE(p.cost, 0)) as total_cost')
|
||||
])
|
||||
->where('dr.dispense_status', 1)
|
||||
->whereNull('o.deleted_at')
|
||||
->whereBetween('o.created_at', [$startDate, $endDate]);
|
||||
|
||||
// 套用篩選到走勢出貨
|
||||
if ($effectiveCompanyId) {
|
||||
$trendDispenseQuery->where('o.company_id', $effectiveCompanyId);
|
||||
}
|
||||
if ($allowedMachineIds !== null) {
|
||||
if (empty($allowedMachineIds)) {
|
||||
$trendDispenseQuery->whereRaw('1 = 0');
|
||||
} else {
|
||||
$trendDispenseQuery->whereIn('o.machine_id', $allowedMachineIds);
|
||||
}
|
||||
}
|
||||
if ($selectedMachineId && $selectedMachineId !== 'ALL') {
|
||||
$trendDispenseQuery->where('o.machine_id', $selectedMachineId);
|
||||
}
|
||||
if ($selectedProductId && $selectedProductId !== 'ALL') {
|
||||
$trendDispenseQuery->where('dr.product_id', $selectedProductId);
|
||||
}
|
||||
if (!empty($paymentTypes)) {
|
||||
$trendDispenseQuery->whereIn('o.payment_type', $paymentTypes);
|
||||
}
|
||||
$trendDispenseQuery->groupBy('date');
|
||||
$trendDispenseData = $trendDispenseQuery->get();
|
||||
|
||||
// 將子查詢結果轉換為 PHP Map 對照
|
||||
$salesMap = [];
|
||||
foreach ($trendSalesData as $row) {
|
||||
$salesMap[$row->date] = $row->total_sales;
|
||||
}
|
||||
|
||||
$dispenseMap = [];
|
||||
foreach ($trendDispenseData as $row) {
|
||||
$dispenseMap[$row->date] = [
|
||||
'quantity' => $row->total_quantity,
|
||||
'cost' => $row->total_cost
|
||||
];
|
||||
}
|
||||
|
||||
// 補齊日期區間內所有空缺日走勢
|
||||
$period = new \DatePeriod(
|
||||
$startDate->copy()->startOfDay(),
|
||||
new \DateInterval('P1D'),
|
||||
$endDate->copy()->endOfDay()
|
||||
);
|
||||
|
||||
$chartData = [];
|
||||
foreach ($period as $date) {
|
||||
$dateStr = $date->format('Y-m-d');
|
||||
$hasSales = isset($salesMap[$dateStr]);
|
||||
$hasDispense = isset($dispenseMap[$dateStr]);
|
||||
|
||||
$chartData[$dateStr] = [
|
||||
'date' => $dateStr,
|
||||
'quantity' => $hasDispense ? (int)$dispenseMap[$dateStr]['quantity'] : 0,
|
||||
'sales' => $hasSales ? round((float)$salesMap[$dateStr], 2) : 0.0,
|
||||
'cost' => $hasDispense ? round((float)$dispenseMap[$dateStr]['cost'], 2) : 0.0,
|
||||
];
|
||||
}
|
||||
$chartData = array_values($chartData);
|
||||
|
||||
// 9. 判斷是否為 AJAX 請求,回傳 JSON 格式
|
||||
if ($request->ajax() || $request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'summary' => $summary,
|
||||
'chart_data' => $chartData,
|
||||
'table_data' => $tableData,
|
||||
]);
|
||||
}
|
||||
|
||||
// 10. 一般 GET 載入視圖
|
||||
return view('admin.analysis.machine-reports', [
|
||||
'title' => '機台報表分析',
|
||||
'description' => '機台運營數據分析報表',
|
||||
'companies' => $companies,
|
||||
'machines' => $machines,
|
||||
'products' => $products,
|
||||
'availablePaymentTypes' => $availablePaymentTypes,
|
||||
'selectedCompanyId' => $selectedCompanyId,
|
||||
'selectedMachineId' => $selectedMachineId,
|
||||
'selectedProductId' => $selectedProductId,
|
||||
'selectedPaymentTypes' => $paymentTypes,
|
||||
'dateRange' => $dateRange,
|
||||
'summary' => $summary,
|
||||
'chartData' => $chartData,
|
||||
'tableData' => $tableData,
|
||||
]);
|
||||
}
|
||||
|
||||
// 商品報表分析
|
||||
public function productReports()
|
||||
public function productReports(Request $request)
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
$user = auth()->user();
|
||||
$companyId = $user->company_id;
|
||||
|
||||
// 1. 取得篩選參數
|
||||
$selectedCompanyId = $request->input('company_id');
|
||||
$selectedProductId = $request->input('product_id', 'ALL');
|
||||
$selectedMachineId = $request->input('machine_id', 'ALL');
|
||||
$dateRange = $request->input('date_range');
|
||||
|
||||
// 2. 解析日期區間
|
||||
if ($dateRange) {
|
||||
$dates = explode(' to ', $dateRange);
|
||||
if (count($dates) === 2) {
|
||||
$startDate = \Illuminate\Support\Carbon::parse($dates[0])->startOfDay();
|
||||
$endDate = \Illuminate\Support\Carbon::parse($dates[1])->endOfDay();
|
||||
} else {
|
||||
$startDate = \Illuminate\Support\Carbon::parse($dates[0])->startOfDay();
|
||||
$endDate = \Illuminate\Support\Carbon::parse($dates[0])->endOfDay();
|
||||
}
|
||||
} else {
|
||||
// 預設為最近 7 天 (含今日)
|
||||
$startDate = now()->subDays(6)->startOfDay();
|
||||
$endDate = now()->endOfDay();
|
||||
$dateRange = $startDate->format('Y-m-d') . ' to ' . $endDate->format('Y-m-d');
|
||||
}
|
||||
|
||||
// 3. 租戶隔離:非系統管理員強制限制為其所屬公司
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$effectiveCompanyId = $companyId;
|
||||
} else {
|
||||
$effectiveCompanyId = $selectedCompanyId;
|
||||
}
|
||||
|
||||
// 取得使用者授權的機台 ID 範圍 (一般用戶)
|
||||
$allowedMachineIds = null;
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$allowedMachineIds = \App\Models\Machine\Machine::pluck('id')->toArray();
|
||||
}
|
||||
|
||||
// 4. 加載下拉選單選項
|
||||
// 公司選單 (僅限 Super Admin)
|
||||
$companies = [];
|
||||
if ($user->isSystemAdmin()) {
|
||||
$companies = \App\Models\System\Company::active()->get(['id', 'name']);
|
||||
}
|
||||
|
||||
// 機台選單 (自動套用多租戶與授權隔離)
|
||||
$machinesQuery = \App\Models\Machine\Machine::query();
|
||||
if ($effectiveCompanyId) {
|
||||
$machinesQuery->where('company_id', $effectiveCompanyId);
|
||||
}
|
||||
$machines = $machinesQuery->get(['id', 'name', 'serial_no']);
|
||||
|
||||
// 商品選單
|
||||
$productsQuery = \App\Models\Product\Product::query();
|
||||
if ($effectiveCompanyId) {
|
||||
$productsQuery->where('company_id', $effectiveCompanyId);
|
||||
}
|
||||
$products = $productsQuery->active()->get(['id', 'name', 'barcode']);
|
||||
|
||||
// 5. 執行主報表統計 SQL
|
||||
// 子查詢:取得每個 order_id + product_id 的銷售單價與品名
|
||||
$itemPriceQuery = \Illuminate\Support\Facades\DB::table('order_items')
|
||||
->select([
|
||||
'order_id',
|
||||
'product_id',
|
||||
\Illuminate\Support\Facades\DB::raw('MIN(price) as unit_price'),
|
||||
\Illuminate\Support\Facades\DB::raw('MIN(barcode) as barcode'),
|
||||
\Illuminate\Support\Facades\DB::raw('MIN(product_name) as product_name')
|
||||
])
|
||||
->groupBy('order_id', 'product_id');
|
||||
|
||||
// 計算各商品的銷售數量、銷售額、總成本、利潤 (以出貨成功為主體)
|
||||
$mainQuery = \Illuminate\Support\Facades\DB::table('dispense_records as dr')
|
||||
->join('orders as o', 'o.id', '=', 'dr.order_id')
|
||||
->leftJoinSub($itemPriceQuery, 'oi', function($join) {
|
||||
$join->on('oi.order_id', '=', 'dr.order_id')
|
||||
->on('oi.product_id', '=', 'dr.product_id');
|
||||
})
|
||||
->leftJoin('products as p', 'p.id', '=', 'dr.product_id')
|
||||
->select([
|
||||
'dr.product_id',
|
||||
\Illuminate\Support\Facades\DB::raw('COALESCE(p.barcode, oi.barcode) as barcode'),
|
||||
\Illuminate\Support\Facades\DB::raw('COALESCE(p.name, oi.product_name) as name'),
|
||||
\Illuminate\Support\Facades\DB::raw('CAST(COUNT(dr.id) as SIGNED) as total_quantity'),
|
||||
\Illuminate\Support\Facades\DB::raw('CAST(SUM(COALESCE(oi.unit_price, 0)) as DECIMAL(10,2)) as total_sales'),
|
||||
\Illuminate\Support\Facades\DB::raw('CAST(SUM(COALESCE(p.cost, 0)) as DECIMAL(10,2)) as total_cost'),
|
||||
\Illuminate\Support\Facades\DB::raw('CAST((SUM(COALESCE(oi.unit_price, 0)) - SUM(COALESCE(p.cost, 0))) as DECIMAL(10,2)) as total_profit')
|
||||
])
|
||||
->where('dr.dispense_status', 1)
|
||||
->where('o.payment_status', \App\Models\Transaction\Order::PAYMENT_STATUS_SUCCESS)
|
||||
->whereNull('o.deleted_at')
|
||||
->whereBetween('o.created_at', [$startDate, $endDate]);
|
||||
|
||||
// 套用公司篩選
|
||||
if ($effectiveCompanyId) {
|
||||
$mainQuery->where('o.company_id', $effectiveCompanyId);
|
||||
}
|
||||
|
||||
// 套用機台權限限制
|
||||
if ($allowedMachineIds !== null) {
|
||||
if (empty($allowedMachineIds)) {
|
||||
$mainQuery->whereRaw('1 = 0');
|
||||
} else {
|
||||
$mainQuery->whereIn('o.machine_id', $allowedMachineIds);
|
||||
}
|
||||
}
|
||||
|
||||
// 套用特定機台篩選
|
||||
if ($selectedMachineId && $selectedMachineId !== 'ALL') {
|
||||
$mainQuery->where('o.machine_id', $selectedMachineId);
|
||||
}
|
||||
|
||||
// 套用特定商品篩選
|
||||
if ($selectedProductId && $selectedProductId !== 'ALL') {
|
||||
$mainQuery->where('dr.product_id', $selectedProductId);
|
||||
}
|
||||
|
||||
$mainQuery->groupBy('dr.product_id')
|
||||
->groupBy(\Illuminate\Support\Facades\DB::raw('COALESCE(p.barcode, oi.barcode)'))
|
||||
->groupBy(\Illuminate\Support\Facades\DB::raw('COALESCE(p.name, oi.product_name)'));
|
||||
$tableData = $mainQuery->get();
|
||||
|
||||
// 6. 計算總計 Summary
|
||||
$totalQuantity = 0;
|
||||
$totalSales = 0.0;
|
||||
$totalCost = 0.0;
|
||||
$totalProfit = 0.0;
|
||||
|
||||
foreach ($tableData as $row) {
|
||||
$row->total_quantity = (int)$row->total_quantity;
|
||||
$row->total_sales = (float)$row->total_sales;
|
||||
$row->total_cost = (float)$row->total_cost;
|
||||
$row->total_profit = (float)$row->total_profit;
|
||||
|
||||
$totalQuantity += $row->total_quantity;
|
||||
$totalSales += $row->total_sales;
|
||||
$totalCost += $row->total_cost;
|
||||
$totalProfit += $row->total_profit;
|
||||
}
|
||||
|
||||
$summary = [
|
||||
'total_quantity' => $totalQuantity,
|
||||
'total_sales' => round($totalSales, 2),
|
||||
'total_cost' => round($totalCost, 2),
|
||||
'total_profit' => round($totalProfit, 2),
|
||||
];
|
||||
|
||||
// 7. 圖表日走勢統計 SQL
|
||||
$trendQuery = \Illuminate\Support\Facades\DB::table('dispense_records as dr')
|
||||
->join('orders as o', 'o.id', '=', 'dr.order_id')
|
||||
->leftJoinSub($itemPriceQuery, 'oi', function($join) {
|
||||
$join->on('oi.order_id', '=', 'dr.order_id')
|
||||
->on('oi.product_id', '=', 'dr.product_id');
|
||||
})
|
||||
->leftJoin('products as p', 'p.id', '=', 'dr.product_id')
|
||||
->select([
|
||||
\Illuminate\Support\Facades\DB::raw("DATE_FORMAT(o.created_at, '%Y-%m-%d') as date"),
|
||||
\Illuminate\Support\Facades\DB::raw('CAST(COUNT(dr.id) as SIGNED) as total_quantity'),
|
||||
\Illuminate\Support\Facades\DB::raw('CAST(SUM(COALESCE(oi.unit_price, 0)) as DECIMAL(10,2)) as total_sales'),
|
||||
\Illuminate\Support\Facades\DB::raw('CAST(SUM(COALESCE(p.cost, 0)) as DECIMAL(10,2)) as total_cost')
|
||||
])
|
||||
->where('dr.dispense_status', 1)
|
||||
->where('o.payment_status', \App\Models\Transaction\Order::PAYMENT_STATUS_SUCCESS)
|
||||
->whereNull('o.deleted_at')
|
||||
->whereBetween('o.created_at', [$startDate, $endDate]);
|
||||
|
||||
if ($effectiveCompanyId) {
|
||||
$trendQuery->where('o.company_id', $effectiveCompanyId);
|
||||
}
|
||||
|
||||
if ($allowedMachineIds !== null) {
|
||||
if (empty($allowedMachineIds)) {
|
||||
$trendQuery->whereRaw('1 = 0');
|
||||
} else {
|
||||
$trendQuery->whereIn('o.machine_id', $allowedMachineIds);
|
||||
}
|
||||
}
|
||||
|
||||
if ($selectedMachineId && $selectedMachineId !== 'ALL') {
|
||||
$trendQuery->where('o.machine_id', $selectedMachineId);
|
||||
}
|
||||
|
||||
if ($selectedProductId && $selectedProductId !== 'ALL') {
|
||||
$trendQuery->where('dr.product_id', $selectedProductId);
|
||||
}
|
||||
|
||||
$trendQuery->groupBy('date')->orderBy('date', 'asc');
|
||||
$trendData = $trendQuery->get();
|
||||
|
||||
// 補齊日期區間內所有空缺日走勢
|
||||
$period = new \DatePeriod(
|
||||
$startDate->copy()->startOfDay(),
|
||||
new \DateInterval('P1D'),
|
||||
$endDate->copy()->endOfDay()
|
||||
);
|
||||
|
||||
$chartData = [];
|
||||
foreach ($period as $date) {
|
||||
$dateStr = $date->format('Y-m-d');
|
||||
$chartData[$dateStr] = [
|
||||
'date' => $dateStr,
|
||||
'quantity' => 0,
|
||||
'sales' => 0.0,
|
||||
'cost' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($trendData as $row) {
|
||||
if (isset($chartData[$row->date])) {
|
||||
$chartData[$row->date]['quantity'] = (int)$row->total_quantity;
|
||||
$chartData[$row->date]['sales'] = round((float)$row->total_sales, 2);
|
||||
$chartData[$row->date]['cost'] = round((float)$row->total_cost, 2);
|
||||
}
|
||||
}
|
||||
$chartData = array_values($chartData);
|
||||
|
||||
// 8. 判斷是否為 AJAX 請求,回傳 JSON 格式
|
||||
if ($request->ajax() || $request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'summary' => $summary,
|
||||
'chart_data' => $chartData,
|
||||
'table_data' => $tableData,
|
||||
]);
|
||||
}
|
||||
|
||||
// 9. 一般 GET 載入視圖
|
||||
return view('admin.analysis.product-reports', [
|
||||
'title' => '商品報表分析',
|
||||
'description' => '商品銷售數據分析',
|
||||
'description' => '商品銷售數據 analysis',
|
||||
'companies' => $companies,
|
||||
'machines' => $machines,
|
||||
'products' => $products,
|
||||
'selectedCompanyId' => $selectedCompanyId,
|
||||
'selectedMachineId' => $selectedMachineId,
|
||||
'selectedProductId' => $selectedProductId,
|
||||
'dateRange' => $dateRange,
|
||||
'summary' => $summary,
|
||||
'chartData' => $chartData,
|
||||
'tableData' => $tableData,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\BasicSettings;
|
||||
|
||||
use App\Http\Controllers\Admin\AdminController;
|
||||
use App\Models\Machine\ApkVersion;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Services\Machine\MqttService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ApkVersionController extends AdminController
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
// 在進入 APK 版本清單時,同步觸發已到期排程的執行(確保無 cron 的開發/測試環境也能 100% 執行)
|
||||
try {
|
||||
\Illuminate\Support\Facades\Artisan::call('ota:process-schedules');
|
||||
} catch (\Exception $e) {
|
||||
\Illuminate\Support\Facades\Log::error('Auto-trigger scheduled OTA failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$versions = ApkVersion::latest()->paginate(10);
|
||||
|
||||
// 統計並附屬機台部署狀態
|
||||
$commands = \App\Models\Machine\RemoteCommand::where('command_type', 'update_app')
|
||||
->with('machine')
|
||||
->get();
|
||||
|
||||
// 撈取所有還沒執行的 OTA 預約排程
|
||||
$pendingSchedules = \App\Models\Machine\OtaSchedule::where('status', 'pending')->get();
|
||||
|
||||
// 蒐集所有預約機台 ID,一次性撈出以避免 N+1
|
||||
$allScheduledMachineIds = [];
|
||||
foreach ($pendingSchedules as $sch) {
|
||||
if (is_array($sch->target_value)) {
|
||||
$allScheduledMachineIds = array_merge($allScheduledMachineIds, $sch->target_value);
|
||||
}
|
||||
}
|
||||
$allScheduledMachineIds = array_unique(array_filter($allScheduledMachineIds));
|
||||
|
||||
$scheduledMachinesMap = [];
|
||||
if (count($allScheduledMachineIds) > 0) {
|
||||
$scheduledMachinesMap = \App\Models\Machine\Machine::whereIn('id', $allScheduledMachineIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
}
|
||||
|
||||
foreach ($versions as $version) {
|
||||
$pending = [];
|
||||
$completed = [];
|
||||
|
||||
// 1. 已排入指令(已發送或執行中)的 RemoteCommand
|
||||
foreach ($commands as $cmd) {
|
||||
$cmdVersionCode = $cmd->payload['version_code'] ?? null;
|
||||
if ($cmdVersionCode == $version->version_code) {
|
||||
if ($cmd->status === 'completed' || $cmd->status === 'success') {
|
||||
$completed[] = $cmd->machine;
|
||||
} elseif ($cmd->status === 'pending' || $cmd->status === 'processing' || $cmd->status === 'sent') {
|
||||
$pending[] = $cmd->machine;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 還沒有執行的預約排程中的機台
|
||||
foreach ($pendingSchedules as $sch) {
|
||||
if ($sch->apk_version_id == $version->id) {
|
||||
$machineIds = $sch->target_value;
|
||||
if (is_array($machineIds)) {
|
||||
foreach ($machineIds as $mId) {
|
||||
if (isset($scheduledMachinesMap[$mId])) {
|
||||
$pending[] = $scheduledMachinesMap[$mId];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$version->pending_machines = collect($pending)->filter()->unique('id');
|
||||
$version->completed_machines = collect($completed)->filter()->unique('id');
|
||||
}
|
||||
|
||||
// 用於 OTA 下發時讓使用者選擇的機台列表
|
||||
$machines = Machine::all();
|
||||
return view('admin.basic-settings.apk-versions.index', compact('versions', 'machines'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.basic-settings.apk-versions.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'version_name' => 'required|string|max:100',
|
||||
'version_code' => 'required|integer|unique:apk_versions,version_code',
|
||||
'flavor' => 'required|string|max:100',
|
||||
'apk_file' => 'required_without:download_url|nullable|file|max:102400', // 上傳限制 100MB
|
||||
'download_url' => 'required_without:apk_file|nullable|url|max:500',
|
||||
'release_notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$filePath = null;
|
||||
if ($request->hasFile('apk_file')) {
|
||||
$filePath = $request->file('apk_file')->store('apks', 'public');
|
||||
}
|
||||
|
||||
ApkVersion::create([
|
||||
'version_name' => $request->version_name,
|
||||
'version_code' => $request->version_code,
|
||||
'flavor' => $request->flavor,
|
||||
'file_path' => $filePath,
|
||||
'download_url' => $request->download_url,
|
||||
'release_notes' => $request->release_notes,
|
||||
'is_forced' => false,
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.basic-settings.apk-versions.index')
|
||||
->with('success', __('APK version uploaded successfully.'));
|
||||
}
|
||||
|
||||
public function destroy(ApkVersion $apkVersion)
|
||||
{
|
||||
if ($apkVersion->file_path) {
|
||||
Storage::disk('public')->delete($apkVersion->file_path);
|
||||
}
|
||||
$apkVersion->delete();
|
||||
|
||||
return redirect()->route('admin.basic-settings.apk-versions.index')
|
||||
->with('success', __('APK version deleted successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送 OTA 更新指令給指定機台
|
||||
*/
|
||||
public function push(Request $request, ApkVersion $apkVersion, MqttService $mqttService)
|
||||
{
|
||||
$request->validate([
|
||||
'machine_ids' => 'required|array',
|
||||
'machine_ids.*' => 'exists:machines,id',
|
||||
'scheduled_at' => 'nullable|date|after:now',
|
||||
]);
|
||||
|
||||
// 如果設定了預約發送時間
|
||||
if ($request->filled('scheduled_at')) {
|
||||
\App\Models\Machine\OtaSchedule::create([
|
||||
'apk_version_id' => $apkVersion->id,
|
||||
'scheduled_at' => $request->scheduled_at,
|
||||
'target_type' => 'custom',
|
||||
'target_value' => $request->machine_ids,
|
||||
'status' => 'pending',
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.basic-settings.apk-versions.index')
|
||||
->with('success', __('OTA update scheduled successfully.'));
|
||||
}
|
||||
|
||||
$machines = Machine::whereIn('id', $request->machine_ids)->get();
|
||||
$successCount = 0;
|
||||
|
||||
foreach ($machines as $machine) {
|
||||
// 1. 先建立 RemoteCommand 紀錄,以取得自增 ID 作為 command_id(與系統慣例一致)
|
||||
$command = \App\Models\Machine\RemoteCommand::create([
|
||||
'machine_id' => $machine->id,
|
||||
'user_id' => auth()->id(),
|
||||
'command_type' => 'update_app',
|
||||
'status' => 'pending',
|
||||
'payload' => [
|
||||
'url' => $apkVersion->url,
|
||||
'version' => $apkVersion->version_name,
|
||||
'version_code' => $apkVersion->version_code,
|
||||
'is_forced' => $apkVersion->is_forced,
|
||||
],
|
||||
]);
|
||||
|
||||
// 2. 推播 MQTT,command_id 使用資料庫自增流水號
|
||||
$pushed = $mqttService->pushCommand(
|
||||
$machine->serial_no,
|
||||
'update_app',
|
||||
$command->payload,
|
||||
(string) $command->id
|
||||
);
|
||||
|
||||
if ($pushed) {
|
||||
$successCount++;
|
||||
} else {
|
||||
// 推播失敗,將紀錄標記為失敗
|
||||
$command->update(['status' => 'failed']);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('admin.basic-settings.apk-versions.index')
|
||||
->with('success', sprintf(__('OTA command sent to %d devices successfully.'), $successCount));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\BasicSettings;
|
||||
|
||||
use App\Http\Controllers\Admin\AdminController;
|
||||
use App\Models\System\Company;
|
||||
use App\Services\Notification\DiscordWebhookService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class DiscordNotificationController extends AdminController
|
||||
{
|
||||
/**
|
||||
* 顯示 Discord 告警設定頁面 (雙 Tab 支援)
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$user = auth()->user();
|
||||
$activeTab = $request->input('tab', 'company');
|
||||
|
||||
// ── Tab 1: 公司全域設定 ──
|
||||
$companyQuery = Company::query();
|
||||
if ($user->isSystemAdmin()) {
|
||||
if ($searchCompany = $request->input('search_company')) {
|
||||
$companyQuery->where(function ($q) use ($searchCompany) {
|
||||
$q->where('name', 'like', "%{$searchCompany}%")
|
||||
->orWhere('code', 'like', "%{$searchCompany}%");
|
||||
});
|
||||
}
|
||||
} else {
|
||||
$companyQuery->where('id', $user->company_id);
|
||||
}
|
||||
$companies = $companyQuery->paginate($request->input('company_per_page', 10), ['*'], 'company_page')
|
||||
->withQueryString();
|
||||
|
||||
// ── Tab 2: 機台個別設定 ──
|
||||
$machineQuery = \App\Models\Machine\Machine::query()->with(['company', 'machineModel']);
|
||||
if ($user->isSystemAdmin()) {
|
||||
if ($filterCompanyId = trim($request->input('filter_company_id', ''))) {
|
||||
$machineQuery->where('company_id', $filterCompanyId);
|
||||
}
|
||||
if ($searchMachine = $request->input('search_machine')) {
|
||||
$machineQuery->where(function ($q) use ($searchMachine) {
|
||||
$q->where('name', 'like', "%{$searchMachine}%")
|
||||
->orWhere('serial_no', 'like', "%{$searchMachine}%");
|
||||
});
|
||||
}
|
||||
} else {
|
||||
$machineQuery->where('company_id', $user->company_id);
|
||||
if ($searchMachine = $request->input('search_machine')) {
|
||||
$machineQuery->where(function ($q) use ($searchMachine) {
|
||||
$q->where('name', 'like', "%{$searchMachine}%")
|
||||
->orWhere('serial_no', 'like', "%{$searchMachine}%");
|
||||
});
|
||||
}
|
||||
}
|
||||
$machines = $machineQuery->paginate($request->input('machine_per_page', 10), ['*'], 'machine_page')
|
||||
->withQueryString();
|
||||
|
||||
// ── 輔助資料 ──
|
||||
// 所有公司列表 (僅 System Admin 用於 Tab 2 篩選下拉)
|
||||
$allCompanies = [];
|
||||
if ($user->isSystemAdmin()) {
|
||||
$allCompanies = Company::select('id', 'name')->get();
|
||||
}
|
||||
|
||||
// 用於前端 Modal「拉式複製」的同公司機台 settings 列表
|
||||
$copyableQuery = \App\Models\Machine\Machine::select('id', 'name', 'serial_no', 'company_id', 'settings');
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$copyableQuery->where('company_id', $user->company_id);
|
||||
}
|
||||
$copyableMachines = $copyableQuery->get();
|
||||
|
||||
return view('admin.basic-settings.discord-notifications.index', [
|
||||
'companies' => $companies,
|
||||
'machines' => $machines,
|
||||
'allCompanies' => $allCompanies,
|
||||
'copyableMachines' => $copyableMachines,
|
||||
'activeTab' => $activeTab,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新公司 Discord 告警設定
|
||||
*/
|
||||
public function updateCompany(Request $request, $id)
|
||||
{
|
||||
$user = auth()->user();
|
||||
if (!$user->isSystemAdmin() && (int)$id !== (int)$user->company_id) {
|
||||
abort(403, __('Unauthorized action.'));
|
||||
}
|
||||
|
||||
$company = Company::findOrFail($id);
|
||||
|
||||
$request->validate([
|
||||
'discord_webhook_url' => 'nullable|url|max:500',
|
||||
'discord_notify_enabled' => 'boolean',
|
||||
'discord_notify_types' => 'array',
|
||||
'discord_notify_types.*' => 'string|in:submachine,status,temperature',
|
||||
'discord_notify_locale' => 'required|string|in:zh_TW,en,ja',
|
||||
]);
|
||||
|
||||
$settings = $company->settings ?? [];
|
||||
$settings['discord_webhook_url'] = $request->input('discord_webhook_url');
|
||||
$settings['discord_notify_enabled'] = (bool) $request->input('discord_notify_enabled', false);
|
||||
$settings['discord_notify_types'] = $request->input('discord_notify_types', []);
|
||||
$settings['locale'] = $request->input('discord_notify_locale', 'zh_TW');
|
||||
|
||||
$company->settings = $settings;
|
||||
$company->save();
|
||||
|
||||
return redirect()->route('admin.basic-settings.discord-notifications.index', ['tab' => 'company'])
|
||||
->with('success', __('Company Discord notification settings updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新機台 Discord 與溫度告警設定
|
||||
*/
|
||||
public function updateMachine(Request $request, $id)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$machine = \App\Models\Machine\Machine::findOrFail($id);
|
||||
|
||||
if (!$user->isSystemAdmin() && (int)$machine->company_id !== (int)$user->company_id) {
|
||||
abort(403, __('Unauthorized action.'));
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'discord_webhook_url' => 'nullable|url|max:500',
|
||||
'notify_types_mode' => 'required|string|in:inherit,custom',
|
||||
'discord_notify_types' => 'nullable|array',
|
||||
'discord_notify_types.*' => 'string|in:submachine,status,temperature',
|
||||
'temp_upper_limit' => 'nullable|numeric|min:-50|max:100',
|
||||
'temp_lower_limit' => 'nullable|numeric|min:-50|max:100',
|
||||
]);
|
||||
|
||||
$settings = $machine->settings ?? [];
|
||||
$settings['discord_webhook_url'] = $request->input('discord_webhook_url');
|
||||
|
||||
$notifyTypesMode = $request->input('notify_types_mode', 'inherit');
|
||||
$notifyTypes = $request->input('discord_notify_types', []);
|
||||
$hasTemperature = in_array('temperature', $notifyTypes, true);
|
||||
|
||||
if ($notifyTypesMode === 'custom') {
|
||||
$settings['discord_notify_types'] = array_values($notifyTypes);
|
||||
if ($hasTemperature) {
|
||||
$settings['temp_alert_enabled'] = 'enabled';
|
||||
$settings['temp_upper_limit'] = $request->filled('temp_upper_limit') ? (float)$request->temp_upper_limit : null;
|
||||
$settings['temp_lower_limit'] = $request->filled('temp_lower_limit') ? (float)$request->temp_lower_limit : null;
|
||||
} else {
|
||||
$settings['temp_alert_enabled'] = 'disabled';
|
||||
$settings['temp_upper_limit'] = null;
|
||||
$settings['temp_lower_limit'] = null;
|
||||
}
|
||||
} else {
|
||||
unset($settings['discord_notify_types']);
|
||||
$settings['temp_alert_enabled'] = 'inherit';
|
||||
$settings['temp_upper_limit'] = null;
|
||||
$settings['temp_lower_limit'] = null;
|
||||
}
|
||||
|
||||
$machine->settings = $settings;
|
||||
$machine->save();
|
||||
|
||||
return redirect()->route('admin.basic-settings.discord-notifications.index', ['tab' => 'machine'])
|
||||
->with('success', __('Machine Discord notification settings updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 測試發送 Discord Webhook
|
||||
*/
|
||||
public function test(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'discord_webhook_url' => 'required|url|max:500',
|
||||
]);
|
||||
|
||||
$webhookUrl = $request->input('discord_webhook_url');
|
||||
|
||||
try {
|
||||
$service = app(DiscordWebhookService::class);
|
||||
$success = $service->sendTestAlert($webhookUrl);
|
||||
|
||||
if ($success) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Test notification sent successfully.'),
|
||||
]);
|
||||
} else {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('Failed to send test notification. Please verify the Webhook URL.'),
|
||||
], 400);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('An error occurred: ') . $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -22,13 +22,22 @@ class MachineModelController extends AdminController
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'temp_upper_limit' => 'nullable|integer|min:-50|max:100',
|
||||
'temp_lower_limit' => 'nullable|integer|min:-50|max:100',
|
||||
]);
|
||||
|
||||
MachineModel::create(array_merge($validated, [
|
||||
$settings = [
|
||||
'temp_upper_limit' => $request->filled('temp_upper_limit') ? (int)$request->temp_upper_limit : null,
|
||||
'temp_lower_limit' => $request->filled('temp_lower_limit') ? (int)$request->temp_lower_limit : null,
|
||||
];
|
||||
|
||||
MachineModel::create([
|
||||
'name' => $validated['name'],
|
||||
'settings' => $settings,
|
||||
'company_id' => auth()->user()->company_id,
|
||||
'creator_id' => auth()->id(),
|
||||
'updater_id' => auth()->id(),
|
||||
]));
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.basic-settings.machines.index', ['tab' => 'models'])
|
||||
->with('success', __('Machine model created successfully.'));
|
||||
@ -47,11 +56,20 @@ class MachineModelController extends AdminController
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'temp_upper_limit' => 'nullable|integer|min:-50|max:100',
|
||||
'temp_lower_limit' => 'nullable|integer|min:-50|max:100',
|
||||
]);
|
||||
|
||||
$machine_model->update(array_merge($validated, [
|
||||
$settings = [
|
||||
'temp_upper_limit' => $request->filled('temp_upper_limit') ? (int)$request->temp_upper_limit : null,
|
||||
'temp_lower_limit' => $request->filled('temp_lower_limit') ? (int)$request->temp_lower_limit : null,
|
||||
];
|
||||
|
||||
$machine_model->update([
|
||||
'name' => $validated['name'],
|
||||
'settings' => $settings,
|
||||
'updater_id' => auth()->id(),
|
||||
]));
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.basic-settings.machines.index', ['tab' => 'models'])
|
||||
->with('success', __('Machine model updated successfully.'));
|
||||
|
||||
@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin\BasicSettings;
|
||||
use App\Http\Controllers\Admin\AdminController;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MachineModel;
|
||||
use App\Models\System\Company;
|
||||
use App\Models\System\PaymentConfig;
|
||||
use App\Traits\ImageHandler;
|
||||
use Illuminate\Http\Request;
|
||||
@ -28,6 +29,23 @@ class MachineSettingController extends AdminController
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$search = $request->input('search');
|
||||
$isAjax = $request->boolean('_ajax');
|
||||
$currentUser = auth()->user();
|
||||
$companyId = trim((string) $request->input('company_id', ''));
|
||||
|
||||
$applyMachineFilters = function ($query) use ($search, $currentUser, $companyId) {
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($currentUser->isSystemAdmin() && $companyId !== '') {
|
||||
$query->where('company_id', $companyId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
};
|
||||
|
||||
// AJAX 模式:只查當前 Tab 的搜尋/分頁結果
|
||||
if ($isAjax) {
|
||||
@ -38,12 +56,13 @@ class MachineSettingController extends AdminController
|
||||
switch ($tab) {
|
||||
case 'machines':
|
||||
$machineQuery = Machine::query()->with(['machineModel', 'paymentConfig', 'company']);
|
||||
if ($search) {
|
||||
$machineQuery->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
$applyMachineFilters($machineQuery);
|
||||
$machines = $machineQuery->latest()->paginate($per_page)->withQueryString();
|
||||
break;
|
||||
|
||||
case 'system_settings':
|
||||
$machineQuery = Machine::query()->with(['company']);
|
||||
$applyMachineFilters($machineQuery);
|
||||
$machines = $machineQuery->latest()->paginate($per_page)->withQueryString();
|
||||
break;
|
||||
|
||||
@ -56,8 +75,10 @@ class MachineSettingController extends AdminController
|
||||
break;
|
||||
|
||||
case 'permissions':
|
||||
// 套用層級可見範圍:系統管理員看全部;租戶僅看可見範圍內帳號(避免跨租戶洩漏)。
|
||||
$userQuery = \App\Models\System\User::query()
|
||||
->where('is_admin', true)
|
||||
->visibleTo($currentUser)
|
||||
->with(['company', 'machines']);
|
||||
if ($search) {
|
||||
$userQuery->where(function($q) use ($search) {
|
||||
@ -65,18 +86,20 @@ class MachineSettingController extends AdminController
|
||||
->orWhere('username', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
if ($request->filled('company_id')) {
|
||||
$userQuery->where('company_id', $request->company_id);
|
||||
if ($currentUser->isSystemAdmin() && $companyId !== '') {
|
||||
$userQuery->where('company_id', $companyId);
|
||||
}
|
||||
$users_list = $userQuery->latest()->paginate($per_page)->withQueryString();
|
||||
break;
|
||||
|
||||
}
|
||||
|
||||
$companies = \App\Models\System\Company::select('id', 'name', 'code')->get();
|
||||
$companies = Company::select('id', 'name', 'code')->orderBy('name')->get();
|
||||
|
||||
$tabView = match($tab) {
|
||||
'models' => 'admin.basic-settings.machines.partials.tab-models',
|
||||
'permissions' => 'admin.basic-settings.machines.partials.tab-permissions',
|
||||
'system_settings' => 'admin.basic-settings.machines.partials.tab-system-settings',
|
||||
default => 'admin.basic-settings.machines.partials.tab-machines',
|
||||
};
|
||||
return response()->view($tabView, compact(
|
||||
@ -85,9 +108,14 @@ class MachineSettingController extends AdminController
|
||||
}
|
||||
|
||||
// SSR 模式:一次查好全部三個 Tab 的首頁資料(供 x-show 即時切換)
|
||||
$machines = Machine::query()
|
||||
->with(['machineModel', 'paymentConfig', 'company'])
|
||||
->latest()->paginate($per_page)->withQueryString();
|
||||
$machineQuery = Machine::query()
|
||||
->with(['machineModel', 'paymentConfig', 'company']);
|
||||
|
||||
if (in_array($tab, ['machines', 'system_settings'], true)) {
|
||||
$applyMachineFilters($machineQuery);
|
||||
}
|
||||
|
||||
$machines = $machineQuery->latest()->paginate($per_page)->withQueryString();
|
||||
|
||||
$models_list = MachineModel::query()
|
||||
->withCount('machines')
|
||||
@ -95,21 +123,26 @@ class MachineSettingController extends AdminController
|
||||
|
||||
$userQuery = \App\Models\System\User::query()
|
||||
->where('is_admin', true)
|
||||
->visibleTo($currentUser)
|
||||
->with(['company', 'machines']);
|
||||
$users_list = $userQuery->latest()->paginate($per_page)->withQueryString();
|
||||
|
||||
// 基礎下拉資料 (用於新增/編輯機台的彈窗)
|
||||
$models = MachineModel::select('id', 'name')->get();
|
||||
$paymentConfigs = PaymentConfig::select('id', 'name')->get();
|
||||
$companies = \App\Models\System\Company::select('id', 'name', 'code')->get();
|
||||
$companies = Company::select('id', 'name', 'code')->orderBy('name')->get();
|
||||
|
||||
// 同步系統設定彈窗的機台選擇器清單 (依 TenantScoped 自動租戶隔離)
|
||||
$allMachines = Machine::select('id', 'name', 'serial_no')->orderBy('name')->get();
|
||||
|
||||
return view('admin.basic-settings.machines.index', compact(
|
||||
'machines',
|
||||
'models_list',
|
||||
'machines',
|
||||
'models_list',
|
||||
'users_list',
|
||||
'models',
|
||||
'paymentConfigs',
|
||||
'models',
|
||||
'paymentConfigs',
|
||||
'companies',
|
||||
'allMachines',
|
||||
'tab'
|
||||
));
|
||||
}
|
||||
@ -125,6 +158,7 @@ class MachineSettingController extends AdminController
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'machine_model_id' => 'required|exists:machine_models,id',
|
||||
'payment_config_id' => 'nullable|exists:payment_configs,id',
|
||||
'key_no' => 'nullable|string|max:255',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'latitude' => 'nullable|numeric|between:-90,90',
|
||||
@ -185,15 +219,19 @@ class MachineSettingController extends AdminController
|
||||
'heating_end_time' => 'nullable|string',
|
||||
'card_reader_no' => 'nullable|string|max:255',
|
||||
'key_no' => 'nullable|string|max:255',
|
||||
'invoice_status' => 'required|integer|in:0,1,2',
|
||||
'welcome_gift_enabled' => 'boolean',
|
||||
'is_spring_slot_1_10' => 'boolean',
|
||||
'is_spring_slot_11_20' => 'boolean',
|
||||
'is_spring_slot_21_30' => 'boolean',
|
||||
'is_spring_slot_31_40' => 'boolean',
|
||||
'is_spring_slot_41_50' => 'boolean',
|
||||
'is_spring_slot_51_60' => 'boolean',
|
||||
'member_system_enabled' => 'boolean',
|
||||
'tax_invoice_enabled' => 'boolean',
|
||||
'card_terminal_enabled' => 'boolean',
|
||||
'scan_pay_esun_enabled' => 'boolean',
|
||||
'scan_pay_linepay_enabled' => 'boolean',
|
||||
'shopping_cart_enabled' => 'boolean',
|
||||
'cash_module_enabled' => 'boolean',
|
||||
'ambient_temp_monitoring_enabled' => 'boolean',
|
||||
'machine_model_id' => 'required|exists:machine_models,id',
|
||||
'payment_config_id' => 'nullable|exists:payment_configs,id',
|
||||
'location' => 'nullable|string|max:255',
|
||||
@ -227,6 +265,13 @@ class MachineSettingController extends AdminController
|
||||
'remove_image_0', 'remove_image_1', 'remove_image_2'
|
||||
]);
|
||||
|
||||
// 偵測序號是否變動,若變動則自動重新產生 API Token
|
||||
$tokenRegenerated = false;
|
||||
if (isset($dataToUpdate['serial_no']) && $dataToUpdate['serial_no'] !== $machine->serial_no) {
|
||||
$dataToUpdate['api_token'] = Str::random(60);
|
||||
$tokenRegenerated = true;
|
||||
}
|
||||
|
||||
$machine->update(array_merge($dataToUpdate, [
|
||||
'updater_id' => auth()->id(),
|
||||
]));
|
||||
@ -264,8 +309,13 @@ class MachineSettingController extends AdminController
|
||||
$machine->update(['images' => array_values($currentImages)]);
|
||||
}
|
||||
|
||||
$successMessage = __('Machine updated successfully.');
|
||||
if ($tokenRegenerated) {
|
||||
$successMessage .= ' ' . __('API Token has been regenerated due to serial number change.');
|
||||
}
|
||||
|
||||
return redirect()->route('admin.basic-settings.machines.index')
|
||||
->with('success', __('Machine settings updated successfully.'));
|
||||
->with('success', $successMessage);
|
||||
}
|
||||
|
||||
public function regenerateToken(Request $request, $serial): \Illuminate\Http\JsonResponse
|
||||
@ -274,6 +324,7 @@ class MachineSettingController extends AdminController
|
||||
$newToken = \Illuminate\Support\Str::random(60);
|
||||
$machine->update(['api_token' => $newToken]);
|
||||
|
||||
|
||||
Log::info('Machine API Token Regenerated', [
|
||||
'machine_id' => $machine->id,
|
||||
'serial_no' => $machine->serial_no,
|
||||
@ -287,6 +338,268 @@ class MachineSettingController extends AdminController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 系統設定錯誤回應:依請求型態回 JSON 或 redirect。
|
||||
*/
|
||||
private function settingsError(Request $request, string $message, int $code): \Illuminate\Http\JsonResponse|RedirectResponse
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['success' => false, 'message' => $message], $code);
|
||||
}
|
||||
return redirect()->back()->with('error', $message)->withInput();
|
||||
}
|
||||
|
||||
public function updateSystemSettings(Request $request, Machine $machine): \Illuminate\Http\JsonResponse|RedirectResponse
|
||||
{
|
||||
\Log::info('Update System Settings Request:', $request->all());
|
||||
if ($request->has('settings')) {
|
||||
$settings = $request->input('settings');
|
||||
$allowedFields = [
|
||||
'tax_invoice_enabled',
|
||||
'card_terminal_enabled',
|
||||
'scan_pay_esun_enabled',
|
||||
'scan_pay_linepay_enabled',
|
||||
'shopping_cart_enabled',
|
||||
'welcome_gift_enabled',
|
||||
'cash_module_enabled',
|
||||
'pickup_module_enabled',
|
||||
'member_system_enabled',
|
||||
'ambient_temp_monitoring_enabled',
|
||||
];
|
||||
|
||||
$allowedShoppingModes = ['basic', 'employee_card', 'pickup_sheet'];
|
||||
$shoppingMode = $settings['shopping_mode'] ?? 'basic';
|
||||
if (!in_array($shoppingMode, $allowedShoppingModes, true)) {
|
||||
$shoppingMode = 'basic';
|
||||
}
|
||||
|
||||
$data = [];
|
||||
foreach ($allowedFields as $field) {
|
||||
$data[$field] = isset($settings[$field]) ? (bool)$settings[$field] : false;
|
||||
}
|
||||
|
||||
$jsonSettings = $machine->settings ?? [];
|
||||
$jsonSettings['shopping_mode'] = $shoppingMode;
|
||||
|
||||
if ($shoppingMode !== 'basic') {
|
||||
$data['card_terminal_enabled'] = false;
|
||||
$data['scan_pay_esun_enabled'] = false;
|
||||
$data['scan_pay_linepay_enabled'] = false;
|
||||
$data['shopping_cart_enabled'] = false;
|
||||
$data['welcome_gift_enabled'] = false;
|
||||
$data['cash_module_enabled'] = false;
|
||||
$data['pickup_module_enabled'] = false;
|
||||
|
||||
$jsonSettings['credit_card_enabled'] = false;
|
||||
$jsonSettings['mobile_pay_enabled'] = false;
|
||||
$jsonSettings['card_pay_enabled'] = false;
|
||||
|
||||
$jsonSettings['scan_pay_enabled'] = false;
|
||||
$jsonSettings['scan_pay_tappay_enabled'] = false;
|
||||
$jsonSettings['tappay_linepay'] = false;
|
||||
$jsonSettings['tappay_jkopay'] = false;
|
||||
$jsonSettings['tappay_pipay'] = false;
|
||||
$jsonSettings['tappay_pluspay'] = false;
|
||||
$jsonSettings['tappay_easywallet'] = false;
|
||||
|
||||
$jsonSettings['cash_bill_1000'] = false;
|
||||
$jsonSettings['cash_bill_500'] = false;
|
||||
$jsonSettings['cash_bill_100'] = false;
|
||||
$jsonSettings['cash_coin_50'] = false;
|
||||
$jsonSettings['cash_coin_10'] = false;
|
||||
$jsonSettings['cash_coin_5'] = false;
|
||||
$jsonSettings['cash_coin_1'] = false;
|
||||
|
||||
$jsonSettings['pickup_code_enabled'] = false;
|
||||
$jsonSettings['pass_code_enabled'] = false;
|
||||
|
||||
// 副櫃系統(格子櫃功能)僅基礎版可用,非基礎版一律關閉
|
||||
$jsonSettings['subcabinet_enabled'] = false;
|
||||
} else {
|
||||
$jsonSettings['credit_card_enabled'] = isset($settings['credit_card_enabled']) ? (bool)$settings['credit_card_enabled'] : false;
|
||||
$jsonSettings['mobile_pay_enabled'] = isset($settings['mobile_pay_enabled']) ? (bool)$settings['mobile_pay_enabled'] : false;
|
||||
$jsonSettings['card_pay_enabled'] = isset($settings['card_pay_enabled']) ? (bool)$settings['card_pay_enabled'] : false;
|
||||
|
||||
$jsonSettings['scan_pay_enabled'] = isset($settings['scan_pay_enabled']) ? (bool)$settings['scan_pay_enabled'] : false;
|
||||
$jsonSettings['scan_pay_tappay_enabled'] = isset($settings['scan_pay_tappay_enabled']) ? (bool)$settings['scan_pay_tappay_enabled'] : false;
|
||||
$jsonSettings['tappay_linepay'] = isset($settings['tappay_linepay']) ? (bool)$settings['tappay_linepay'] : false;
|
||||
$jsonSettings['tappay_jkopay'] = isset($settings['tappay_jkopay']) ? (bool)$settings['tappay_jkopay'] : false;
|
||||
$jsonSettings['tappay_pipay'] = isset($settings['tappay_pipay']) ? (bool)$settings['tappay_pipay'] : false;
|
||||
$jsonSettings['tappay_pluspay'] = isset($settings['tappay_pluspay']) ? (bool)$settings['tappay_pluspay'] : false;
|
||||
$jsonSettings['tappay_easywallet'] = isset($settings['tappay_easywallet']) ? (bool)$settings['tappay_easywallet'] : false;
|
||||
|
||||
$jsonSettings['cash_bill_1000'] = isset($settings['cash_bill_1000']) ? (bool)$settings['cash_bill_1000'] : false;
|
||||
$jsonSettings['cash_bill_500'] = isset($settings['cash_bill_500']) ? (bool)$settings['cash_bill_500'] : false;
|
||||
$jsonSettings['cash_bill_100'] = isset($settings['cash_bill_100']) ? (bool)$settings['cash_bill_100'] : false;
|
||||
$jsonSettings['cash_coin_50'] = isset($settings['cash_coin_50']) ? (bool)$settings['cash_coin_50'] : false;
|
||||
$jsonSettings['cash_coin_10'] = isset($settings['cash_coin_10']) ? (bool)$settings['cash_coin_10'] : false;
|
||||
$jsonSettings['cash_coin_5'] = isset($settings['cash_coin_5']) ? (bool)$settings['cash_coin_5'] : false;
|
||||
$jsonSettings['cash_coin_1'] = isset($settings['cash_coin_1']) ? (bool)$settings['cash_coin_1'] : false;
|
||||
|
||||
$jsonSettings['pickup_code_enabled'] = isset($settings['pickup_code_enabled']) ? (bool)$settings['pickup_code_enabled'] : false;
|
||||
$jsonSettings['pass_code_enabled'] = isset($settings['pass_code_enabled']) ? (bool)$settings['pass_code_enabled'] : false;
|
||||
|
||||
// 副櫃系統(格子櫃功能):基礎版可設定
|
||||
$jsonSettings['subcabinet_enabled'] = isset($settings['subcabinet_enabled']) ? (bool)$settings['subcabinet_enabled'] : false;
|
||||
}
|
||||
|
||||
foreach ($allowedFields as $field) {
|
||||
$jsonSettings[$field] = $data[$field];
|
||||
}
|
||||
|
||||
// 領藥單模組:僅在「取物單(pickup_sheet)」模式下可啟用,其他模式一律關閉
|
||||
$jsonSettings['pharmacy_pickup_enabled'] = ($shoppingMode === 'pickup_sheet')
|
||||
? (bool) ($settings['pharmacy_pickup_enabled'] ?? false)
|
||||
: false;
|
||||
|
||||
// 顯示語系 (languages):機台螢幕可切換的語系,最多 N 種,僅系統管理員可設定。
|
||||
// 商品翻譯範圍由「公司機台語系聯集」反推,故此處異動需失效聯集快取並重建商品目錄。
|
||||
// 非系統管理員即使送出 languages 亦一律忽略(不報錯,讓其仍可儲存其他設定)。
|
||||
$languagesChanged = false;
|
||||
if (array_key_exists('languages', $settings) && auth()->user()->isSystemAdmin()) {
|
||||
$whitelist = array_keys(config('locales.supported', []));
|
||||
$max = (int) config('locales.max_per_machine', 5);
|
||||
|
||||
// 去重、保留順序、僅留白名單內語系
|
||||
$languages = array_values(array_unique(array_filter(
|
||||
(array) $settings['languages'],
|
||||
fn ($l) => in_array($l, $whitelist, true)
|
||||
)));
|
||||
|
||||
if (count((array) $settings['languages']) > $max || count($languages) > $max) {
|
||||
return $this->settingsError($request, __('You can select at most :max languages.', ['max' => $max]), 422);
|
||||
}
|
||||
|
||||
$jsonSettings['languages'] = $languages;
|
||||
$languagesChanged = true;
|
||||
}
|
||||
|
||||
$machine->update([
|
||||
'settings' => $jsonSettings,
|
||||
'updater_id' => auth()->id()
|
||||
]);
|
||||
|
||||
// 同時更新 model 的屬性欄位 (以防 mutator 同步,以及確保 dirty check 正確)
|
||||
$machine->update(array_merge($data, ['updater_id' => auth()->id()]));
|
||||
|
||||
// 語系異動:失效公司語系聯集快取並重建商品目錄 i18n(B012 下發內容隨之更新)
|
||||
if ($languagesChanged) {
|
||||
\App\Models\System\Company::forgetActiveLocales($machine->company_id);
|
||||
app(\App\Services\Product\ProductCatalogService::class)->rebuildCache($machine->company_id);
|
||||
}
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Settings updated successfully.')
|
||||
]);
|
||||
}
|
||||
return redirect()->back()->with('success', __('Settings updated successfully.'));
|
||||
}
|
||||
|
||||
$field = $request->input('field');
|
||||
$value = $request->boolean('value');
|
||||
|
||||
$allowedFields = [
|
||||
'tax_invoice_enabled',
|
||||
'card_terminal_enabled',
|
||||
'scan_pay_esun_enabled',
|
||||
'scan_pay_linepay_enabled',
|
||||
'shopping_cart_enabled',
|
||||
'welcome_gift_enabled',
|
||||
'cash_module_enabled',
|
||||
'member_system_enabled',
|
||||
'ambient_temp_monitoring_enabled',
|
||||
];
|
||||
|
||||
if (!in_array($field, $allowedFields)) {
|
||||
return response()->json(['success' => false, 'message' => 'Invalid field'], 400);
|
||||
}
|
||||
|
||||
$machine->update([$field => $value, 'updater_id' => auth()->id()]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Setting updated successfully.')
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步系統設定至機台:透過 MQTT 下發 update_settings 指令,
|
||||
* 通知機台 APP 主動回抓最新系統設定 (B014)。
|
||||
* 參考廣告同步 (AdvertisementController::syncToMachine) 的併行控制模式。
|
||||
*/
|
||||
public function syncSettings(Request $request, Machine $machine, \App\Services\Machine\MqttService $mqttService): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
// 併行檢查:若有 pending 指令,超過 1 分鐘視為逾時可覆蓋
|
||||
$pendingCommand = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
|
||||
->where('command_type', 'update_settings')
|
||||
->where('status', 'pending')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if ($pendingCommand) {
|
||||
$isTimedOut = $pendingCommand->created_at->lt(now()->subMinute());
|
||||
|
||||
if (!$isTimedOut) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('This machine has a pending command. Please wait.')
|
||||
], 422);
|
||||
}
|
||||
|
||||
$pendingCommand->update([
|
||||
'status' => 'timeout',
|
||||
'note' => __('Superseded by new command (Timeout)'),
|
||||
]);
|
||||
}
|
||||
|
||||
$command = \App\Models\Machine\RemoteCommand::create([
|
||||
'machine_id' => $machine->id,
|
||||
'user_id' => auth()->id(),
|
||||
'command_type' => 'update_settings',
|
||||
'payload' => [],
|
||||
'status' => 'pending',
|
||||
'remark' => $request->filled('note') ? $request->input('note') : __('Manual Sync Settings'),
|
||||
]);
|
||||
|
||||
$success = $mqttService->pushCommand(
|
||||
$machine->serial_no,
|
||||
'update_settings',
|
||||
[],
|
||||
(string) $command->id
|
||||
);
|
||||
|
||||
if ($success) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Sync command sent successfully.')
|
||||
]);
|
||||
}
|
||||
|
||||
$command->update(['status' => 'failed', 'note' => 'MQTT push failed']);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('Failed to send sync command.')
|
||||
], 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除機台 (僅限系統管理員)
|
||||
*/
|
||||
public function destroy(Machine $machine): RedirectResponse
|
||||
{
|
||||
if (!auth()->user()->isSystemAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$machine->delete();
|
||||
|
||||
return redirect()->route('admin.basic-settings.machines.index')
|
||||
->with('success', __('Machine deleted successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 公開機台分布地圖
|
||||
*/
|
||||
@ -294,7 +607,8 @@ class MachineSettingController extends AdminController
|
||||
{
|
||||
$machines = Machine::whereNotNull('latitude')
|
||||
->whereNotNull('longitude')
|
||||
->get(['id', 'name', 'serial_no', 'address', 'location', 'latitude', 'longitude', 'status']);
|
||||
->with('machineModel')
|
||||
->get(['id', 'name', 'serial_no', 'address', 'location', 'latitude', 'longitude', 'status', 'machine_model_id']);
|
||||
|
||||
return view('admin.basic-settings.machines.distribution', compact('machines'));
|
||||
}
|
||||
|
||||
@ -49,9 +49,9 @@ class PaymentConfigController extends AdminController
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'company_id' => 'required|exists:companies,id',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'settings' => 'required|array',
|
||||
]);
|
||||
] + $this->ecpayInvoiceRules(), $this->ecpayInvoiceMessages());
|
||||
|
||||
PaymentConfig::create([
|
||||
'name' => $request->name,
|
||||
@ -81,9 +81,9 @@ class PaymentConfigController extends AdminController
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'company_id' => 'required|exists:companies,id',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'settings' => 'required|array',
|
||||
]);
|
||||
] + $this->ecpayInvoiceRules(), $this->ecpayInvoiceMessages());
|
||||
|
||||
$paymentConfig->update([
|
||||
'name' => $request->name,
|
||||
@ -105,4 +105,30 @@ class PaymentConfigController extends AdminController
|
||||
return redirect()->route('admin.basic-settings.payment-configs.index')
|
||||
->with('success', __('Payment Configuration deleted successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 綠界電子發票群組驗證規則:整組選填,但只要填了任一欄,
|
||||
* store_id / hash_key / hash_iv / email 即全部必填(綠界開立發票需金鑰齊備,
|
||||
* 且 email 為「電話/信箱擇一」的唯一來源,缺一即無法開立)。
|
||||
* 依賴 ConvertEmptyStringsToNull middleware:空欄送出為 null,required_with 不會誤觸發。
|
||||
*/
|
||||
private function ecpayInvoiceRules(): array
|
||||
{
|
||||
$g = 'settings.ecpay_invoice';
|
||||
return [
|
||||
"{$g}.store_id" => "nullable|string|max:20|required_with:{$g}.hash_key,{$g}.hash_iv,{$g}.email",
|
||||
"{$g}.hash_key" => "nullable|string|max:64|required_with:{$g}.store_id,{$g}.hash_iv,{$g}.email",
|
||||
"{$g}.hash_iv" => "nullable|string|max:64|required_with:{$g}.store_id,{$g}.hash_key,{$g}.email",
|
||||
"{$g}.email" => "nullable|email|max:100|required_with:{$g}.store_id,{$g}.hash_key,{$g}.hash_iv",
|
||||
];
|
||||
}
|
||||
|
||||
/** 綠界電子發票群組的驗證訊息(三語系)。 */
|
||||
private function ecpayInvoiceMessages(): array
|
||||
{
|
||||
return [
|
||||
'settings.ecpay_invoice.*.required_with' => __('ECPay e-invoice is optional, but once any field is filled, Store ID / Hash Key / Hash IV / Email are all required.'),
|
||||
'settings.ecpay_invoice.email.email' => __('Please enter a valid ECPay e-invoice notification email.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,9 +6,11 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\System\Company;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Traits\ImageHandler;
|
||||
|
||||
class CompanyController extends Controller
|
||||
{
|
||||
use ImageHandler;
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
@ -38,7 +40,10 @@ class CompanyController extends Controller
|
||||
->where('name', '!=', 'super-admin')
|
||||
->get();
|
||||
|
||||
return view('admin.companies.index', compact('companies', 'template_roles'));
|
||||
// 取得發生驗證錯誤的公司資訊 (用於維持 Modal 開啟)
|
||||
$error_company = session('error_company_id') ? Company::find(session('error_company_id')) : null;
|
||||
|
||||
return view('admin.companies.index', compact('companies', 'template_roles', 'error_company'));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -48,13 +53,13 @@ class CompanyController extends Controller
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'code' => 'required|string|max:50|unique:companies,code',
|
||||
'code' => 'nullable|string|max:50|unique:companies,code',
|
||||
'original_type' => 'required|string|in:buyout,lease',
|
||||
'tax_id' => 'nullable|string|max:50',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'start_date' => 'required|date',
|
||||
'start_date' => 'nullable|date',
|
||||
'end_date' => 'nullable|date',
|
||||
'warranty_start_date' => 'nullable|date',
|
||||
'warranty_end_date' => 'nullable|date',
|
||||
@ -74,13 +79,6 @@ class CompanyController extends Controller
|
||||
if (isset($validated['settings'])) {
|
||||
$validated['settings']['enable_material_code'] = filter_var($validated['settings']['enable_material_code'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$validated['settings']['enable_points'] = filter_var($validated['settings']['enable_points'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$validated['settings']['tax_invoice'] = filter_var($validated['settings']['tax_invoice'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$validated['settings']['card_terminal'] = filter_var($validated['settings']['card_terminal'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$validated['settings']['scan_pay_esun'] = filter_var($validated['settings']['scan_pay_esun'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$validated['settings']['scan_pay_linepay'] = filter_var($validated['settings']['scan_pay_linepay'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$validated['settings']['shopping_cart'] = filter_var($validated['settings']['shopping_cart'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$validated['settings']['welcome_gift'] = filter_var($validated['settings']['welcome_gift'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$validated['settings']['cash_module'] = filter_var($validated['settings']['cash_module'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($validated) {
|
||||
@ -125,6 +123,7 @@ class CompanyController extends Controller
|
||||
'password' => \Illuminate\Support\Facades\Hash::make($validated['admin_password']),
|
||||
'name' => $validated['admin_name'] ?: ($validated['contact_name'] ?: $validated['name']),
|
||||
'status' => 1,
|
||||
'is_admin' => true,
|
||||
]);
|
||||
|
||||
// 角色初始化與克隆邏輯 (優先使用選擇的角色,否則使用預設)
|
||||
@ -164,13 +163,13 @@ class CompanyController extends Controller
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'code' => 'required|string|max:50|unique:companies,code,' . $company->id,
|
||||
'code' => 'nullable|string|max:50|unique:companies,code,' . $company->id,
|
||||
'current_type' => 'required|string|in:buyout,lease',
|
||||
'tax_id' => 'nullable|string|max:50',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'start_date' => 'required|date',
|
||||
'start_date' => 'nullable|date',
|
||||
'end_date' => 'nullable|date',
|
||||
'warranty_start_date' => 'nullable|date',
|
||||
'warranty_end_date' => 'nullable|date',
|
||||
@ -185,13 +184,6 @@ class CompanyController extends Controller
|
||||
if (isset($validated['settings'])) {
|
||||
$validated['settings']['enable_material_code'] = filter_var($validated['settings']['enable_material_code'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$validated['settings']['enable_points'] = filter_var($validated['settings']['enable_points'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$validated['settings']['tax_invoice'] = filter_var($validated['settings']['tax_invoice'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$validated['settings']['card_terminal'] = filter_var($validated['settings']['card_terminal'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$validated['settings']['scan_pay_esun'] = filter_var($validated['settings']['scan_pay_esun'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$validated['settings']['scan_pay_linepay'] = filter_var($validated['settings']['scan_pay_linepay'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$validated['settings']['shopping_cart'] = filter_var($validated['settings']['shopping_cart'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$validated['settings']['welcome_gift'] = filter_var($validated['settings']['welcome_gift'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$validated['settings']['cash_module'] = filter_var($validated['settings']['cash_module'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($validated, $company) {
|
||||
@ -226,18 +218,14 @@ class CompanyController extends Controller
|
||||
{
|
||||
\Log::info('updateSettings payload:', $request->all());
|
||||
$settings = $request->input('settings', []);
|
||||
$currentSettings = $company->settings ?? [];
|
||||
|
||||
$formattedSettings = [
|
||||
// 採用合併方式,防止覆蓋品牌設定或其他未包含的欄位
|
||||
$formattedSettings = array_merge($currentSettings, [
|
||||
'enable_material_code' => filter_var($settings['enable_material_code'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
'enable_points' => filter_var($settings['enable_points'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
'tax_invoice' => filter_var($settings['tax_invoice'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
'card_terminal' => filter_var($settings['card_terminal'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
'scan_pay_esun' => filter_var($settings['scan_pay_esun'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
'scan_pay_linepay' => filter_var($settings['scan_pay_linepay'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
'shopping_cart' => filter_var($settings['shopping_cart'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
'welcome_gift' => filter_var($settings['welcome_gift'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
'cash_module' => filter_var($settings['cash_module'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
];
|
||||
'enable_custom_branding' => filter_var($settings['enable_custom_branding'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
]);
|
||||
|
||||
// Ensure we force save the JSON cast properly
|
||||
$company->settings = $formattedSettings;
|
||||
@ -247,6 +235,122 @@ class CompanyController extends Controller
|
||||
->with('success', __('System settings updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示客製化品牌樣式設定頁面
|
||||
*/
|
||||
public function editBranding(Company $company)
|
||||
{
|
||||
$settings = $company->settings ?? [];
|
||||
$enableCustomBranding = filter_var($settings['enable_custom_branding'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
if (!$enableCustomBranding) {
|
||||
return redirect()->route('admin.permission.companies.index')
|
||||
->with('error', __('Custom branding function is not enabled for this company.'));
|
||||
}
|
||||
|
||||
return view('admin.companies.branding', compact('company'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新公司品牌客製化樣式設定 (自訂 Logo 與歡迎詞)
|
||||
*/
|
||||
public function updateBranding(Request $request, Company $company)
|
||||
{
|
||||
\Log::info('updateBranding payload:', [
|
||||
'company_id' => $company->id,
|
||||
'request_all' => $request->all(),
|
||||
'files' => array_keys($request->allFiles())
|
||||
]);
|
||||
|
||||
$settings = $company->settings ?? [];
|
||||
|
||||
// 1. 安全檢驗:如果未授權此功能,則拒絕操作
|
||||
$enableCustomBranding = filter_var($settings['enable_custom_branding'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
if (!$enableCustomBranding) {
|
||||
return redirect()->back()->with('error', __('Custom branding function is not enabled for this company.'));
|
||||
}
|
||||
|
||||
// 2. 欄位驗證
|
||||
$validator = \Illuminate\Support\Facades\Validator::make($request->all(), [
|
||||
'logo_file' => 'nullable|image|mimes:jpeg,png,jpg,gif,svg,webp|max:10240', // 限制最大 10MB
|
||||
'login_bg_file' => 'nullable|image|mimes:jpeg,png,jpg,gif,svg,webp|max:10240', // 限制最大 10MB
|
||||
'login_card_bg_file' => 'nullable|image|mimes:jpeg,png,jpg,gif,svg,webp|max:10240', // 限制最大 10MB
|
||||
'login_title' => 'nullable|string|max:100',
|
||||
'login_main_title' => 'nullable|string|max:100',
|
||||
'login_sub_title' => 'nullable|string|max:100',
|
||||
'clear_logo' => 'nullable|boolean', // 用於提供清除 Logo 功能
|
||||
'clear_login_bg' => 'nullable|boolean', // 清除大背景
|
||||
'clear_login_card_bg' => 'nullable|boolean', // 清除卡片背景
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return redirect()->back()
|
||||
->withErrors($validator)
|
||||
->withInput()
|
||||
->with('error', __('Validation failed. Please check the styling configurations.'));
|
||||
}
|
||||
|
||||
$logoPath = $settings['logo_path'] ?? null;
|
||||
$loginBgPath = $settings['login_bg_path'] ?? null;
|
||||
$loginCardBgPath = $settings['login_card_bg_path'] ?? null;
|
||||
|
||||
// 3. 處理清除圖片的需求
|
||||
if ($request->boolean('clear_logo')) {
|
||||
if ($logoPath) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($logoPath);
|
||||
$logoPath = null;
|
||||
}
|
||||
}
|
||||
if ($request->boolean('clear_login_bg')) {
|
||||
if ($loginBgPath) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($loginBgPath);
|
||||
$loginBgPath = null;
|
||||
}
|
||||
}
|
||||
if ($request->boolean('clear_login_card_bg')) {
|
||||
if ($loginCardBgPath) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($loginCardBgPath);
|
||||
$loginCardBgPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 處理圖片檔案上傳 (轉為高品質 webp)
|
||||
if ($request->hasFile('logo_file')) {
|
||||
if ($logoPath) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($logoPath);
|
||||
}
|
||||
$logoPath = $this->storeAsWebp($request->file('logo_file'), 'company_logos', 80);
|
||||
}
|
||||
if ($request->hasFile('login_bg_file')) {
|
||||
if ($loginBgPath) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($loginBgPath);
|
||||
}
|
||||
$loginBgPath = $this->storeAsWebp($request->file('login_bg_file'), 'company_backgrounds', 85);
|
||||
}
|
||||
if ($request->hasFile('login_card_bg_file')) {
|
||||
if ($loginCardBgPath) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($loginCardBgPath);
|
||||
}
|
||||
$loginCardBgPath = $this->storeAsWebp($request->file('login_card_bg_file'), 'company_backgrounds', 85);
|
||||
}
|
||||
|
||||
// 5. 合併寫入 settings JSON 中,防止覆寫其他功能開關
|
||||
$company->settings = array_merge($settings, [
|
||||
'logo_path' => $logoPath,
|
||||
'login_bg_path' => $loginBgPath,
|
||||
'login_card_bg_path' => $loginCardBgPath,
|
||||
'login_title' => $request->input('login_title'),
|
||||
'login_main_title' => $request->input('login_main_title'),
|
||||
'login_sub_title' => $request->input('login_sub_title'),
|
||||
]);
|
||||
|
||||
$company->save();
|
||||
|
||||
return redirect()->route('admin.permission.companies.branding.edit', $company->id)
|
||||
->with('success', __('Brand styling updated successfully.'));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 切換客戶狀態
|
||||
*/
|
||||
|
||||
@ -3,8 +3,10 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Transaction\Order;
|
||||
use App\Models\Machine\Machine;
|
||||
use Illuminate\Http\Request;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
@ -16,12 +18,37 @@ class DashboardController extends Controller
|
||||
$perPage = 10;
|
||||
|
||||
// 從資料庫獲取真實統計數據
|
||||
$totalRevenue = \App\Models\Member\MemberWallet::sum('balance');
|
||||
$activeMachines = Machine::online()->count();
|
||||
$offlineMachines = Machine::offline()->count();
|
||||
$alertsPending = Machine::hasError()->count();
|
||||
$alertsPending = Machine::hasAnyAlert()->count();
|
||||
$memberCount = \App\Models\Member\Member::count();
|
||||
|
||||
// 交易營收統計 (真實數據)
|
||||
$todayRevenue = Order::where('payment_status', Order::PAYMENT_STATUS_SUCCESS)
|
||||
->whereDate('payment_at', Carbon::today())
|
||||
->sum('pay_amount');
|
||||
|
||||
$yesterdayRevenue = Order::where('payment_status', Order::PAYMENT_STATUS_SUCCESS)
|
||||
->whereDate('payment_at', Carbon::yesterday())
|
||||
->sum('pay_amount');
|
||||
|
||||
$dayBeforeRevenue = Order::where('payment_status', Order::PAYMENT_STATUS_SUCCESS)
|
||||
->whereDate('payment_at', Carbon::yesterday()->subDay())
|
||||
->sum('pay_amount');
|
||||
|
||||
$monthlyRevenue = Order::where('payment_status', Order::PAYMENT_STATUS_SUCCESS)
|
||||
->whereMonth('payment_at', Carbon::now()->month)
|
||||
->whereYear('payment_at', Carbon::now()->year)
|
||||
->sum('pay_amount');
|
||||
|
||||
// 計算昨日增長趨勢 (百分比)
|
||||
$yesterdayTrend = 0;
|
||||
if ($yesterdayRevenue > 0) {
|
||||
$yesterdayTrend = (($todayRevenue - $yesterdayRevenue) / $yesterdayRevenue) * 100;
|
||||
} elseif ($todayRevenue > 0) {
|
||||
$yesterdayTrend = 100;
|
||||
}
|
||||
|
||||
// 獲取機台列表 (分頁)
|
||||
$machines = Machine::when($request->search, function ($query, $search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
@ -29,16 +56,26 @@ class DashboardController extends Controller
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->withSum('slots', 'stock')
|
||||
->withSum('slots', 'max_stock')
|
||||
->withSum(['orders as today_sales_sum' => function($query) {
|
||||
$query->whereDate('payment_at', now()->toDateString())
|
||||
->where('payment_status', Order::PAYMENT_STATUS_SUCCESS);
|
||||
}], 'pay_amount')
|
||||
->orderByDesc('last_heartbeat_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.dashboard', compact(
|
||||
'totalRevenue',
|
||||
'activeMachines',
|
||||
'offlineMachines',
|
||||
'alertsPending',
|
||||
'memberCount',
|
||||
'todayRevenue',
|
||||
'yesterdayRevenue',
|
||||
'dayBeforeRevenue',
|
||||
'monthlyRevenue',
|
||||
'yesterdayTrend',
|
||||
'machines'
|
||||
));
|
||||
}
|
||||
|
||||
@ -25,6 +25,9 @@ class GeocodingController extends Controller
|
||||
|
||||
$address = $request->input('address');
|
||||
|
||||
// 1. 清洗台灣地址中的樓層、室、地下室與括號備註,避免干擾地理編碼解析
|
||||
$address = $this->cleanFloorInfo($address);
|
||||
|
||||
// 策略 1:結構化搜尋(台灣地址精確度最高)
|
||||
$parsed = $this->parseTaiwanAddress($address);
|
||||
|
||||
@ -114,8 +117,8 @@ class GeocodingController extends Controller
|
||||
$streetPart = $address;
|
||||
}
|
||||
|
||||
// 拆分路名與門牌號碼
|
||||
preg_match('/^(.+?(?:路|街|大道|巷|弄)(?:[一二三四五六七八九十]+段)?)\s*(.*)/u', $streetPart, $roadMatch);
|
||||
// 拆分路名與門牌號碼(層級感知台灣地址結構,完整保留巷弄)
|
||||
preg_match('/^(.+?(?:路|街|大道)(?:[一二三四五六七八九十]+段)?(?:[0-9一二三四五六七八九十百]+巷)?(?:[0-9一二三四五六七八九十百]+弄)?)\s*(.*)/u', $streetPart, $roadMatch);
|
||||
|
||||
$street = '';
|
||||
$houseNumber = '';
|
||||
@ -134,4 +137,21 @@ class GeocodingController extends Controller
|
||||
'street' => $fullStreet,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗台灣地址中的樓層、室、地下室與括號備註,避免干擾 OpenStreetMap 解析
|
||||
*/
|
||||
private function cleanFloorInfo(string $address): string
|
||||
{
|
||||
// 1. 移除常見的樓層與地下室資訊,以及其後方的所有贅字:例如 "2樓", "12樓之3", "89F", "B1", "地下1樓" 等
|
||||
$address = preg_replace('/(?:地下一?|地下)?(?:[0-9一二三四五六七八九十百]+樓|[0-9a-zA-Z]+[fF]|[bB][0-9]+)(?:之[0-9一二三四五六七八九十百]+)?.*/u', '', $address);
|
||||
|
||||
// 2. 移除 "x室", "x房", "x戶" 等室內資訊
|
||||
$address = preg_replace('/(?:[0-9a-zA-Z一二三四五六七八九十百]+(?:室|房|戶)).*/u', '', $address);
|
||||
|
||||
// 3. 移除常見 the 括號備註,例如 "(五樓)", "(A棟)", "(總統府)"
|
||||
$address = preg_replace('/[\((][^\))]+[\))]/u', '', $address);
|
||||
|
||||
return trim($address);
|
||||
}
|
||||
}
|
||||
|
||||
98
app/Http/Controllers/Admin/ImpersonateController.php
Normal file
98
app/Http/Controllers/Admin/ImpersonateController.php
Normal file
@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\System\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ImpersonateController extends Controller
|
||||
{
|
||||
/**
|
||||
* Start impersonating a user.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'user_id' => 'required|exists:users,id',
|
||||
]);
|
||||
|
||||
$currentUser = $request->user();
|
||||
|
||||
// 1. Only system admins can impersonate
|
||||
if (!$currentUser->isSystemAdmin()) {
|
||||
abort(403, '只有系統管理員可以使用此功能。');
|
||||
}
|
||||
|
||||
// 2. Prevent nested impersonation (cannot impersonate someone while already impersonating)
|
||||
if (session()->has('impersonated_by')) {
|
||||
return back()->with('error', '您目前已經處於切換狀態,無法再次切換。請先退出目前的切換狀態。');
|
||||
}
|
||||
|
||||
$targetUserId = $request->input('user_id');
|
||||
|
||||
// 3. Cannot impersonate oneself
|
||||
if ($currentUser->id == $targetUserId) {
|
||||
return back()->with('error', '無法切換至自己的帳號。');
|
||||
}
|
||||
|
||||
$targetUser = User::findOrFail($targetUserId);
|
||||
|
||||
// 4. Set session and login
|
||||
session()->put('impersonated_by', $currentUser->id);
|
||||
Auth::loginUsingId($targetUser->id);
|
||||
|
||||
return redirect()->route('admin.dashboard')->with('success', "已成功切換至 {$targetUser->name} 的帳號。");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop impersonating and return to the original user.
|
||||
*/
|
||||
public function leave(Request $request)
|
||||
{
|
||||
if (!session()->has('impersonated_by')) {
|
||||
return back()->with('error', '您目前並未處於切換狀態。');
|
||||
}
|
||||
|
||||
$originalUserId = session()->pull('impersonated_by');
|
||||
Auth::loginUsingId($originalUserId);
|
||||
|
||||
return redirect()->route('admin.dashboard')->with('success', '已退出切換狀態,返回原帳號。');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch companies for the impersonate modal.
|
||||
*/
|
||||
public function companies(Request $request)
|
||||
{
|
||||
if (!$request->user()->isSystemAdmin()) abort(403);
|
||||
|
||||
$companies = \App\Models\System\Company::select('id', 'name')->orderBy('name')->get();
|
||||
return response()->json($companies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch users for the impersonate modal, optionally filtered by company.
|
||||
*/
|
||||
public function users(Request $request)
|
||||
{
|
||||
if (!$request->user()->isSystemAdmin()) abort(403);
|
||||
|
||||
$query = User::select('id', 'name', 'username', 'email', 'company_id');
|
||||
|
||||
if ($request->filled('company_id')) {
|
||||
$query->where('company_id', $request->company_id);
|
||||
} else {
|
||||
// Include system accounts (company_id is null) when no company is selected?
|
||||
// Actually let's just return all users if no company selected, or just system accounts.
|
||||
// But if company_id is explicitly sent as 'system', we filter for null.
|
||||
if ($request->company_id === 'system') {
|
||||
$query->whereNull('company_id');
|
||||
}
|
||||
}
|
||||
|
||||
$users = $query->orderBy('name')->get();
|
||||
return response()->json($users);
|
||||
}
|
||||
}
|
||||
@ -32,10 +32,9 @@ class MachinePermissionController extends AdminController
|
||||
}])
|
||||
->whereNotNull('company_id');
|
||||
|
||||
// 非系統管理員僅能看到同公司的帳號 (因 User Model 排除 TenantScoped 全域過濾,需手動注入)
|
||||
if (!$currentUser->isSystemAdmin()) {
|
||||
$userQuery->where('company_id', $currentUser->company_id);
|
||||
} elseif ($company_id) {
|
||||
// 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層(避免洩漏旁線帳號)。
|
||||
$userQuery->visibleTo($currentUser);
|
||||
if ($currentUser->isSystemAdmin() && $company_id) {
|
||||
// 系統管理員的篩選邏輯
|
||||
$userQuery->where('company_id', $company_id);
|
||||
}
|
||||
@ -54,6 +53,31 @@ class MachinePermissionController extends AdminController
|
||||
return view('admin.machines.permissions', compact('users_list', 'companies'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作者「可授權」的機台 ID 集合(授權子集約束):
|
||||
* - 系統管理員:指定公司(或全部)的所有機台
|
||||
* - 主帳號:同公司全部機台
|
||||
* - 子帳號:僅自己被授權的機台(machine_user)
|
||||
*/
|
||||
private function assignableMachineIds(User $operator, ?int $companyId): \Illuminate\Support\Collection
|
||||
{
|
||||
// 目標帳號無所屬公司(如系統管理員帳號)時,沒有可授權的公司機台。
|
||||
if (is_null($companyId)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
if ($operator->isSystemAdmin() || $operator->is_admin) {
|
||||
return Machine::withoutGlobalScope('machine_access')
|
||||
->where('company_id', $companyId)
|
||||
->pluck('id');
|
||||
}
|
||||
|
||||
return $operator->machines()
|
||||
->withoutGlobalScope('machine_access')
|
||||
->where('machines.company_id', $companyId)
|
||||
->pluck('machines.id');
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: 取得特定帳號的機台分配狀態
|
||||
*/
|
||||
@ -61,17 +85,18 @@ class MachinePermissionController extends AdminController
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
|
||||
// 安全檢查:只能操作自己公司的帳號(除非是系統管理員)
|
||||
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
|
||||
// 層級越權防護:只能操作可管轄範圍內的帳號。
|
||||
if (!$currentUser->canManageAccount($user)) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
// 取得該使用者所屬公司之所有機台 (忽略個別帳號的 machine_access 限制,以公司為單位顯示)
|
||||
// 可授權機台清單限縮為「操作者本身可授權」的機台子集,避免把自己沒有的機台授權出去。
|
||||
$assignableIds = $this->assignableMachineIds($currentUser, $user->company_id);
|
||||
$machines = Machine::withoutGlobalScope('machine_access')
|
||||
->where('company_id', $user->company_id)
|
||||
->whereIn('id', $assignableIds)
|
||||
->get(['id', 'name', 'serial_no']);
|
||||
|
||||
$assignedIds = $user->machines()->pluck('machines.id')->toArray();
|
||||
|
||||
$assignedIds = $user->machines()->withoutGlobalScope('machine_access')->pluck('machines.id')->toArray();
|
||||
|
||||
return response()->json([
|
||||
'user' => $user,
|
||||
@ -87,8 +112,8 @@ class MachinePermissionController extends AdminController
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
|
||||
// 安全檢查
|
||||
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
|
||||
// 層級越權防護:只能操作可管轄範圍內的帳號。
|
||||
if (!$currentUser->canManageAccount($user)) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
@ -97,20 +122,27 @@ class MachinePermissionController extends AdminController
|
||||
'machine_ids.*' => 'exists:machines,id'
|
||||
]);
|
||||
|
||||
// 加固驗證:確保所有機台 ID 都屬於該使用者的公司 (使用 withoutGlobalScope 避免管理員自身權限影響驗證邏輯)
|
||||
if ($request->has('machine_ids')) {
|
||||
$machineIds = array_unique($request->machine_ids);
|
||||
$validCount = Machine::withoutGlobalScope('machine_access')
|
||||
->where('company_id', $user->company_id)
|
||||
->whereIn('id', $machineIds)
|
||||
->count();
|
||||
|
||||
if ($validCount !== count($machineIds)) {
|
||||
return response()->json(['error' => 'Invalid machine IDs provided.'], 422);
|
||||
}
|
||||
// 授權子集約束:被指派的機台必須是「操作者本身可授權機台」的子集,
|
||||
// 既確保同公司,也避免子帳號把自己沒被授權的機台授權給他人。
|
||||
$assignableIds = $this->assignableMachineIds($currentUser, $user->company_id);
|
||||
$submittedIds = collect($request->machine_ids ?? [])->map(fn ($id) => (int) $id)->unique();
|
||||
if ($submittedIds->diff($assignableIds)->isNotEmpty()) {
|
||||
return response()->json(['error' => 'Invalid machine IDs provided.'], 422);
|
||||
}
|
||||
|
||||
$user->machines()->sync($request->machine_ids ?? []);
|
||||
// 只在「操作者可授權子集」範圍內做增刪;範圍外的既有授權一律保留,避免覆蓋式 sync 誤刪
|
||||
// 操作者看不到(不在其可授權子集內)的機台。明確 detach/attach 以避開 machine_access
|
||||
// 全域 scope 對 sync 取「現有附加」造成的干擾(該 scope 依登入者過濾 Machine 查詢)。
|
||||
$existingIds = $user->machines()->withoutGlobalScope('machine_access')->pluck('machines.id');
|
||||
$currentInScope = $existingIds->intersect($assignableIds);
|
||||
$toAttach = $submittedIds->diff($currentInScope);
|
||||
$toDetach = $currentInScope->diff($submittedIds);
|
||||
if ($toDetach->isNotEmpty()) {
|
||||
$user->machines()->detach($toDetach->all());
|
||||
}
|
||||
if ($toAttach->isNotEmpty()) {
|
||||
$user->machines()->attach($toAttach->all());
|
||||
}
|
||||
|
||||
$message = __('Machine permissions updated successfully.');
|
||||
session()->flash('success', $message);
|
||||
|
||||
@ -2,9 +2,15 @@
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Jobs\Product\SendProductSyncCommandJob;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MachineProductPrice;
|
||||
use App\Models\Product\Product;
|
||||
use App\Models\System\Company;
|
||||
use App\Services\Machine\MachineService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MachineController extends AdminController
|
||||
@ -16,8 +22,10 @@ class MachineController extends AdminController
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$companyId = trim((string) $request->input('company_id', ''));
|
||||
|
||||
$query = Machine::query();
|
||||
$currentUser = auth()->user();
|
||||
|
||||
// 搜尋:名稱或序號
|
||||
if ($search = $request->input('search')) {
|
||||
@ -27,13 +35,36 @@ class MachineController extends AdminController
|
||||
});
|
||||
}
|
||||
|
||||
if ($currentUser->isSystemAdmin() && $companyId !== '') {
|
||||
$query->where('company_id', $companyId);
|
||||
}
|
||||
|
||||
// 刷卡機狀態:以彙總/相關子查詢預載,避免列表逐列 N+1
|
||||
$query->select('machines.*')
|
||||
->withCount([
|
||||
'logs as card_terminal_error_count' => fn($q) => $q->where('type', 'card_terminal')->where('level', 'error')->where('is_resolved', false),
|
||||
'logs as card_terminal_warning_count' => fn($q) => $q->where('type', 'card_terminal')->where('level', 'warning')->where('is_resolved', false),
|
||||
])
|
||||
->addSelect(['latest_card_terminal_log_at' => \App\Models\Machine\MachineLog::select('created_at')
|
||||
->whereColumn('machine_id', 'machines.id')
|
||||
->where('type', 'card_terminal')
|
||||
->whereIn('level', ['error', 'warning'])
|
||||
->where('is_resolved', false)
|
||||
->latest()
|
||||
->limit(1)
|
||||
]);
|
||||
|
||||
// 預加載統計資料
|
||||
$machines = $query->orderBy("last_heartbeat_at", "desc")
|
||||
->orderBy("id", "desc")
|
||||
->paginate($per_page)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.machines.index', compact('machines'));
|
||||
$companies = $currentUser->isSystemAdmin()
|
||||
? Company::select('id', 'name', 'code')->orderBy('name')->get()
|
||||
: collect();
|
||||
|
||||
return view('admin.machines.index', compact('machines', 'companies'));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -43,10 +74,48 @@ class MachineController extends AdminController
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'ambient_temp_setting' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$machine->update($validated);
|
||||
|
||||
if ($machine->wasChanged('ambient_temp_setting')) {
|
||||
$newSetting = $machine->ambient_temp_setting;
|
||||
|
||||
// 將先前未執行的同類型指令標記為 superseded (被取代)
|
||||
\App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
|
||||
->where('command_type', 'ambient_temp_limit')
|
||||
->where('status', 'pending')
|
||||
->update([
|
||||
'status' => 'superseded',
|
||||
'remark' => __('Superseded by new command'),
|
||||
'executed_at' => now(),
|
||||
]);
|
||||
|
||||
// 建立新指令
|
||||
$command = \App\Models\Machine\RemoteCommand::create([
|
||||
'machine_id' => $machine->id,
|
||||
'user_id' => auth()->id() ?: 1,
|
||||
'command_type' => 'ambient_temp_limit',
|
||||
'payload' => [
|
||||
'temperature' => $newSetting !== null ? (int)$newSetting : null
|
||||
],
|
||||
'status' => 'pending',
|
||||
'remark' => $newSetting !== null
|
||||
? __('Temperature reaches :temp degrees, the fan will turn on', ['temp' => $newSetting])
|
||||
: __('Disabled ambient temperature monitoring')
|
||||
]);
|
||||
|
||||
// 發送 MQTT 指令
|
||||
$mqttService = app(\App\Services\Machine\MqttService::class);
|
||||
$mqttService->pushCommand(
|
||||
$machine->serial_no,
|
||||
$command->command_type,
|
||||
$command->payload,
|
||||
(string)$command->id
|
||||
);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.machines.index')
|
||||
->with('success', __('Machine updated successfully.'));
|
||||
}
|
||||
@ -139,9 +208,12 @@ class MachineController extends AdminController
|
||||
->get();
|
||||
|
||||
// 取得該機台目前所有等待中的庫存更新指令 (Pending Commands)
|
||||
// 僅將「1 分鐘內」的 pending 視為鎖定中;超過 1 分鐘未收到機台回傳 (ACK)
|
||||
// 視為逾時,自動解除鎖定,讓使用者可重新編輯與重新下發 (與 dispense 逾時邏輯一致)
|
||||
$pendingSlotNos = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
|
||||
->where('command_type', 'reload_stock')
|
||||
->where('status', 'pending')
|
||||
->where('created_at', '>=', now()->subMinute())
|
||||
->get()
|
||||
->pluck('payload.slot_no')
|
||||
->flatten()
|
||||
@ -149,7 +221,7 @@ class MachineController extends AdminController
|
||||
|
||||
// 將待處理狀態注入貨道物件
|
||||
$slots->each(function ($slot) use ($pendingSlotNos) {
|
||||
$slot->is_pending = in_array((int)$slot->slot_no, $pendingSlotNos);
|
||||
$slot->is_pending = in_array((int) $slot->slot_no, $pendingSlotNos);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
@ -164,6 +236,11 @@ class MachineController extends AdminController
|
||||
*/
|
||||
public function updateSlotExpiry(Request $request, Machine $machine)
|
||||
{
|
||||
// 確保庫存數值為整數,處理如 "01" 轉為 1 的情況
|
||||
if ($request->has('stock') && !is_null($request->stock) && $request->stock !== '') {
|
||||
$request->merge(['stock' => (int) $request->stock]);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'slot_no' => 'required|integer',
|
||||
'stock' => 'nullable|integer|min:0',
|
||||
@ -188,6 +265,117 @@ class MachineController extends AdminController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: 遠端鎖定/解鎖單一貨道(暫停販售)。
|
||||
* 與 updateSlotExpiry 共用 updateSlot 下發 update_inventory;只帶 is_locked,不動效期/庫存。
|
||||
*/
|
||||
public function toggleSlotLock(Request $request, Machine $machine)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'slot_no' => 'required|integer',
|
||||
'is_locked' => 'required|boolean',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->machineService->updateSlot($machine, $validated, auth()->id());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $validated['is_locked'] ? __('Slot locked.') : __('Slot unlocked.'),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 機台專屬定價頁:列出該機台「所屬公司的完整商品目錄」(非僅貨道商品),
|
||||
* 可在上貨前先把機台價/會員價訂好。未填=沿用全域 products 價。
|
||||
*/
|
||||
public function pricing(Machine $machine): View
|
||||
{
|
||||
$products = Product::where('company_id', $machine->company_id)
|
||||
->active()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'image_url', 'barcode', 'price', 'member_price']);
|
||||
|
||||
$overrides = $machine->productPrices()->get()->keyBy('product_id');
|
||||
|
||||
$items = $products->map(function (Product $p) use ($overrides) {
|
||||
$ov = $overrides->get($p->id);
|
||||
return [
|
||||
'product_id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'image_url' => $p->image_url,
|
||||
'barcode' => $p->barcode,
|
||||
'global_price' => (float) $p->price,
|
||||
'global_member_price' => $p->member_price !== null ? (float) $p->member_price : null,
|
||||
'override_price' => $ov && $ov->price !== null ? (float) $ov->price : null,
|
||||
'override_member_price' => $ov && $ov->member_price !== null ? (float) $ov->member_price : null,
|
||||
];
|
||||
})->values();
|
||||
|
||||
return view('admin.machines.pricing', compact('machine', 'items'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批次儲存機台專屬定價。
|
||||
*
|
||||
* - price/member_price 皆空 → 刪除覆蓋,回到全域價。
|
||||
* - 任一有值 → 建立/更新覆蓋;會員價於 B012 下發時自動夾為不高於機台售價。
|
||||
* - 用 quiet 寫入避免逐筆觸發 observer 重複推送,最後統一推一次 B012。
|
||||
* - 商品須屬於該機台公司(跨租戶防護)。
|
||||
*/
|
||||
public function updatePricing(Request $request, Machine $machine): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'items' => 'required|array',
|
||||
'items.*.product_id' => 'required|integer|exists:products,id',
|
||||
'items.*.price' => 'nullable|numeric|min:1|max:99999999',
|
||||
'items.*.member_price' => 'nullable|numeric|min:1|max:99999999',
|
||||
]);
|
||||
|
||||
$allowedProductIds = Product::where('company_id', $machine->company_id)
|
||||
->pluck('id')
|
||||
->flip();
|
||||
|
||||
DB::transaction(function () use ($validated, $machine, $allowedProductIds) {
|
||||
foreach ($validated['items'] as $item) {
|
||||
$productId = (int) $item['product_id'];
|
||||
if (!$allowedProductIds->has($productId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$price = isset($item['price']) && $item['price'] !== '' ? (float) $item['price'] : null;
|
||||
$memberPrice = isset($item['member_price']) && $item['member_price'] !== '' ? (float) $item['member_price'] : null;
|
||||
|
||||
if ($price === null && $memberPrice === null) {
|
||||
$machine->productPrices()->where('product_id', $productId)->delete();
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = MachineProductPrice::firstOrNew([
|
||||
'machine_id' => $machine->id,
|
||||
'product_id' => $productId,
|
||||
]);
|
||||
$row->price = $price;
|
||||
$row->member_price = $memberPrice;
|
||||
$row->saveQuietly();
|
||||
}
|
||||
});
|
||||
|
||||
// 統一推一次 B012 同步給該機台(覆蓋於請求當下即時套用,無需失效公司快取)
|
||||
SendProductSyncCommandJob::dispatch($machine->id, __('Machine pricing updated'), Auth::id());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Machine pricing saved and sync command pushed.'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得機台統計數據 (AJAX)
|
||||
*/
|
||||
@ -216,4 +404,81 @@ class MachineController extends AdminController
|
||||
{
|
||||
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: 清除機台所有未解決的警告與異常 (手動排除)
|
||||
*/
|
||||
public function resolveLogs(Machine $machine)
|
||||
{
|
||||
$machine->logs()
|
||||
->where('is_resolved', false)
|
||||
->whereIn('level', ['error', 'warning'])
|
||||
->update(['is_resolved' => true]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('All issues marked as resolved.')
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: 取得機台溫度歷史紀錄 (供圖表使用)
|
||||
*/
|
||||
public function temperatureAjax(Request $request, Machine $machine)
|
||||
{
|
||||
$startDate = $request->get('start_date');
|
||||
$endDate = $request->get('end_date');
|
||||
|
||||
$logs = $machine->logs()
|
||||
->where('message', 'Temperature reported: :temp°C')
|
||||
->when($startDate, function ($query, $start) {
|
||||
return $query->where('created_at', '>=', str_replace('T', ' ', $start));
|
||||
})
|
||||
->when($endDate, function ($query, $end) {
|
||||
return $query->where('created_at', '<=', str_replace('T', ' ', $end));
|
||||
})
|
||||
->oldest()
|
||||
->get();
|
||||
|
||||
$chartData = $logs->map(fn($log) => [
|
||||
'x' => $log->created_at->getTimestamp() * 1000,
|
||||
'y' => (int) ($log->context['temp'] ?? 0),
|
||||
])->values()->toArray();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $chartData
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: 取得機台環境溫度歷史紀錄 (供圖表使用)
|
||||
*/
|
||||
public function ambientTemperatureAjax(Request $request, Machine $machine)
|
||||
{
|
||||
$startDate = $request->get('start_date');
|
||||
$endDate = $request->get('end_date');
|
||||
|
||||
$logs = $machine->logs()
|
||||
->where('type', 'ambient_temp')
|
||||
->where('message', 'Ambient temperature reported: :temp°C')
|
||||
->when($startDate, function ($query, $start) {
|
||||
return $query->where('created_at', '>=', str_replace('T', ' ', $start));
|
||||
})
|
||||
->when($endDate, function ($query, $end) {
|
||||
return $query->where('created_at', '<=', str_replace('T', ' ', $end));
|
||||
})
|
||||
->oldest()
|
||||
->get();
|
||||
|
||||
$chartData = $logs->map(fn($log) => [
|
||||
'x' => $log->created_at->getTimestamp() * 1000,
|
||||
'y' => (int) ($log->context['temp'] ?? 0),
|
||||
])->values()->toArray();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $chartData
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MaintenanceRecord;
|
||||
use App\Models\System\Company;
|
||||
use App\Traits\ImageHandler;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@ -21,6 +22,9 @@ class MaintenanceController extends Controller
|
||||
{
|
||||
$this->authorize('viewAny', MaintenanceRecord::class);
|
||||
|
||||
$currentUser = auth()->user();
|
||||
$companyId = trim((string) $request->input('company_id', ''));
|
||||
|
||||
$query = MaintenanceRecord::with(['machine', 'user', 'company'])
|
||||
->whereHas('machine') // 確保僅顯示該帳號「看得見」的機台紀錄,避開因權限隔離導致的 null 報錯
|
||||
->latest('maintenance_at');
|
||||
@ -38,9 +42,16 @@ class MaintenanceController extends Controller
|
||||
$query->where('category', $request->category);
|
||||
}
|
||||
|
||||
$records = $query->paginate(15)->withQueryString();
|
||||
if ($currentUser->isSystemAdmin() && $companyId !== '') {
|
||||
$query->where('company_id', $companyId);
|
||||
}
|
||||
|
||||
return view('admin.maintenance.index', compact('records'));
|
||||
$records = $query->paginate(15)->withQueryString();
|
||||
$companies = $currentUser->isSystemAdmin()
|
||||
? Company::select('id', 'name', 'code')->orderBy('name')->get()
|
||||
: collect();
|
||||
|
||||
return view('admin.maintenance.index', compact('records', 'companies'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -10,30 +10,30 @@ class PermissionController extends Controller
|
||||
// 權限角色設定
|
||||
public function roles()
|
||||
{
|
||||
$per_page = request()->input('per_page', 10);
|
||||
$per_page = request()->input('roles_per_page', request()->input('per_page', 10));
|
||||
$user = auth()->user();
|
||||
$query = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
|
||||
|
||||
// 租戶隔離:租戶只能看到自己公司的角色
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$query->where('company_id', $user->company_id);
|
||||
}
|
||||
// 層級隔離:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己建立的角色。
|
||||
$query->visibleTo($user);
|
||||
|
||||
// 搜尋:角色名稱
|
||||
if ($search = request()->input('search')) {
|
||||
// 搜尋:角色名稱(支援 roles_search 命名空間 與舊版 search)
|
||||
$search = request()->input('roles_search', request()->input('search'));
|
||||
if ($search) {
|
||||
$query->where('name', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
// 篩選:公司名稱 (僅限系統管理員)
|
||||
if ($user->isSystemAdmin() && request()->filled('company_id')) {
|
||||
if (request()->company_id === 'system') {
|
||||
// 篩選:公司名稱(支援 roles_company_id 命名空間 與舊版 company_id)
|
||||
$company_id = request()->input('roles_company_id', request()->input('company_id'));
|
||||
if ($user->isSystemAdmin() && filled($company_id)) {
|
||||
if ($company_id === 'system') {
|
||||
$query->whereNull('company_id');
|
||||
} else {
|
||||
$query->where('company_id', request()->company_id);
|
||||
$query->where('company_id', $company_id);
|
||||
}
|
||||
}
|
||||
|
||||
$roles = $query->latest()->paginate($per_page)->withQueryString();
|
||||
$roles = $query->latest()->paginate($per_page, ['*'], 'roles_page')->withQueryString();
|
||||
$companies = $user->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
|
||||
|
||||
// 權限分組邏輯中的標題與過濾
|
||||
@ -66,13 +66,9 @@ class PermissionController extends Controller
|
||||
$role = new \App\Models\System\Role();
|
||||
$user = auth()->user();
|
||||
|
||||
// 權限遞迴約束
|
||||
$permissionQuery = \Spatie\Permission\Models\Permission::query();
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
|
||||
}
|
||||
// 權限遞迴約束由 getFilteredPermissions 處理
|
||||
|
||||
$all_permissions = $permissionQuery->get()->groupBy(fn($p) => str_starts_with($p->name, 'menu.') ? 'menu' : 'other');
|
||||
$all_permissions = $this->getFilteredPermissions($user);
|
||||
|
||||
$title = request()->routeIs('*.sub-account-roles.create') ? __('Create Sub Account Role') : __('Create New Role');
|
||||
$back_url = request()->routeIs('*.sub-account-roles.create')
|
||||
@ -90,21 +86,14 @@ class PermissionController extends Controller
|
||||
$role = \App\Models\System\Role::findOrFail($id);
|
||||
$user = auth()->user();
|
||||
|
||||
// 權限遞迴約束:租戶管理員只能看到並指派自己擁有的權限
|
||||
$permissionQuery = \Spatie\Permission\Models\Permission::query();
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
|
||||
// 層級隔離:子帳號只能編輯自己建立的角色,不可開啟上層/旁線建立的角色。
|
||||
if (!$role->canBeManagedBy($user)) {
|
||||
return redirect()->back()->with('error', __('You do not have permission to manage this role.'));
|
||||
}
|
||||
|
||||
// 權限分組邏輯
|
||||
$all_permissions = $permissionQuery->get()
|
||||
->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles')
|
||||
->groupBy(function($perm) {
|
||||
if (str_starts_with($perm->name, 'menu.')) {
|
||||
return 'menu';
|
||||
}
|
||||
return 'other';
|
||||
});
|
||||
// 權限遞迴約束與分組邏輯由 getFilteredPermissions 處理
|
||||
|
||||
$all_permissions = $this->getFilteredPermissions($user);
|
||||
|
||||
// 根據路由決定標題
|
||||
$title = request()->routeIs('*.sub-account-roles.edit') ? __('Edit Sub Account Role') : __('Edit Role Permissions');
|
||||
@ -140,6 +129,7 @@ class PermissionController extends Controller
|
||||
'name' => $validated['name'],
|
||||
'guard_name' => 'web',
|
||||
'company_id' => $is_system ? null : auth()->user()->company_id,
|
||||
'created_by' => auth()->id(),
|
||||
'is_system' => $is_system,
|
||||
]);
|
||||
|
||||
@ -156,7 +146,7 @@ class PermissionController extends Controller
|
||||
|
||||
// 如果不是系統角色,排除主選單的系統權限
|
||||
if (!$is_system) {
|
||||
$perms = array_diff($perms, ['menu.basic-settings', 'menu.permissions']);
|
||||
$perms = array_diff($perms, ['menu.basic', 'menu.permissions']);
|
||||
}
|
||||
$role->syncPermissions($perms);
|
||||
}
|
||||
@ -196,6 +186,11 @@ class PermissionController extends Controller
|
||||
return redirect()->back()->with('error', __('System roles cannot be modified by tenant administrators.'));
|
||||
}
|
||||
|
||||
// 層級隔離:子帳號只能修改自己建立的角色。
|
||||
if (!$role->canBeManagedBy(auth()->user())) {
|
||||
return redirect()->back()->with('error', __('You do not have permission to manage this role.'));
|
||||
}
|
||||
|
||||
$is_system = auth()->user()->isSystemAdmin() ? $request->boolean('is_system') : $role->is_system;
|
||||
|
||||
$updateData = [
|
||||
@ -218,7 +213,7 @@ class PermissionController extends Controller
|
||||
|
||||
// 如果不是系統角色,排除主選單的系統權限
|
||||
if (!$is_system) {
|
||||
$perms = array_diff($perms, ['menu.basic-settings', 'menu.permissions']);
|
||||
$perms = array_diff($perms, ['menu.basic', 'menu.permissions']);
|
||||
}
|
||||
$role->syncPermissions($perms);
|
||||
|
||||
@ -237,10 +232,20 @@ class PermissionController extends Controller
|
||||
return redirect()->back()->with('error', __('The Super Admin role cannot be deleted.'));
|
||||
}
|
||||
|
||||
// 公司主帳號角色保護:比照 super-admin,公司層級的「管理員」角色為該公司最高權限角色,不可刪除。
|
||||
if ($role->is_company_admin) {
|
||||
return redirect()->back()->with('error', __('The company admin role cannot be deleted.'));
|
||||
}
|
||||
|
||||
if (!auth()->user()->isSystemAdmin() && $role->is_system) {
|
||||
return redirect()->back()->with('error', __('System roles cannot be deleted by tenant administrators.'));
|
||||
}
|
||||
|
||||
// 層級隔離:子帳號只能刪除自己建立的角色。
|
||||
if (!$role->canBeManagedBy(auth()->user())) {
|
||||
return redirect()->back()->with('error', __('You do not have permission to manage this role.'));
|
||||
}
|
||||
|
||||
if ($role->users()->count() > 0) {
|
||||
return redirect()->back()->with('error', __('Cannot delete role with active users.'));
|
||||
}
|
||||
@ -259,85 +264,69 @@ class PermissionController extends Controller
|
||||
{
|
||||
$user = auth()->user();
|
||||
$isSubAccountRoute = $request->routeIs('admin.data-config.sub-accounts');
|
||||
$tab = $request->input('tab', 'accounts');
|
||||
$tab = $request->input('tab', 'accounts'); // 僅用於設定 Alpine activeTab 初始值
|
||||
|
||||
// 初始化變數
|
||||
$users = collect();
|
||||
$roles = collect();
|
||||
$companies = $user->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
|
||||
$currentUserRoleIds = $user->roles->pluck('id')->toArray();
|
||||
|
||||
// ── 帳號列表 ──────────────────────────────────────────────────
|
||||
// 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層。
|
||||
$usersQuery = \App\Models\System\User::query()->with(['company', 'roles', 'machines'])->visibleTo($user);
|
||||
if ($search = $request->input('accounts_search', $request->input('search'))) {
|
||||
$usersQuery->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('username', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
if ($user->isSystemAdmin() && $request->filled('accounts_company_id')) {
|
||||
$cid = $request->input('accounts_company_id');
|
||||
$cid === 'system' ? $usersQuery->whereNull('company_id') : $usersQuery->where('company_id', $cid);
|
||||
}
|
||||
$accounts_per_page = $request->input('accounts_per_page', 10);
|
||||
$users = $usersQuery->latest()->paginate($accounts_per_page, ['*'], 'accounts_page')->withQueryString();
|
||||
|
||||
// Modal 用的角色選單(不分頁)。層級隔離:子帳號僅見自己建立的角色(與角色清單一致)。
|
||||
// 預先載入 permissions 以避免下方子集過濾造成 N+1。
|
||||
$rolesForSelect = \App\Models\System\Role::query()->with('permissions')->visibleTo($user);
|
||||
$roles = $rolesForSelect->get();
|
||||
// 子集過濾:租戶只能指派「權限為自身子集」的角色(與 store/update 的後端 guard 一致,避免下拉出現越權選項)。
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$operatorPerms = $user->getAllPermissions()->pluck('name');
|
||||
$roles = $roles->filter(
|
||||
fn($r) => collect($r->getPermissionNames())->diff($operatorPerms)->isEmpty()
|
||||
)->values();
|
||||
}
|
||||
|
||||
// ── 角色列表 (僅 data-config Tab 版需要) ─────────────────────
|
||||
$paginated_roles = collect();
|
||||
$all_permissions = collect();
|
||||
$currentUserRoleIds = $user->roles->pluck('id')->toArray();
|
||||
$companies = $user->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
|
||||
|
||||
if ($isSubAccountRoute && $tab === 'roles') {
|
||||
// 處理角色分頁邏輯 (移植自 roles())
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$roles_query = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
|
||||
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$roles_query->where('company_id', $user->company_id);
|
||||
if ($isSubAccountRoute) {
|
||||
$rolesQuery = \App\Models\System\Role::query()->with(['permissions', 'users', 'company'])->visibleTo($user);
|
||||
if ($search = $request->input('roles_search')) {
|
||||
$rolesQuery->where('name', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
if ($search = $request->input('search')) {
|
||||
$roles_query->where('name', 'like', "%{$search}%");
|
||||
if ($user->isSystemAdmin() && $request->filled('roles_company_id')) {
|
||||
$cid = $request->input('roles_company_id');
|
||||
$cid === 'system' ? $rolesQuery->where('is_system', true) : $rolesQuery->where('company_id', $cid);
|
||||
}
|
||||
$roles_per_page = $request->input('roles_per_page', 10);
|
||||
$paginated_roles = $rolesQuery->latest()->paginate($roles_per_page, ['*'], 'roles_page')->withQueryString();
|
||||
|
||||
if ($user->isSystemAdmin() && $request->filled('company_id')) {
|
||||
if ($request->company_id === 'system') {
|
||||
$roles_query->where('is_system', true);
|
||||
} else {
|
||||
$roles_query->where('company_id', $request->company_id);
|
||||
}
|
||||
}
|
||||
|
||||
$paginated_roles = $roles_query->latest()->paginate($per_page)->withQueryString();
|
||||
|
||||
// 權限分組邏輯
|
||||
$permissionQuery = \Spatie\Permission\Models\Permission::query();
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
|
||||
}
|
||||
|
||||
$all_permissions = $permissionQuery->get()
|
||||
->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles')
|
||||
->groupBy(fn($p) => str_starts_with($p->name, 'menu.') ? 'menu' : 'other');
|
||||
} else {
|
||||
// 處理帳號名單邏輯
|
||||
$query = \App\Models\System\User::query()->with(['company', 'roles', 'machines']);
|
||||
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$query->where('company_id', $user->company_id);
|
||||
}
|
||||
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('username', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($user->isSystemAdmin() && $request->filled('company_id')) {
|
||||
if ($request->company_id === 'system') {
|
||||
$query->whereNull('company_id');
|
||||
} else {
|
||||
$query->where('company_id', $request->company_id);
|
||||
}
|
||||
}
|
||||
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$users = $query->latest()->paginate($per_page)->withQueryString();
|
||||
|
||||
$roles_query = \App\Models\System\Role::query();
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$roles_query->forCompany($user->company_id);
|
||||
}
|
||||
$roles = $roles_query->get();
|
||||
}
|
||||
|
||||
$title = $isSubAccountRoute ? __('Sub Account Management') : __('Account Management');
|
||||
$view = $isSubAccountRoute ? 'admin.data-config.accounts' : 'admin.permission.accounts';
|
||||
|
||||
return view('admin.data-config.accounts', compact(
|
||||
return view($view, compact(
|
||||
'users', 'companies', 'roles', 'paginated_roles', 'all_permissions', 'title', 'tab', 'currentUserRoleIds'
|
||||
));
|
||||
}
|
||||
@ -358,6 +347,12 @@ class PermissionController extends Controller
|
||||
'phone' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
// 深度上限:level >= MAX_LEVEL 的子帳號不可再建立下層(系統管理員不受限)。
|
||||
// 此為後端硬擋,獨立於權限判斷(即使持有子帳號管理權限也不得突破層級)。
|
||||
if (!auth()->user()->canCreateSubAccount()) {
|
||||
return redirect()->back()->with('error', __('Sub-accounts at this level cannot create further sub-accounts.'));
|
||||
}
|
||||
|
||||
$company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id;
|
||||
|
||||
// 查找角色:優先尋找該公司的角色,若無則尋找全域範本
|
||||
@ -404,6 +399,7 @@ class PermissionController extends Controller
|
||||
'guard_name' => 'web',
|
||||
'company_id' => $company_id,
|
||||
'is_system' => false,
|
||||
'is_company_admin' => true,
|
||||
]);
|
||||
$newRole->syncPermissions($role->getPermissionNames());
|
||||
$role = $newRole;
|
||||
@ -413,6 +409,39 @@ class PermissionController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// 角色指派子集驗證:租戶只能指派「權限為自身子集」的角色,避免透過指派高權限角色造成權限提升。
|
||||
if (!auth()->user()->isSystemAdmin()) {
|
||||
$operatorPerms = auth()->user()->getAllPermissions()->pluck('name');
|
||||
if (collect($role->getPermissionNames())->diff($operatorPerms)->isNotEmpty()) {
|
||||
return redirect()->back()->with('error', __('You cannot assign a role with permissions you do not possess.'));
|
||||
}
|
||||
}
|
||||
|
||||
// 主帳號唯一性:一家公司只在「尚無主帳號」時,由系統管理員建立的第一個帳號自動成為主帳號 (is_admin)。
|
||||
// 之後建立的帳號(含子帳號自建)一律 is_admin=false,確保每家公司恰有一個主帳號。
|
||||
$creator = auth()->user();
|
||||
$companyHasAdmin = $company_id
|
||||
? \App\Models\System\User::where('company_id', $company_id)->where('is_admin', true)->exists()
|
||||
: false;
|
||||
$isMainAccount = $creator->isSystemAdmin() && !empty($company_id) && !$companyHasAdmin;
|
||||
|
||||
// 樹狀層級歸屬:
|
||||
// - 主帳號:level 0、parent_id null。
|
||||
// - 系統管理員建立的非主帳號:掛在該公司主帳號下、level 1。
|
||||
// - 租戶自建:掛在建立者下、level = 建立者 level + 1。
|
||||
if ($isMainAccount) {
|
||||
$parentId = null;
|
||||
$level = 0;
|
||||
} elseif ($creator->isSystemAdmin()) {
|
||||
$parentId = $company_id
|
||||
? \App\Models\System\User::where('company_id', $company_id)->where('is_admin', true)->value('id')
|
||||
: null;
|
||||
$level = $parentId ? 1 : 0;
|
||||
} else {
|
||||
$parentId = $creator->id;
|
||||
$level = $creator->level + 1;
|
||||
}
|
||||
|
||||
$user = \App\Models\System\User::create([
|
||||
'name' => $validated['name'],
|
||||
'username' => $validated['username'],
|
||||
@ -420,8 +449,10 @@ class PermissionController extends Controller
|
||||
'password' => \Illuminate\Support\Facades\Hash::make($validated['password']),
|
||||
'status' => $validated['status'],
|
||||
'company_id' => $company_id,
|
||||
'parent_id' => $parentId,
|
||||
'level' => $level,
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'is_admin' => (auth()->user()->isSystemAdmin() && !empty($validated['company_id'])),
|
||||
'is_admin' => $isMainAccount,
|
||||
]);
|
||||
|
||||
$user->assignRole($role);
|
||||
@ -440,6 +471,11 @@ class PermissionController extends Controller
|
||||
return redirect()->back()->with('error', __('System super admin accounts can only be modified by other super admins.'));
|
||||
}
|
||||
|
||||
// 層級越權防護:租戶只能管理可管轄範圍內的帳號(主帳號=同公司全部;子帳號=自己直接建立的下層)。
|
||||
if (!auth()->user()->canManageAccount($user)) {
|
||||
return redirect()->back()->with('error', __('You do not have permission to manage this account.'));
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'username' => 'required|string|max:255|unique:users,username,' . $id,
|
||||
@ -530,7 +566,7 @@ class PermissionController extends Controller
|
||||
'guard_name' => 'web',
|
||||
'company_id' => $target_company_id,
|
||||
'is_system' => false,
|
||||
'is_admin' => true,
|
||||
'is_company_admin' => true,
|
||||
]);
|
||||
$newRole->syncPermissions($roleObj->getPermissionNames());
|
||||
$roleObj = $newRole;
|
||||
@ -539,6 +575,14 @@ class PermissionController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// 角色指派子集驗證:租戶只能指派「權限為自身子集」的角色,避免權限提升。
|
||||
if (!auth()->user()->isSystemAdmin()) {
|
||||
$operatorPerms = auth()->user()->getAllPermissions()->pluck('name');
|
||||
if (collect($roleObj->getPermissionNames())->diff($operatorPerms)->isNotEmpty()) {
|
||||
return redirect()->back()->with('error', __('You cannot assign a role with permissions you do not possess.'));
|
||||
}
|
||||
}
|
||||
|
||||
$user->update($updateData);
|
||||
|
||||
// 如果是編輯自己且原本是超級管理員,強制保留 super-admin 角色
|
||||
@ -557,15 +601,31 @@ class PermissionController extends Controller
|
||||
public function destroyAccount($id)
|
||||
{
|
||||
$user = \App\Models\System\User::findOrFail($id);
|
||||
|
||||
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
return redirect()->back()->with('error', __('System super admin accounts can only be deleted by other super admins.'));
|
||||
}
|
||||
|
||||
// 層級越權防護:租戶只能刪除可管轄範圍內的帳號。
|
||||
if (!auth()->user()->canManageAccount($user)) {
|
||||
return redirect()->back()->with('error', __('You do not have permission to manage this account.'));
|
||||
}
|
||||
|
||||
if ($user->id === auth()->id()) {
|
||||
return redirect()->back()->with('error', __('You cannot delete your own account.'));
|
||||
}
|
||||
|
||||
// 主帳號保護:若該帳號是公司唯一主帳號,且公司仍有其他帳號,禁止刪除(須先轉移主帳號身分),
|
||||
// 以維持「有帳號的公司恰有一個主帳號」的不變量。公司僅剩此主帳號時允許刪除(公司歸零)。
|
||||
if ($user->is_admin && $user->company_id) {
|
||||
$hasOtherAccounts = \App\Models\System\User::where('company_id', $user->company_id)
|
||||
->where('id', '!=', $user->id)
|
||||
->exists();
|
||||
if ($hasOtherAccounts) {
|
||||
return redirect()->back()->with('error', __('Cannot delete the company main account while other accounts exist. Please transfer the main account role first.'));
|
||||
}
|
||||
}
|
||||
|
||||
// 為了解決軟刪除導致的唯一索引佔用問題,刪除前先重命名唯一欄位
|
||||
$timestamp = now()->getTimestamp();
|
||||
$user->username = $user->username . '.deleted.' . $timestamp;
|
||||
@ -586,10 +646,57 @@ class PermissionController extends Controller
|
||||
return back()->with('error', __('Only Super Admins can change other Super Admin status.'));
|
||||
}
|
||||
|
||||
// 層級越權防護:租戶只能切換可管轄範圍內的帳號狀態。
|
||||
if (!auth()->user()->canManageAccount($user)) {
|
||||
return back()->with('error', __('You do not have permission to manage this account.'));
|
||||
}
|
||||
|
||||
$user->status = $user->status ? 0 : 1;
|
||||
$user->save();
|
||||
|
||||
$statusText = $user->status ? __('Enabled') : __('Disabled');
|
||||
return back()->with('success', __('Account :name status has been changed to :status.', ['name' => $user->name, 'status' => $statusText]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 獲取過濾後的活動模組權限清單
|
||||
*/
|
||||
private function getFilteredPermissions($user)
|
||||
{
|
||||
$permissionQuery = \Spatie\Permission\Models\Permission::query();
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
|
||||
}
|
||||
|
||||
$activeModules = [
|
||||
'menu.machines',
|
||||
'menu.warehouses',
|
||||
'menu.sales',
|
||||
'menu.analysis',
|
||||
'menu.data-config',
|
||||
'menu.remote',
|
||||
'menu.basic',
|
||||
'menu.permissions',
|
||||
];
|
||||
|
||||
return $permissionQuery->get()
|
||||
->filter(function($p) use ($activeModules) {
|
||||
if (str_starts_with($p->name, 'menu.')) {
|
||||
foreach ($activeModules as $module) {
|
||||
if ($p->name === $module || str_starts_with($p->name, $module . '.')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles')
|
||||
->groupBy(function($perm) {
|
||||
if (str_starts_with($perm->name, 'menu.')) {
|
||||
return 'menu';
|
||||
}
|
||||
return 'other';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
154
app/Http/Controllers/Admin/PharmacyPickupController.php
Normal file
154
app/Http/Controllers/Admin/PharmacyPickupController.php
Normal file
@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\System\SystemOperationLog;
|
||||
use App\Models\Transaction\Order;
|
||||
use App\Services\Transaction\PharmacyPickupService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
|
||||
/**
|
||||
* 領藥單管理(銷售管理 → 領藥單)。
|
||||
*
|
||||
* 授權雙閘:
|
||||
* 1. 選單/頁面:權限 menu.sales.pharmacy-pickup(角色/帳號權限可勾選)。
|
||||
* 2. 機台行為:機台系統設定 shopping_mode=pickup_sheet 且 pharmacy_pickup_enabled=true。
|
||||
*/
|
||||
class PharmacyPickupController extends AdminController
|
||||
{
|
||||
public function __construct(private PharmacyPickupService $service)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
// 僅列出「取物單模式 + 領藥單開關開啟」的機台(受 TenantScoped 自動租戶隔離)
|
||||
$machines = Machine::query()
|
||||
->where('settings->shopping_mode', 'pickup_sheet')
|
||||
->where('settings->pharmacy_pickup_enabled', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$selectedMachine = null;
|
||||
$products = new Collection();
|
||||
|
||||
if ($request->filled('machine_id')) {
|
||||
$selectedMachine = $machines->firstWhere('id', (int) $request->input('machine_id'));
|
||||
if ($selectedMachine) {
|
||||
$products = $this->service->availableProducts($selectedMachine);
|
||||
}
|
||||
}
|
||||
|
||||
$orders = Order::pharmacyPickup()
|
||||
->with(['machine:id,name,serial_no', 'items', 'creator:id,name', 'pickupCode'])
|
||||
->latest()
|
||||
->paginate($request->input('per_page', 10))
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.sales.pharmacy-pickup.index', [
|
||||
'title' => __('menu.sales.pharmacy-pickup'),
|
||||
'machines' => $machines,
|
||||
'selectedMachine' => $selectedMachine,
|
||||
'products' => $products,
|
||||
'orders' => $orders,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX:回傳某機台目前可領藥品(建立領藥單彈窗用,對齊取貨碼 slots-ajax 模式)。
|
||||
*/
|
||||
public function productsAjax(Machine $machine)
|
||||
{
|
||||
$enabled = ($machine->settings['shopping_mode'] ?? null) === 'pickup_sheet'
|
||||
&& (bool) ($machine->settings['pharmacy_pickup_enabled'] ?? false);
|
||||
abort_unless($enabled, 403);
|
||||
|
||||
return response()->json([
|
||||
'products' => $this->service->availableProducts($machine)->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'machine_id' => 'required|exists:machines,id',
|
||||
'pricing_slip_no' => 'nullable|string|max:64',
|
||||
]);
|
||||
|
||||
// 由勾選的 products[] + qty[product_id] 組出 items
|
||||
$productIds = (array) $request->input('products', []);
|
||||
$qtyMap = (array) $request->input('qty', []);
|
||||
$items = [];
|
||||
foreach ($productIds as $pid) {
|
||||
$q = (int) ($qtyMap[$pid] ?? 0);
|
||||
if ($q > 0) {
|
||||
$items[] = ['product_id' => (int) $pid, 'quantity' => $q];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$order = $this->service->create([
|
||||
'machine_id' => (int) $request->input('machine_id'),
|
||||
'pricing_slip_no' => $request->input('pricing_slip_no'),
|
||||
'items' => $items,
|
||||
], Auth::id());
|
||||
} catch (ValidationException $e) {
|
||||
return back()->withErrors($e->errors())->withInput();
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.sales.pharmacy-pickup', ['machine_id' => $request->input('machine_id')])
|
||||
->with('success', __('Pharmacy pickup order created: :no', ['no' => $order->order_no]))
|
||||
->with('created_order_id', $order->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可列印的領藥單(QR 內嵌 SVG,內容為 8 碼領藥碼,機台掃描後送 B660 驗證)。
|
||||
*/
|
||||
public function print(Order $order)
|
||||
{
|
||||
abort_unless($order->order_type === Order::TYPE_PHARMACY_PICKUP, 404);
|
||||
|
||||
$order->load(['items', 'machine', 'pickupCode', 'creator:id,name']);
|
||||
$code = $order->pickupCode?->code;
|
||||
$qrSvg = $code
|
||||
? QrCode::format('svg')->size(220)->margin(1)->errorCorrection('M')->generate($code)
|
||||
: null;
|
||||
|
||||
return view('admin.sales.pharmacy-pickup.print', [
|
||||
'order' => $order,
|
||||
'qrSvg' => $qrSvg,
|
||||
]);
|
||||
}
|
||||
|
||||
public function cancel(Order $order)
|
||||
{
|
||||
abort_unless($order->order_type === Order::TYPE_PHARMACY_PICKUP, 404);
|
||||
|
||||
if (in_array($order->status, [Order::STATUS_COMPLETED, Order::STATUS_FAILED], true)
|
||||
|| $order->delivery_status === Order::DELIVERY_STATUS_SUCCESS) {
|
||||
return back()->with('error', __('This pharmacy pickup order can no longer be cancelled.'));
|
||||
}
|
||||
|
||||
$order->update(['status' => Order::STATUS_FAILED]);
|
||||
if ($order->pickupCode) {
|
||||
$order->pickupCode->update(['status' => 'cancelled']);
|
||||
}
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $order->company_id,
|
||||
'user_id' => Auth::id(),
|
||||
'module' => 'pharmacy_pickup',
|
||||
'action' => 'cancel',
|
||||
'target_id' => $order->id,
|
||||
'target_type' => Order::class,
|
||||
]);
|
||||
|
||||
return back()->with('success', __('Pharmacy pickup order cancelled.'));
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@ use App\Models\System\Translation;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\System\SystemOperationLog;
|
||||
|
||||
class ProductCategoryController extends Controller
|
||||
{
|
||||
@ -73,6 +74,17 @@ class ProductCategoryController extends Controller
|
||||
'name_dictionary_key' => $dictKey,
|
||||
]);
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $category->company_id,
|
||||
'user_id' => auth()->id(),
|
||||
'module' => 'category',
|
||||
'action' => 'create',
|
||||
'target_id' => $category->id,
|
||||
'target_type' => ProductCategory::class,
|
||||
'note' => 'category_created',
|
||||
'new_values' => $category->toArray(),
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return redirect()->back()->with('success', __('Category created successfully'));
|
||||
@ -125,10 +137,24 @@ class ProductCategoryController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
$oldValues = $category->toArray();
|
||||
$category->update([
|
||||
'name' => $request->names['zh_TW'] ?? $category->name,
|
||||
'name_dictionary_key' => $dictKey,
|
||||
]);
|
||||
$newValues = $category->fresh()->toArray();
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $category->company_id,
|
||||
'user_id' => auth()->id(),
|
||||
'module' => 'category',
|
||||
'action' => 'update',
|
||||
'target_id' => $category->id,
|
||||
'target_type' => ProductCategory::class,
|
||||
'note' => 'category_updated',
|
||||
'old_values' => $oldValues,
|
||||
'new_values' => $newValues,
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
@ -157,12 +183,22 @@ class ProductCategoryController extends Controller
|
||||
return redirect()->back()->with('error', $errorMsg);
|
||||
}
|
||||
|
||||
if ($category->name_dictionary_key) {
|
||||
Translation::withoutGlobalScopes()->where('group', 'category')->where('key', $category->name_dictionary_key)->delete();
|
||||
}
|
||||
|
||||
$oldValues = $category->toArray();
|
||||
$categoryId = $category->id;
|
||||
$companyId = $category->company_id;
|
||||
$category->delete();
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $companyId,
|
||||
'user_id' => auth()->id(),
|
||||
'module' => 'category',
|
||||
'action' => 'delete',
|
||||
'target_id' => $categoryId,
|
||||
'target_type' => ProductCategory::class,
|
||||
'note' => 'category_deleted',
|
||||
'old_values' => $oldValues,
|
||||
]);
|
||||
|
||||
if (request()->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
|
||||
@ -12,10 +12,63 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Services\Product\ProductCatalogService;
|
||||
use App\Services\ProductImportService;
|
||||
use App\Models\System\SystemOperationLog;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
use \App\Traits\ImageHandler;
|
||||
|
||||
public function __construct(
|
||||
private readonly ProductCatalogService $catalogService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Upsert a locale=>value map into translations for a given group/key.
|
||||
* 空值的語系會被刪除(清空翻譯)。繞過 TenantScoped。
|
||||
*
|
||||
* @param string $group product / product_spec
|
||||
* @param string $key 對應的 dictionary key
|
||||
* @param array $values locale => value
|
||||
* @param int|null $companyId
|
||||
*/
|
||||
private function upsertTranslations(string $group, string $key, array $values, ?int $companyId): void
|
||||
{
|
||||
foreach ($values as $locale => $value) {
|
||||
if (empty($value)) {
|
||||
Translation::withoutGlobalScopes()->where([
|
||||
'group' => $group,
|
||||
'key' => $key,
|
||||
'locale' => $locale,
|
||||
])->delete();
|
||||
continue;
|
||||
}
|
||||
|
||||
Translation::withoutGlobalScopes()->updateOrCreate(
|
||||
['group' => $group, 'key' => $key, 'locale' => $locale],
|
||||
['value' => $value, 'company_id' => $companyId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析規格多語系輸入:優先採 specs 物件;
|
||||
* 若僅送舊版單一 spec 欄位則轉為 ['zh_TW' => spec] 以維持相容。
|
||||
*
|
||||
* @return array locale => value
|
||||
*/
|
||||
private function resolveSpecs(Request $request): array
|
||||
{
|
||||
$specs = $request->input('specs');
|
||||
if (is_array($specs) && !empty($specs)) {
|
||||
return $specs;
|
||||
}
|
||||
|
||||
$spec = $request->input('spec');
|
||||
return $spec !== null && $spec !== '' ? ['zh_TW' => $spec] : [];
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
@ -26,8 +79,8 @@ class ProductController extends Controller
|
||||
$productQuery = Product::with(['category.translations', 'translations', 'company']);
|
||||
|
||||
// 搜尋
|
||||
if ($request->filled('product_search')) {
|
||||
$search = $request->product_search;
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$productQuery->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('barcode', 'like', "%{$search}%")
|
||||
@ -46,7 +99,7 @@ class ProductController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$products = $productQuery->latest()->paginate($per_page, ['*'], 'product_page')->withQueryString();
|
||||
$products = $productQuery->orderBy('created_at', 'desc')->orderBy('id', 'desc')->paginate($per_page, ['*'], 'product_page')->withQueryString();
|
||||
|
||||
// Categories Query
|
||||
$categoryQuery = ProductCategory::with(['translations', 'company']);
|
||||
@ -55,14 +108,14 @@ class ProductController extends Controller
|
||||
$categoryQuery->where('company_id', $request->category_company_id);
|
||||
}
|
||||
|
||||
if ($request->filled('category_search')) {
|
||||
$search = $request->category_search;
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$categoryQuery->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$categories = $categoryQuery->latest()->paginate($per_page, ['*'], 'category_page')->withQueryString();
|
||||
$categories = $categoryQuery->orderBy('created_at', 'desc')->orderBy('id', 'desc')->paginate($per_page, ['*'], 'category_page')->withQueryString();
|
||||
$companies = $user->isSystemAdmin() ? Company::all() : collect();
|
||||
|
||||
// Settings for Modal (use current user company or fallback)
|
||||
@ -80,12 +133,13 @@ class ProductController extends Controller
|
||||
'success' => true,
|
||||
'html' => view('admin.products.partials.tab-products', [
|
||||
'products' => $products,
|
||||
'categories' => $categories,
|
||||
'companySettings' => $companySettings,
|
||||
'companies' => $companies,
|
||||
'routeName' => $routeName
|
||||
])->render()
|
||||
]);
|
||||
} else {
|
||||
} elseif ($tab === 'categories') {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'html' => view('admin.products.partials.tab-categories', [
|
||||
@ -94,15 +148,68 @@ class ProductController extends Controller
|
||||
'routeName' => $routeName
|
||||
])->render()
|
||||
]);
|
||||
} elseif ($tab === 'logs') {
|
||||
$logQuery = SystemOperationLog::with(['user', 'company', 'target'])
|
||||
->whereIn('module', ['product', 'category']);
|
||||
|
||||
if ($user->isSystemAdmin()) {
|
||||
if ($request->filled('log_company_id')) {
|
||||
$logQuery->where('company_id', $request->log_company_id);
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->filled('search_log')) {
|
||||
$search = $request->search_log;
|
||||
$logQuery->where(function($q) use ($search) {
|
||||
$q->where('note', 'like', "%{$search}%")
|
||||
->orWhere('old_values', 'like', "%{$search}%")
|
||||
->orWhere('new_values', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$logDefaultStart = now()->startOfDay()->format('Y-m-d H:i');
|
||||
$logDefaultEnd = now()->endOfDay()->format('Y-m-d H:i');
|
||||
|
||||
if ($request->filled('start_date') || $request->filled('end_date')) {
|
||||
if ($request->filled('start_date')) {
|
||||
$logQuery->where('created_at', '>=', \Illuminate\Support\Carbon::parse($request->start_date));
|
||||
}
|
||||
if ($request->filled('end_date')) {
|
||||
$logQuery->where('created_at', '<=', \Illuminate\Support\Carbon::parse($request->end_date)->endOfMinute());
|
||||
}
|
||||
} else {
|
||||
$logQuery->whereBetween('created_at', [now()->startOfDay(), now()->endOfDay()]);
|
||||
$request->merge([
|
||||
'start_date' => $logDefaultStart,
|
||||
'end_date' => $logDefaultEnd
|
||||
]);
|
||||
}
|
||||
|
||||
$logs = $logQuery->orderBy('created_at', 'desc')->orderBy('id', 'desc')->paginate($per_page, ['*'], 'log_page')->withQueryString();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'html' => view('admin.products.partials.tab-logs', [
|
||||
'logs' => $logs,
|
||||
'companies' => $companies,
|
||||
'logDefaultStart' => $logDefaultStart,
|
||||
'logDefaultEnd' => $logDefaultEnd
|
||||
])->render()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$logDefaultStart = now()->startOfDay()->format('Y-m-d H:i');
|
||||
$logDefaultEnd = now()->endOfDay()->format('Y-m-d H:i');
|
||||
|
||||
return view('admin.products.index', [
|
||||
'products' => $products,
|
||||
'categories' => $categories,
|
||||
'companies' => $companies,
|
||||
'companySettings' => $companySettings,
|
||||
'routeName' => $routeName
|
||||
'routeName' => $routeName,
|
||||
'logDefaultStart' => $logDefaultStart,
|
||||
'logDefaultEnd' => $logDefaultEnd
|
||||
]);
|
||||
}
|
||||
|
||||
@ -121,6 +228,9 @@ class ProductController extends Controller
|
||||
'categories' => $categories,
|
||||
'companies' => $companies,
|
||||
'companySettings' => $companySettings,
|
||||
// 商品多語系 Tab:公司機台語系聯集(見 product_multilingual_spec §4.2)
|
||||
'locales' => Company::activeLocalesFor($companyId),
|
||||
'localeLabels' => config('locales.supported', ['zh_TW' => '繁體中文']),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -129,12 +239,18 @@ class ProductController extends Controller
|
||||
$user = auth()->user();
|
||||
// 繞過 TenantScoped 載入翻譯,確保系統管理員能看到租戶公司的翻譯資料
|
||||
$product = Product::with(['company'])->findOrFail($id);
|
||||
$product->setRelation('translations',
|
||||
$product->setRelation('translations',
|
||||
Translation::withoutGlobalScopes()
|
||||
->where('group', 'product')
|
||||
->where('key', $product->name_dictionary_key)
|
||||
->get()
|
||||
);
|
||||
$product->setRelation('specTranslations',
|
||||
Translation::withoutGlobalScopes()
|
||||
->where('group', 'product_spec')
|
||||
->where('key', $product->spec_dictionary_key)
|
||||
->get()
|
||||
);
|
||||
$categories = ProductCategory::with('translations')->get();
|
||||
$companies = $user->isSystemAdmin() ? Company::all() : collect();
|
||||
|
||||
@ -152,11 +268,13 @@ class ProductController extends Controller
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'names' => 'required|array',
|
||||
'names.zh_TW' => 'required|string|max:255',
|
||||
'names.en' => 'nullable|string|max:255',
|
||||
'names.ja' => 'nullable|string|max:255',
|
||||
'names.*' => 'nullable|string|max:255',
|
||||
'specs' => 'nullable|array',
|
||||
'specs.*' => 'nullable|string|max:2000',
|
||||
'barcode' => 'nullable|string|max:100',
|
||||
'spec' => 'nullable|string|max:255',
|
||||
'spec' => 'nullable|string|max:2000',
|
||||
'category_id' => 'nullable|exists:product_categories,id',
|
||||
'manufacturer' => 'nullable|string|max:255',
|
||||
'track_limit' => 'required|integer|min:1',
|
||||
@ -174,28 +292,22 @@ class ProductController extends Controller
|
||||
DB::beginTransaction();
|
||||
|
||||
$dictKey = \Illuminate\Support\Str::uuid()->toString();
|
||||
$specDictKey = \Illuminate\Support\Str::uuid()->toString();
|
||||
// Determine company_id: prioritized from request (for sys admin) then from user
|
||||
$company_id = (auth()->user()->isSystemAdmin() && $request->filled('company_id'))
|
||||
? $request->company_id
|
||||
$company_id = (auth()->user()->isSystemAdmin() && $request->filled('company_id'))
|
||||
? $request->company_id
|
||||
: auth()->user()->company_id;
|
||||
|
||||
// 儲存多語系翻譯(繞過 TenantScoped,避免系統管理員操作租戶資料時被過濾)
|
||||
foreach ($request->names as $locale => $name) {
|
||||
if (empty($name)) continue;
|
||||
Translation::withoutGlobalScopes()->create([
|
||||
'group' => 'product',
|
||||
'key' => $dictKey,
|
||||
'locale' => $locale,
|
||||
'value' => $name,
|
||||
'company_id' => $company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
// 儲存名稱多語系翻譯(繞過 TenantScoped,避免系統管理員操作租戶資料時被過濾)
|
||||
$this->upsertTranslations('product', $dictKey, $request->input('names', []), $company_id);
|
||||
|
||||
// 儲存規格多語系翻譯(與名稱同邏輯,group=product_spec)
|
||||
$specs = $this->resolveSpecs($request);
|
||||
$this->upsertTranslations('product_spec', $specDictKey, $specs, $company_id);
|
||||
|
||||
$imageUrl = null;
|
||||
if ($request->hasFile('image')) {
|
||||
$path = $this->storeAsWebp($request->file('image'), 'products');
|
||||
$path = $this->storeAsWebp($request->file('image'), 'products', 80, 320, 320);
|
||||
$imageUrl = Storage::url($path);
|
||||
}
|
||||
|
||||
@ -206,7 +318,8 @@ class ProductController extends Controller
|
||||
'name_dictionary_key' => $dictKey,
|
||||
'image_url' => $imageUrl,
|
||||
'barcode' => $request->barcode,
|
||||
'spec' => $request->spec,
|
||||
'spec' => $specs['zh_TW'] ?? '',
|
||||
'spec_dictionary_key' => $specDictKey,
|
||||
'manufacturer' => $request->manufacturer,
|
||||
'track_limit' => $request->track_limit,
|
||||
'spring_limit' => $request->spring_limit,
|
||||
@ -217,8 +330,22 @@ class ProductController extends Controller
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
]);
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $product->company_id,
|
||||
'user_id' => auth()->id(),
|
||||
'module' => 'product',
|
||||
'action' => 'create',
|
||||
'target_id' => $product->id,
|
||||
'target_type' => Product::class,
|
||||
'note' => 'product_created',
|
||||
'new_values' => $product->toArray(),
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
// Rebuild catalog cache
|
||||
$this->catalogService->rebuildCache($product->company_id);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@ -243,11 +370,13 @@ class ProductController extends Controller
|
||||
$product = Product::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'names' => 'required|array',
|
||||
'names.zh_TW' => 'required|string|max:255',
|
||||
'names.en' => 'nullable|string|max:255',
|
||||
'names.ja' => 'nullable|string|max:255',
|
||||
'names.*' => 'nullable|string|max:255',
|
||||
'specs' => 'nullable|array',
|
||||
'specs.*' => 'nullable|string|max:2000',
|
||||
'barcode' => 'nullable|string|max:100',
|
||||
'spec' => 'nullable|string|max:255',
|
||||
'spec' => 'nullable|string|max:2000',
|
||||
'category_id' => 'nullable|exists:product_categories,id',
|
||||
'manufacturer' => 'nullable|string|max:255',
|
||||
'track_limit' => 'required|integer|min:1',
|
||||
@ -265,34 +394,19 @@ class ProductController extends Controller
|
||||
DB::beginTransaction();
|
||||
|
||||
$dictKey = $product->name_dictionary_key ?: \Illuminate\Support\Str::uuid()->toString();
|
||||
// 既有商品若尚無 spec 字典鍵,首次編輯時補建
|
||||
$specDictKey = $product->spec_dictionary_key ?: \Illuminate\Support\Str::uuid()->toString();
|
||||
// Determine company_id: prioritized from request (for sys admin) then from product's current company
|
||||
$company_id = (auth()->user()->isSystemAdmin() && $request->filled('company_id'))
|
||||
? $request->company_id
|
||||
$company_id = (auth()->user()->isSystemAdmin() && $request->filled('company_id'))
|
||||
? $request->company_id
|
||||
: $product->company_id;
|
||||
|
||||
// 更新或建立多語系翻譯(繞過 TenantScoped,避免系統管理員操作租戶資料時被過濾)
|
||||
foreach ($request->names as $locale => $name) {
|
||||
if (empty($name)) {
|
||||
Translation::withoutGlobalScopes()->where([
|
||||
'group' => 'product',
|
||||
'key' => $dictKey,
|
||||
'locale' => $locale
|
||||
])->delete();
|
||||
continue;
|
||||
}
|
||||
// 更新或建立名稱多語系翻譯(繞過 TenantScoped,避免系統管理員操作租戶資料時被過濾)
|
||||
$this->upsertTranslations('product', $dictKey, $request->input('names', []), $company_id);
|
||||
|
||||
Translation::withoutGlobalScopes()->updateOrCreate(
|
||||
[
|
||||
'group' => 'product',
|
||||
'key' => $dictKey,
|
||||
'locale' => $locale,
|
||||
],
|
||||
[
|
||||
'value' => $name,
|
||||
'company_id' => $company_id,
|
||||
]
|
||||
);
|
||||
}
|
||||
// 更新或建立規格多語系翻譯(與名稱同邏輯,group=product_spec)
|
||||
$specs = $this->resolveSpecs($request);
|
||||
$this->upsertTranslations('product_spec', $specDictKey, $specs, $company_id);
|
||||
|
||||
$data = [
|
||||
'company_id' => $company_id,
|
||||
@ -300,7 +414,8 @@ class ProductController extends Controller
|
||||
'name' => $request->names['zh_TW'] ?? ($product->name ?? 'Untitled'),
|
||||
'name_dictionary_key' => $dictKey,
|
||||
'barcode' => $request->barcode,
|
||||
'spec' => $request->spec,
|
||||
'spec' => $specs['zh_TW'] ?? ($product->spec ?? ''),
|
||||
'spec_dictionary_key' => $specDictKey,
|
||||
'manufacturer' => $request->manufacturer,
|
||||
'track_limit' => $request->track_limit,
|
||||
'spring_limit' => $request->spring_limit,
|
||||
@ -317,7 +432,7 @@ class ProductController extends Controller
|
||||
$oldPath = str_replace('/storage/', '', $product->image_url);
|
||||
Storage::disk('public')->delete($oldPath);
|
||||
}
|
||||
$path = $this->storeAsWebp($request->file('image'), 'products');
|
||||
$path = $this->storeAsWebp($request->file('image'), 'products', 80, 320, 320);
|
||||
$data['image_url'] = Storage::url($path);
|
||||
} elseif ($request->boolean('remove_image')) {
|
||||
if ($product->image_url) {
|
||||
@ -327,10 +442,27 @@ class ProductController extends Controller
|
||||
$data['image_url'] = null;
|
||||
}
|
||||
|
||||
$oldValues = $product->toArray();
|
||||
$product->update($data);
|
||||
$newValues = $product->fresh()->toArray();
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $product->company_id,
|
||||
'user_id' => auth()->id(),
|
||||
'module' => 'product',
|
||||
'action' => 'update',
|
||||
'target_id' => $product->id,
|
||||
'target_type' => Product::class,
|
||||
'note' => 'product_updated',
|
||||
'old_values' => $oldValues,
|
||||
'new_values' => $newValues,
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
// Rebuild catalog cache
|
||||
$this->catalogService->rebuildCache($product->company_id);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@ -354,8 +486,25 @@ class ProductController extends Controller
|
||||
{
|
||||
try {
|
||||
$product = Product::findOrFail($id);
|
||||
$oldValues = $product->toArray();
|
||||
$product->is_active = !$product->is_active;
|
||||
$product->save();
|
||||
$newValues = $product->fresh()->toArray();
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $product->company_id,
|
||||
'user_id' => auth()->id(),
|
||||
'module' => 'product',
|
||||
'action' => 'update',
|
||||
'target_id' => $product->id,
|
||||
'target_type' => Product::class,
|
||||
'note' => 'product_status_toggled',
|
||||
'old_values' => $oldValues,
|
||||
'new_values' => $newValues,
|
||||
]);
|
||||
|
||||
// Rebuild catalog cache
|
||||
$this->catalogService->rebuildCache($product->company_id);
|
||||
|
||||
$status = $product->is_active ? __('Enabled') : __('Disabled');
|
||||
|
||||
@ -384,10 +533,13 @@ class ProductController extends Controller
|
||||
try {
|
||||
$product = Product::findOrFail($id);
|
||||
|
||||
// 刪除與此商品關聯的翻譯資料(繞過 TenantScoped)
|
||||
// 刪除與此商品關聯的翻譯資料(名稱 + 規格,繞過 TenantScoped)
|
||||
if ($product->name_dictionary_key) {
|
||||
Translation::withoutGlobalScopes()->where('key', $product->name_dictionary_key)->delete();
|
||||
}
|
||||
if ($product->spec_dictionary_key) {
|
||||
Translation::withoutGlobalScopes()->where('key', $product->spec_dictionary_key)->delete();
|
||||
}
|
||||
|
||||
// Delete image
|
||||
if ($product->image_url) {
|
||||
@ -395,8 +547,24 @@ class ProductController extends Controller
|
||||
Storage::disk('public')->delete($oldPath);
|
||||
}
|
||||
|
||||
$companyId = $product->company_id;
|
||||
$oldValues = $product->toArray();
|
||||
$product->delete();
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $companyId,
|
||||
'user_id' => auth()->id(),
|
||||
'module' => 'product',
|
||||
'action' => 'delete',
|
||||
'target_id' => $id,
|
||||
'target_type' => Product::class,
|
||||
'note' => 'product_deleted',
|
||||
'old_values' => $oldValues,
|
||||
]);
|
||||
|
||||
// Rebuild catalog cache
|
||||
$this->catalogService->rebuildCache($companyId);
|
||||
|
||||
if (request()->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@ -415,4 +583,243 @@ class ProductController extends Controller
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 匯出商品資料(CSV / Excel),套用與 index 相同的篩選(搜尋/分類/公司)。
|
||||
* 參考 SalesController::handleExport 的串流下載模式;欄位順序對齊匯入範本,方便編輯後回匯入。
|
||||
*/
|
||||
public function export(Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$type = $request->input('export', 'csv');
|
||||
$isExcel = ($type === 'excel');
|
||||
$ext = $isExcel ? 'xls' : 'csv';
|
||||
$contentType = $isExcel ? 'application/vnd.ms-excel; charset=utf-8' : 'text/csv; charset=utf-8';
|
||||
|
||||
// 與 index 相同的商品查詢與篩選(租戶隔離沿用 Product 全域 scope)
|
||||
$query = Product::with(['category.translations', 'translations', 'company']);
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('barcode', 'like', "%{$search}%")
|
||||
->orWhere('spec', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('category_id')) {
|
||||
$query->where('category_id', $request->category_id);
|
||||
}
|
||||
|
||||
if ($user->isSystemAdmin() && $request->filled('product_company_id')) {
|
||||
$query->where('company_id', $request->product_company_id);
|
||||
}
|
||||
|
||||
// 欄位順序對齊匯入範本(ProductImportService::downloadTemplate)
|
||||
$headers = [
|
||||
'分類',
|
||||
'商品名稱(zh_TW)',
|
||||
'商品名稱(en)',
|
||||
'商品名稱(ja)',
|
||||
'條碼',
|
||||
'規格',
|
||||
'生產公司',
|
||||
'售價',
|
||||
'會員價',
|
||||
'成本',
|
||||
'履帶貨道上限',
|
||||
'彈簧貨道上限',
|
||||
'物料代碼',
|
||||
'全額點數',
|
||||
'半額點數',
|
||||
'半額折抵金額',
|
||||
'是否啟用',
|
||||
'圖片檔名',
|
||||
];
|
||||
|
||||
$filename = '商品資料_' . now()->format('YmdHis') . '.' . $ext;
|
||||
|
||||
$callback = function () use ($query, $headers, $isExcel) {
|
||||
$file = fopen('php://output', 'w');
|
||||
if ($isExcel) {
|
||||
fwrite($file, '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40">');
|
||||
fwrite($file, '<head><meta charset="utf-8"/><style>table { border-collapse: collapse; } th, td { border: 1px solid #cbd5e1; padding: 8px 12px; text-align: left; } th { background-color: #f1f5f9; font-weight: bold; }</style></head><body>');
|
||||
fwrite($file, '<table><thead><tr>');
|
||||
foreach ($headers as $header) {
|
||||
fwrite($file, '<th>' . htmlspecialchars($header) . '</th>');
|
||||
}
|
||||
fwrite($file, '</tr></thead><tbody>');
|
||||
} else {
|
||||
fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOM,Excel 開啟不亂碼
|
||||
fputcsv($file, $headers);
|
||||
}
|
||||
|
||||
$locale = app()->getLocale();
|
||||
|
||||
$query->orderBy('id')->chunk(500, function ($products) use ($file, $isExcel, $locale) {
|
||||
foreach ($products as $p) {
|
||||
$meta = is_array($p->metadata) ? $p->metadata : [];
|
||||
$nameEn = optional($p->translations->firstWhere('locale', 'en'))->value;
|
||||
$nameJa = optional($p->translations->firstWhere('locale', 'ja'))->value;
|
||||
|
||||
$categoryName = '';
|
||||
if ($p->category) {
|
||||
$catTrans = $p->category->translations->firstWhere('locale', $locale)
|
||||
?? $p->category->translations->firstWhere('locale', 'zh_TW');
|
||||
$categoryName = $catTrans->value ?? ($p->category->name ?? '');
|
||||
}
|
||||
|
||||
$imageFilename = $p->image_url ? basename($p->image_url) : '';
|
||||
|
||||
$row = [
|
||||
$categoryName,
|
||||
$p->name,
|
||||
$nameEn,
|
||||
$nameJa,
|
||||
$p->barcode,
|
||||
$p->spec,
|
||||
$p->manufacturer,
|
||||
$p->price,
|
||||
$p->member_price,
|
||||
$p->cost,
|
||||
$p->track_limit,
|
||||
$p->spring_limit,
|
||||
$meta['material_code'] ?? '',
|
||||
$meta['points_full'] ?? 0,
|
||||
$meta['points_half'] ?? 0,
|
||||
$meta['points_half_amount'] ?? 0,
|
||||
$p->is_active ? 1 : 0,
|
||||
$imageFilename,
|
||||
];
|
||||
|
||||
if ($isExcel) {
|
||||
fwrite($file, '<tr>');
|
||||
foreach ($row as $cell) {
|
||||
fwrite($file, '<td>' . htmlspecialchars((string) $cell) . '</td>');
|
||||
}
|
||||
fwrite($file, '</tr>');
|
||||
} else {
|
||||
fputcsv($file, $row);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if ($isExcel) {
|
||||
fwrite($file, '</tbody></table></body></html>');
|
||||
}
|
||||
fclose($file);
|
||||
};
|
||||
|
||||
return response()->streamDownload($callback, $filename, ['Content-Type' => $contentType]);
|
||||
}
|
||||
|
||||
public function downloadTemplate(ProductImportService $importService)
|
||||
{
|
||||
return $importService->downloadTemplate();
|
||||
}
|
||||
|
||||
public function import(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:xlsx|max:5120',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'images' => 'nullable|file|mimes:zip|max:61440', // 60MB;落在 php 預設 100M 與 Cloudflare 100M 之內,免改機器設定
|
||||
]);
|
||||
|
||||
$companyId = $request->get('company_id') ?: auth()->user()->company_id;
|
||||
|
||||
if (auth()->user()->isSystemAdmin() && !$companyId) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('Please select a company'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 存檔到耐久暫存目錄並派發背景 Job,請求立即回應,避免大批匯入受 Cloudflare 100 秒等請求逾時限制
|
||||
$baseDir = storage_path('app/product-imports/' . Str::uuid()->toString());
|
||||
\Illuminate\Support\Facades\File::ensureDirectoryExists($baseDir);
|
||||
|
||||
$request->file('file')->move($baseDir, 'data.xlsx');
|
||||
$xlsxPath = $baseDir . '/data.xlsx';
|
||||
|
||||
$zipPath = null;
|
||||
if ($request->hasFile('images')) {
|
||||
$request->file('images')->move($baseDir, 'images.zip');
|
||||
$zipPath = $baseDir . '/images.zip';
|
||||
}
|
||||
|
||||
\App\Jobs\Product\ProcessProductImportJob::dispatch(
|
||||
$baseDir,
|
||||
$xlsxPath,
|
||||
$zipPath,
|
||||
$companyId,
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Import has been queued. Refresh the list shortly to see the imported products.'),
|
||||
], 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步商品目錄至全公司機台 (Phase 2)
|
||||
*/
|
||||
public function syncToAllMachines(Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
// 1. 決定目標公司 ID
|
||||
$companyId = $user->isSystemAdmin()
|
||||
? $request->input('company_id')
|
||||
: $user->company_id;
|
||||
|
||||
if (!$companyId) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('Please select a company.')
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 3. 頻率限制:1 分鐘內不可重複對同一家公司發送同步指令
|
||||
$machineIds = \App\Models\Machine\Machine::where('company_id', $companyId)->pluck('id');
|
||||
$recentCommand = \App\Models\Machine\RemoteCommand::whereIn('machine_id', $machineIds)
|
||||
->where('command_type', 'update_products')
|
||||
->where('created_at', '>=', now()->subMinute())
|
||||
->whereIn('status', ['pending', 'sent'])
|
||||
->exists();
|
||||
|
||||
if ($recentCommand) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('A sync command was recently sent. Please wait 1 minute.')
|
||||
], 422);
|
||||
}
|
||||
|
||||
$remark = $request->input('remark') ?: __('Manual Sync Products');
|
||||
|
||||
// 2. 派遣延遲廣播 Job
|
||||
\App\Jobs\Product\SyncProductsToMachinesJob::dispatch(
|
||||
(int) $companyId,
|
||||
$remark,
|
||||
$user->id
|
||||
);
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $companyId,
|
||||
'user_id' => $user->id,
|
||||
'module' => 'product',
|
||||
'action' => 'sync',
|
||||
'target_id' => $companyId,
|
||||
'target_type' => Company::class,
|
||||
'note' => 'product_catalog_synced_to_all_machines',
|
||||
'new_values' => ['remark' => $remark],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Batch sync command has been queued. Machines will be updated sequentially.')
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,19 +30,22 @@ class RemoteController extends Controller
|
||||
|
||||
// --- 1. 機台列表處理 (New Command Tab) ---
|
||||
$machineQuery = Machine::withCount(['slots'])->orderBy('last_heartbeat_at', 'desc')->orderBy('id', 'desc');
|
||||
|
||||
|
||||
if ($request->filled('search') && $request->input('tab') === 'list') {
|
||||
$search = $request->input('search');
|
||||
$machineQuery->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
$machines = $machineQuery->paginate($request->input('per_page', 10), ['*'], 'machine_page');
|
||||
|
||||
// --- 2. 歷史紀錄處理 (Operation Records Tab) ---
|
||||
// whereHas('machine') 會套用 Machine 的 machine_access 全域作用域,
|
||||
// 確保租戶僅能看到自己被授權機台的指令紀錄(系統管理員不受作用域限制,仍可看全部)。
|
||||
$historyQuery = RemoteCommand::where('command_type', '!=', 'reload_stock')
|
||||
->whereHas('machine')
|
||||
->with(['machine', 'user']);
|
||||
|
||||
if ($request->filled('search') && ($request->input('tab') === 'history' || !$request->has('tab'))) {
|
||||
@ -53,7 +56,8 @@ class RemoteController extends Controller
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
})->orWhereHas('user', function ($uq) use ($search) {
|
||||
$uq->where('name', 'like', "%{$search}%");
|
||||
});
|
||||
})->orWhere('remark', 'like', "%{$search}%")
|
||||
->orWhere('note', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
@ -71,6 +75,9 @@ class RemoteController extends Controller
|
||||
} catch (\Exception $e) {
|
||||
// 忽略解析錯誤
|
||||
}
|
||||
} else {
|
||||
// 預設過濾當天紀錄
|
||||
$historyQuery->whereDate('created_at', now()->toDateString());
|
||||
}
|
||||
|
||||
// 指令類型過濾
|
||||
@ -102,7 +109,7 @@ class RemoteController extends Controller
|
||||
if ($request->has('tab')) {
|
||||
$tab = $request->input('tab');
|
||||
$viewPath = $tab === 'list' ? 'admin.remote.partials.tab-machines-index' : 'admin.remote.partials.tab-history-index';
|
||||
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'html' => view($viewPath, [
|
||||
@ -135,7 +142,7 @@ class RemoteController extends Controller
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'machine_id' => 'required|exists:machines,id',
|
||||
'command_type' => 'required|string|in:reboot,reboot_card,checkout,lock,unlock,change,dispense',
|
||||
'command_type' => 'required|string|in:reboot,reboot_force,reboot_card,checkout,lock,unlock,change,dispense,fanon,fanoff,fanauto',
|
||||
'amount' => 'nullable|integer|min:0',
|
||||
'slot_no' => 'nullable|string',
|
||||
'note' => 'nullable|string|max:255',
|
||||
@ -145,7 +152,8 @@ class RemoteController extends Controller
|
||||
$this->machineService->dispatchDispense(
|
||||
Machine::findOrFail($validated['machine_id']),
|
||||
$validated['slot_no'],
|
||||
auth()->id()
|
||||
auth()->id(),
|
||||
$validated['note'] ?? null
|
||||
);
|
||||
} else {
|
||||
$payload = [];
|
||||
@ -169,7 +177,7 @@ class RemoteController extends Controller
|
||||
'command_type' => $validated['command_type'],
|
||||
'payload' => $payload,
|
||||
'status' => 'pending',
|
||||
'note' => $validated['note'] ?? null,
|
||||
'remark' => $validated['note'] ?? null,
|
||||
]);
|
||||
|
||||
// 推播 MQTT 指令
|
||||
@ -205,7 +213,8 @@ class RemoteController extends Controller
|
||||
$machineQuery = Machine::withCount([
|
||||
'slots as slots_count',
|
||||
'slots as low_stock_count' => function ($query) {
|
||||
$query->where('stock', '<=', 5);
|
||||
$query->where('max_stock', '>', 0)
|
||||
->whereRaw('stock <= (max_stock * 0.2)');
|
||||
},
|
||||
'slots as expiring_soon_count' => function ($query) {
|
||||
$query->whereNotNull('expiry_date')
|
||||
@ -227,8 +236,9 @@ class RemoteController extends Controller
|
||||
->paginate($request->input('per_page', 10), ['*'], 'machine_page');
|
||||
|
||||
// 2. 歷史紀錄查詢與分頁
|
||||
// whereHas('machine') 套用 machine_access 作用域,租戶僅能看到自己授權機台的補貨紀錄。
|
||||
$historyQuery = RemoteCommand::with(['machine', 'user']);
|
||||
$historyQuery->where('command_type', 'reload_stock');
|
||||
$historyQuery->whereHas('machine')->where('command_type', 'reload_stock');
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->input('search');
|
||||
@ -256,6 +266,9 @@ class RemoteController extends Controller
|
||||
} catch (\Exception $e) {
|
||||
// 忽略解析錯誤
|
||||
}
|
||||
} else {
|
||||
// 預設過濾當天紀錄
|
||||
$historyQuery->whereDate('created_at', now()->toDateString());
|
||||
}
|
||||
|
||||
// 狀態過濾
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -15,22 +15,4 @@ class SpecialPermissionController extends Controller
|
||||
'description' => '特殊權限庫存清空功能',
|
||||
]);
|
||||
}
|
||||
|
||||
// APK版本管理
|
||||
public function apkVersions()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => 'APK版本管理',
|
||||
'description' => 'APP版本控制與更新',
|
||||
]);
|
||||
}
|
||||
|
||||
// Discord通知設定
|
||||
public function discordNotifications()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => 'Discord通知設定',
|
||||
'description' => 'Discord通知整合設定',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
188
app/Http/Controllers/Admin/StaffCardController.php
Normal file
188
app/Http/Controllers/Admin/StaffCardController.php
Normal file
@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\StaffCard;
|
||||
use App\Models\StaffCardLog;
|
||||
use App\Services\StaffCardImportService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class StaffCardController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$tab = $request->get('tab', 'staff_list');
|
||||
$isSystemAdmin = auth()->user()->isSystemAdmin();
|
||||
|
||||
$companies = [];
|
||||
if ($isSystemAdmin) {
|
||||
$companies = \App\Models\System\Company::active()->orderBy('name')->get();
|
||||
}
|
||||
|
||||
$cardsQuery = StaffCard::with('lastMachine')
|
||||
->when($request->search, function ($q) use ($request) {
|
||||
$q->where(function ($sq) use ($request) {
|
||||
$sq->where('name', 'like', "%{$request->search}%")
|
||||
->orWhere('employee_id', 'like', "%{$request->search}%")
|
||||
->orWhere('card_uid', 'like', "%{$request->search}%");
|
||||
});
|
||||
})
|
||||
->when($isSystemAdmin && $request->company_id, function ($q) use ($request) {
|
||||
$q->where('company_id', $request->company_id);
|
||||
});
|
||||
|
||||
$logsQuery = StaffCardLog::with(['staffCard', 'machine', 'order'])
|
||||
->when($request->log_search, function ($q) use ($request) {
|
||||
$q->whereHas('staffCard', function ($sq) use ($request) {
|
||||
$sq->where('name', 'like', "%{$request->log_search}%")
|
||||
->orWhere('employee_id', 'like', "%{$request->log_search}%")
|
||||
->orWhere('card_uid', 'like', "%{$request->log_search}%");
|
||||
});
|
||||
})
|
||||
->when($isSystemAdmin && $request->log_company_id, function ($q) use ($request) {
|
||||
$q->where('company_id', $request->log_company_id);
|
||||
});
|
||||
|
||||
$staffPerPage = $request->input('staff_per_page', 10);
|
||||
$usagePerPage = $request->input('usage_per_page', 10);
|
||||
|
||||
if ($request->ajax() && $request->has('_ajax')) {
|
||||
if ($tab === 'staff_list') {
|
||||
$cards = $cardsQuery->latest()->paginate($staffPerPage, ['*'], 'staff_page');
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'html' => view('admin.data-config.staff-cards.partials.tab-staff-list', compact('cards', 'companies'))->render()
|
||||
]);
|
||||
} else {
|
||||
$logs = $logsQuery->latest()->paginate($usagePerPage, ['*'], 'usage_page');
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'html' => view('admin.data-config.staff-cards.partials.tab-usage-logs', compact('logs', 'companies'))->render()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$cards = $cardsQuery->latest()->paginate($staffPerPage, ['*'], 'staff_page');
|
||||
$logs = $logsQuery->latest()->paginate($usagePerPage, ['*'], 'usage_page');
|
||||
|
||||
|
||||
return view('admin.data-config.staff-cards.index', compact('cards', 'logs', 'tab', 'companies'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'employee_id' => 'required|string|max:50',
|
||||
'name' => 'required|string|max:100',
|
||||
'card_uid' => 'required|string|max:100',
|
||||
'status' => 'nullable|string|in:active,inactive',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$validated['status'] = $request->get('status', 'active');
|
||||
|
||||
$card = StaffCard::create($validated);
|
||||
|
||||
if ($request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Staff card created successfully'),
|
||||
'data' => $card
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.data-config.staff-cards.index')->with('success', __('Staff card created successfully'));
|
||||
}
|
||||
|
||||
public function update(Request $request, StaffCard $staffCard)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'employee_id' => 'required|string|max:50',
|
||||
'name' => 'required|string|max:100',
|
||||
'card_uid' => 'required|string|max:100',
|
||||
'status' => 'nullable|string|in:active,inactive',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$validated['status'] = $request->get('status', $staffCard->status);
|
||||
|
||||
// 寫入安全:company_id 不得由前端任意決定或被洗成 null
|
||||
// - 非系統管理員:強制綁定自身公司 (租戶隔離)
|
||||
// - 系統管理員:若未帶入公司,保留原本歸屬,避免變成無公司歸屬的孤兒卡 (機台將查無此卡)
|
||||
if (!auth()->user()->isSystemAdmin()) {
|
||||
$validated['company_id'] = auth()->user()->company_id;
|
||||
} elseif (empty($validated['company_id'])) {
|
||||
$validated['company_id'] = $staffCard->company_id;
|
||||
}
|
||||
|
||||
$staffCard->update($validated);
|
||||
|
||||
if ($request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Staff card updated successfully'),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.data-config.staff-cards.index')->with('success', __('Staff card updated successfully'));
|
||||
}
|
||||
|
||||
public function destroy(StaffCard $staffCard)
|
||||
{
|
||||
$staffCard->delete();
|
||||
|
||||
if (request()->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Staff card deleted successfully'),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.data-config.staff-cards.index')->with('success', __('Staff card deleted successfully'));
|
||||
}
|
||||
|
||||
public function toggleStatus(StaffCard $staffCard)
|
||||
{
|
||||
$staffCard->status = $staffCard->status === 'active' ? 'inactive' : 'active';
|
||||
$staffCard->save();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Status updated successfully'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function downloadTemplate(StaffCardImportService $importService)
|
||||
{
|
||||
return $importService->downloadTemplate();
|
||||
}
|
||||
|
||||
public function import(Request $request, StaffCardImportService $importService)
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:xlsx,xls,csv|max:5120',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
]);
|
||||
|
||||
$companyId = $request->get('company_id') ?: auth()->user()->company_id;
|
||||
|
||||
if (auth()->user()->isSystemAdmin() && !$companyId) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('Please select a company'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$results = $importService->import($request->file('file')->getRealPath(), $companyId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __(':count staff cards imported successfully', ['count' => $results['success_count']]),
|
||||
'results' => $results,
|
||||
]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -19,24 +19,15 @@ class MachineAuthController extends Controller
|
||||
*/
|
||||
public function loginB000(Request $request): JsonResponse
|
||||
{
|
||||
// 1. 驗證欄位 (相容舊版 Java App 發送的 JSON 格式)
|
||||
// 1. 驗證帳密與 IP
|
||||
$validated = $request->validate([
|
||||
'machine' => 'required|string',
|
||||
'Su_Account' => 'required|string',
|
||||
'Su_Password' => 'required|string',
|
||||
'ip' => 'nullable|string',
|
||||
'type' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// 2. 取得機台物件 (需優先於帳密驗證,以便記錄日誌到正確機台)
|
||||
$machine = Machine::withoutGlobalScopes()->where('serial_no', $validated['machine'])->first();
|
||||
|
||||
if (!$machine) {
|
||||
Log::warning("B000 機台登入失敗: 伺服器找不到該機台", [
|
||||
'machine_serial' => $validated['machine']
|
||||
]);
|
||||
return response()->json(['message' => 'Failed']);
|
||||
}
|
||||
// 2. 取得機台物件 (由 iot.auth 中介層注入)
|
||||
$machine = $request->get('machine');
|
||||
|
||||
// 3. 透過帳號尋找使用者 (允許使用 username 或 email)
|
||||
$user = User::where('username', $validated['Su_Account'])
|
||||
@ -47,7 +38,7 @@ class MachineAuthController extends Controller
|
||||
if (!$user || !Hash::check($validated['Su_Password'], $user->password)) {
|
||||
Log::warning("B000 機台登入失敗: 帳密錯誤", [
|
||||
'account' => $validated['Su_Account'],
|
||||
'machine' => $validated['machine']
|
||||
'machine' => $machine->serial_no
|
||||
]);
|
||||
|
||||
// 寫入機台日誌
|
||||
@ -60,20 +51,30 @@ class MachineAuthController extends Controller
|
||||
'login'
|
||||
);
|
||||
|
||||
return response()->json(['message' => 'Failed']);
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 401,
|
||||
'message' => 'Failed'
|
||||
], 401);
|
||||
}
|
||||
|
||||
// 5. RBAC 權限驗證 (遵循多租戶與機台授權規範)
|
||||
// $identity: 登入者身分 (system:系統登入者 / company:公司帳號 / staff:一般人員)
|
||||
// 僅在授權通過時賦值,確保未授權者不洩漏身分
|
||||
$isAuthorized = false;
|
||||
$identity = null;
|
||||
if ($user->isSystemAdmin()) {
|
||||
$isAuthorized = true;
|
||||
$identity = 'system';
|
||||
} elseif ($user->is_admin) {
|
||||
if ($machine->company_id === $user->company_id) {
|
||||
$isAuthorized = true;
|
||||
$identity = 'company';
|
||||
}
|
||||
} else {
|
||||
if ($user->machines()->where('machine_id', $machine->id)->exists()) {
|
||||
$isAuthorized = true;
|
||||
$identity = 'staff';
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,7 +93,11 @@ class MachineAuthController extends Controller
|
||||
'login'
|
||||
);
|
||||
|
||||
return response()->json(['message' => 'Forbidden']);
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 403,
|
||||
'message' => 'Forbidden'
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 6. 驗證完美通過!
|
||||
@ -101,6 +106,12 @@ class MachineAuthController extends Controller
|
||||
'machine' => $machine->serial_no
|
||||
]);
|
||||
|
||||
// 自我修復:自動將先前所有未解決的登入失敗警告標記為已解決
|
||||
$machine->logs()
|
||||
->where('type', 'login')
|
||||
->where('is_resolved', false)
|
||||
->update(['is_resolved' => true]);
|
||||
|
||||
// 寫入成功登入日誌
|
||||
ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
@ -112,7 +123,10 @@ class MachineAuthController extends Controller
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'Success',
|
||||
'identity' => $identity,
|
||||
'token' => $user->createToken('technician-setup', ['*'], now()->addHours(8))->plainTextToken
|
||||
]);
|
||||
}
|
||||
|
||||
@ -7,13 +7,24 @@ use Illuminate\Http\Request;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\System\User;
|
||||
use App\Jobs\Machine\ProcessHeartbeat;
|
||||
use App\Jobs\Machine\ProcessTimerStatus;
|
||||
use App\Jobs\Machine\ProcessCoinInventory;
|
||||
use App\Jobs\Machine\ProcessMachineError;
|
||||
use App\Jobs\Machine\ProcessStateLog;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Models\Machine\MachineStockMovement;
|
||||
use App\Models\Transaction\PickupCode;
|
||||
use App\Models\Transaction\PickupCodeLog;
|
||||
use App\Models\Transaction\PassCode;
|
||||
use App\Models\StaffCard;
|
||||
use App\Models\StaffCardLog;
|
||||
use App\Models\Transaction\PassCodeLog;
|
||||
use App\Models\Transaction\WelcomeGift;
|
||||
use App\Models\Transaction\WelcomeGiftLog;
|
||||
use App\Models\System\SystemOperationLog;
|
||||
use App\Services\Machine\MachineService;
|
||||
use App\Services\Product\ProductCatalogService;
|
||||
|
||||
class MachineController extends Controller
|
||||
{
|
||||
@ -188,18 +199,6 @@ class MachineController extends Controller
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* B710: Sync Timer status (Asynchronous)
|
||||
*/
|
||||
public function syncTimer(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
|
||||
|
||||
ProcessTimerStatus::dispatch($machine->serial_no, $data);
|
||||
|
||||
return response()->json(['success' => true], 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* B220: Sync Coin Inventory (Asynchronous)
|
||||
@ -289,10 +288,10 @@ class MachineController extends Controller
|
||||
|
||||
return [
|
||||
't070v01' => $ma->advertisement->name,
|
||||
't070v02' => (string) ($ma->advertisement->duration ?? 15), // 秒數改放這裡
|
||||
't070v03' => (string) ($posIdMap[$ma->position] ?? '1'), // 位置數字改放這裡 (App 會讀這欄當 Flag)
|
||||
't070v02' => (int) ($ma->advertisement->duration ?? 15),
|
||||
't070v03' => (int) ($posIdMap[$ma->position] ?? 1),
|
||||
't070v04' => $ma->advertisement->url ? (str_starts_with($ma->advertisement->url, 'http') ? $ma->advertisement->url : asset($ma->advertisement->url)) : '',
|
||||
't070v05' => (string) $ma->sort_order,
|
||||
't070v05' => (int) $ma->sort_order,
|
||||
'raw_pos_weight' => $posWeight[$ma->position] ?? 99,
|
||||
'raw_sort' => (int) $ma->sort_order
|
||||
];
|
||||
@ -368,10 +367,16 @@ class MachineController extends Controller
|
||||
|
||||
$mappedSlots = array_map(function ($item) {
|
||||
return [
|
||||
'slot_no' => $item['tid'] ?? null,
|
||||
'slot_no' => isset($item['tid']) ? (string) $item['tid'] : null,
|
||||
'product_id' => $item['t060v00'] ?? null,
|
||||
'stock' => $item['num'] ?? 0,
|
||||
'type' => $item['type'] ?? null,
|
||||
'stock' => isset($item['num']) ? (int) $item['num'] : 0,
|
||||
'type' => isset($item['type']) ? (int) $item['type'] : null,
|
||||
// 雙向 LWW:鎖定與效期/批號 + 各自的編輯序號(App 端 store 編輯才遞增)
|
||||
'is_locked' => isset($item['is_locked']) ? (bool) $item['is_locked'] : false,
|
||||
'lock_rev' => isset($item['lock_rev']) ? (int) $item['lock_rev'] : 0,
|
||||
'expiry_date' => $item['expiry_date'] ?? null,
|
||||
'batch_no' => $item['batch_no'] ?? null,
|
||||
'expiry_rev' => isset($item['expiry_rev']) ? (int) $item['expiry_rev'] : 0,
|
||||
];
|
||||
}, $legacyData);
|
||||
|
||||
@ -385,49 +390,20 @@ class MachineController extends Controller
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => __('Slot report synchronized success'),
|
||||
'status' => '49'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* B012_new: Download Product Catalog (Synchronous)
|
||||
*/
|
||||
public function getProducts(Request $request)
|
||||
public function getProducts(Request $request, ProductCatalogService $catalogService)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
|
||||
$products = \App\Models\Product\Product::where('company_id', $machine->company_id)
|
||||
->with(['translations'])
|
||||
->active()
|
||||
->get()
|
||||
->map(function ($product) {
|
||||
// 提取多語系名稱 (Extract translations)
|
||||
$nameEn = $product->translations->firstWhere('locale', 'en')?->value ?? '';
|
||||
$nameJp = $product->translations->firstWhere('locale', 'ja')?->value ?? '';
|
||||
// 公司基底目錄(快取) + 該機台專屬定價覆蓋(即時套用,見 getMachinePayload)
|
||||
$payload = $catalogService->getMachinePayload($machine);
|
||||
|
||||
return [
|
||||
't060v00' => (string) $product->id, // ID 仍建議維持字串,增加未來編號彈性
|
||||
't060v01' => $product->name,
|
||||
't060v01_en' => $nameEn,
|
||||
't060v01_jp' => $nameJp,
|
||||
't060v03' => $product->spec ?? '',
|
||||
't060v06' => $product->image_url ? (str_starts_with($product->image_url, 'http') ? $product->image_url : asset($product->image_url)) : '',
|
||||
't060v09' => (float) $product->price,
|
||||
't060v11' => (int) ($product->track_limit ?? 10),
|
||||
't060v30' => (float) ($product->member_price ?? $product->price),
|
||||
't060v40' => $product->metadata['marketing_plan'] ?? '', // 行銷計畫
|
||||
't060v41' => $product->metadata['material_code'] ?? $product->barcode ?? '', // 物料編碼 (優先從 metadata 找,回退至條碼)
|
||||
'spring_limit' => (int) ($product->spring_limit ?? 10),
|
||||
'track_limit' => (int) ($product->track_limit ?? 10),
|
||||
't063v03' => (float) $product->price,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'data' => $products
|
||||
]);
|
||||
return response()->json($payload);
|
||||
}
|
||||
|
||||
|
||||
@ -453,43 +429,568 @@ class MachineController extends Controller
|
||||
], 404);
|
||||
}
|
||||
|
||||
// 2. 獲取關聯設定 (依據使用者要求,跳過權限校驗,只要有序號即可獲取)
|
||||
// 2. 獲取金流配置 (依據使用者要求,跳過權限校驗,只要有序號即可獲取)
|
||||
// 來源統一為 payment_configs.settings 的巢狀結構 (對齊「編輯金流配置」表單)
|
||||
$paymentSettings = $machine->paymentConfig->settings ?? [];
|
||||
$companySettings = $machine->company->settings ?? [];
|
||||
|
||||
// 4. 映射 App 預期欄位 (嚴格遵守 HttpAPI.java 結構)
|
||||
// 4. 映射 App 預期欄位 (輸出鍵維持 HttpAPI.java 結構,僅改值來源為巢狀)
|
||||
$data = [
|
||||
't050v01' => $machine->serial_no,
|
||||
'api_token' => $machine->api_token, // 向 App 核發正式通訊 Token
|
||||
|
||||
// 玉山支付
|
||||
't050v41' => $paymentSettings['esun_store_id'] ?? '',
|
||||
't050v42' => $paymentSettings['esun_term_id'] ?? '',
|
||||
't050v43' => $paymentSettings['esun_hash'] ?? '',
|
||||
// 玉山掃碼 (esun_scan)
|
||||
't050v41' => data_get($paymentSettings, 'esun_scan.store_id') ?? '',
|
||||
't050v42' => data_get($paymentSettings, 'esun_scan.term_id') ?? '',
|
||||
't050v43' => data_get($paymentSettings, 'esun_scan.key') ?? '',
|
||||
|
||||
// 電子發票 (綠界)
|
||||
't050v34' => $companySettings['invoice_merchant_id'] ?? '',
|
||||
't050v35' => $companySettings['invoice_hash_key'] ?? '',
|
||||
't050v36' => $companySettings['invoice_hash_iv'] ?? '',
|
||||
't050v38' => $companySettings['invoice_email'] ?? '',
|
||||
// 綠界電子發票 (ecpay_invoice) — 改讀金流配置,不再讀 company.settings
|
||||
't050v34' => data_get($paymentSettings, 'ecpay_invoice.store_id') ?? '',
|
||||
't050v35' => data_get($paymentSettings, 'ecpay_invoice.hash_key') ?? '',
|
||||
't050v36' => data_get($paymentSettings, 'ecpay_invoice.hash_iv') ?? '',
|
||||
't050v38' => data_get($paymentSettings, 'ecpay_invoice.email') ?? '',
|
||||
|
||||
// 趨勢支付 (TrendPay/Greenpay)
|
||||
'TP_APP_ID' => $paymentSettings['tp_app_id'] ?? '',
|
||||
'TP_APP_KEY' => $paymentSettings['tp_app_key'] ?? '',
|
||||
'TP_PARTNER_KEY' => $paymentSettings['tp_partner_key'] ?? '',
|
||||
// TapPay / 趨勢支付 (tappay)
|
||||
'TP_APP_ID' => data_get($paymentSettings, 'tappay.app_id') ?? '',
|
||||
'TP_APP_KEY' => data_get($paymentSettings, 'tappay.app_key') ?? '',
|
||||
'TP_PARTNER_KEY' => data_get($paymentSettings, 'tappay.partner_key') ?? '',
|
||||
|
||||
// 各類行動支付特店 ID
|
||||
'TP_LINE_MERCHANT_ID' => $paymentSettings['tp_line_merchant_id'] ?? '',
|
||||
'TP_PS_MERCHANT_ID' => $paymentSettings['tp_ps_merchant_id'] ?? '',
|
||||
'TP_EASY_MERCHANT_ID' => $paymentSettings['tp_easy_merchant_id'] ?? '',
|
||||
'TP_PI_MERCHANT_ID' => $paymentSettings['tp_pi_merchant_id'] ?? '',
|
||||
'TP_JKO_MERCHANT_ID' => $paymentSettings['tp_jko_merchant_id'] ?? '',
|
||||
// 各類行動支付特店 ID (tappay.*_merchant_id)
|
||||
'TP_LINE_MERCHANT_ID' => data_get($paymentSettings, 'tappay.line_merchant_id') ?? '',
|
||||
'TP_PS_MERCHANT_ID' => data_get($paymentSettings, 'tappay.ps_merchant_id') ?? '',
|
||||
'TP_EASY_MERCHANT_ID' => data_get($paymentSettings, 'tappay.easy_merchant_id') ?? '',
|
||||
'TP_PI_MERCHANT_ID' => data_get($paymentSettings, 'tappay.pi_merchant_id') ?? '',
|
||||
'TP_JKO_MERCHANT_ID' => data_get($paymentSettings, 'tappay.jko_merchant_id') ?? '',
|
||||
];
|
||||
|
||||
// 5. 機台系統設定 (machines.settings) → 全部以機台端 *Set + PascalCase 風格下發。
|
||||
// 註:機台 App 端目前讀本地設定,需另案修改 App 才會實際消費此處下發值。
|
||||
$s = $machine->settings ?? [];
|
||||
|
||||
// 5-1 DevSet:支付旗標(命名沿用機台 DevSetStructure;DevCreditCard/DevMobilePay/
|
||||
// DevCardPay/DevScanPay 為我方新定義,機台端需比照新增欄位)
|
||||
$data['DevSet'] = [
|
||||
'ShoppingCar' => (bool) ($s['shopping_cart_enabled'] ?? false), // 購物車
|
||||
'Invoice' => (bool) ($s['tax_invoice_enabled'] ?? false), // 電子發票
|
||||
'DevNFCPay' => (bool) ($s['card_terminal_enabled'] ?? false), // 刷卡機(總開關)
|
||||
'DevCreditCard' => (bool) ($s['credit_card_enabled'] ?? false), // 信用卡支付(新定義)
|
||||
'DevMobilePay' => (bool) ($s['mobile_pay_enabled'] ?? false), // 手機支付(新定義)
|
||||
'DevCardPay' => (bool) ($s['card_pay_enabled'] ?? false), // 卡片支付(新定義)
|
||||
'DevScanPay' => (bool) ($s['scan_pay_enabled'] ?? false), // 掃碼(總開關,新定義)
|
||||
'DevEsunPay' => (bool) ($s['scan_pay_esun_enabled'] ?? false), // 玉山掃碼
|
||||
'DevTapPay' => (bool) ($s['scan_pay_tappay_enabled'] ?? false), // TapPay 掃碼
|
||||
'DevCash' => (bool) ($s['cash_module_enabled'] ?? false), // 現金
|
||||
'DevLinePay' => (bool) ($s['scan_pay_linepay_enabled'] ?? false), // LINE Pay 官方直連(Line官方支付,與 TapPay30 不同)
|
||||
'TapPay30' => (bool) ($s['tappay_linepay'] ?? false), // TapPay 底下的 LINE Pay
|
||||
'TapPay31' => (bool) ($s['tappay_jkopay'] ?? false), // TapPay-街口支付
|
||||
'TapPay32' => (bool) ($s['tappay_easywallet'] ?? false), // TapPay-悠遊付
|
||||
'TapPay33' => (bool) ($s['tappay_pipay'] ?? false), // TapPay-Pi 支付
|
||||
'TapPay34' => (bool) ($s['tappay_pluspay'] ?? false), // TapPay-全盈+支付
|
||||
// 註:VMC/Electic 為機台硬體類型,雲端無來源,由機台本地保留
|
||||
];
|
||||
|
||||
// 5-2 CashSet:現金面額(對齊機台 CashSetStructure)
|
||||
$data['CashSet'] = [
|
||||
'BillF1000' => (bool) ($s['cash_bill_1000'] ?? false),
|
||||
'BillE500' => (bool) ($s['cash_bill_500'] ?? false),
|
||||
'BillD100' => (bool) ($s['cash_bill_100'] ?? false),
|
||||
'CoinF50' => (bool) ($s['cash_coin_50'] ?? false),
|
||||
'CoinE10' => (bool) ($s['cash_coin_10'] ?? false),
|
||||
'CoinD5' => (bool) ($s['cash_coin_5'] ?? false),
|
||||
'CoinC1' => (bool) ($s['cash_coin_1'] ?? false),
|
||||
];
|
||||
|
||||
// 5-3 FunctionSet:非支付功能模組旗標(我方新定義,機台端待實作對應結構)
|
||||
$data['FunctionSet'] = [
|
||||
'PickupModule' => (bool) ($s['pickup_module_enabled'] ?? false), // 取貨模組
|
||||
'PickupCode' => (bool) ($s['pickup_code_enabled'] ?? false), // 取貨碼
|
||||
'PassCode' => (bool) ($s['pass_code_enabled'] ?? false), // 通行碼
|
||||
'WelcomeGift' => (bool) ($s['welcome_gift_enabled'] ?? false), // 來店禮
|
||||
'MemberSystem' => (bool) ($s['member_system_enabled'] ?? false), // 會員系統
|
||||
'AmbientTemp' => (bool) ($s['ambient_temp_monitoring_enabled'] ?? false), // 環境溫度監控
|
||||
'PharmacyPickup' => (bool) ($s['pharmacy_pickup_enabled'] ?? false), // 領藥單(雲端建單,取物單模式下開關)
|
||||
'Subcabinet' => (bool) ($s['subcabinet_enabled'] ?? false), // 副櫃系統(格子櫃功能,基礎版授權開關)
|
||||
];
|
||||
|
||||
// 5-4 ShoppingMode:購物方式(頂層字串)
|
||||
$data['ShoppingMode'] = (string) ($s['shopping_mode'] ?? 'basic'); // basic / employee_card / pickup_sheet
|
||||
|
||||
// 5-4b LangSet:機台顯示語系(後台勾選,最多 N 種)。機台據此渲染語系切換 UI,
|
||||
// Default 為開機/idle 預設語系(清單第一個)。未設定時退化為 fallback 單語。
|
||||
$langs = array_values(array_filter((array) ($s['languages'] ?? [])));
|
||||
if (empty($langs)) {
|
||||
$langs = [config('locales.fallback', 'zh_TW')];
|
||||
}
|
||||
$data['LangSet'] = [
|
||||
'Languages' => $langs, // 有序清單,順序即切換順序
|
||||
'Default' => $langs[0], // 預設語系(清單第一個)
|
||||
];
|
||||
|
||||
// 5-5 OperationSet:運作參數(machines 實體欄位)
|
||||
$data['OperationSet'] = [
|
||||
'CardReaderSeconds' => (int) ($machine->card_reader_seconds ?? 0), // 刷卡機秒數
|
||||
'PaymentBufferSeconds' => (int) ($machine->payment_buffer_seconds ?? 0), // 金流緩衝秒數
|
||||
'CheckoutTime1' => (string) ($machine->card_reader_checkout_time_1 ?? ''), // 結帳時間 1
|
||||
'CheckoutTime2' => (string) ($machine->card_reader_checkout_time_2 ?? ''), // 結帳時間 2
|
||||
'HeatingStartTime' => (string) ($machine->heating_start_time ?? ''), // 加熱開始時間
|
||||
'HeatingEndTime' => (string) ($machine->heating_end_time ?? ''), // 加熱結束時間
|
||||
];
|
||||
|
||||
// 5-6 HardwareSet:硬體與貨道(machines 實體欄位;SpringSlot* true=彈簧 / false=履帶)
|
||||
$data['HardwareSet'] = [
|
||||
'CardReaderNo' => (string) ($machine->card_reader_no ?? ''), // 刷卡機編號
|
||||
'SpringSlot1_10' => (bool) $machine->is_spring_slot_1_10,
|
||||
'SpringSlot11_20' => (bool) $machine->is_spring_slot_11_20,
|
||||
'SpringSlot21_30' => (bool) $machine->is_spring_slot_21_30,
|
||||
'SpringSlot31_40' => (bool) $machine->is_spring_slot_31_40,
|
||||
'SpringSlot41_50' => (bool) $machine->is_spring_slot_41_50,
|
||||
'SpringSlot51_60' => (bool) $machine->is_spring_slot_51_60,
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'data' => [$data] // App 預期的是包含單一物件的陣列
|
||||
'data' => $data
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* B016: Update Machine System Settings (Write-back from machine console)
|
||||
* 機台主控台「系統設定」由系統方 (identity=system) 編輯後回寫雲端。
|
||||
* 認證:B000 核發的使用者 Token (auth:sanctum);僅系統管理員可操作。
|
||||
* Body: { machine: 序號, settings: { is_spring_slot_*: bool, ... } }
|
||||
* 機台端僅回寫硬體貨道類型 (is_spring_slot_*) 實體欄位;其餘支付旗標、
|
||||
* 現金面額、功能模組等開關純由後台決定,B016 不接收,未列入者一律忽略。
|
||||
*/
|
||||
public function updateSettings(Request $request)
|
||||
{
|
||||
// 1. 僅系統方可回寫(雙重把關:App 端隱藏控制 + 此處伺服器驗權)
|
||||
$user = $request->user();
|
||||
if (!$user || !$user->isSystemAdmin()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 403,
|
||||
'message' => __('Forbidden')
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 2. 查找機台
|
||||
$serialNo = $request->input('machine');
|
||||
$machine = Machine::withoutGlobalScopes()
|
||||
->where('serial_no', $serialNo)
|
||||
->first();
|
||||
|
||||
if (!$machine) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 404,
|
||||
'message' => __('Machine not found')
|
||||
], 404);
|
||||
}
|
||||
|
||||
$settings = $request->input('settings', []);
|
||||
if (!is_array($settings)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 422,
|
||||
'message' => __('Invalid settings payload')
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 3. 直接欄位白名單:machines 實體布林欄位(硬體貨道類型)
|
||||
// 機台端 B016 僅回寫貨道類型,其餘系統設定由後台單向決定,不在此處接收。
|
||||
$columnBoolKeys = [
|
||||
'is_spring_slot_1_10', 'is_spring_slot_11_20', 'is_spring_slot_21_30',
|
||||
'is_spring_slot_31_40', 'is_spring_slot_41_50', 'is_spring_slot_51_60',
|
||||
];
|
||||
|
||||
// 4. 整理實體欄位更新(僅貨道類型,只覆寫有送來的鍵)
|
||||
$columnData = [];
|
||||
foreach ($columnBoolKeys as $k) {
|
||||
if (array_key_exists($k, $settings)) {
|
||||
$columnData[$k] = (bool) $settings[$k];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($columnData)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 422,
|
||||
'message' => __('Invalid settings payload')
|
||||
], 422);
|
||||
}
|
||||
|
||||
$machine->update(array_merge($columnData, [
|
||||
'updater_id' => $user->id,
|
||||
]));
|
||||
|
||||
\Log::info('B016 Machine settings updated', [
|
||||
'machine' => $machine->serial_no,
|
||||
'user_id' => $user->id,
|
||||
'keys' => array_keys($settings),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => __('Settings updated successfully.')
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* B660: Verify/Consume Pickup Code
|
||||
* POST: 驗證 8 位數取貨碼 (與原邏輯一致,但加入日誌)
|
||||
* PUT: 消耗取貨碼 (扣庫存)
|
||||
*/
|
||||
public function verifyPickupCode(Request $request)
|
||||
{
|
||||
return $this->handlePickupVerify($request, $request->get('machine'));
|
||||
}
|
||||
|
||||
private function handlePickupVerify(Request $request, $machine)
|
||||
{
|
||||
$lockoutKey = "pickup_lockout:{$machine->id}";
|
||||
if (Cache::has($lockoutKey)) {
|
||||
return response()->json(['success' => false, 'message' => 'Too many failed attempts.', 'code' => 429], 429);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), ['code' => 'required|string|size:8']);
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['success' => false, 'message' => 'Invalid format', 'code' => 400], 400);
|
||||
}
|
||||
|
||||
$code = $request->input('code');
|
||||
$pickupCode = PickupCode::where('machine_id', $machine->id)
|
||||
->where('code', $code)
|
||||
->where('status', 'active')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->first();
|
||||
|
||||
if (!$pickupCode) {
|
||||
$fails = Cache::increment("pickup_fails:{$machine->id}");
|
||||
if ($fails === 1)
|
||||
Cache::put("pickup_fails:{$machine->id}", 1, 600);
|
||||
if ($fails >= 5) {
|
||||
Cache::put($lockoutKey, true, 60);
|
||||
Cache::forget("pickup_fails:{$machine->id}");
|
||||
}
|
||||
|
||||
ProcessStateLog::dispatch($machine->id, $machine->company_id, "[PickupCode] Verification failed: :code", 'info', ['code' => $code]);
|
||||
|
||||
return response()->json(['success' => false, 'message' => 'Invalid code', 'code' => 404, 'remaining_attempts' => 5 - $fails], 404);
|
||||
}
|
||||
|
||||
Cache::forget("pickup_fails:{$machine->id}");
|
||||
|
||||
// 建立取貨碼專屬日誌 (驗證成功)
|
||||
PickupCodeLog::create([
|
||||
'company_id' => $machine->company_id,
|
||||
'machine_id' => $machine->id,
|
||||
'pickup_code_id' => $pickupCode->id,
|
||||
'action' => 'verify_success',
|
||||
'remark' => "log.pickup.verify_success",
|
||||
'raw_data' => [
|
||||
'machine_sn' => $machine->serial_no,
|
||||
'code' => $pickupCode->code,
|
||||
'slot' => $pickupCode->slot_no
|
||||
]
|
||||
]);
|
||||
|
||||
// 移除 StaffCardLog 重複紀錄,因為取貨碼本身有 status 和 order_id 可以追蹤
|
||||
// 一般取貨碼的「核銷」仍由 MQTT finalizeTransaction 流程處理(行為不變)。
|
||||
|
||||
$data = [
|
||||
'slot_no' => $pickupCode->slot_no, // 保留:既有綁貨道碼讀此欄(綁商品碼/領藥單為 null)
|
||||
'product_id' => $pickupCode->product_id, // 綁商品碼:App 據此自挑可出貨道(綁貨道碼為 null)
|
||||
'pickup_code_id' => $pickupCode->id,
|
||||
'code_id' => $pickupCode->id, // 統一回傳 code_id
|
||||
'status' => $pickupCode->status,
|
||||
];
|
||||
|
||||
// === 領藥單(pharmacy_pickup)擴充:回傳多貨道清單 + 標記碼已領(僅領藥單,不影響既有單貨道流程) ===
|
||||
$order = $pickupCode->order;
|
||||
$isPharmacy = $order && $order->order_type === \App\Models\Transaction\Order::TYPE_PHARMACY_PICKUP;
|
||||
|
||||
if ($isPharmacy) {
|
||||
// Gate:機台須仍為「取物單模式 + 領藥單開關開啟」才放行領藥碼
|
||||
$settings = $machine->settings ?? [];
|
||||
$enabled = (($settings['shopping_mode'] ?? null) === 'pickup_sheet')
|
||||
&& (bool) ($settings['pharmacy_pickup_enabled'] ?? false);
|
||||
|
||||
if (!$enabled) {
|
||||
return response()->json(['success' => false, 'message' => 'Pharmacy pickup not enabled on this machine', 'code' => 403], 403);
|
||||
}
|
||||
|
||||
$pharmacyService = app(\App\Services\Transaction\PharmacyPickupService::class);
|
||||
|
||||
// 嚴格擋關:任一商品的貨道可出量 < 領藥單需求量 → 整單擋下、不出貨、不消耗領藥碼,回明確提示
|
||||
$shortage = $pharmacyService->dispenseShortage($order);
|
||||
if (!empty($shortage)) {
|
||||
$detail = collect($shortage)
|
||||
->map(fn ($s) => "{$s['product_name']}(需 {$s['required']}、可出 {$s['available']})")
|
||||
->implode('、');
|
||||
|
||||
PickupCodeLog::create([
|
||||
'company_id' => $machine->company_id,
|
||||
'machine_id' => $machine->id,
|
||||
'pickup_code_id' => $pickupCode->id,
|
||||
'order_id' => $order->id,
|
||||
'action' => 'verify_failed',
|
||||
'remark' => 'log.pickup.pharmacy_insufficient_stock',
|
||||
'raw_data' => ['order_no' => $order->order_no, 'shortage' => $shortage],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 409,
|
||||
'message' => '因貨道庫存不足,無法出貨:' . $detail,
|
||||
], 409);
|
||||
}
|
||||
|
||||
// 依目前貨道庫存解析多貨道出貨清單(一商品可跨多貨道湊量)
|
||||
$items = $pharmacyService->resolveDispenseItems($order);
|
||||
|
||||
// 解析不到可出貨貨道(機台無對應貨道/庫存不足)→ 不消耗領藥碼,回明確錯誤讓藥師處理
|
||||
if (empty($items)) {
|
||||
PickupCodeLog::create([
|
||||
'company_id' => $machine->company_id,
|
||||
'machine_id' => $machine->id,
|
||||
'pickup_code_id' => $pickupCode->id,
|
||||
'order_id' => $order->id,
|
||||
'action' => 'verify_failed',
|
||||
'remark' => 'log.pickup.pharmacy_no_slot',
|
||||
'raw_data' => ['order_no' => $order->order_no, 'reason' => 'no available slot/stock'],
|
||||
]);
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 409,
|
||||
'message' => '此領藥單在本機台無可出貨貨道(請確認貨道對應與庫存)',
|
||||
], 409);
|
||||
}
|
||||
|
||||
// 不在「驗證/掃碼」當下核銷領藥碼。核銷統一改由機台實際出貨後的
|
||||
// MQTT finalize(TransactionService::finalizePharmacyDispense)標記為 used,
|
||||
// 這樣顧客掃碼後若未按「確定取貨」(逾時/取消、未出貨),領藥碼仍維持 active 可再次掃碼領取。
|
||||
// (原本在此處 usage_count++/轉 used 會在尚未取貨時就把碼鎖死,導致再掃顯示「已領」。)
|
||||
|
||||
$data['order_type'] = \App\Models\Transaction\Order::TYPE_PHARMACY_PICKUP;
|
||||
$data['order_no'] = $order->order_no;
|
||||
$data['pricing_slip_no'] = $order->pricing_slip_no ?? ''; // 批價單號(藥師批價依據);機台「確認領藥」畫面備註欄顯示
|
||||
$data['items'] = $items; // [{slot_no, product_id, product_name, qty}, ...]
|
||||
$data['status'] = $pickupCode->status;
|
||||
}
|
||||
|
||||
// === 綁商品取貨碼(非領藥單)擋關:機台須仍有此商品貨道,否則 App 必然挑不到貨道 ===
|
||||
// 即時庫存/鎖定/效期由 App 刷碼當下自行判斷;此處只擋「機台根本沒上這商品」的明確錯誤。
|
||||
if (!$isPharmacy && $pickupCode->product_id) {
|
||||
$hasProduct = $machine->slots()->where('product_id', $pickupCode->product_id)->exists();
|
||||
if (!$hasProduct) {
|
||||
PickupCodeLog::create([
|
||||
'company_id' => $machine->company_id,
|
||||
'machine_id' => $machine->id,
|
||||
'pickup_code_id' => $pickupCode->id,
|
||||
'action' => 'verify_failed',
|
||||
'remark' => 'log.pickup.no_slot_for_product',
|
||||
'raw_data' => ['code' => $pickupCode->code, 'product_id' => $pickupCode->product_id],
|
||||
]);
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 409,
|
||||
'message' => '此機台目前無此商品貨道,無法取貨',
|
||||
], 409);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* B670: Verify Pass Code (For Technicians)
|
||||
*/
|
||||
public function verifyPassCode(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$lockoutKey = "passcode_lockout:{$machine->id}";
|
||||
|
||||
if (Cache::has($lockoutKey)) {
|
||||
return response()->json(['success' => false, 'message' => 'Locked', 'code' => 429], 429);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), ['code' => 'required|string|size:8']);
|
||||
if ($validator->fails())
|
||||
return response()->json(['success' => false, 'code' => 400], 400);
|
||||
|
||||
$code = $request->input('code');
|
||||
$passCode = PassCode::where('machine_id', $machine->id)
|
||||
->where('code', $code)
|
||||
->where('status', 'active')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->first();
|
||||
|
||||
if (!$passCode) {
|
||||
$fails = Cache::increment("passcode_fails:{$machine->id}");
|
||||
if ($fails >= 5)
|
||||
Cache::put($lockoutKey, true, 60);
|
||||
return response()->json(['success' => false, 'code' => 404], 404);
|
||||
}
|
||||
|
||||
// 建立通行碼專屬日誌 (驗證成功)
|
||||
PassCodeLog::create([
|
||||
'company_id' => $machine->company_id,
|
||||
'machine_id' => $machine->id,
|
||||
'pass_code_id' => $passCode->id,
|
||||
'status' => 'verified',
|
||||
'action' => 'verify_success',
|
||||
'raw_data' => ['machine_sn' => $machine->serial_no, 'code' => $passCode->code]
|
||||
]);
|
||||
|
||||
// 單次/限次通行碼:驗證成功即計為使用一次;達上限就標記 used,
|
||||
// 下次驗證會被上方 where('status','active') 過濾擋掉,達成「只能使用一次」。
|
||||
// usage_limit 為 null(未勾單次)時不計數,維持原本無限次行為。
|
||||
if (!is_null($passCode->usage_limit)) {
|
||||
$newCount = $passCode->usage_count + 1;
|
||||
$passCode->update([
|
||||
'usage_count' => $newCount,
|
||||
'status' => $newCount >= $passCode->usage_limit ? 'used' : $passCode->status,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'data' => [
|
||||
'name' => $passCode->name,
|
||||
'code_id' => $passCode->id, // 統一回傳 code_id
|
||||
'status' => 'active'
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* B680: Verify Staff Card (New)
|
||||
*/
|
||||
public function verifyStaffCard(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'code' => 'required|string', // 卡片 UID
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['success' => false, 'code' => 400], 400);
|
||||
}
|
||||
|
||||
$uid = $request->input('code');
|
||||
|
||||
// 搜尋該公司的有效員工卡 (遵循租戶隔離)
|
||||
$staffCard = StaffCard::where('company_id', $machine->company_id)
|
||||
->where('card_uid', $uid)
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
|
||||
if (!$staffCard) {
|
||||
ProcessStateLog::dispatch($machine->id, $machine->company_id, "[StaffCard] Verification failed: :uid", 'info', ['uid' => $uid]);
|
||||
return response()->json(['success' => false, 'code' => 404, 'message' => 'Card not found or inactive'], 404);
|
||||
}
|
||||
|
||||
// 建立刷卡日誌 (作為閉環起點)
|
||||
StaffCardLog::create([
|
||||
'company_id' => $machine->company_id,
|
||||
'staff_card_id' => $staffCard->id,
|
||||
'machine_id' => $machine->id,
|
||||
'payment_type' => 41, // Staff Card
|
||||
'code_id' => $staffCard->id,
|
||||
'action' => 'verify_success',
|
||||
'raw_data' => ['machine_sn' => $machine->serial_no, 'uid' => $uid]
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'data' => [
|
||||
'name' => $staffCard->name,
|
||||
'code_id' => $staffCard->id, // 統一回傳 code_id
|
||||
'status' => 'active'
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* B690: Verify Welcome Gift (New)
|
||||
*/
|
||||
public function verifyWelcomeGift(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$lockoutKey = "welcome_gift_lockout:{$machine->id}";
|
||||
|
||||
if (Cache::has($lockoutKey)) {
|
||||
return response()->json(['success' => false, 'message' => 'Too many attempts. Locked.', 'code' => 429], 429);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), ['code' => 'required|string|size:8']);
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['success' => false, 'message' => 'Invalid format', 'code' => 400], 400);
|
||||
}
|
||||
|
||||
$code = $request->input('code');
|
||||
$gift = WelcomeGift::where('machine_id', $machine->id)
|
||||
->where('code', $code)
|
||||
->where('status', 'active')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->first();
|
||||
|
||||
if (!$gift || !$gift->isValid()) {
|
||||
$fails = Cache::increment("welcome_gift_fails:{$machine->id}");
|
||||
if ($fails === 1) {
|
||||
Cache::put("welcome_gift_fails:{$machine->id}", 1, 600);
|
||||
}
|
||||
if ($fails >= 5) {
|
||||
Cache::put($lockoutKey, true, 60);
|
||||
Cache::forget("welcome_gift_fails:{$machine->id}");
|
||||
}
|
||||
return response()->json(['success' => false, 'message' => 'Invalid or expired code', 'code' => 404, 'remaining_attempts' => max(0, 5 - $fails)], 404);
|
||||
}
|
||||
|
||||
Cache::forget("welcome_gift_fails:{$machine->id}");
|
||||
|
||||
// 建立來店禮驗證日誌 (驗證成功)
|
||||
WelcomeGiftLog::create([
|
||||
'company_id' => $machine->company_id,
|
||||
'machine_id' => $machine->id,
|
||||
'welcome_gift_id' => $gift->id,
|
||||
'action' => 'verify_success',
|
||||
'remark' => 'log.welcome_gift.verify_success',
|
||||
'raw_data' => [
|
||||
'code' => $gift->code,
|
||||
'discount_type' => $gift->discount_type,
|
||||
'discount_value' => $gift->discount_value
|
||||
]
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'data' => [
|
||||
'name' => $gift->name,
|
||||
'code_id' => $gift->id,
|
||||
'discount_type' => $gift->discount_type,
|
||||
'discount_value' => $gift->discount_value,
|
||||
'discount_label' => $gift->discount_label, // 回傳自動算好的中文折數 (如「八折」、「折50元」)
|
||||
'usage_type' => $gift->usage_type,
|
||||
'status' => 'active'
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,6 +27,9 @@ class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
$request->authenticate();
|
||||
|
||||
// 剔除該帳號在其他設備上的所有 Session 連線
|
||||
Auth::logoutOtherDevices($request->password);
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(RouteServiceProvider::HOME);
|
||||
|
||||
144
app/Http/Controllers/Auth/TenantLoginController.php
Normal file
144
app/Http/Controllers/Auth/TenantLoginController.php
Normal file
@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\System\Company;
|
||||
use App\Models\System\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TenantLoginController extends Controller
|
||||
{
|
||||
/**
|
||||
* 顯示專屬客戶的客製化登入頁面
|
||||
*/
|
||||
public function showLoginForm($company_code)
|
||||
{
|
||||
// 1. 查詢啟用的公司
|
||||
$company = Company::active()->where('code', $company_code)->first();
|
||||
|
||||
// 2. 安全防護:若公司不存在,或沒有啟用品牌客製化功能授權,一律自動重導向至通用登入頁
|
||||
if (!$company) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$settings = $company->settings ?? [];
|
||||
$enableCustomBranding = filter_var($settings['enable_custom_branding'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
if (!$enableCustomBranding) {
|
||||
Log::warning("Tenant login attempted for unauthorized company [{$company_code}]. Redirecting to main login.");
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
// 3. 渲染共用的 login Blade,傳入當前公司的上下文
|
||||
return view('auth.login', compact('company'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 處理專屬客戶的登入請求 (安全性加強版)
|
||||
*/
|
||||
public function login($company_code, Request $request)
|
||||
{
|
||||
// 1. 查詢並確認該公司
|
||||
$company = Company::active()->where('code', $company_code)->first();
|
||||
if (!$company) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$settings = $company->settings ?? [];
|
||||
$enableCustomBranding = filter_var($settings['enable_custom_branding'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
if (!$enableCustomBranding) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
// 2. 驗證輸入欄位
|
||||
$request->validate([
|
||||
'username' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]);
|
||||
|
||||
// 3. 查詢使用者
|
||||
$user = User::where('username', $request->username)->first();
|
||||
|
||||
// 4. 【關鍵安全防錯】驗證此使用者是否確實屬於該公司
|
||||
if (!$user || (int)$user->company_id !== (int)$company->id) {
|
||||
Log::warning("Tenant login security violation: User [{$request->username}] attempted login at company [{$company_code}] portal. User Company ID: " . var_export($user ? $user->company_id : null, true) . ", Target Company ID: {$company->id}");
|
||||
|
||||
return back()->withErrors([
|
||||
'username' => __('This account does not belong to this company.'),
|
||||
])->withInput($request->only('username', 'remember'));
|
||||
}
|
||||
|
||||
// 5. 驗證帳號狀態
|
||||
if ($user->status !== 1) {
|
||||
return back()->withErrors([
|
||||
'username' => __('Your account is disabled.'),
|
||||
])->withInput($request->only('username', 'remember'));
|
||||
}
|
||||
|
||||
// 6. 驗證密碼
|
||||
if (!Hash::check($request->password, $user->password)) {
|
||||
return back()->withErrors([
|
||||
'password' => __('auth.failed'),
|
||||
])->withInput($request->only('username', 'remember'));
|
||||
}
|
||||
|
||||
// 7. 執行登入與安全 Session 重新生成
|
||||
Auth::login($user, $request->boolean('remember'));
|
||||
$request->session()->regenerate();
|
||||
|
||||
Log::info("User [{$user->username}] successfully logged in via company portal [{$company_code}].");
|
||||
|
||||
return redirect()->intended(route('admin.dashboard'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 預覽專屬客戶的客製化登入頁面 (不受 guest 中間件阻擋,供後台預覽用)
|
||||
*/
|
||||
public function preview($company_code)
|
||||
{
|
||||
// 1. 查詢啟用的公司
|
||||
$company = Company::active()->where('code', $company_code)->first();
|
||||
|
||||
// 2. 若公司不存在,重導向至主登入頁
|
||||
if (!$company) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$settings = $company->settings ?? [];
|
||||
$enableCustomBranding = filter_var($settings['enable_custom_branding'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
if (!$enableCustomBranding) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$isPreview = true;
|
||||
|
||||
// 3. 渲染共用的 login Blade,並傳入預覽標記
|
||||
return view('auth.login', compact('company', 'isPreview'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 處理打 /c/{company_code} 的跳轉邏輯
|
||||
*/
|
||||
public function indexRedirect($company_code)
|
||||
{
|
||||
// 1. 查詢啟用的公司
|
||||
$company = Company::active()->where('code', $company_code)->first();
|
||||
|
||||
if (!$company) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
// 2. 已登入則去 dashboard,未登入則去專屬登入頁
|
||||
if (auth()->check()) {
|
||||
return redirect()->route('admin.dashboard');
|
||||
}
|
||||
|
||||
return redirect()->route('tenant.login', $company_code);
|
||||
}
|
||||
}
|
||||
|
||||
27
app/Http/Controllers/Guest/PassCodeController.php
Normal file
27
app/Http/Controllers/Guest/PassCodeController.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Guest;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Transaction\PassCode;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PassCodeController extends Controller
|
||||
{
|
||||
/**
|
||||
* 顯示公開通行憑證頁面
|
||||
*/
|
||||
public function show($slug)
|
||||
{
|
||||
$passCode = PassCode::with(['machine'])
|
||||
->where('slug', $slug)
|
||||
->where('status', 'active')
|
||||
->where(function($query) {
|
||||
$query->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->firstOrFail();
|
||||
|
||||
return view('guest.pass-code.show', compact('passCode'));
|
||||
}
|
||||
}
|
||||
27
app/Http/Controllers/Guest/PickupController.php
Normal file
27
app/Http/Controllers/Guest/PickupController.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Guest;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Transaction\PickupCode;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PickupController extends Controller
|
||||
{
|
||||
/**
|
||||
* 顯示公開取貨憑證頁面
|
||||
*/
|
||||
public function show($slug)
|
||||
{
|
||||
$pickupCode = PickupCode::with(['machine.slots.product', 'product'])
|
||||
->where(function($query) use ($slug) {
|
||||
$query->where('slug', $slug)
|
||||
->orWhere('code', $slug); // 相容舊版或測試
|
||||
})
|
||||
->where('status', 'active')
|
||||
->where('expires_at', '>', now())
|
||||
->firstOrFail();
|
||||
|
||||
return view('guest.pickup.show', compact('pickupCode'));
|
||||
}
|
||||
}
|
||||
26
app/Http/Controllers/Guest/WelcomeGiftController.php
Normal file
26
app/Http/Controllers/Guest/WelcomeGiftController.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Guest;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Transaction\WelcomeGift;
|
||||
|
||||
class WelcomeGiftController extends Controller
|
||||
{
|
||||
/**
|
||||
* 顯示公開來店禮憑證頁面
|
||||
*/
|
||||
public function show($slug)
|
||||
{
|
||||
$welcomeGift = WelcomeGift::with(['machine'])
|
||||
->where('slug', $slug)
|
||||
->where('status', 'active')
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->firstOrFail();
|
||||
|
||||
return view('guest.welcome-gift.show', compact('welcomeGift'));
|
||||
}
|
||||
}
|
||||
@ -9,9 +9,11 @@ use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
use App\Traits\ImageHandler;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
use ImageHandler;
|
||||
/**
|
||||
* Display the user's profile form.
|
||||
*/
|
||||
@ -48,7 +50,7 @@ class ProfileController extends Controller
|
||||
public function updateAvatar(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'avatar' => ['required', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:1024'],
|
||||
'avatar' => ['required', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:5120'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
@ -59,7 +61,8 @@ class ProfileController extends Controller
|
||||
Storage::disk('public')->delete($user->avatar);
|
||||
}
|
||||
|
||||
$path = $request->file('avatar')->store('avatars', 'public');
|
||||
// 將上傳的大頭貼轉換為高壓縮率且清晰的 WebP 格式 (300x300 居中裁切)
|
||||
$path = $this->storeAsWebp($request->file('avatar'), 'avatars', 85, 300, 300);
|
||||
$user->avatar = $path;
|
||||
$user->save();
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ class ProfileUpdateRequest extends FormRequest
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique(User::class)->ignore($this->user()->id)],
|
||||
'phone' => ['nullable', 'string', 'max:20'],
|
||||
'avatar' => ['nullable', 'image', 'mimes:jpeg,png,jpg,gif', 'max:2048'],
|
||||
'avatar' => ['nullable', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:5120'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
94
app/Jobs/Machine/ProcessAmbientTemp.php
Normal file
94
app/Jobs/Machine/ProcessAmbientTemp.php
Normal file
@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Machine;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ProcessAmbientTemp implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
protected $serialNo;
|
||||
protected $payload;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(string $serialNo, $payload)
|
||||
{
|
||||
$this->serialNo = $serialNo;
|
||||
$this->payload = (array) $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$machine = Machine::withoutGlobalScopes()->where('serial_no', $this->serialNo)->first();
|
||||
|
||||
if (!$machine) {
|
||||
Log::warning("MQTT Ambient Temp: Machine not found", ['serial_no' => $this->serialNo]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 環境溫度欄位更新
|
||||
// 支持機台傳送 payload 為 {"temperature": 25} 或 {"ambient_temp": 25} 等結構
|
||||
$temp = null;
|
||||
if (isset($this->payload['temperature'])) {
|
||||
$temp = (int) $this->payload['temperature'];
|
||||
} elseif (isset($this->payload['ambient_temp'])) {
|
||||
$temp = (int) $this->payload['ambient_temp'];
|
||||
} elseif (isset($this->payload['raw_data'])) {
|
||||
$temp = (int) $this->payload['raw_data'];
|
||||
}
|
||||
|
||||
if ($temp === null) {
|
||||
Log::warning("MQTT Ambient Temp: Missing temperature field in payload", [
|
||||
'serial_no' => $this->serialNo,
|
||||
'payload' => $this->payload
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
Log::debug("ProcessAmbientTemp: Ambient temperature reported for {$this->serialNo}: {$temp}");
|
||||
|
||||
$updateData = [
|
||||
'ambient_temperature' => $temp,
|
||||
];
|
||||
|
||||
// 讀取最後一次記錄日誌的溫度與時間,避免頻繁寫入相同的溫度日誌
|
||||
$tempLogCacheKey = "machine:{$this->serialNo}:last_ambient_temp_log";
|
||||
$lastLogEntry = Cache::get($tempLogCacheKey);
|
||||
|
||||
// 優先從快取取值,若無則從資料庫取
|
||||
$oldTemp = isset($lastLogEntry['value']) ? (int)$lastLogEntry['value'] : $machine->ambient_temperature;
|
||||
|
||||
if ($temp !== (int)$oldTemp) {
|
||||
Log::debug("ProcessAmbientTemp: Ambient temperature changed from {$oldTemp} to {$temp}. Triggering log.");
|
||||
\App\Jobs\Machine\ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
$machine->company_id,
|
||||
"Ambient temperature reported: :temp°C",
|
||||
'info',
|
||||
['temp' => $temp],
|
||||
'ambient_temp' // type 欄位
|
||||
);
|
||||
}
|
||||
|
||||
// 更新快取 (存活時間為 7 天)
|
||||
Cache::put($tempLogCacheKey, [
|
||||
'value' => $temp,
|
||||
'at' => now()->toDateTimeString()
|
||||
], 604800);
|
||||
|
||||
$machine->update($updateData);
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,9 @@
|
||||
namespace App\Jobs\Machine;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MachineStockMovement;
|
||||
use App\Models\Machine\RemoteCommand;
|
||||
use App\Services\Machine\MachineService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -34,7 +36,7 @@ class ProcessCommandAck implements ShouldQueue
|
||||
* 處理 B055 出貨回報 ACK
|
||||
* 原始邏輯移植自 MachineController::reportDispenseResult()
|
||||
*/
|
||||
public function handle(): void
|
||||
public function handle(MachineService $machineService): void
|
||||
{
|
||||
$commandId = $this->payload['command_id'] ?? null;
|
||||
$resultCode = $this->payload['result'] ?? null;
|
||||
@ -77,6 +79,19 @@ class ProcessCommandAck implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
// OTA 更新例外處理:APK 下載/安裝為長時程作業,機台會持續回報「進度」ACK
|
||||
// (result="progress",如「Downloading 45%」「Installing...」)。這些屬於過程回報,
|
||||
// 不可視為終態,否則指令會在「剛開始下載」就被標記完成,與機台實際版本不符。
|
||||
// 僅將進度文字寫入 note 供後台檢視,並保持 pending,等待最終 success/failed。
|
||||
if ($command->command_type === 'update_app' && $resultCode === 'progress') {
|
||||
$command->update(['note' => $this->payload['message'] ?? $command->note]);
|
||||
Log::info('MQTT CommandAck: update_app progress (kept pending)', [
|
||||
'command_id' => $commandId,
|
||||
'message' => $this->payload['message'] ?? null,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 判定執行結果 (result: "success" 或 "0" 代表成功)
|
||||
$status = in_array($resultCode, ['success', '0'], true) ? 'success' : 'failed';
|
||||
|
||||
@ -88,41 +103,20 @@ class ProcessCommandAck implements ShouldQueue
|
||||
$machine = $command->machine;
|
||||
|
||||
if ($command->command_type === 'dispense') {
|
||||
$slotNo = $command->payload['slot_no'] ?? null;
|
||||
$slot = $machine->slots()->where('slot_no', $slotNo)->first();
|
||||
// 遠端出貨指令僅紀錄狀態,不再執行庫存預扣或回滾。
|
||||
// 真正的庫存異動將由機台發送的 Transaction Finalize (結案) 流程處理。
|
||||
$msgKey = ($status === 'success') ? "Remote dispense successful for slot :slot" : "Remote dispense failed for slot :slot";
|
||||
$level = ($status === 'success') ? 'info' : 'error';
|
||||
|
||||
if ($slot) {
|
||||
if ($status === 'success') {
|
||||
// 若出貨成功,且 APP 回報了最新的庫存值,則以 APP 為準進行校準
|
||||
// 否則維持預扣後的狀態 (不需要額外動作,因為下發時已扣除)
|
||||
if ($stockReported !== null) {
|
||||
$slot->update(['stock' => (int)$stockReported]);
|
||||
$payloadUpdates['new_stock'] = (int)$stockReported;
|
||||
}
|
||||
|
||||
ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
$machine->company_id,
|
||||
"Remote dispense successful for slot :slot",
|
||||
'info',
|
||||
['slot' => $slotNo]
|
||||
);
|
||||
} else {
|
||||
// 若出貨失敗,執行回滾 (Rollback):將預扣的庫存加回去
|
||||
$oldStock = $command->payload['old_stock'] ?? $slot->stock;
|
||||
$slot->update(['stock' => $oldStock]);
|
||||
|
||||
$payloadUpdates['rolled_back'] = true;
|
||||
$payloadUpdates['new_stock'] = $oldStock;
|
||||
|
||||
Log::warning('MQTT Dispense failed, rolled back inventory', [
|
||||
'serial_no' => $this->serialNo,
|
||||
'slot_no' => $slotNo,
|
||||
'rolled_back_to' => $oldStock
|
||||
]);
|
||||
}
|
||||
}
|
||||
ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
$machine->company_id,
|
||||
$msgKey,
|
||||
$level,
|
||||
['slot' => $command->payload['slot_no'] ?? 'N/A']
|
||||
);
|
||||
}
|
||||
|
||||
elseif ($command->command_type === 'reload_stock') {
|
||||
// 若同步失敗,自動 Rollback 回滾庫存
|
||||
if ($status === 'failed' && isset($command->payload['slot_no'], $command->payload['old'])) {
|
||||
@ -152,6 +146,20 @@ class ProcessCommandAck implements ShouldQueue
|
||||
'payload' => array_merge($command->payload, $payloadUpdates),
|
||||
]);
|
||||
|
||||
// 記錄維護類指令到機台日誌 (MachineLog)
|
||||
if (in_array($command->command_type, ['reboot', 'reboot_force', 'reboot_card', 'lock', 'unlock', 'checkout', 'change', 'reload_stock', 'update_products', 'update_ads', 'update_settings'], true)) {
|
||||
$msgKey = $status === 'success' ? 'log.command.success' : 'log.command.failed';
|
||||
$level = $status === 'success' ? 'info' : 'warning';
|
||||
|
||||
ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
$machine->company_id,
|
||||
$msgKey,
|
||||
$level,
|
||||
['type' => __("command.{$command->command_type}")]
|
||||
);
|
||||
}
|
||||
|
||||
Log::info("MQTT CommandAck processed", [
|
||||
'serial_no' => $this->serialNo,
|
||||
'command_id' => $commandId,
|
||||
|
||||
@ -48,25 +48,40 @@ class ProcessHeartbeat implements ShouldQueue
|
||||
// === 狀態異動觸發 (Redis 快取,讓 MQTT 也支援日誌) ===
|
||||
$cacheKey = "machine:{$this->serialNo}:state";
|
||||
$oldState = \Illuminate\Support\Facades\Cache::get($cacheKey);
|
||||
// 1. 偵測狀態由離線轉在線 (寫入日誌)
|
||||
if ($machine->status === 'offline') {
|
||||
\App\Jobs\Machine\ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
$machine->company_id,
|
||||
"Machine is online",
|
||||
'info',
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
$newState = $oldState ?? [];
|
||||
|
||||
// 1. 韌體版本比對 (有改才傳,有改才記)
|
||||
// 2. 韌體版本比對 (有改才傳,有改才記)
|
||||
if (isset($this->payload['firmware_version'])) {
|
||||
$fv = (string) $this->payload['firmware_version'];
|
||||
if (!isset($oldState['firmware_version']) || (string)$oldState['firmware_version'] !== $fv) {
|
||||
|
||||
// 優先從快取取舊版本,若無則從資料庫取
|
||||
$oldVersion = $oldState['firmware_version'] ?? $machine->firmware_version;
|
||||
|
||||
if ((string)$oldVersion !== $fv) {
|
||||
\App\Jobs\Machine\ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
$machine->company_id,
|
||||
"Firmware updated to :version",
|
||||
'info',
|
||||
['version' => $fv, 'old' => $oldState['firmware_version'] ?? 'Unknown']
|
||||
['version' => $fv, 'old' => $oldVersion ?? 'Unknown']
|
||||
);
|
||||
}
|
||||
$updateData['firmware_version'] = $fv;
|
||||
$newState['firmware_version'] = $fv;
|
||||
}
|
||||
|
||||
// 2. 溫度更新 (只要有變動就紀錄日誌)
|
||||
// 2. 溫度更新 (只要有變動就紀錄日誌,並進行溫度監控告警判定)
|
||||
if (isset($this->payload['temperature'])) {
|
||||
$temp = (int) $this->payload['temperature'];
|
||||
Log::debug("ProcessHeartbeat: Temperature reported for {$this->serialNo}: {$temp}");
|
||||
@ -76,38 +91,147 @@ class ProcessHeartbeat implements ShouldQueue
|
||||
$tempLogCacheKey = "machine:{$this->serialNo}:last_temp_log";
|
||||
$lastLogEntry = \Illuminate\Support\Facades\Cache::get($tempLogCacheKey);
|
||||
|
||||
$shouldLog = false;
|
||||
if (!$lastLogEntry) {
|
||||
$shouldLog = true;
|
||||
} else {
|
||||
$lastValue = (int) $lastLogEntry['value'];
|
||||
$lastAt = \Carbon\Carbon::parse($lastLogEntry['at']);
|
||||
// 統一邏輯:優先從快取取值,若無則從資料庫取
|
||||
$oldTemp = isset($lastLogEntry['value']) ? (int)$lastLogEntry['value'] : $machine->temperature;
|
||||
|
||||
// 條件 A: 只要溫度與上次記錄不同就記錄 (整數比對)
|
||||
if ($temp !== $lastValue) {
|
||||
Log::debug("ProcessHeartbeat: Temperature changed from {$lastValue} to {$temp}. Triggering log.");
|
||||
$shouldLog = true;
|
||||
}
|
||||
// 條件 B: 距離上次日誌超過 4 小時 (保底機制)
|
||||
if ($lastAt->diffInHours(now()) >= 4) {
|
||||
$shouldLog = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($shouldLog) {
|
||||
if ($temp !== (int)$oldTemp) {
|
||||
Log::debug("ProcessHeartbeat: Temperature changed from {$oldTemp} to {$temp}. Triggering ordinary log.");
|
||||
\App\Jobs\Machine\ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
$machine->company_id,
|
||||
"Temperature reported: :temp°C",
|
||||
'info',
|
||||
['temp' => $temp]
|
||||
['temp' => $temp],
|
||||
'status' // 普通狀態日誌,不發送 Discord
|
||||
);
|
||||
}
|
||||
|
||||
// 更新快取,保留 7 天
|
||||
\Illuminate\Support\Facades\Cache::put($tempLogCacheKey, [
|
||||
'value' => $temp,
|
||||
'at' => now()->toDateTimeString()
|
||||
], 604800);
|
||||
// 無論是否有變動,都更新/延長快取壽命,確保快取資料存在
|
||||
\Illuminate\Support\Facades\Cache::put($tempLogCacheKey, [
|
||||
'value' => $temp,
|
||||
'at' => now()->toDateTimeString()
|
||||
], 604800);
|
||||
|
||||
// === 溫度自適應告警判定 ===
|
||||
$tempAlertEnabled = false;
|
||||
$machineSettings = $machine->settings ?? [];
|
||||
$company = $machine->company;
|
||||
$companySettings = $company ? ($company->settings ?? []) : [];
|
||||
|
||||
// 1. 決定是否啟用溫度監控
|
||||
$enabledSetting = $machineSettings['temp_alert_enabled'] ?? 'inherit';
|
||||
if ($enabledSetting === 'enabled') {
|
||||
$tempAlertEnabled = true;
|
||||
} elseif ($enabledSetting === 'disabled') {
|
||||
$tempAlertEnabled = false;
|
||||
} else {
|
||||
// inherit 或是沒設定,繼承公司設定
|
||||
$companyEnabled = $companySettings['discord_notify_enabled'] ?? false;
|
||||
$companyTypes = $companySettings['discord_notify_types'] ?? [];
|
||||
if ($companyEnabled && in_array('temperature', $companyTypes)) {
|
||||
$tempAlertEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($tempAlertEnabled) {
|
||||
$upperLimit = null;
|
||||
$lowerLimit = null;
|
||||
|
||||
// 優先度 1: 機台自訂
|
||||
if (isset($machineSettings['temp_upper_limit']) && $machineSettings['temp_upper_limit'] !== '') {
|
||||
$upperLimit = (int)$machineSettings['temp_upper_limit'];
|
||||
}
|
||||
if (isset($machineSettings['temp_lower_limit']) && $machineSettings['temp_lower_limit'] !== '') {
|
||||
$lowerLimit = (int)$machineSettings['temp_lower_limit'];
|
||||
}
|
||||
|
||||
// 優先度 2: 型號預設
|
||||
$model = $machine->machineModel;
|
||||
if ($model) {
|
||||
$modelSettings = $model->settings ?? [];
|
||||
if ($upperLimit === null && isset($modelSettings['temp_upper_limit']) && $modelSettings['temp_upper_limit'] !== '') {
|
||||
$upperLimit = (int)$modelSettings['temp_upper_limit'];
|
||||
}
|
||||
if ($lowerLimit === null && isset($modelSettings['temp_lower_limit']) && $modelSettings['temp_lower_limit'] !== '') {
|
||||
$lowerLimit = (int)$modelSettings['temp_lower_limit'];
|
||||
}
|
||||
}
|
||||
|
||||
// 優先度 3: 公司預設
|
||||
if ($upperLimit === null && isset($companySettings['temp_upper_limit']) && $companySettings['temp_upper_limit'] !== '') {
|
||||
$upperLimit = (int)$companySettings['temp_upper_limit'];
|
||||
}
|
||||
if ($lowerLimit === null && isset($companySettings['temp_lower_limit']) && $companySettings['temp_lower_limit'] !== '') {
|
||||
$lowerLimit = (int)$companySettings['temp_lower_limit'];
|
||||
}
|
||||
|
||||
// 全域預設值 fallback
|
||||
if ($upperLimit === null) {
|
||||
$upperLimit = 40;
|
||||
}
|
||||
if ($lowerLimit === null) {
|
||||
$lowerLimit = 0;
|
||||
}
|
||||
|
||||
$activeKey = "machine:{$this->serialNo}:temp_alert_active";
|
||||
$cooldownKey = "machine:{$this->serialNo}:temp_alert_cooldown";
|
||||
$activeStatus = \Illuminate\Support\Facades\Cache::get($activeKey);
|
||||
|
||||
if ($temp > $upperLimit) {
|
||||
// 若從低溫直接跳到高溫,清除低溫告警冷卻快取,但不發送恢復通知
|
||||
if ($activeStatus === 'low') {
|
||||
\Illuminate\Support\Facades\Cache::forget($cooldownKey);
|
||||
}
|
||||
|
||||
$hasCooldown = \Illuminate\Support\Facades\Cache::has($cooldownKey);
|
||||
if ($activeStatus !== 'high' || !$hasCooldown) {
|
||||
\App\Jobs\Machine\ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
$machine->company_id,
|
||||
"Temperature too high: :temp°C",
|
||||
'error',
|
||||
['temp' => $temp, 'limit' => $upperLimit],
|
||||
'temperature'
|
||||
);
|
||||
|
||||
\Illuminate\Support\Facades\Cache::put($cooldownKey, true, 3600);
|
||||
\Illuminate\Support\Facades\Cache::put($activeKey, 'high', 86400);
|
||||
}
|
||||
} elseif ($temp < $lowerLimit) {
|
||||
// 若從高溫直接跳到低溫,清除高溫告警冷卻快取,但不發送恢復通知
|
||||
if ($activeStatus === 'high') {
|
||||
\Illuminate\Support\Facades\Cache::forget($cooldownKey);
|
||||
}
|
||||
|
||||
$hasCooldown = \Illuminate\Support\Facades\Cache::has($cooldownKey);
|
||||
if ($activeStatus !== 'low' || !$hasCooldown) {
|
||||
\App\Jobs\Machine\ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
$machine->company_id,
|
||||
"Temperature too low: :temp°C",
|
||||
'error',
|
||||
['temp' => $temp, 'limit' => $lowerLimit],
|
||||
'temperature'
|
||||
);
|
||||
|
||||
\Illuminate\Support\Facades\Cache::put($cooldownKey, true, 3600);
|
||||
\Illuminate\Support\Facades\Cache::put($activeKey, 'low', 86400);
|
||||
}
|
||||
} else {
|
||||
// 溫度正常
|
||||
if ($activeStatus) {
|
||||
\App\Jobs\Machine\ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
$machine->company_id,
|
||||
"Temperature restored: :temp°C",
|
||||
'info',
|
||||
['temp' => $temp],
|
||||
'temperature'
|
||||
);
|
||||
\Illuminate\Support\Facades\Cache::forget($activeKey);
|
||||
\Illuminate\Support\Facades\Cache::forget($cooldownKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
59
app/Jobs/Machine/ProcessMachineEvent.php
Normal file
59
app/Jobs/Machine/ProcessMachineEvent.php
Normal file
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Machine;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProcessMachineEvent implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
protected $serialNo;
|
||||
protected $payload;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct($serialNo, $payload)
|
||||
{
|
||||
$this->serialNo = $serialNo;
|
||||
$this->payload = $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$machine = Machine::withoutGlobalScopes()->where('serial_no', $this->serialNo)->first();
|
||||
|
||||
if (!$machine) {
|
||||
Log::warning("ProcessMachineEvent: Machine not found [{$this->serialNo}]");
|
||||
return;
|
||||
}
|
||||
|
||||
$event = $this->payload['event'] ?? null;
|
||||
|
||||
if (!$event) {
|
||||
Log::warning("ProcessMachineEvent: Missing event in payload", ['payload' => $this->payload]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 寫入環境溫度日誌,type 為 'ambient_temp' 以便正確歸類顯示於「環境溫度回傳」日誌中
|
||||
$machine->logs()->create([
|
||||
'company_id' => $machine->company_id,
|
||||
'type' => 'ambient_temp', // 歸類至環境溫度回傳
|
||||
'level' => 'info',
|
||||
'message' => $event, // 例如 "fanon" 或 "fanoff"
|
||||
'context' => $this->payload,
|
||||
]);
|
||||
|
||||
Log::info("ProcessMachineEvent: Machine [{$machine->serial_no}] recorded event [{$event}] to status logs");
|
||||
}
|
||||
}
|
||||
@ -23,9 +23,7 @@ class ProcessStatus implements ShouldQueue
|
||||
*/
|
||||
public function __construct($serialNo, $payload)
|
||||
{
|
||||
// 防止 Client ID 帶有 SC_ 前綴或隨機碼 (如 SC_M0408_test)
|
||||
$cleanSerial = preg_replace('/^SC_/', '', $serialNo);
|
||||
$this->serialNo = explode('_', $cleanSerial)[0];
|
||||
$this->serialNo = $serialNo;
|
||||
$this->payload = $payload;
|
||||
}
|
||||
|
||||
@ -54,16 +52,30 @@ class ProcessStatus implements ShouldQueue
|
||||
$updateData = ['status' => $status];
|
||||
if ($status === 'online') {
|
||||
$updateData['last_heartbeat_at'] = now();
|
||||
|
||||
// 自動消警:當機台恢復連線時,自動標記之前的「斷線 (LWT)」警告為已解決
|
||||
$machine->logs()
|
||||
->where('type', 'status')
|
||||
->where('level', 'warning')
|
||||
->where('message', 'Connection lost (LWT)')
|
||||
->where('is_resolved', false)
|
||||
->update(['is_resolved' => true]);
|
||||
}
|
||||
|
||||
$machine->update($updateData);
|
||||
|
||||
// If status changed, log it
|
||||
if ($oldStatus !== $status) {
|
||||
$level = ($status === 'offline') ? 'warning' : 'info';
|
||||
$message = ($status === 'offline')
|
||||
? "Connection lost (LWT)"
|
||||
: "Connection restored";
|
||||
$level = 'info';
|
||||
$message = "Connection restored";
|
||||
|
||||
if ($status === 'offline') {
|
||||
$level = 'warning';
|
||||
$message = "Connection lost (LWT)";
|
||||
} elseif ($status === 'restarting') {
|
||||
$level = 'info';
|
||||
$message = "System restarting";
|
||||
}
|
||||
|
||||
ProcessStateLog::dispatch($machine->id, $machine->company_id, $message, $level);
|
||||
|
||||
|
||||
@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Machine;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\TimerStatus;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProcessTimerStatus implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
protected $serialNo;
|
||||
protected $data;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(string $serialNo, array $data)
|
||||
{
|
||||
$this->serialNo = $serialNo;
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
$machine = Machine::where('serial_no', $this->serialNo)->firstOrFail();
|
||||
|
||||
TimerStatus::updateOrCreate(
|
||||
['machine_id' => $machine->id, 'slot_no' => $this->data['slot_no']],
|
||||
[
|
||||
'status' => $this->data['status'],
|
||||
'remaining_seconds' => $this->data['remaining_seconds'],
|
||||
'end_at' => isset($this->data['end_at']) ? \Carbon\Carbon::parse($this->data['end_at']) : null,
|
||||
]
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to process timer status for machine {$this->serialNo}: " . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -29,7 +29,7 @@ class ProcessTransaction implements ShouldQueue
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(\App\Services\Transaction\TransactionService $transactionService): void
|
||||
public function handle(\App\Services\Transaction\TransactionService $transactionService, \App\Services\Machine\MqttService $mqttService): void
|
||||
{
|
||||
$machine = Machine::withoutGlobalScopes()->where('serial_no', $this->serialNo)->first();
|
||||
|
||||
@ -38,6 +38,8 @@ class ProcessTransaction implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
$flowId = $this->payload['flow_id'] ?? 'N/A';
|
||||
|
||||
try {
|
||||
// 將 serial_no 放入 payload 供 Service 使用
|
||||
$data = $this->payload;
|
||||
@ -50,11 +52,30 @@ class ProcessTransaction implements ShouldQueue
|
||||
$machine->company_id,
|
||||
"Transaction processed: :id",
|
||||
'info',
|
||||
array_merge($this->payload, ['id' => ($this->payload['flow_id'] ?? 'N/A')]),
|
||||
array_merge($this->payload, ['id' => $flowId]),
|
||||
'transaction'
|
||||
);
|
||||
|
||||
// 發送成功回饋 (ACK)
|
||||
$mqttService->pushCommand($this->serialNo, 'transaction_ack', [
|
||||
'flow_id' => $flowId,
|
||||
'status' => 'success',
|
||||
'message' => 'Processed successfully'
|
||||
], (string) $flowId);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to process MQTT transaction for machine {$this->serialNo}: " . $e->getMessage());
|
||||
Log::error("Failed to process MQTT transaction for machine {$this->serialNo}: " . $e->getMessage(), [
|
||||
'payload' => $this->payload,
|
||||
'exception' => $e
|
||||
]);
|
||||
|
||||
// 發送失敗回饋 (ACK)
|
||||
$mqttService->pushCommand($this->serialNo, 'transaction_ack', [
|
||||
'flow_id' => $flowId,
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage()
|
||||
], (string) $flowId);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
84
app/Jobs/Notification/SendDiscordNotificationJob.php
Normal file
84
app/Jobs/Notification/SendDiscordNotificationJob.php
Normal file
@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Notification;
|
||||
|
||||
use App\Models\Machine\MachineLog;
|
||||
use App\Services\Notification\DiscordWebhookService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SendDiscordNotificationJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* 任務最大重試次數
|
||||
*/
|
||||
public int $tries = 3;
|
||||
|
||||
protected int $logId;
|
||||
|
||||
/**
|
||||
* 建立新任務實例
|
||||
*/
|
||||
public function __construct(int $logId)
|
||||
{
|
||||
$this->logId = $logId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 執行任務
|
||||
*/
|
||||
public function handle(DiscordWebhookService $service): void
|
||||
{
|
||||
$log = MachineLog::find($this->logId);
|
||||
|
||||
if (!$log) {
|
||||
Log::warning("SendDiscordNotificationJob: MachineLog [{$this->logId}] not found. Skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
$machine = $log->machine;
|
||||
if (!$machine || !$machine->company) {
|
||||
return;
|
||||
}
|
||||
|
||||
$company = $machine->company;
|
||||
$companySettings = $company->settings ?? [];
|
||||
|
||||
// 驗證是否啟用 Discord 通知
|
||||
if (!($companySettings['discord_notify_enabled'] ?? false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 優先使用機台專屬 Webhook URL,其次為全域設定
|
||||
$machineSettings = $machine->settings ?? [];
|
||||
$webhookUrl = $machineSettings['discord_webhook_url'] ?? $companySettings['discord_webhook_url'] ?? null;
|
||||
if (!$webhookUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 僅針對類型進行過濾,不再進行重要級別 (Levels) 的篩選
|
||||
$allowedTypes = $companySettings['discord_notify_types'] ?? [];
|
||||
|
||||
if (!in_array($log->type, $allowedTypes)) {
|
||||
Log::info("SendDiscordNotificationJob: Log [{$this->logId}] filtered by type settings.", [
|
||||
'log_type' => $log->type,
|
||||
'allowed_types' => $allowedTypes
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 發送告警通知
|
||||
$success = $service->sendMachineLogAlert($webhookUrl, $log);
|
||||
|
||||
if (!$success) {
|
||||
// 拋出 Exception 以便 Laravel Queue 機制自動重試
|
||||
throw new \RuntimeException("Failed to deliver Discord notification for Log ID: {$log->id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
142
app/Jobs/Product/ProcessProductImportJob.php
Normal file
142
app/Jobs/Product/ProcessProductImportJob.php
Normal file
@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Product;
|
||||
|
||||
use App\Models\Product\Product;
|
||||
use App\Models\System\SystemOperationLog;
|
||||
use App\Services\Product\ProductCatalogService;
|
||||
use App\Services\ProductImportService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 全背景處理商品匯入:解壓圖片壓縮檔、匯入商品(含圖片轉 WebP 入庫)、重建目錄快取、寫入結果摘要日誌、清除暫存。
|
||||
* Controller 僅負責存檔並派發本 Job,請求立即回應,避免大批匯入受 Cloudflare 100 秒等請求逾時限制。
|
||||
*/
|
||||
class ProcessProductImportJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 1800; // 30 分鐘,容納大批商品與圖片轉檔
|
||||
public int $tries = 1; // 不重試整批,避免重複建立商品;結果以日誌呈現
|
||||
|
||||
public function __construct(
|
||||
protected string $baseDir,
|
||||
protected string $xlsxPath,
|
||||
protected ?string $zipPath,
|
||||
protected ?int $companyId = null,
|
||||
protected ?int $userId = null
|
||||
) {}
|
||||
|
||||
public function handle(ProductImportService $importService, ProductCatalogService $catalogService): void
|
||||
{
|
||||
try {
|
||||
$imageDir = ($this->zipPath && is_file($this->zipPath))
|
||||
? $this->extractImageZip($this->zipPath)
|
||||
: null;
|
||||
|
||||
// 背景執行,無請求逾時壓力,圖片同步轉檔即可(deferImages = false)
|
||||
$results = $importService->import($this->xlsxPath, $this->companyId, $this->userId, $imageDir);
|
||||
|
||||
foreach ($results['company_ids'] as $companyId) {
|
||||
$catalogService->rebuildCache($companyId);
|
||||
}
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $this->companyId,
|
||||
'user_id' => $this->userId,
|
||||
'module' => 'product',
|
||||
'action' => 'import',
|
||||
'target_type' => Product::class,
|
||||
'note' => 'product_import_completed',
|
||||
'new_values' => [
|
||||
'success_count' => $results['success_count'],
|
||||
'created_count' => $results['created_count'],
|
||||
'updated_count' => $results['updated_count'],
|
||||
'error_count' => $results['error_count'],
|
||||
'image_success' => $results['image_success'],
|
||||
'image_missing' => count($results['image_missing']),
|
||||
'errors' => array_slice($results['errors'], 0, 50),
|
||||
'image_missing_detail' => array_slice($results['image_missing'], 0, 50),
|
||||
],
|
||||
]);
|
||||
} finally {
|
||||
if (is_dir($this->baseDir)) {
|
||||
File::deleteDirectory($this->baseDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 將圖片壓縮檔安全解壓到 baseDir/images,回傳目錄路徑。
|
||||
* 防護:副檔名白名單、basename 防穿越、項目數/單檔/總量上限 (防 zip bomb)、內容驗證為圖片。
|
||||
*/
|
||||
private function extractImageZip(string $zipPath): string
|
||||
{
|
||||
$allowedExt = ['jpg', 'jpeg', 'png', 'webp'];
|
||||
$maxEntries = 2000;
|
||||
$maxFileBytes = 5 * 1024 * 1024;
|
||||
$maxTotalBytes = 200 * 1024 * 1024;
|
||||
|
||||
$dir = rtrim($this->baseDir, '/') . '/images';
|
||||
File::ensureDirectoryExists($dir);
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($zipPath) !== true) {
|
||||
throw new \RuntimeException(__('Unable to read the image archive'));
|
||||
}
|
||||
|
||||
if ($zip->numFiles > $maxEntries) {
|
||||
$zip->close();
|
||||
throw new \RuntimeException(__('The image archive contains too many files'));
|
||||
}
|
||||
|
||||
$totalBytes = 0;
|
||||
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$stat = $zip->statIndex($i);
|
||||
$entryName = $stat['name'];
|
||||
|
||||
if (str_ends_with($entryName, '/') || str_contains($entryName, '..')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$base = basename($entryName);
|
||||
$ext = strtolower(pathinfo($base, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, $allowedExt, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($stat['size'] > $maxFileBytes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$totalBytes += $stat['size'];
|
||||
if ($totalBytes > $maxTotalBytes) {
|
||||
$zip->close();
|
||||
throw new \RuntimeException(__('The image archive is too large when extracted'));
|
||||
}
|
||||
|
||||
$stream = $zip->getStream($entryName);
|
||||
if ($stream === false) {
|
||||
continue;
|
||||
}
|
||||
$contents = stream_get_contents($stream);
|
||||
fclose($stream);
|
||||
|
||||
if ($contents === false || getimagesizefromstring($contents) === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
file_put_contents($dir . '/' . $base, $contents);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $dir;
|
||||
}
|
||||
}
|
||||
76
app/Jobs/Product/SendProductSyncCommandJob.php
Normal file
76
app/Jobs/Product/SendProductSyncCommandJob.php
Normal file
@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Product;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\RemoteCommand;
|
||||
use App\Services\Machine\MqttService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SendProductSyncCommandJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @param int $machineId
|
||||
* @param string|null $remark
|
||||
* @param int|null $userId
|
||||
*/
|
||||
public function __construct(
|
||||
protected int $machineId,
|
||||
protected ?string $remark = null,
|
||||
protected ?int $userId = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @param MqttService $mqttService
|
||||
* @return void
|
||||
*/
|
||||
public function handle(MqttService $mqttService): void
|
||||
{
|
||||
$machine = Machine::find($this->machineId);
|
||||
|
||||
if (!$machine) {
|
||||
Log::error("SendProductSyncCommandJob: Machine not found", ['id' => $this->machineId]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 覆蓋任何該機台尚在「待處理」狀態的商品同步指令
|
||||
RemoteCommand::where('machine_id', $machine->id)
|
||||
->where('command_type', 'update_products')
|
||||
->where('status', 'pending')
|
||||
->update([
|
||||
'status' => 'superseded',
|
||||
'note' => 'Superseded by new command'
|
||||
]);
|
||||
|
||||
$command = RemoteCommand::create([
|
||||
'machine_id' => $machine->id,
|
||||
'user_id' => $this->userId,
|
||||
'command_type' => 'update_products',
|
||||
'payload' => [], // No extra payload needed for B012 trigger
|
||||
'status' => 'pending',
|
||||
'remark' => $this->remark ?: __('Batch Product Sync'),
|
||||
]);
|
||||
|
||||
$success = $mqttService->pushCommand(
|
||||
$machine->serial_no,
|
||||
'update_products',
|
||||
[],
|
||||
(string) $command->id
|
||||
);
|
||||
|
||||
if (!$success) {
|
||||
$command->update(['status' => 'failed', 'note' => 'MQTT push failed']);
|
||||
}
|
||||
}
|
||||
}
|
||||
52
app/Jobs/Product/SyncProductsToMachinesJob.php
Normal file
52
app/Jobs/Product/SyncProductsToMachinesJob.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Product;
|
||||
|
||||
use App\Models\System\Company;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class SyncProductsToMachinesJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @param int $companyId
|
||||
* @param string|null $remark
|
||||
* @param int|null $userId
|
||||
*/
|
||||
public function __construct(
|
||||
protected int $companyId,
|
||||
protected ?string $remark = null,
|
||||
protected ?int $userId = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$company = Company::with('machines')->find($this->companyId);
|
||||
|
||||
if (!$company || $company->machines->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$delayMs = 100; // 100ms per machine
|
||||
|
||||
foreach ($company->machines as $index => $machine) {
|
||||
// Calculate delay to avoid thundering herd (100ms, 200ms, 300ms...)
|
||||
$currentDelay = ($index * $delayMs) / 1000;
|
||||
|
||||
SendProductSyncCommandJob::dispatch($machine->id, $this->remark, $this->userId)
|
||||
->delay(now()->addSeconds($currentDelay));
|
||||
}
|
||||
}
|
||||
}
|
||||
103
app/Jobs/Transaction/IssueInvoiceJob.php
Normal file
103
app/Jobs/Transaction/IssueInvoiceJob.php
Normal file
@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Transaction;
|
||||
|
||||
use App\Models\Transaction\Invoice;
|
||||
use App\Services\Invoice\EcpayInvoiceService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 後台開立電子發票(取代機台直連綠界)。
|
||||
*
|
||||
* finalize 建立 pending 發票後派發;以 invoice.relate_number 為冪等鍵向綠界開立(Issue)。
|
||||
* 成功→issued、失敗→failed(待人工/排程補開)。連線失敗則 retry,由佇列重試機制接手;
|
||||
* 仍有殘留 pending 者由 invoices:reconcile(GetIssue)做最終查證,避免重複開立。
|
||||
*/
|
||||
class IssueInvoiceJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
public int $backoff = 30;
|
||||
|
||||
public function __construct(public int $invoiceId)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(EcpayInvoiceService $ecpay): void
|
||||
{
|
||||
$invoice = Invoice::with(['machine.paymentConfig', 'order.items'])->find($this->invoiceId);
|
||||
if (!$invoice) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 僅處理待開立;已開/失敗/作廢不重複觸發(冪等)
|
||||
if ($invoice->status !== Invoice::STATUS_PENDING) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 機台無綠界設定 → 留 pending(理論上不會發生:無設定機台不會帶 invoice)
|
||||
if (!$invoice->machine || !$ecpay->configForMachine($invoice->machine)) {
|
||||
Log::warning('IssueInvoiceJob: machine has no ECPay config, leave pending', [
|
||||
'invoice_id' => $invoice->id,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $ecpay->reissue($invoice);
|
||||
|
||||
// 連線/解密失敗 → 丟例外讓佇列重試(不改狀態)
|
||||
if ($result === null) {
|
||||
$invoice->increment('retry_count');
|
||||
$invoice->update(['last_checked_at' => now()]);
|
||||
throw new \RuntimeException('ECPay issue returned null for invoice ' . $invoice->id);
|
||||
}
|
||||
|
||||
$rtnCode = (string) ($result['RtnCode'] ?? '');
|
||||
$invoiceNo = $result['InvoiceNo'] ?? '';
|
||||
|
||||
if ($rtnCode === '1' && !empty($invoiceNo)) {
|
||||
$invoice->update([
|
||||
'status' => Invoice::STATUS_ISSUED,
|
||||
'invoice_no' => $invoiceNo,
|
||||
'invoice_date' => $this->safeDate($result['InvoiceDate'] ?? null),
|
||||
'random_number' => $result['RandomNumber'] ?? $invoice->random_number,
|
||||
'rtn_code' => $rtnCode,
|
||||
'rtn_msg' => $result['RtnMsg'] ?? null,
|
||||
'last_checked_at' => now(),
|
||||
]);
|
||||
Log::info('Invoice issued by backend', ['invoice_id' => $invoice->id, 'invoice_no' => $invoiceNo]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 綠界明確回失敗 → 標 failed(待補開),不重試
|
||||
$invoice->update([
|
||||
'status' => Invoice::STATUS_FAILED,
|
||||
'rtn_code' => $rtnCode ?: $invoice->rtn_code,
|
||||
'rtn_msg' => $result['RtnMsg'] ?? $invoice->rtn_msg,
|
||||
'last_checked_at' => now(),
|
||||
]);
|
||||
Log::warning('Invoice issue failed', [
|
||||
'invoice_id' => $invoice->id,
|
||||
'rtn_code' => $rtnCode,
|
||||
'rtn_msg' => $result['RtnMsg'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
private function safeDate($raw): ?string
|
||||
{
|
||||
if (empty($raw)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return \Carbon\Carbon::parse($raw)->toDateString();
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -27,12 +27,34 @@ class ProcessDispenseRecord implements ShouldQueue
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(TransactionService $transactionService): void
|
||||
public function handle(\App\Services\Transaction\TransactionService $transactionService, \App\Services\Machine\MqttService $mqttService): void
|
||||
{
|
||||
$serialNo = $this->data['serial_no'] ?? 'unknown';
|
||||
$orderNo = $this->data['order_no'] ?? 'N/A';
|
||||
|
||||
try {
|
||||
$transactionService->recordDispense($this->data);
|
||||
|
||||
// 發送成功回饋 (ACK)
|
||||
$mqttService->pushCommand($serialNo, 'dispense_ack', [
|
||||
'order_no' => $orderNo,
|
||||
'status' => 'success',
|
||||
'message' => 'Processed successfully'
|
||||
], $orderNo);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to record dispense for machine {$this->data['serial_no']}: " . $e->getMessage());
|
||||
Log::error("Failed to record dispense for machine {$serialNo}: " . $e->getMessage(), [
|
||||
'data' => $this->data,
|
||||
'exception' => $e
|
||||
]);
|
||||
|
||||
// 發送失敗回饋 (ACK)
|
||||
$mqttService->pushCommand($serialNo, 'dispense_ack', [
|
||||
'order_no' => $orderNo,
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage()
|
||||
], $orderNo);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,15 +27,34 @@ class ProcessInvoice implements ShouldQueue
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(TransactionService $transactionService): void
|
||||
public function handle(\App\Services\Transaction\TransactionService $transactionService, \App\Services\Machine\MqttService $mqttService): void
|
||||
{
|
||||
$serialNo = $this->data['serial_no'] ?? 'unknown';
|
||||
$invoiceNo = $this->data['invoice_no'] ?? 'N/A';
|
||||
|
||||
try {
|
||||
$transactionService->recordInvoice($this->data);
|
||||
|
||||
// 發送成功回饋 (ACK)
|
||||
$mqttService->pushCommand($serialNo, 'invoice_ack', [
|
||||
'invoice_no' => $invoiceNo,
|
||||
'status' => 'success',
|
||||
'message' => 'Processed successfully'
|
||||
], $invoiceNo);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to process invoice: ' . $e->getMessage(), [
|
||||
'data' => $this->data,
|
||||
'exception' => $e
|
||||
]);
|
||||
|
||||
// 發送失敗回饋 (ACK)
|
||||
$mqttService->pushCommand($serialNo, 'invoice_ack', [
|
||||
'invoice_no' => $invoiceNo,
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage()
|
||||
], $invoiceNo);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
141
app/Jobs/Transaction/ProcessTransactionFinalized.php
Normal file
141
app/Jobs/Transaction/ProcessTransactionFinalized.php
Normal file
@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Transaction;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Services\Transaction\TransactionService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use App\Jobs\Machine\ProcessStateLog;
|
||||
use App\Models\Transaction\Order;
|
||||
use App\Services\Machine\MachineService;
|
||||
|
||||
class ProcessTransactionFinalized implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/** 走實體刷卡機(Nexsys)的支付類型:1 信用卡、2 電子票證、10 手機感應支付,共用同一台機器。 */
|
||||
private const CARD_TERMINAL_PAYMENT_TYPES = [1, 2, 10];
|
||||
|
||||
protected $serialNo;
|
||||
protected $payload;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(string $serialNo, array $payload)
|
||||
{
|
||||
$this->serialNo = $serialNo;
|
||||
$this->payload = $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(\App\Services\Transaction\TransactionService $transactionService, \App\Services\Machine\MqttService $mqttService): void
|
||||
{
|
||||
$machine = Machine::withoutGlobalScopes()->where('serial_no', $this->serialNo)->first();
|
||||
|
||||
if (!$machine) {
|
||||
Log::warning("MQTT Transaction Finalized: Machine not found", ['serial_no' => $this->serialNo]);
|
||||
return;
|
||||
}
|
||||
|
||||
$flowId = $this->payload['order']['flow_id'] ?? ($this->payload['flow_id'] ?? 'N/A');
|
||||
|
||||
try {
|
||||
$data = $this->payload;
|
||||
$data['serial_no'] = $this->serialNo;
|
||||
|
||||
$order = $transactionService->finalizeTransaction($data);
|
||||
|
||||
ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
$machine->company_id,
|
||||
"Transaction finalized: :id",
|
||||
'info',
|
||||
[
|
||||
'id' => $order->order_no,
|
||||
'flow_id' => $order->flow_id
|
||||
],
|
||||
'transaction'
|
||||
);
|
||||
|
||||
// 刷卡機健康狀態同步:成功消警 / 失敗寫 card_terminal 日誌
|
||||
$this->syncCardTerminalStatus($machine, $order);
|
||||
|
||||
Log::info("MQTT Transaction [finalize] processed successfully", [
|
||||
'serial_no' => $this->serialNo,
|
||||
'order_no' => $order->order_no
|
||||
]);
|
||||
|
||||
// 發送成功回饋 (ACK)
|
||||
$mqttService->pushCommand($this->serialNo, 'transaction_ack', [
|
||||
'flow_id' => $flowId,
|
||||
'order_no' => $order->order_no,
|
||||
'status' => 'success',
|
||||
'message' => 'Processed successfully'
|
||||
], (string) $flowId);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to process MQTT transaction finalized for machine {$this->serialNo}: " . $e->getMessage(), [
|
||||
'payload' => $this->payload,
|
||||
'exception' => $e
|
||||
]);
|
||||
|
||||
// 發送失敗回饋 (ACK)
|
||||
$mqttService->pushCommand($this->serialNo, 'transaction_ack', [
|
||||
'flow_id' => $flowId,
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage()
|
||||
], (string) $flowId);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷卡機 (信用卡/電子票證/手機支付,payment_type 1/2/10) 狀態同步。
|
||||
* 刷卡機沒有斷線心跳,所以靠交易結果維護燈號:
|
||||
* - 成功交易 → 自動消除該機台先前未解決的刷卡機警告,恢復正常。
|
||||
* - 失敗交易(payment_response 帶 NFC 回應碼) → 寫一筆 type='card_terminal' 的 warning 日誌
|
||||
* (warning 級依 MachineLog 既有規則不會觸發 Discord)。
|
||||
*/
|
||||
private function syncCardTerminalStatus(Machine $machine, Order $order): void
|
||||
{
|
||||
if (!in_array((int) $order->payment_type, self::CARD_TERMINAL_PAYMENT_TYPES, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 刷卡成功 → 消警,恢復刷卡機燈號
|
||||
if ((int) $order->payment_status === Order::PAYMENT_STATUS_SUCCESS) {
|
||||
$machine->logs()
|
||||
->where('type', 'card_terminal')
|
||||
->where('is_resolved', false)
|
||||
->update(['is_resolved' => true]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 刷卡失敗 → 解析 App 端格式 "[電文] | code=XXXX" 取回應碼
|
||||
if (!preg_match('/\|\s*code=([A-Za-z0-9_]+)/', (string) $order->payment_response, $m)) {
|
||||
return; // 無刷卡機回應碼(例如顧客未感應就放棄),不視為硬體訊號問題
|
||||
}
|
||||
$code = $m[1];
|
||||
if ($code === '0000') {
|
||||
return; // 0000 為成功碼,理論上不會走到失敗分支,保險過濾
|
||||
}
|
||||
|
||||
ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
$machine->company_id,
|
||||
'Card payment failed',
|
||||
'warning',
|
||||
['card_code' => $code, 'order_no' => $order->order_no],
|
||||
'card_terminal'
|
||||
);
|
||||
}
|
||||
}
|
||||
31
app/Models/Machine/ApkVersion.php
Normal file
31
app/Models/Machine/ApkVersion.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Machine;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ApkVersion extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'version_name',
|
||||
'version_code',
|
||||
'flavor',
|
||||
'file_path',
|
||||
'download_url',
|
||||
'release_notes',
|
||||
'is_forced',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_forced' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* 取得最終下載 URL
|
||||
*/
|
||||
public function getUrlAttribute(): string
|
||||
{
|
||||
return $this->download_url ?: Storage::disk('public')->url($this->file_path);
|
||||
}
|
||||
}
|
||||
@ -10,15 +10,14 @@ use App\Traits\TenantScoped;
|
||||
class Machine extends Model
|
||||
{
|
||||
use HasFactory, TenantScoped;
|
||||
use \Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
// 權限隔離:一般帳號登入時只能看到自己被分配的機台
|
||||
static::addGlobalScope('machine_access', function (\Illuminate\Database\Eloquent\Builder $builder) {
|
||||
$user = auth()->user();
|
||||
// 如果是在 Console、或是系統管理員,則不限制 (可看所有機台)
|
||||
if (app()->runningInConsole() || !$user || $user->isSystemAdmin()) {
|
||||
// 如果是在 Console(且非單元測試)、或是系統管理員,則不限制 (可看所有機台)
|
||||
if ((app()->runningInConsole() && !app()->runningUnitTests()) || !$user || $user->isSystemAdmin()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -44,6 +43,25 @@ class Machine extends Model
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($machine->wasChanged([
|
||||
'is_spring_slot_1_10',
|
||||
'is_spring_slot_11_20',
|
||||
'is_spring_slot_21_30',
|
||||
'is_spring_slot_31_40',
|
||||
'is_spring_slot_41_50',
|
||||
'is_spring_slot_51_60',
|
||||
])) {
|
||||
app(\App\Services\Machine\MachineService::class)->syncMachineSlotMaxStock($machine);
|
||||
}
|
||||
|
||||
// 機台轉移公司:不同公司商品 ID 體系不同,舊的機台專屬定價必須清空,
|
||||
// 避免跨租戶價格錯位與資料污染。批次刪除不觸發 model 事件,故補推一次
|
||||
// B012 同步,讓機台重抓新公司目錄與(已清空的)定價。
|
||||
if ($machine->wasChanged('company_id')) {
|
||||
$machine->productPrices()->delete();
|
||||
\App\Jobs\Product\SendProductSyncCommandJob::dispatch($machine->id, __('Machine company changed'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -60,6 +78,9 @@ class Machine extends Model
|
||||
'current_page',
|
||||
'door_status',
|
||||
'temperature',
|
||||
'ambient_temperature',
|
||||
'ambient_temp_setting',
|
||||
'ambient_temp_monitoring_enabled',
|
||||
'firmware_version',
|
||||
'api_token',
|
||||
'last_heartbeat_at',
|
||||
@ -85,12 +106,19 @@ class Machine extends Model
|
||||
'images',
|
||||
'creator_id',
|
||||
'updater_id',
|
||||
'tax_invoice_enabled',
|
||||
'card_terminal_enabled',
|
||||
'scan_pay_esun_enabled',
|
||||
'scan_pay_linepay_enabled',
|
||||
'shopping_cart_enabled',
|
||||
'cash_module_enabled',
|
||||
'settings',
|
||||
];
|
||||
|
||||
protected $appends = ['image_urls', 'calculated_status'];
|
||||
protected $appends = ['image_urls', 'calculated_status', 'hardware_status', 'latest_status_log_time', 'latest_hardware_log_time', 'is_unstable', 'recent_disconnection_count', 'has_login_warning'];
|
||||
|
||||
/**
|
||||
* 動態計算機台當前狀態
|
||||
* 動態計算機台當前狀態 (僅限連線與系統層級)
|
||||
* 優先讀取資料庫 status 欄位
|
||||
* 若在線,則進一步檢查過去 15 分鐘是否為異常
|
||||
*/
|
||||
@ -101,17 +129,150 @@ class Machine extends Model
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
// 判定異常 (檢查過去 15 分鐘內是否有 error 日誌)
|
||||
// 判定異常 (僅檢查 type = 'status' 的未解決日誌)
|
||||
$hasRecentErrors = $this->logs()
|
||||
->where('type', 'status')
|
||||
->where('level', 'error')
|
||||
->where('created_at', '>=', now()->subMinutes(15))
|
||||
->where('is_resolved', false)
|
||||
->exists();
|
||||
|
||||
if ($hasRecentErrors) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
return 'online';
|
||||
// 判定警告 (僅檢查 type = 'status' 的未解決日誌)
|
||||
// 優化:當機台在線時,自動忽略「斷線 (LWT)」警告,因為狀態已恢復
|
||||
$hasRecentWarnings = $this->logs()
|
||||
->where('type', 'status')
|
||||
->where('level', 'warning')
|
||||
->where('is_resolved', false)
|
||||
->where('message', '!=', 'Connection lost (LWT)')
|
||||
->exists();
|
||||
|
||||
if ($hasRecentWarnings) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return $this->status ?? 'online';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定機台目前是否有未解決的登入失敗警告 (需達 3 次未解決警告才亮起)
|
||||
*/
|
||||
public function getHasLoginWarningAttribute(): bool
|
||||
{
|
||||
return $this->logs()
|
||||
->where('type', 'login')
|
||||
->where('level', 'warning')
|
||||
->where('is_resolved', false)
|
||||
->count() >= 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下位機/硬體健康狀態 (基於 type = 'submachine' 日誌)
|
||||
*/
|
||||
public function getHardwareStatusAttribute(): string
|
||||
{
|
||||
// 判定異常
|
||||
$hasErrors = $this->logs()
|
||||
->where('type', 'submachine')
|
||||
->where('level', 'error')
|
||||
->where('is_resolved', false)
|
||||
->exists();
|
||||
|
||||
if ($hasErrors) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
// 判定警告
|
||||
$hasWarnings = $this->logs()
|
||||
->where('type', 'submachine')
|
||||
->where('level', 'warning')
|
||||
->where('is_resolved', false)
|
||||
->exists();
|
||||
|
||||
if ($hasWarnings) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得最新一筆未解決的系統狀態日誌時間
|
||||
*/
|
||||
public function getLatestStatusLogTimeAttribute()
|
||||
{
|
||||
$query = $this->logs()
|
||||
->where('type', 'status')
|
||||
->whereIn('level', ['error', 'warning'])
|
||||
->where('is_resolved', false);
|
||||
|
||||
// 優化:若在線,不顯示已恢復的斷線時間
|
||||
if ($this->status === 'online') {
|
||||
$query->where('message', '!=', 'Connection lost (LWT)');
|
||||
}
|
||||
|
||||
return $query->latest()->first()?->created_at;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得最新一筆未解決的下位機硬體日誌時間
|
||||
*/
|
||||
public function getLatestHardwareLogTimeAttribute()
|
||||
{
|
||||
return $this->logs()
|
||||
->where('type', 'submachine')
|
||||
->whereIn('level', ['error', 'warning'])
|
||||
->where('is_resolved', false)
|
||||
->latest()
|
||||
->first()?->created_at;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷卡機健康狀態 (基於 type = 'card_terminal' 未解決日誌)。
|
||||
* 信用卡/電子票證/手機支付共用同一台實體刷卡機,刷卡失敗會寫此類日誌,
|
||||
* 下一次刷卡成功時由 finalize 自動消警恢復正常。
|
||||
* 列表頁優先使用 MachineController 預載的彙總欄位 (避免 N+1),其餘情境退回即時查詢。
|
||||
*/
|
||||
public function getCardTerminalStatusAttribute(): string
|
||||
{
|
||||
if (array_key_exists('card_terminal_error_count', $this->attributes)
|
||||
|| array_key_exists('card_terminal_warning_count', $this->attributes)) {
|
||||
if (($this->attributes['card_terminal_error_count'] ?? 0) > 0) {
|
||||
return 'error';
|
||||
}
|
||||
if (($this->attributes['card_terminal_warning_count'] ?? 0) > 0) {
|
||||
return 'warning';
|
||||
}
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
if ($this->logs()->where('type', 'card_terminal')->where('level', 'error')->where('is_resolved', false)->exists()) {
|
||||
return 'error';
|
||||
}
|
||||
if ($this->logs()->where('type', 'card_terminal')->where('level', 'warning')->where('is_resolved', false)->exists()) {
|
||||
return 'warning';
|
||||
}
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得最新一筆未解決的刷卡機日誌時間 (列表頁優先用預載 subquery 欄位,避免 N+1)。
|
||||
*/
|
||||
public function getLatestCardTerminalLogTimeAttribute()
|
||||
{
|
||||
if (array_key_exists('latest_card_terminal_log_at', $this->attributes)) {
|
||||
$value = $this->attributes['latest_card_terminal_log_at'];
|
||||
return $value ? \Illuminate\Support\Carbon::parse($value) : null;
|
||||
}
|
||||
|
||||
return $this->logs()
|
||||
->where('type', 'card_terminal')
|
||||
->whereIn('level', ['error', 'warning'])
|
||||
->where('is_resolved', false)
|
||||
->latest()
|
||||
->first()?->created_at;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -130,31 +291,67 @@ class Machine extends Model
|
||||
return $query->where('status', 'offline');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 判定異常 (過去 15 分鐘內有錯誤或警告日誌)
|
||||
*/
|
||||
public function scopeHasError($query)
|
||||
{
|
||||
return $query->whereExists(function ($q) {
|
||||
$q->select(\Illuminate\Support\Facades\DB::raw(1))
|
||||
->from('machine_logs')
|
||||
->whereColumn('machine_logs.machine_id', 'machines.id')
|
||||
->whereIn('level', ['error', 'warning'])
|
||||
->where('created_at', '>=', now()->subMinutes(15));
|
||||
return $query->whereHas('logs', function ($q) {
|
||||
$q->where('is_resolved', false)
|
||||
->where('level', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 判定運行中 (在線且無近期異常)
|
||||
* Scope: 判定是否有任何待處理的異常 (排除掉單純的連線中斷)
|
||||
* 只有當登入失敗次數達 3 次以上時,才列入待處理告警。
|
||||
*/
|
||||
public function scopeHasAnyAlert($query)
|
||||
{
|
||||
return $query->where(function($outer) {
|
||||
// 1. 有任何非登入類型的待處理異常
|
||||
$outer->whereHas('logs', function ($q) {
|
||||
$q->where(function($sub) {
|
||||
$sub->where('is_resolved', false)
|
||||
->orWhereNull('is_resolved');
|
||||
})
|
||||
->whereIn('level', ['error', 'warning'])
|
||||
->where('type', '!=', 'login')
|
||||
->where('message', '!=', 'Connection lost (LWT)');
|
||||
})
|
||||
// 2. 或者有至少 3 筆未解決的登入異常
|
||||
->orWhereHas('logs', function ($q) {
|
||||
$q->where('is_resolved', false)
|
||||
->where('type', 'login')
|
||||
->where('level', 'warning');
|
||||
}, '>=', 3);
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeHasStatusError($query)
|
||||
{
|
||||
return $query->whereHas('logs', function ($q) {
|
||||
$q->where('is_resolved', false)
|
||||
->where('type', 'status')
|
||||
->where('level', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeHasHardwareError($query)
|
||||
{
|
||||
return $query->whereHas('logs', function ($q) {
|
||||
$q->where('is_resolved', false)
|
||||
->where('type', 'submachine')
|
||||
->where('level', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 判定運行中 (在線且無近期系統異常)
|
||||
*/
|
||||
public function scopeRunning($query)
|
||||
{
|
||||
return $query->online()->whereNotExists(function ($q) {
|
||||
$q->select(\Illuminate\Support\Facades\DB::raw(1))
|
||||
->from('machine_logs')
|
||||
->whereColumn('machine_logs.machine_id', 'machines.id')
|
||||
->whereIn('level', ['error', 'warning'])
|
||||
->where('created_at', '>=', now()->subMinutes(15));
|
||||
return $query->online()->whereDoesntHave('logs', function ($q) {
|
||||
$q->where('is_resolved', false)
|
||||
->where('type', 'status')
|
||||
->whereIn('level', ['error', 'warning']);
|
||||
});
|
||||
}
|
||||
|
||||
@ -169,6 +366,9 @@ class Machine extends Model
|
||||
protected $casts = [
|
||||
'last_heartbeat_at' => 'datetime',
|
||||
'temperature' => 'integer',
|
||||
'ambient_temperature' => 'integer',
|
||||
'ambient_temp_setting' => 'integer',
|
||||
'ambient_temp_monitoring_enabled' => 'boolean',
|
||||
'welcome_gift_enabled' => 'boolean',
|
||||
'is_spring_slot_1_10' => 'boolean',
|
||||
'is_spring_slot_11_20' => 'boolean',
|
||||
@ -177,7 +377,14 @@ class Machine extends Model
|
||||
'is_spring_slot_41_50' => 'boolean',
|
||||
'is_spring_slot_51_60' => 'boolean',
|
||||
'member_system_enabled' => 'boolean',
|
||||
'tax_invoice_enabled' => 'boolean',
|
||||
'card_terminal_enabled' => 'boolean',
|
||||
'scan_pay_esun_enabled' => 'boolean',
|
||||
'scan_pay_linepay_enabled' => 'boolean',
|
||||
'shopping_cart_enabled' => 'boolean',
|
||||
'cash_module_enabled' => 'boolean',
|
||||
'images' => 'array',
|
||||
'settings' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
@ -202,6 +409,11 @@ class Machine extends Model
|
||||
return $this->hasMany(MachineSlot::class);
|
||||
}
|
||||
|
||||
public function productPrices()
|
||||
{
|
||||
return $this->hasMany(MachineProductPrice::class);
|
||||
}
|
||||
|
||||
public function commands()
|
||||
{
|
||||
return $this->hasMany(RemoteCommand::class);
|
||||
@ -228,7 +440,7 @@ class Machine extends Model
|
||||
}
|
||||
|
||||
public const PAGE_STATUSES = [
|
||||
'0' => 'Offline',
|
||||
'0' => 'Standby',
|
||||
'1' => 'Home Page',
|
||||
'2' => 'Vending Page',
|
||||
'3' => 'Admin Page',
|
||||
@ -258,8 +470,174 @@ class Machine extends Model
|
||||
return __($label);
|
||||
}
|
||||
|
||||
public function getIsUnstableAttribute(): bool
|
||||
{
|
||||
// 抖動偵測:1 小時內斷線次數 >= 3
|
||||
return $this->recent_disconnection_count >= 3;
|
||||
}
|
||||
|
||||
public function getRecentDisconnectionCountAttribute(): int
|
||||
{
|
||||
return $this->logs()
|
||||
->where('type', 'status')
|
||||
->where('message', 'Connection lost (LWT)')
|
||||
->where('created_at', '>=', now()->subHour())
|
||||
->count();
|
||||
}
|
||||
|
||||
// === settings JSON 重構 Accessors & Mutators ===
|
||||
|
||||
public function getShoppingModeAttribute(): string
|
||||
{
|
||||
return $this->settings['shopping_mode'] ?? 'basic';
|
||||
}
|
||||
|
||||
public function setShoppingModeAttribute($value)
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$settings['shopping_mode'] = (string) $value;
|
||||
$this->settings = $settings;
|
||||
}
|
||||
|
||||
public function getTaxInvoiceEnabledAttribute(): bool
|
||||
{
|
||||
return (bool) ($this->settings['tax_invoice_enabled'] ?? ($this->attributes['tax_invoice_enabled'] ?? false));
|
||||
}
|
||||
|
||||
public function setTaxInvoiceEnabledAttribute($value)
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$settings['tax_invoice_enabled'] = (bool) $value;
|
||||
$this->settings = $settings;
|
||||
$this->attributes['tax_invoice_enabled'] = (bool) $value;
|
||||
}
|
||||
|
||||
public function getCardTerminalEnabledAttribute(): bool
|
||||
{
|
||||
return (bool) ($this->settings['card_terminal_enabled'] ?? ($this->attributes['card_terminal_enabled'] ?? false));
|
||||
}
|
||||
|
||||
public function setCardTerminalEnabledAttribute($value)
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$settings['card_terminal_enabled'] = (bool) $value;
|
||||
$this->settings = $settings;
|
||||
$this->attributes['card_terminal_enabled'] = (bool) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否需要在後台呈現刷卡機狀態燈號與日誌分頁。
|
||||
* 僅「基礎版(shopping_mode=basic)」且「刷卡機支付已啟用」的機台才有意義;
|
||||
* 購物車模式或未啟用刷卡機的機台不顯示。
|
||||
*/
|
||||
public function getShowCardTerminalAttribute(): bool
|
||||
{
|
||||
return $this->shopping_mode === 'basic' && $this->card_terminal_enabled;
|
||||
}
|
||||
|
||||
public function getScanPayEsunEnabledAttribute(): bool
|
||||
{
|
||||
return (bool) ($this->settings['scan_pay_esun_enabled'] ?? ($this->attributes['scan_pay_esun_enabled'] ?? false));
|
||||
}
|
||||
|
||||
public function setScanPayEsunEnabledAttribute($value)
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$settings['scan_pay_esun_enabled'] = (bool) $value;
|
||||
$this->settings = $settings;
|
||||
$this->attributes['scan_pay_esun_enabled'] = (bool) $value;
|
||||
}
|
||||
|
||||
public function getScanPayLinepayEnabledAttribute(): bool
|
||||
{
|
||||
return (bool) ($this->settings['scan_pay_linepay_enabled'] ?? ($this->attributes['scan_pay_linepay_enabled'] ?? false));
|
||||
}
|
||||
|
||||
public function setScanPayLinepayEnabledAttribute($value)
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$settings['scan_pay_linepay_enabled'] = (bool) $value;
|
||||
$this->settings = $settings;
|
||||
$this->attributes['scan_pay_linepay_enabled'] = (bool) $value;
|
||||
}
|
||||
|
||||
public function getShoppingCartEnabledAttribute(): bool
|
||||
{
|
||||
return (bool) ($this->settings['shopping_cart_enabled'] ?? ($this->attributes['shopping_cart_enabled'] ?? false));
|
||||
}
|
||||
|
||||
public function setShoppingCartEnabledAttribute($value)
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$settings['shopping_cart_enabled'] = (bool) $value;
|
||||
$this->settings = $settings;
|
||||
$this->attributes['shopping_cart_enabled'] = (bool) $value;
|
||||
}
|
||||
|
||||
public function getWelcomeGiftEnabledAttribute(): bool
|
||||
{
|
||||
return (bool) ($this->settings['welcome_gift_enabled'] ?? ($this->attributes['welcome_gift_enabled'] ?? false));
|
||||
}
|
||||
|
||||
public function setWelcomeGiftEnabledAttribute($value)
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$settings['welcome_gift_enabled'] = (bool) $value;
|
||||
$this->settings = $settings;
|
||||
$this->attributes['welcome_gift_enabled'] = (bool) $value;
|
||||
}
|
||||
|
||||
public function getCashModuleEnabledAttribute(): bool
|
||||
{
|
||||
return (bool) ($this->settings['cash_module_enabled'] ?? ($this->attributes['cash_module_enabled'] ?? false));
|
||||
}
|
||||
|
||||
public function setCashModuleEnabledAttribute($value)
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$settings['cash_module_enabled'] = (bool) $value;
|
||||
$this->settings = $settings;
|
||||
$this->attributes['cash_module_enabled'] = (bool) $value;
|
||||
}
|
||||
|
||||
public function getMemberSystemEnabledAttribute(): bool
|
||||
{
|
||||
return (bool) ($this->settings['member_system_enabled'] ?? ($this->attributes['member_system_enabled'] ?? false));
|
||||
}
|
||||
|
||||
public function setMemberSystemEnabledAttribute($value)
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$settings['member_system_enabled'] = (bool) $value;
|
||||
$this->settings = $settings;
|
||||
$this->attributes['member_system_enabled'] = (bool) $value;
|
||||
}
|
||||
|
||||
public function getAmbientTempMonitoringEnabledAttribute(): bool
|
||||
{
|
||||
return (bool) ($this->settings['ambient_temp_monitoring_enabled'] ?? ($this->attributes['ambient_temp_monitoring_enabled'] ?? false));
|
||||
}
|
||||
|
||||
public function setAmbientTempMonitoringEnabledAttribute($value)
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$settings['ambient_temp_monitoring_enabled'] = (bool) $value;
|
||||
$this->settings = $settings;
|
||||
$this->attributes['ambient_temp_monitoring_enabled'] = (bool) $value;
|
||||
}
|
||||
|
||||
public function users()
|
||||
{
|
||||
return $this->belongsToMany(\App\Models\System\User::class);
|
||||
}
|
||||
|
||||
public function orders()
|
||||
{
|
||||
return $this->hasMany(\App\Models\Transaction\Order::class);
|
||||
}
|
||||
|
||||
public function stockMovements()
|
||||
{
|
||||
return $this->hasMany(MachineStockMovement::class);
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,68 @@ class MachineLog extends Model
|
||||
|
||||
const UPDATED_AT = null;
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::created(function (MachineLog $log) {
|
||||
// 1. 取得機台與公司,若無公司則直接 return
|
||||
$machine = $log->machine;
|
||||
if (!$machine || !$machine->company) {
|
||||
return;
|
||||
}
|
||||
|
||||
$company = $machine->company;
|
||||
$companySettings = $company->settings ?? [];
|
||||
|
||||
// 必須啟用 Discord 通知功能
|
||||
if (!($companySettings['discord_notify_enabled'] ?? false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 優先讀取機台專屬 Webhook,無則繼承公司全域 Webhook
|
||||
$machineSettings = $machine->settings ?? [];
|
||||
$webhookUrl = $machineSettings['discord_webhook_url'] ?? $companySettings['discord_webhook_url'] ?? null;
|
||||
if (empty($webhookUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 判定是否符合發送條件
|
||||
$shouldNotify = false;
|
||||
|
||||
if ($log->type === 'temperature') {
|
||||
$shouldNotify = true;
|
||||
} elseif ($log->type === 'status') {
|
||||
if ($log->message === 'Connection lost (LWT)') {
|
||||
$shouldNotify = true;
|
||||
} elseif ($log->message === 'Connection restored') {
|
||||
// 尋找此機台前一筆狀態日誌 (ID 比當前小,type 為 status,倒序第一筆)
|
||||
$previousStatus = self::withoutGlobalScopes()
|
||||
->where('machine_id', $log->machine_id)
|
||||
->where('type', 'status')
|
||||
->where('id', '<', $log->id)
|
||||
->orderBy('id', 'desc')
|
||||
->first();
|
||||
|
||||
if ($previousStatus && $previousStatus->message === 'Connection lost (LWT)' && $previousStatus->is_resolved) {
|
||||
// 只有當前一筆是真實 LWT 斷線,且已被消警 (is_resolved = true) 時,才發送恢復通知
|
||||
$shouldNotify = true;
|
||||
}
|
||||
}
|
||||
} elseif ($log->type === 'submachine') {
|
||||
// 下位機日誌僅在 warning 或 error 級別時發送 Discord 告警,info 狀態日誌不發送
|
||||
if (in_array($log->level, ['warning', 'error'])) {
|
||||
$shouldNotify = true;
|
||||
}
|
||||
} elseif ($log->level === 'error') {
|
||||
$shouldNotify = true;
|
||||
}
|
||||
|
||||
// 3. 若符合發送條件,調度非同步任務
|
||||
if ($shouldNotify) {
|
||||
\App\Jobs\Notification\SendDiscordNotificationJob::dispatch($log->id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'machine_id',
|
||||
@ -18,10 +80,12 @@ class MachineLog extends Model
|
||||
'type',
|
||||
'message',
|
||||
'context',
|
||||
'is_resolved',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'context' => 'array',
|
||||
'is_resolved' => 'boolean',
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
@ -35,11 +99,19 @@ class MachineLog extends Model
|
||||
{
|
||||
$context = $this->context;
|
||||
|
||||
// 若 context 中已有翻譯標籤 (B013 封裝),則進行動態重組
|
||||
// 刷卡機回應碼:用目前代碼表重組失敗原因,支援多語系顯示。
|
||||
if (isset($context['card_code'])) {
|
||||
$code = $context['card_code'];
|
||||
$reason = \App\Services\Machine\MachineService::CARD_TERMINAL_CODE_MAP[$code] ?? 'Transaction failed';
|
||||
return __('Card payment failed') . ':' . __($reason) . " (Code: {$code})";
|
||||
}
|
||||
|
||||
// B013 封裝:優先用目前代碼表重組,讓舊日誌也能吃到最新文案。
|
||||
if (isset($context['translated_label'])) {
|
||||
$label = __($context['translated_label']);
|
||||
$code = $context['raw_code'] ?? $context['error_code'] ?? '0000';
|
||||
$mapping = \App\Services\Machine\MachineService::ERROR_CODE_MAP[$code] ?? null;
|
||||
$label = __($mapping['label'] ?? $context['translated_label']);
|
||||
$tid = $context['tid'] ?? null;
|
||||
$code = $context['raw_code'] ?? '0000';
|
||||
|
||||
if ($tid) {
|
||||
return __('Slot') . " {$tid}: {$label} (Code: {$code})";
|
||||
|
||||
@ -13,6 +13,11 @@ class MachineModel extends Model
|
||||
'company_id',
|
||||
'creator_id',
|
||||
'updater_id',
|
||||
'settings',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'settings' => 'array',
|
||||
];
|
||||
|
||||
public function machines()
|
||||
|
||||
58
app/Models/Machine/MachineProductPrice.php
Normal file
58
app/Models/Machine/MachineProductPrice.php
Normal file
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Machine;
|
||||
|
||||
use App\Jobs\Product\SendProductSyncCommandJob;
|
||||
use App\Models\Product\Product;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* 機台專屬定價:特定機台 × 特定商品的價格覆蓋。
|
||||
*
|
||||
* - price / member_price 為 NULL 代表沿用全域 products 價。
|
||||
* - 任何新增/修改/刪除都會推送 B012 同步指令給「該台」機台,使其即時重抓目錄。
|
||||
* 目錄覆蓋於 B012 請求當下即時套用(見 ProductCatalogService::getMachinePayload),
|
||||
* 不進公司目錄快取,故改價不需失效公司快取。
|
||||
*/
|
||||
class MachineProductPrice extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'machine_id',
|
||||
'product_id',
|
||||
'price',
|
||||
'member_price',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:2',
|
||||
'member_price' => 'decimal:2',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saved(fn (self $row) => $row->dispatchSync());
|
||||
static::deleted(fn (self $row) => $row->dispatchSync());
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送 B012 同步指令給該機台,讓它即時重抓最新定價。
|
||||
* 控制台/離線判定沿用 SendProductSyncCommandJob 既有邏輯。
|
||||
*/
|
||||
protected function dispatchSync(): void
|
||||
{
|
||||
SendProductSyncCommandJob::dispatch($this->machine_id, __('Machine pricing updated'));
|
||||
}
|
||||
|
||||
public function machine()
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
@ -20,12 +20,19 @@ class MachineSlot extends Model
|
||||
'expiry_date',
|
||||
'batch_no',
|
||||
'is_active',
|
||||
'is_locked',
|
||||
'last_app_lock_rev',
|
||||
'last_app_expiry_rev',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:2',
|
||||
'last_restocked_at' => 'datetime',
|
||||
'expiry_date' => 'date:Y-m-d',
|
||||
'is_active' => 'boolean',
|
||||
'is_locked' => 'boolean',
|
||||
'last_app_lock_rev' => 'integer',
|
||||
'last_app_expiry_rev' => 'integer',
|
||||
];
|
||||
|
||||
public function machine()
|
||||
|
||||
119
app/Models/Machine/MachineStockMovement.php
Normal file
119
app/Models/Machine/MachineStockMovement.php
Normal file
@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Machine;
|
||||
|
||||
use App\Traits\TenantScoped;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use App\Models\Product\Product;
|
||||
use App\Models\System\User;
|
||||
|
||||
class MachineStockMovement extends Model
|
||||
{
|
||||
use TenantScoped;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'machine_id',
|
||||
'product_id',
|
||||
'slot_no',
|
||||
'type',
|
||||
'quantity',
|
||||
'before_qty',
|
||||
'after_qty',
|
||||
'reference_type',
|
||||
'reference_id',
|
||||
'note',
|
||||
'context',
|
||||
'created_by',
|
||||
];
|
||||
protected $appends = [
|
||||
'translated_note',
|
||||
'translated_type',
|
||||
];
|
||||
|
||||
public function getTranslatedNoteAttribute(): ?string
|
||||
{
|
||||
if (empty($this->note)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return __($this->note, $this->context ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 動態翻譯類型
|
||||
*/
|
||||
public function getTranslatedTypeAttribute(): string
|
||||
{
|
||||
return __("movement.type.{$this->type}");
|
||||
}
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'integer',
|
||||
'before_qty' => 'integer',
|
||||
'after_qty' => 'integer',
|
||||
'context' => 'array',
|
||||
];
|
||||
|
||||
// ─── 異動類型常數 ───
|
||||
|
||||
const TYPE_REPLENISHMENT = 'replenishment'; // 補貨單完成
|
||||
const TYPE_PICKUP = 'pickup'; // 取貨碼核銷
|
||||
const TYPE_REMOTE_DISPENSE = 'remote_dispense'; // 遠端出貨 (B055 樂觀扣除)
|
||||
const TYPE_SALE = 'sale'; // 一般訂單銷售
|
||||
const TYPE_ADJUSTMENT = 'adjustment'; // B009 回報 / 後台手動修改
|
||||
const TYPE_ROLLBACK = 'rollback'; // B055 出貨失敗回退
|
||||
const TYPE_DECOMMISSION = 'decommission'; // B009 全量同步時移除不在清單的貨道
|
||||
|
||||
// ─── 關聯 ───
|
||||
|
||||
public function machine(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class)->withTrashed();
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 多型關聯:補貨單、取貨碼操作記錄、遠端指令等
|
||||
*/
|
||||
public function reference(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
// ─── 輔助方法 ───
|
||||
|
||||
/**
|
||||
* 是否為增加庫存的類型
|
||||
*/
|
||||
public function isInbound(): bool
|
||||
{
|
||||
return in_array($this->type, [
|
||||
self::TYPE_REPLENISHMENT,
|
||||
self::TYPE_ROLLBACK,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否為減少庫存的類型
|
||||
*/
|
||||
public function isOutbound(): bool
|
||||
{
|
||||
return in_array($this->type, [
|
||||
self::TYPE_PICKUP,
|
||||
self::TYPE_REMOTE_DISPENSE,
|
||||
self::TYPE_SALE,
|
||||
]);
|
||||
}
|
||||
}
|
||||
35
app/Models/Machine/OtaSchedule.php
Normal file
35
app/Models/Machine/OtaSchedule.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Machine;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class OtaSchedule extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'apk_version_id',
|
||||
'scheduled_at',
|
||||
'target_type',
|
||||
'target_value',
|
||||
'status',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'scheduled_at' => 'datetime',
|
||||
'target_value' => 'array',
|
||||
];
|
||||
|
||||
public function apkVersion()
|
||||
{
|
||||
return $this->belongsTo(ApkVersion::class);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\User::class);
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,8 @@ class RemoteCommand extends Model
|
||||
'payload',
|
||||
'status',
|
||||
'ttl',
|
||||
'note',
|
||||
'remark',
|
||||
'executed_at',
|
||||
];
|
||||
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Machine;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TimerStatus extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'machine_id',
|
||||
'slot_no',
|
||||
'status',
|
||||
'remaining_seconds',
|
||||
'end_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'end_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function machine()
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
}
|
||||
@ -5,11 +5,21 @@ namespace App\Models\Product;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use App\Services\Machine\MachineService;
|
||||
use App\Traits\TenantScoped;
|
||||
|
||||
class Product extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, TenantScoped;
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::updated(function (Product $product) {
|
||||
if ($product->wasChanged(['track_limit', 'spring_limit'])) {
|
||||
app(MachineService::class)->syncProductSlotMaxStock($product);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include active products.
|
||||
@ -26,6 +36,7 @@ class Product extends Model
|
||||
'name_dictionary_key',
|
||||
'barcode',
|
||||
'spec',
|
||||
'spec_dictionary_key',
|
||||
'manufacturer',
|
||||
'description',
|
||||
'price',
|
||||
@ -58,7 +69,7 @@ class Product extends Model
|
||||
/**
|
||||
* 自動附加到 JSON/陣列輸出的屬性(供 Alpine.js 等前端使用)
|
||||
*/
|
||||
protected $appends = ['localized_name'];
|
||||
protected $appends = ['localized_name', 'localized_spec'];
|
||||
|
||||
/**
|
||||
* 取得當前語系的商品名稱。
|
||||
@ -82,6 +93,26 @@ class Product extends Model
|
||||
return $this->name ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得當前語系的商品規格。
|
||||
* 回退順序:當前語系 → zh_TW → spec 欄位(與名稱同邏輯)
|
||||
*/
|
||||
public function getLocalizedSpecAttribute(): string
|
||||
{
|
||||
if ($this->relationLoaded('specTranslations') && $this->specTranslations->isNotEmpty()) {
|
||||
$locale = app()->getLocale();
|
||||
$translation = $this->specTranslations->firstWhere('locale', $locale);
|
||||
if ($translation) {
|
||||
return $translation->value;
|
||||
}
|
||||
$fallback = $this->specTranslations->firstWhere('locale', 'zh_TW');
|
||||
if ($fallback) {
|
||||
return $fallback->value;
|
||||
}
|
||||
}
|
||||
return $this->spec ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the translations for the product name.
|
||||
*/
|
||||
@ -91,6 +122,15 @@ class Product extends Model
|
||||
->where('group', 'product');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the translations for the product spec.
|
||||
*/
|
||||
public function specTranslations()
|
||||
{
|
||||
return $this->hasMany(\App\Models\System\Translation::class, 'key', 'spec_dictionary_key')
|
||||
->where('group', 'product_spec');
|
||||
}
|
||||
|
||||
/**
|
||||
* 倉庫庫存紀錄
|
||||
*/
|
||||
|
||||
45
app/Models/StaffCard.php
Normal file
45
app/Models/StaffCard.php
Normal file
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\TenantScoped;
|
||||
use App\Models\Machine\Machine;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class StaffCard extends Model
|
||||
{
|
||||
use TenantScoped;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'employee_id',
|
||||
'name',
|
||||
'card_uid',
|
||||
'status',
|
||||
'notes',
|
||||
'last_used_at',
|
||||
'last_machine_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_used_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 取得此卡片的刷卡紀錄
|
||||
*/
|
||||
public function logs(): HasMany
|
||||
{
|
||||
return $this->hasMany(StaffCardLog::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得最後一次使用的機台
|
||||
*/
|
||||
public function lastMachine(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Machine::class, 'last_machine_id');
|
||||
}
|
||||
}
|
||||
75
app/Models/StaffCardLog.php
Normal file
75
app/Models/StaffCardLog.php
Normal file
@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\TenantScoped;
|
||||
use App\Models\Machine\Machine;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class StaffCardLog extends Model
|
||||
{
|
||||
use TenantScoped;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'staff_card_id',
|
||||
'machine_id',
|
||||
'payment_type',
|
||||
'code_id',
|
||||
'order_id',
|
||||
'action',
|
||||
'remark',
|
||||
'raw_data',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'raw_data' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 註冊 Model 事件以自動更新 StaffCard 的最後使用狀態
|
||||
*/
|
||||
protected static function booted()
|
||||
{
|
||||
static::created(function ($log) {
|
||||
if ($log->staff_card_id) {
|
||||
// 使用 withoutGlobalScopes 確保不論在任何 Session Context 下皆能正確寫入
|
||||
$staffCard = StaffCard::withoutGlobalScopes()->find($log->staff_card_id);
|
||||
if ($staffCard) {
|
||||
$staffCard->update([
|
||||
'last_used_at' => $log->created_at ?? now(),
|
||||
'last_machine_id' => $log->machine_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得關聯的員工卡片
|
||||
*/
|
||||
public function staffCard(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StaffCard::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得刷卡的機台
|
||||
*/
|
||||
public function machine(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得關聯的訂單
|
||||
*/
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Transaction\Order::class);
|
||||
}
|
||||
}
|
||||
@ -68,6 +68,14 @@ class Company extends Model
|
||||
return $this->hasMany(Machine::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the products for the company.
|
||||
*/
|
||||
public function products(): HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\Product\Product::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope:僅篩選啟用的公司
|
||||
*/
|
||||
@ -75,4 +83,101 @@ class Company extends Model
|
||||
{
|
||||
return $query->where('status', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 獲取公司自訂 Logo 的完整 URL (Accessor)
|
||||
*/
|
||||
public function getLogoUrlAttribute(): ?string
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$logoPath = $settings['logo_path'] ?? null;
|
||||
|
||||
// 只有在啟用品牌自訂功能且上傳了 Logo 時才使用自訂 Logo
|
||||
if ($this->hasCustomBrandingEnabled() && $logoPath) {
|
||||
return \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath);
|
||||
}
|
||||
|
||||
return asset('starcloud_icon.png');
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查此公司是否啟用且配置了品牌自訂功能
|
||||
*/
|
||||
public function hasCustomBrandingEnabled(): bool
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$enableCustomBranding = filter_var($settings['enable_custom_branding'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$logoPath = $settings['logo_path'] ?? null;
|
||||
|
||||
return $enableCustomBranding && !empty($logoPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得指定公司「所有機台已開語系的聯集」(商品翻譯實際要維護的語系)。
|
||||
*
|
||||
* - 來源:各機台 settings['languages'] 去重後聯集。
|
||||
* - 一律保證含 fallback 語系 (zh_TW),且依系統白名單定義順序排列(穩定、不隨機台增減跳動)。
|
||||
* - 僅回傳仍在白名單內的語系(機台殘留的失效語系自動濾除)。
|
||||
* - 公司沒有任何機台、或機台皆未設語系時:退化為僅 [fallback]。
|
||||
*
|
||||
* 結果快取於 'company_active_locales:{companyId}',於機台語系異動時失效
|
||||
* (見 forgetActiveLocales())。
|
||||
*
|
||||
* @param int|null $companyId null 代表系統層級(無租戶機台),直接回 [fallback]。
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public static function activeLocalesFor(?int $companyId): array
|
||||
{
|
||||
$fallback = config('locales.fallback', 'zh_TW');
|
||||
$whitelist = array_keys(config('locales.supported', [$fallback => $fallback]));
|
||||
|
||||
if ($companyId === null) {
|
||||
return [$fallback];
|
||||
}
|
||||
|
||||
return \Illuminate\Support\Facades\Cache::rememberForever(
|
||||
"company_active_locales:{$companyId}",
|
||||
function () use ($companyId, $fallback, $whitelist) {
|
||||
// 繞過全域範圍:本計算需涵蓋公司全部機台,與登入者可見範圍無關。
|
||||
$langSets = Machine::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->pluck('settings');
|
||||
|
||||
$union = [];
|
||||
foreach ($langSets as $settings) {
|
||||
foreach ((array) ($settings['languages'] ?? []) as $locale) {
|
||||
$union[$locale] = true;
|
||||
}
|
||||
}
|
||||
// 保證含 fallback,並只保留白名單內語系、依白名單順序輸出。
|
||||
$union[$fallback] = true;
|
||||
|
||||
return array_values(array_filter(
|
||||
$whitelist,
|
||||
fn ($locale) => isset($union[$locale])
|
||||
));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 失效指定公司的語系聯集快取(機台 settings.languages 異動後呼叫)。
|
||||
*/
|
||||
public static function forgetActiveLocales(?int $companyId): void
|
||||
{
|
||||
if ($companyId !== null) {
|
||||
\Illuminate\Support\Facades\Cache::forget("company_active_locales:{$companyId}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 實例代理:當前公司的語系聯集。
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public function activeLocales(): array
|
||||
{
|
||||
return static::activeLocalesFor($this->id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -10,11 +10,14 @@ class Role extends SpatieRole
|
||||
'name',
|
||||
'guard_name',
|
||||
'company_id',
|
||||
'created_by',
|
||||
'is_system',
|
||||
'is_company_admin',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_system' => 'boolean',
|
||||
'is_company_admin' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
@ -25,6 +28,14 @@ class Role extends SpatieRole
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立者帳號。
|
||||
*/
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include roles for a specific company or system roles.
|
||||
*/
|
||||
@ -35,4 +46,43 @@ class Role extends SpatieRole
|
||||
->orWhereNull('company_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查詢範圍:限制為指定使用者「可見」的角色(鏡像 User::scopeVisibleTo)。
|
||||
* - 系統管理員:全部
|
||||
* - 主帳號:同公司全部
|
||||
* - 子帳號:僅自己建立的角色
|
||||
*/
|
||||
public function scopeVisibleTo($query, User $viewer)
|
||||
{
|
||||
if ($viewer->isSystemAdmin()) {
|
||||
return $query;
|
||||
}
|
||||
$query->where($this->getTable() . '.company_id', $viewer->company_id);
|
||||
if (!$viewer->is_admin) {
|
||||
$query->where($this->getTable() . '.created_by', $viewer->id);
|
||||
}
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作者是否有權管理(編輯/刪除)此角色。
|
||||
* - 系統管理員:全部
|
||||
* - 跨公司:禁止
|
||||
* - 主帳號:同公司全部
|
||||
* - 子帳號:僅自己建立的角色
|
||||
*/
|
||||
public function canBeManagedBy(User $user): bool
|
||||
{
|
||||
if ($user->isSystemAdmin()) {
|
||||
return true;
|
||||
}
|
||||
if ($this->company_id !== $user->company_id) {
|
||||
return false;
|
||||
}
|
||||
if ($user->is_admin) {
|
||||
return true;
|
||||
}
|
||||
return $this->created_by === $user->id;
|
||||
}
|
||||
}
|
||||
|
||||
97
app/Models/System/SystemOperationLog.php
Normal file
97
app/Models/System/SystemOperationLog.php
Normal file
@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\System;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Traits\TenantScoped;
|
||||
use App\Models\System\User;
|
||||
use App\Models\System\Company;
|
||||
|
||||
class SystemOperationLog extends Model
|
||||
{
|
||||
use HasFactory, TenantScoped;
|
||||
|
||||
protected $appends = [
|
||||
'translated_note',
|
||||
];
|
||||
|
||||
/**
|
||||
* 動態翻譯備註
|
||||
*/
|
||||
public function getTranslatedNoteAttribute(): ?string
|
||||
{
|
||||
if (empty($this->note)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 過濾掉非字串/數值的數值,避免 Laravel 翻譯引擎處理巢狀陣列時報錯
|
||||
$replacements = collect(array_merge(
|
||||
$this->old_values ?? [],
|
||||
$this->new_values ?? []
|
||||
))->filter(fn($val) => is_scalar($val))->toArray();
|
||||
|
||||
return __($this->note, $replacements);
|
||||
}
|
||||
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'user_id',
|
||||
'module',
|
||||
'action',
|
||||
'target_id',
|
||||
'target_type',
|
||||
'old_values',
|
||||
'new_values',
|
||||
'note',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'old_values' => 'array',
|
||||
'new_values' => 'array',
|
||||
];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function company()
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Polymorphic relationship to the target entity
|
||||
*/
|
||||
public function target()
|
||||
{
|
||||
return $this->morphTo()->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the target entity from relation or values
|
||||
*/
|
||||
public function getTargetNameAttribute(): string
|
||||
{
|
||||
// 1. Try from loaded relationship
|
||||
if ($this->target) {
|
||||
if (method_exists($this->target, 'getLocalizedNameAttribute')) {
|
||||
return $this->target->localized_name;
|
||||
}
|
||||
return $this->target->name ?? '';
|
||||
}
|
||||
|
||||
// 2. Try from values
|
||||
$name = $this->new_values['name'] ?? $this->old_values['name'] ??
|
||||
$this->new_values['code'] ?? $this->old_values['code'] ?? null;
|
||||
if ($name) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,8 @@ class User extends Authenticatable
|
||||
*/
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'parent_id',
|
||||
'level',
|
||||
'username',
|
||||
'name',
|
||||
'email',
|
||||
@ -53,8 +55,14 @@ class User extends Authenticatable
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_admin' => 'boolean',
|
||||
'level' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 帳號層級上限:子帳號最多兩層(主帳號 level 0 → 子 level 1 → 孫 level 2,level 2 不可再建)。
|
||||
*/
|
||||
public const MAX_LEVEL = 2;
|
||||
|
||||
/**
|
||||
* Get the login logs for the user.
|
||||
*/
|
||||
@ -95,6 +103,85 @@ class User extends Authenticatable
|
||||
return !is_null($this->company_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上層(建立者)帳號。
|
||||
*/
|
||||
public function parent()
|
||||
{
|
||||
return $this->belongsTo(self::class, 'parent_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接建立的下層帳號。
|
||||
*/
|
||||
public function children()
|
||||
{
|
||||
return $this->hasMany(self::class, 'parent_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否為該公司主帳號(租戶層級且 is_admin)。
|
||||
*/
|
||||
public function isMainAccount(): bool
|
||||
{
|
||||
return $this->isTenant() && $this->is_admin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否為子帳號(租戶層級且非主帳號)。
|
||||
*/
|
||||
public function isSubAccount(): bool
|
||||
{
|
||||
return $this->isTenant() && !$this->is_admin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否還能再建立下層子帳號(深度上限約束,系統管理員不受限)。
|
||||
*/
|
||||
public function canCreateSubAccount(): bool
|
||||
{
|
||||
return $this->isSystemAdmin() || $this->level < self::MAX_LEVEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查詢範圍:限制為指定使用者「可見」的帳號。
|
||||
* - 系統管理員:全部
|
||||
* - 主帳號:同公司全部(安全網)
|
||||
* - 子帳號:僅自己直接建立的下層帳號
|
||||
*/
|
||||
public function scopeVisibleTo($query, self $viewer)
|
||||
{
|
||||
if ($viewer->isSystemAdmin()) {
|
||||
return $query;
|
||||
}
|
||||
$query->where($this->getTable() . '.company_id', $viewer->company_id);
|
||||
if (!$viewer->is_admin) {
|
||||
$query->where($this->getTable() . '.parent_id', $viewer->id);
|
||||
}
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作者是否有權管理(編輯/刪除/停用)指定帳號。
|
||||
* - 系統管理員:全部
|
||||
* - 跨公司:禁止
|
||||
* - 主帳號:同公司全部
|
||||
* - 子帳號:僅自己直接建立的下層帳號
|
||||
*/
|
||||
public function canManageAccount(self $target): bool
|
||||
{
|
||||
if ($this->isSystemAdmin()) {
|
||||
return true;
|
||||
}
|
||||
if ($target->company_id !== $this->company_id) {
|
||||
return false;
|
||||
}
|
||||
if ($this->is_admin) {
|
||||
return true;
|
||||
}
|
||||
return $target->parent_id === $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL for the user's avatar.
|
||||
*/
|
||||
|
||||
@ -45,6 +45,6 @@ class DispenseRecord extends Model
|
||||
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
return $this->belongsTo(Product::class)->withTrashed();
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,10 +5,15 @@ namespace App\Models\Transaction;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use App\Traits\TenantScoped;
|
||||
use App\Traits\HasDisplayFlowId;
|
||||
|
||||
class Invoice extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
// TenantScoped:自動以登入者 company_id 過濾(含 route-model binding 與列表查詢),
|
||||
// 防止跨公司讀取/操作他人發票。console(佇列 Job / reconcile 命令)會自動跳過 scope,
|
||||
// 系統身分仍可跨公司開立/對帳。
|
||||
use HasFactory, SoftDeletes, TenantScoped, HasDisplayFlowId;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
@ -16,9 +21,17 @@ class Invoice extends Model
|
||||
'machine_id',
|
||||
'flow_id',
|
||||
'invoice_no',
|
||||
'status',
|
||||
'relate_number',
|
||||
'amount',
|
||||
'carrier_id',
|
||||
'invoice_date',
|
||||
'machine_time',
|
||||
'last_checked_at',
|
||||
'retry_count',
|
||||
'voided_at',
|
||||
'void_reason',
|
||||
'remark',
|
||||
'random_number',
|
||||
'love_code',
|
||||
'rtn_code',
|
||||
@ -27,13 +40,28 @@ class Invoice extends Model
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'total_amount' => 'decimal:2',
|
||||
'tax_amount' => 'decimal:2',
|
||||
'amount' => 'decimal:2',
|
||||
'invoice_date' => 'date',
|
||||
'machine_time' => 'datetime',
|
||||
'last_checked_at' => 'datetime',
|
||||
'voided_at' => 'datetime',
|
||||
'retry_count' => 'integer',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
// 發票狀態常數
|
||||
public const STATUS_PENDING = 'pending'; // 已送出綠界、尚未確認
|
||||
public const STATUS_ISSUED = 'issued'; // 已開立成功
|
||||
public const STATUS_FAILED = 'failed'; // 綠界回失敗,待補開
|
||||
public const STATUS_VOID = 'void'; // 已作廢
|
||||
|
||||
public function order()
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
public function machine()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Machine\Machine::class);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,42 +6,260 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use App\Traits\TenantScoped;
|
||||
use App\Traits\HasDisplayFlowId;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Member\Member;
|
||||
|
||||
class Order extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, TenantScoped;
|
||||
use HasFactory, SoftDeletes, TenantScoped, HasDisplayFlowId;
|
||||
|
||||
// 訂單類型 (order_type)
|
||||
public const TYPE_SALE = 'sale'; // 一般銷售
|
||||
public const TYPE_PHARMACY_PICKUP = 'pharmacy_pickup'; // 領藥單
|
||||
public const TYPE_MANUAL = 'manual'; // 手動補單(後台人工補登,機台斷線漏報時使用)
|
||||
|
||||
// 支付狀態
|
||||
public const PAYMENT_STATUS_FAILED = 0;
|
||||
public const PAYMENT_STATUS_SUCCESS = 1;
|
||||
|
||||
// 出貨狀態
|
||||
public const DELIVERY_STATUS_FAILED = 0;
|
||||
public const DELIVERY_STATUS_SUCCESS = 1;
|
||||
public const DELIVERY_STATUS_PARTIAL = 2;
|
||||
|
||||
// 交易生命週期狀態(status 欄位,字串)。
|
||||
// pending:進入付款、等待結果;completed:付款成功(出貨另看 delivery_status);
|
||||
// failed:付款失敗/逾時;abandoned:感應前取消(由排程把逾時 pending 標記)。
|
||||
// ⚠ 終態 = [completed, failed, abandoned],一律冪等不可覆蓋(保護線上 main 機台行為不變)。
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_ABANDONED = 'abandoned';
|
||||
// 領藥單專用:已開立、等待病人到機台領取(非 pending,不被逾時清掃;非終態,可被出貨 finalize 更新)。
|
||||
public const STATUS_AWAITING_PICKUP = 'awaiting_pickup';
|
||||
|
||||
/** 終態清單:到了這些狀態就不再變更(冪等)。 */
|
||||
public const TERMINAL_STATUSES = [
|
||||
self::STATUS_COMPLETED,
|
||||
self::STATUS_FAILED,
|
||||
self::STATUS_ABANDONED,
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'flow_id',
|
||||
'order_no',
|
||||
'order_type',
|
||||
'pricing_slip_no',
|
||||
'created_by',
|
||||
'machine_id',
|
||||
'member_id',
|
||||
'payment_type',
|
||||
'total_amount',
|
||||
'discount_amount',
|
||||
'pay_amount',
|
||||
'payment_type',
|
||||
'change_amount',
|
||||
'cash_detail',
|
||||
'points_used',
|
||||
'original_amount',
|
||||
'payment_status',
|
||||
'payment_request',
|
||||
'payment_response',
|
||||
'payment_at',
|
||||
'member_barcode',
|
||||
'code_id',
|
||||
'welcome_gift_id',
|
||||
'invoice_info',
|
||||
'machine_time',
|
||||
'status',
|
||||
'metadata',
|
||||
'delivery_status',
|
||||
'remark',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'total_amount' => 'decimal:2',
|
||||
'discount_amount' => 'decimal:2',
|
||||
'pay_amount' => 'decimal:2',
|
||||
'change_amount' => 'decimal:2',
|
||||
'cash_detail' => 'array',
|
||||
'original_amount' => 'decimal:2',
|
||||
'payment_at' => 'datetime',
|
||||
'machine_time' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
'payment_type' => 'integer',
|
||||
'delivery_status' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 現金收款摘要字串:「收:{總收金額}(只列有收到的面額明細) 找:{找零}」。
|
||||
* 例:收:120(佰:1、10:2) 找:30。無 cash_detail(非現金或舊資料)或全為 0 時回 null。
|
||||
* 總收金額由各面額張數加總得出,與括號內明細一致。
|
||||
*/
|
||||
public function getCashReceivedSummaryAttribute(): ?string
|
||||
{
|
||||
$cd = $this->cash_detail;
|
||||
if (empty($cd)) {
|
||||
return null;
|
||||
}
|
||||
// 面額顯示標籤 + 幣值(依大到小)
|
||||
$denoms = [
|
||||
'b1000' => ['label' => '仟', 'unit' => 1000],
|
||||
'b500' => ['label' => '伍佰', 'unit' => 500],
|
||||
'b100' => ['label' => '佰', 'unit' => 100],
|
||||
'c50' => ['label' => '50', 'unit' => 50],
|
||||
'c10' => ['label' => '10', 'unit' => 10],
|
||||
'c5' => ['label' => '5', 'unit' => 5],
|
||||
'c1' => ['label' => '1', 'unit' => 1],
|
||||
];
|
||||
$received = 0;
|
||||
$parts = [];
|
||||
foreach ($denoms as $key => $d) {
|
||||
$n = (int) ($cd[$key] ?? 0);
|
||||
if ($n > 0) {
|
||||
$parts[] = $d['label'] . ':' . $n; // 只列有收到的幣別
|
||||
$received += $n * $d['unit'];
|
||||
}
|
||||
}
|
||||
if (empty($parts)) {
|
||||
return null;
|
||||
}
|
||||
return '收:' . $received . '(' . implode('、', $parts) . ') 找:' . number_format($this->change_amount, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得所有支付類型的對照標籤
|
||||
*/
|
||||
public static function getPaymentTypeLabels(): array
|
||||
{
|
||||
return [
|
||||
1 => __('Credit Card'),
|
||||
2 => __('E-Ticket (EasyCard/iPass)'),
|
||||
3 => __('QR Code Payment - E.SUN'), // 掃碼支付-玉山
|
||||
4 => __('Bill Acceptor'),
|
||||
5 => __('Pass Code'), // 使用者要求由「通關密碼」改為「通行碼」
|
||||
6 => __('Pickup Code'),
|
||||
7 => __('Welcome Gift'),
|
||||
8 => __('Questionnaire'),
|
||||
9 => __('Coin Acceptor'),
|
||||
10 => __('Mobile Pay'), // NFC 手機感應(行動支付,信用卡軌道,依使用者按鈕分類)
|
||||
11 => __('QR Code Payment - TayPay'), // 掃碼支付-TayPay
|
||||
21 => __('Offline + 1'),
|
||||
22 => __('Offline + 2'),
|
||||
23 => __('Offline + 3'),
|
||||
24 => __('Offline + 4'),
|
||||
25 => __('Offline + 9'),
|
||||
30 => __('LINE Pay'),
|
||||
31 => __('JKO Pay'),
|
||||
32 => __('Easy Wallet'),
|
||||
33 => __('Pi Pay'),
|
||||
34 => __('PlusPay'),
|
||||
40 => __('Member Verify Pickup'),
|
||||
41 => __('Staff Card'),
|
||||
42 => __('Pickup Voucher'),
|
||||
50 => __('Offline + LINE Pay'),
|
||||
51 => __('Offline + JKO Pay'),
|
||||
52 => __('Offline + Easy Wallet'),
|
||||
53 => __('Offline + Pi Pay'),
|
||||
54 => __('Offline + PlusPay'),
|
||||
60 => __('Points/Voucher'),
|
||||
61 => __('Member + 1'),
|
||||
62 => __('Member + 2'),
|
||||
63 => __('Member + 3'),
|
||||
64 => __('Member + 4'),
|
||||
65 => __('Member + 5'),
|
||||
66 => __('Member + 6'),
|
||||
67 => __('Member + 7'),
|
||||
68 => __('Member + 8'),
|
||||
69 => __('Member + 9'),
|
||||
70 => __('LINE Pay (Official)'), // LINE Pay 官方直連(非 TapPay 底下的 30)
|
||||
90 => __('Member + LINE Pay'),
|
||||
91 => __('Member + JKO Pay'),
|
||||
92 => __('Member + Easy Wallet'),
|
||||
93 => __('Member + Pi Pay'),
|
||||
94 => __('Member + PlusPay'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得目前的支付類型標籤 (Accessor)
|
||||
*/
|
||||
public function getPaymentTypeLabelAttribute(): string
|
||||
{
|
||||
return self::getPaymentTypeLabels()[$this->payment_type] ?? __('Unknown');
|
||||
}
|
||||
|
||||
public function getMaskedPickupRecipientAttribute(): ?string
|
||||
{
|
||||
if (empty($this->member_barcode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::maskPickupRecipientName($this->member_barcode);
|
||||
}
|
||||
|
||||
public static function maskPickupRecipientName(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (preg_match('/^([^\s((]+)(.*)$/us', $value, $matches) !== 1) {
|
||||
return self::maskName($value);
|
||||
}
|
||||
|
||||
return self::maskName($matches[1]) . ($matches[2] ?? '');
|
||||
}
|
||||
|
||||
private static function maskName(string $name): string
|
||||
{
|
||||
$length = mb_strlen($name);
|
||||
|
||||
if ($length <= 1) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
if ($length === 2) {
|
||||
return mb_substr($name, 0, 1) . 'O';
|
||||
}
|
||||
|
||||
return mb_substr($name, 0, 1)
|
||||
. str_repeat('O', $length - 2)
|
||||
. mb_substr($name, -1);
|
||||
}
|
||||
|
||||
/** 一般銷售訂單 */
|
||||
public function scopeSales($query)
|
||||
{
|
||||
return $query->where('order_type', self::TYPE_SALE);
|
||||
}
|
||||
|
||||
/** 領藥單 */
|
||||
public function scopePharmacyPickup($query)
|
||||
{
|
||||
return $query->where('order_type', self::TYPE_PHARMACY_PICKUP);
|
||||
}
|
||||
|
||||
/** 是否為後台手動補單 */
|
||||
public function getIsManualAttribute(): bool
|
||||
{
|
||||
return $this->order_type === self::TYPE_MANUAL;
|
||||
}
|
||||
|
||||
public function machine()
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
/** 建單者 (領藥單藥師) */
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function member()
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
@ -61,4 +279,54 @@ class Order extends Model
|
||||
{
|
||||
return $this->hasMany(DispenseRecord::class);
|
||||
}
|
||||
|
||||
/** 領藥單對應的領藥碼(一單一碼) */
|
||||
public function pickupCode()
|
||||
{
|
||||
return $this->hasOne(PickupCode::class);
|
||||
}
|
||||
|
||||
public function staffCardLog()
|
||||
{
|
||||
return $this->hasOne(\App\Models\StaffCardLog::class);
|
||||
}
|
||||
|
||||
public function welcomeGift()
|
||||
{
|
||||
return $this->belongsTo(WelcomeGift::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得所有出貨狀態的對照標籤
|
||||
*/
|
||||
public static function getDeliveryStatusLabels(): array
|
||||
{
|
||||
return [
|
||||
self::DELIVERY_STATUS_FAILED => __('Dispense Failed'),
|
||||
self::DELIVERY_STATUS_SUCCESS => __('Dispense Success'),
|
||||
self::DELIVERY_STATUS_PARTIAL => __('Partial Dispense Success'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 出貨狀態是否有意義:唯有付款成功(completed/paid)、機台真的動作過的單才有出貨結果。
|
||||
* pending(未完成)、failed(支付失敗)、abandoned(未支付)從未出貨,出貨狀態應顯示 '--'。
|
||||
* ⚠ delivery_status 欄位 DB 預設為 1(出貨成功),這些單若直接讀值會誤顯示成「出貨成功」。
|
||||
*/
|
||||
public function hasDeliveryOutcome(): bool
|
||||
{
|
||||
return in_array($this->status, [self::STATUS_COMPLETED, 'paid'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得目前出貨狀態標籤 (Accessor)
|
||||
*/
|
||||
public function getDeliveryStatusLabelAttribute(): string
|
||||
{
|
||||
if (! $this->hasDeliveryOutcome()) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return self::getDeliveryStatusLabels()[$this->delivery_status] ?? __('Unknown');
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ class OrderItem extends Model
|
||||
'order_id',
|
||||
'product_id',
|
||||
'product_name',
|
||||
'slot_no',
|
||||
'barcode',
|
||||
'price',
|
||||
'quantity',
|
||||
@ -34,6 +35,6 @@ class OrderItem extends Model
|
||||
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
return $this->belongsTo(Product::class)->withTrashed();
|
||||
}
|
||||
}
|
||||
|
||||
109
app/Models/Transaction/PassCode.php
Normal file
109
app/Models/Transaction/PassCode.php
Normal file
@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Transaction;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\System\User;
|
||||
use App\Traits\TenantScoped;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PassCode extends Model
|
||||
{
|
||||
use TenantScoped;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'machine_id',
|
||||
'name',
|
||||
'code',
|
||||
'slug',
|
||||
'batch_no',
|
||||
'expires_at',
|
||||
'status',
|
||||
'usage_limit', // 可用次數上限;null=無限次。勾「只能使用一次」時=1
|
||||
'usage_count', // 已使用次數
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 獲取公開通行連結
|
||||
*/
|
||||
public function getTicketUrlAttribute()
|
||||
{
|
||||
return $this->slug ? route('pass-code.ticket', $this->slug) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the model.
|
||||
*/
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($passCode) {
|
||||
if (!$passCode->slug) {
|
||||
$passCode->slug = \Illuminate\Support\Str::random(16);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯機台
|
||||
*/
|
||||
public function machine(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯建立者
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 判斷是否可用
|
||||
*/
|
||||
public function isValid(): bool
|
||||
{
|
||||
if ($this->status !== 'active') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->expires_at && $this->expires_at->isPast()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 有設使用次數上限(如「只能使用一次」usage_limit=1)且已用完 → 失效。null=無限次。
|
||||
if (!is_null($this->usage_limit) && $this->usage_count >= $this->usage_limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生唯一的通行碼 (8位)
|
||||
*/
|
||||
public static function generateUniqueCode(int $machineId): string
|
||||
{
|
||||
do {
|
||||
$code = str_pad(rand(0, 99999999), 8, '0', STR_PAD_LEFT);
|
||||
$exists = self::where('machine_id', $machineId)
|
||||
->where('code', $code)
|
||||
->where('status', 'active')
|
||||
->where(function($query) {
|
||||
$query->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->exists();
|
||||
} while ($exists);
|
||||
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
47
app/Models/Transaction/PassCodeLog.php
Normal file
47
app/Models/Transaction/PassCodeLog.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Transaction;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Traits\TenantScoped;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PassCodeLog extends Model
|
||||
{
|
||||
use TenantScoped;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'machine_id',
|
||||
'pass_code_id',
|
||||
'action',
|
||||
'user_id',
|
||||
'order_id',
|
||||
'raw_data',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'raw_data' => 'array',
|
||||
];
|
||||
|
||||
public function passCode(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PassCode::class);
|
||||
}
|
||||
|
||||
public function machine(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\User::class);
|
||||
}
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
}
|
||||
@ -4,11 +4,10 @@ namespace App\Models\Transaction;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class PaymentType extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
|
||||
113
app/Models/Transaction/PickupCode.php
Normal file
113
app/Models/Transaction/PickupCode.php
Normal file
@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Transaction;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\System\User;
|
||||
use App\Traits\TenantScoped;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PickupCode extends Model
|
||||
{
|
||||
use TenantScoped;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'machine_id',
|
||||
'product_id',
|
||||
'slot_no',
|
||||
'code',
|
||||
'slug',
|
||||
'batch_no',
|
||||
'status',
|
||||
'expires_at',
|
||||
'used_at',
|
||||
'usage_limit',
|
||||
'usage_count',
|
||||
'created_by',
|
||||
'order_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* 獲取公開取貨連結
|
||||
*/
|
||||
public function getTicketUrlAttribute()
|
||||
{
|
||||
return $this->slug ? route('pickup.ticket', $this->slug) : route('pickup.ticket', $this->code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the model.
|
||||
*/
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($pickupCode) {
|
||||
$pickupCode->slug = \Illuminate\Support\Str::random(16);
|
||||
});
|
||||
}
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
'used_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 關聯機台
|
||||
*/
|
||||
public function machine(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯商品(取貨碼改綁商品時使用)
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Product\Product::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯建立者
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯訂單
|
||||
*/
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判斷是否可用
|
||||
*/
|
||||
public function isValid(): bool
|
||||
{
|
||||
return $this->status === 'active'
|
||||
&& ($this->usage_count < $this->usage_limit)
|
||||
&& ($this->expires_at?->isFuture() ?? true); // null expires_at = 無到期限制
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生唯一的取貨碼 (8位)
|
||||
*/
|
||||
public static function generateUniqueCode(int $machineId): string
|
||||
{
|
||||
do {
|
||||
$code = str_pad(rand(0, 99999999), 8, '0', STR_PAD_LEFT);
|
||||
$exists = self::where('machine_id', $machineId)
|
||||
->where('code', $code)
|
||||
->where('status', 'active')
|
||||
->where('expires_at', '>', now())
|
||||
->exists();
|
||||
} while ($exists);
|
||||
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
48
app/Models/Transaction/PickupCodeLog.php
Normal file
48
app/Models/Transaction/PickupCodeLog.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Transaction;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\System\User;
|
||||
use App\Traits\TenantScoped;
|
||||
|
||||
class PickupCodeLog extends Model
|
||||
{
|
||||
use TenantScoped;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'machine_id',
|
||||
'pickup_code_id',
|
||||
'order_id',
|
||||
'action',
|
||||
'user_id', // Optional, for admin actions recorded here
|
||||
'remark',
|
||||
'raw_data'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'raw_data' => 'array'
|
||||
];
|
||||
|
||||
public function machine()
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
public function pickupCode()
|
||||
{
|
||||
return $this->belongsTo(PickupCode::class);
|
||||
}
|
||||
|
||||
public function order()
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
153
app/Models/Transaction/WelcomeGift.php
Normal file
153
app/Models/Transaction/WelcomeGift.php
Normal file
@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Transaction;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\System\User;
|
||||
use App\Traits\TenantScoped;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WelcomeGift extends Model
|
||||
{
|
||||
use TenantScoped;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'machine_id',
|
||||
'name',
|
||||
'code',
|
||||
'slug',
|
||||
'discount_type',
|
||||
'discount_value',
|
||||
'usage_type',
|
||||
'usage_limit',
|
||||
'usage_count',
|
||||
'expires_at',
|
||||
'status',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
'discount_value' => 'integer',
|
||||
];
|
||||
|
||||
protected $appends = ['discount_label', 'input_fold'];
|
||||
|
||||
/**
|
||||
* 獲取公開來店禮連結
|
||||
*/
|
||||
public function getTicketUrlAttribute()
|
||||
{
|
||||
return $this->slug ? route('welcome-gift.ticket', $this->slug) : null;
|
||||
}
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($gift) {
|
||||
if (!$gift->slug) {
|
||||
$gift->slug = \Illuminate\Support\Str::random(16);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 後台編輯時回填使用的折數 (如趴數 20 換算回 8 折,15 換算回 8.5 折)
|
||||
*/
|
||||
public function getInputFoldAttribute()
|
||||
{
|
||||
if ($this->discount_type !== 'percentage') {
|
||||
return null;
|
||||
}
|
||||
return (100 - $this->discount_value) / 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自動轉換為台灣消費者習慣的折數標籤 (例如:八折、八五折、折 50 元)
|
||||
*/
|
||||
public function getDiscountLabelAttribute(): string
|
||||
{
|
||||
if ($this->discount_type === 'amount') {
|
||||
return "折 {$this->discount_value} 元";
|
||||
}
|
||||
|
||||
if ($this->discount_type === 'percentage') {
|
||||
$fold = $this->input_fold;
|
||||
return $this->convertToChineseDiscount($fold);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function convertToChineseDiscount($fold): string
|
||||
{
|
||||
$map = [
|
||||
'1' => '一', '2' => '二', '3' => '三', '4' => '四', '5' => '五',
|
||||
'6' => '六', '7' => '七', '8' => '八', '9' => '九', '0' => '零'
|
||||
];
|
||||
|
||||
$foldStr = (string)$fold;
|
||||
if (strpos($foldStr, '.') !== false) {
|
||||
list($integer, $decimal) = explode('.', $foldStr);
|
||||
$intChar = $map[$integer] ?? $integer;
|
||||
$decChar = $map[substr($decimal, 0, 1)] ?? substr($decimal, 0, 1);
|
||||
return "{$intChar}{$decChar}折";
|
||||
}
|
||||
|
||||
$char = $map[$foldStr] ?? $foldStr;
|
||||
return "{$char}折";
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯機台
|
||||
*/
|
||||
public function machine(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯建立者
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 判斷是否可用
|
||||
*/
|
||||
public function isValid(): bool
|
||||
{
|
||||
if ($this->status !== 'active') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->expires_at && $this->expires_at->isPast()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->usage_type === 'once' && $this->usage_count >= ($this->usage_limit ?? 1)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生唯一的來店禮代碼 (8位)
|
||||
*/
|
||||
public static function generateUniqueCode(int $machineId): string
|
||||
{
|
||||
do {
|
||||
$code = str_pad(rand(0, 99999999), 8, '0', STR_PAD_LEFT);
|
||||
$exists = self::where('machine_id', $machineId)
|
||||
->where('code', $code)
|
||||
->where('status', 'active')
|
||||
->exists();
|
||||
} while ($exists);
|
||||
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
56
app/Models/Transaction/WelcomeGiftLog.php
Normal file
56
app/Models/Transaction/WelcomeGiftLog.php
Normal file
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Transaction;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\System\User;
|
||||
use App\Traits\TenantScoped;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WelcomeGiftLog extends Model
|
||||
{
|
||||
use TenantScoped;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'machine_id',
|
||||
'welcome_gift_id',
|
||||
'order_id',
|
||||
'action',
|
||||
'remark',
|
||||
'raw_data',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'raw_data' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* 關聯來店禮
|
||||
*/
|
||||
public function welcomeGift(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WelcomeGift::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯機台
|
||||
*/
|
||||
public function machine(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯訂單
|
||||
*/
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯操作人員(一般是系統透過訂單綁定或背景核銷,如有操作人可由關聯取得,通常從 Order 或由 SystemOperationLog 獲取)
|
||||
*/
|
||||
}
|
||||
@ -36,6 +36,42 @@ class ReplenishmentOrder extends Model
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
/**
|
||||
* 合法狀態轉換定義
|
||||
* key = 當前狀態, value = 可轉換至的狀態陣列
|
||||
*/
|
||||
public const TRANSITIONS = [
|
||||
self::STATUS_PENDING => [self::STATUS_PREPARED, self::STATUS_CANCELLED],
|
||||
self::STATUS_PREPARED => [self::STATUS_DELIVERING, self::STATUS_CANCELLED],
|
||||
self::STATUS_DELIVERING => [self::STATUS_COMPLETED, self::STATUS_CANCELLED],
|
||||
self::STATUS_COMPLETED => [],
|
||||
self::STATUS_CANCELLED => [],
|
||||
];
|
||||
|
||||
/**
|
||||
* 檢查是否可轉換至指定狀態
|
||||
*/
|
||||
public function canTransitionTo(string $status): bool
|
||||
{
|
||||
return in_array($status, self::TRANSITIONS[$this->status] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 排除已取消的訂單
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', '!=', self::STATUS_CANCELLED);
|
||||
}
|
||||
|
||||
/**
|
||||
* 補貨總數量
|
||||
*/
|
||||
public function getTotalQuantityAttribute(): int
|
||||
{
|
||||
return $this->items->sum('quantity');
|
||||
}
|
||||
|
||||
/**
|
||||
* 出貨倉庫
|
||||
*/
|
||||
|
||||
@ -14,12 +14,25 @@ class ReplenishmentOrderItem extends Model
|
||||
'product_id',
|
||||
'slot_no',
|
||||
'quantity',
|
||||
'current_stock',
|
||||
'max_stock',
|
||||
'snapshot_product_name',
|
||||
'snapshot_product_name_key',
|
||||
'snapshot_product_barcode',
|
||||
'snapshot_product_image_url',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'integer',
|
||||
'current_stock' => 'integer',
|
||||
'max_stock' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 自動附加到 JSON/陣列輸出的屬性
|
||||
*/
|
||||
protected $appends = ['localized_name', 'barcode', 'image_url'];
|
||||
|
||||
/**
|
||||
* 所屬補貨單
|
||||
*/
|
||||
@ -29,10 +42,85 @@ class ReplenishmentOrderItem extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* 對應商品
|
||||
* 對應商品 (包含被軟刪除的商品以相容舊資料)
|
||||
*/
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Product\Product::class);
|
||||
return $this->belongsTo(\App\Models\Product\Product::class)->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得快照商品名稱的字典翻譯
|
||||
*/
|
||||
public function translations()
|
||||
{
|
||||
return $this->hasMany(\App\Models\System\Translation::class, 'key', 'snapshot_product_name_key')
|
||||
->where('group', 'product');
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得商品名稱。
|
||||
* 回退順序:快照字典語系翻譯 → 快照字典 zh_TW → 快照名稱 → 關聯商品名稱 → 空值
|
||||
*/
|
||||
public function getLocalizedNameAttribute(): string
|
||||
{
|
||||
if ($this->snapshot_product_name_key) {
|
||||
if ($this->relationLoaded('translations') && $this->translations->isNotEmpty()) {
|
||||
$locale = app()->getLocale();
|
||||
// 優先找當前語系
|
||||
$translation = $this->translations->firstWhere('locale', $locale);
|
||||
if ($translation) {
|
||||
return $translation->value;
|
||||
}
|
||||
// 回退至 zh_TW
|
||||
$fallback = $this->translations->firstWhere('locale', 'zh_TW');
|
||||
if ($fallback) {
|
||||
return $fallback->value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->snapshot_product_name) {
|
||||
return $this->snapshot_product_name;
|
||||
}
|
||||
|
||||
if ($this->product) {
|
||||
return $this->product->localized_name;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得商品條碼。
|
||||
* 回退順序:快照條碼 → 關聯商品條碼 → 空值
|
||||
*/
|
||||
public function getBarcodeAttribute(): string
|
||||
{
|
||||
if ($this->snapshot_product_barcode) {
|
||||
return $this->snapshot_product_barcode;
|
||||
}
|
||||
|
||||
if ($this->product) {
|
||||
return $this->product->barcode ?? '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得商品圖片。
|
||||
* 回退順序:快照圖片 → 關聯商品圖片 → 空值
|
||||
*/
|
||||
public function getImageUrlAttribute(): ?string
|
||||
{
|
||||
$url = $this->snapshot_product_image_url ?: ($this->product ? $this->product->image_url : null);
|
||||
|
||||
// 防禦性過濾無效路徑或空值
|
||||
if (empty($url) || trim($url) === '' || in_array(rtrim($url, '/'), ['/storage', '/storage/products'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,10 @@ class StockInOrder extends Model
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
protected $casts = [
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
|
||||
@ -13,12 +13,21 @@ class TransferOrderItem extends Model
|
||||
'transfer_order_id',
|
||||
'product_id',
|
||||
'quantity',
|
||||
'snapshot_product_name',
|
||||
'snapshot_product_name_key',
|
||||
'snapshot_product_barcode',
|
||||
'snapshot_product_image_url',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 自動附加到 JSON/陣列輸出的屬性
|
||||
*/
|
||||
protected $appends = ['localized_name', 'barcode', 'image_url'];
|
||||
|
||||
/**
|
||||
* 所屬調撥單
|
||||
*/
|
||||
@ -28,10 +37,85 @@ class TransferOrderItem extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* 對應商品
|
||||
* 對應商品 (包含被軟刪除的商品以相容舊資料)
|
||||
*/
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Product\Product::class);
|
||||
return $this->belongsTo(\App\Models\Product\Product::class)->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得快照商品名稱的字典翻譯
|
||||
*/
|
||||
public function translations()
|
||||
{
|
||||
return $this->hasMany(\App\Models\System\Translation::class, 'key', 'snapshot_product_name_key')
|
||||
->where('group', 'product');
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得商品名稱。
|
||||
* 回退順序:快照字典語系翻譯 → 快照字典 zh_TW → 快照名稱 → 關聯商品名稱 → 空值
|
||||
*/
|
||||
public function getLocalizedNameAttribute(): string
|
||||
{
|
||||
if ($this->snapshot_product_name_key) {
|
||||
if ($this->relationLoaded('translations') && $this->translations->isNotEmpty()) {
|
||||
$locale = app()->getLocale();
|
||||
// 優先找當前語系
|
||||
$translation = $this->translations->firstWhere('locale', $locale);
|
||||
if ($translation) {
|
||||
return $translation->value;
|
||||
}
|
||||
// 回退至 zh_TW
|
||||
$fallback = $this->translations->firstWhere('locale', 'zh_TW');
|
||||
if ($fallback) {
|
||||
return $fallback->value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->snapshot_product_name) {
|
||||
return $this->snapshot_product_name;
|
||||
}
|
||||
|
||||
if ($this->product) {
|
||||
return $this->product->localized_name;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得商品條碼。
|
||||
* 回退順序:快照條碼 → 關聯商品條碼 → 空值
|
||||
*/
|
||||
public function getBarcodeAttribute(): string
|
||||
{
|
||||
if ($this->snapshot_product_barcode) {
|
||||
return $this->snapshot_product_barcode;
|
||||
}
|
||||
|
||||
if ($this->product) {
|
||||
return $this->product->barcode ?? '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得商品圖片。
|
||||
* 回退順序:快照圖片 → 關聯商品圖片 → 空值
|
||||
*/
|
||||
public function getImageUrlAttribute(): ?string
|
||||
{
|
||||
$url = $this->snapshot_product_image_url ?: ($this->product ? $this->product->image_url : null);
|
||||
|
||||
// 防禦性過濾無效路徑或空值
|
||||
if (empty($url) || trim($url) === '' || in_array(rtrim($url, '/'), ['/storage', '/storage/products'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
250
app/Services/Invoice/EcpayInvoiceService.php
Normal file
250
app/Services/Invoice/EcpayInvoiceService.php
Normal file
@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Invoice;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Transaction\Invoice;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 綠界電子發票 B2C API 服務(後台對帳/補開/作廢用)。
|
||||
*
|
||||
* 機台端在出貨時已「直連綠界」開立並把結果隨 finalize 上報;本服務負責後台側:
|
||||
* - GetIssue:對 pending 發票以 RelateNumber 去綠界查詢真實狀態(處理掉包/未知,避免重複開立)
|
||||
* - Issue :對確認未開立者補開(沿用同一 RelateNumber,冪等)
|
||||
* - Invalid :對「已開票未出貨」等情況作廢
|
||||
*
|
||||
* 金鑰來源:機台關聯的金流配置 payment_configs.settings.ecpay_invoice(store_id/hash_key/hash_iv/email),
|
||||
* 與機台 B014 下發同源。未設定者一律略過(回 null),確保未開發票的機台零接觸。
|
||||
*/
|
||||
class EcpayInvoiceService
|
||||
{
|
||||
private const REVISION = '3.2.9';
|
||||
|
||||
private function baseUrl(): string
|
||||
{
|
||||
// fail-safe:一律預設「測試站」,只有明確設定 ECPAY_INVOICE_BASE_URL 才會打正式站。
|
||||
// 不可用 APP_ENV 判斷——demo 站的 APP_ENV 也是 production,靠它會誤打正式綠界開出真發票。
|
||||
// 正式環境必須在 .env 設 ECPAY_INVOICE_BASE_URL=https://einvoice.ecpay.com.tw;
|
||||
// 若漏設,最壞情況是正式環境開到測試站(無真實發票、可被察覺),不會誤開真發票。
|
||||
$configured = config('services.ecpay_invoice.base_url');
|
||||
if (!empty($configured)) {
|
||||
return rtrim($configured, '/');
|
||||
}
|
||||
return 'https://einvoice-stage.ecpay.com.tw';
|
||||
}
|
||||
|
||||
/** 取得機台綠界發票設定;未設定回 null。 */
|
||||
public function configForMachine(Machine $machine): ?array
|
||||
{
|
||||
$settings = $machine->paymentConfig->settings ?? [];
|
||||
$merchantId = data_get($settings, 'ecpay_invoice.store_id');
|
||||
$hashKey = data_get($settings, 'ecpay_invoice.hash_key');
|
||||
$hashIv = data_get($settings, 'ecpay_invoice.hash_iv');
|
||||
|
||||
if (empty($merchantId) || empty($hashKey) || empty($hashIv)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'merchant_id' => $merchantId,
|
||||
'hash_key' => $hashKey,
|
||||
'hash_iv' => $hashIv,
|
||||
'email' => data_get($settings, 'ecpay_invoice.email', ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function encrypt(array $data, string $key, string $iv): string
|
||||
{
|
||||
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return base64_encode(openssl_encrypt(urlencode($json), 'AES-128-CBC', $key, OPENSSL_RAW_DATA, $iv));
|
||||
}
|
||||
|
||||
private function decrypt(?string $data, string $key, string $iv): array
|
||||
{
|
||||
if (empty($data)) {
|
||||
return [];
|
||||
}
|
||||
$decrypted = openssl_decrypt(base64_decode($data), 'AES-128-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
||||
if ($decrypted === false) {
|
||||
return [];
|
||||
}
|
||||
return json_decode(urldecode($decrypted), true) ?: [];
|
||||
}
|
||||
|
||||
/** 共用 POST:包 RqHeader + AES 加密 Data,回傳 decode 後的 Data 陣列(失敗回 null)。 */
|
||||
private function post(string $endpoint, array $cfg, array $payloadData): ?array
|
||||
{
|
||||
$body = [
|
||||
'MerchantID' => $cfg['merchant_id'],
|
||||
'RqHeader' => ['Timestamp' => time(), 'Revision' => self::REVISION],
|
||||
'Data' => $this->encrypt($payloadData, $cfg['hash_key'], $cfg['hash_iv']),
|
||||
];
|
||||
|
||||
try {
|
||||
$resp = Http::timeout(15)->asJson()->post($this->baseUrl() . $endpoint, $body);
|
||||
$json = $resp->json();
|
||||
if (!is_array($json) || !isset($json['Data'])) {
|
||||
Log::warning('ECPay invoice response missing Data', ['endpoint' => $endpoint, 'resp' => $json]);
|
||||
return null;
|
||||
}
|
||||
return $this->decrypt($json['Data'], $cfg['hash_key'], $cfg['hash_iv']);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ECPay invoice request failed: ' . $e->getMessage(), ['endpoint' => $endpoint]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查詢發票(GetIssue),以 RelateNumber 查。
|
||||
* 回傳 decode 後陣列(含 RtnCode/IIS_Number 等)或 null(金鑰缺/連線失敗)。
|
||||
*/
|
||||
public function getIssue(Machine $machine, string $relateNumber): ?array
|
||||
{
|
||||
$cfg = $this->configForMachine($machine);
|
||||
if (!$cfg) {
|
||||
return null;
|
||||
}
|
||||
return $this->post('/B2CInvoice/GetIssue', $cfg, [
|
||||
'MerchantID' => $cfg['merchant_id'],
|
||||
'RelateNumber' => $relateNumber,
|
||||
]);
|
||||
}
|
||||
|
||||
/** 補開:以完整開立內容呼叫 Issue。回傳 decode 後陣列或 null。 */
|
||||
public function issue(Machine $machine, array $invoicePayload): ?array
|
||||
{
|
||||
$cfg = $this->configForMachine($machine);
|
||||
if (!$cfg) {
|
||||
return null;
|
||||
}
|
||||
$invoicePayload['MerchantID'] = $cfg['merchant_id'];
|
||||
return $this->post('/B2CInvoice/Issue', $cfg, $invoicePayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得發票列印頁網址(InvoicePrint)。
|
||||
* 綠界回傳 Data.InvoiceHtml 為可列印頁 URL(自呼叫起 1 小時內有效)。
|
||||
* 回傳 decode 後陣列(含 RtnCode/InvoiceHtml)或 null(金鑰缺/連線失敗)。
|
||||
*/
|
||||
public function invoicePrint(Machine $machine, string $invoiceNo, string $invoiceDate): ?array
|
||||
{
|
||||
$cfg = $this->configForMachine($machine);
|
||||
if (!$cfg) {
|
||||
return null;
|
||||
}
|
||||
return $this->post('/B2CInvoice/InvoicePrint', $cfg, [
|
||||
'MerchantID' => $cfg['merchant_id'],
|
||||
'InvoiceNo' => $invoiceNo,
|
||||
'InvoiceDate' => $invoiceDate,
|
||||
'PrintStyle' => 1,
|
||||
'IsShowingDetail' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
/** 作廢(Invalid)。 */
|
||||
public function invalid(Machine $machine, string $invoiceNo, string $invoiceDate, string $reason = '作廢'): ?array
|
||||
{
|
||||
$cfg = $this->configForMachine($machine);
|
||||
if (!$cfg) {
|
||||
return null;
|
||||
}
|
||||
return $this->post('/B2CInvoice/Invalid', $cfg, [
|
||||
'MerchantID' => $cfg['merchant_id'],
|
||||
'InvoiceNo' => $invoiceNo,
|
||||
'InvoiceDate' => $invoiceDate,
|
||||
'Reason' => mb_substr($reason ?: '作廢', 0, 20),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 由既有 Invoice(+ 關聯 Order/items)重建開立內容並補開。
|
||||
* 沿用 invoice.relate_number 當 RelateNumber(冪等,不會重複開立)。
|
||||
*/
|
||||
public function reissue(Invoice $invoice): ?array
|
||||
{
|
||||
$machine = $invoice->machine;
|
||||
$cfg = $this->configForMachine($machine);
|
||||
if (!$cfg || empty($invoice->relate_number)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$order = $invoice->order;
|
||||
$metadata = $invoice->metadata ?? [];
|
||||
$businessTaxId = $metadata['business_tax_id'] ?? '';
|
||||
$carrier = $invoice->carrier_id ?? '';
|
||||
$loveCode = $invoice->love_code ?? '';
|
||||
|
||||
// 載具類型判定(對齊機台端邏輯)
|
||||
$carrierType = '';
|
||||
$donation = '0';
|
||||
if (strlen($loveCode) > 2) {
|
||||
$donation = '1';
|
||||
$carrierType = '';
|
||||
} elseif (strlen($carrier) === 8) {
|
||||
$carrierType = '3'; // 手機條碼
|
||||
} elseif (strlen($carrier) === 16) {
|
||||
$carrierType = '2'; // 自然人憑證
|
||||
} elseif (empty($businessTaxId)) {
|
||||
$carrierType = '1'; // 綠界會員載具
|
||||
}
|
||||
|
||||
// 品項
|
||||
$items = [];
|
||||
$seq = 1;
|
||||
$orderItems = $order ? $order->items : collect();
|
||||
foreach ($orderItems as $oi) {
|
||||
$items[] = [
|
||||
'ItemSeq' => $seq++,
|
||||
'ItemName' => $oi->product_name ?: ('商品' . $oi->product_id),
|
||||
'ItemCount' => (int) $oi->quantity,
|
||||
'ItemWord' => '個',
|
||||
'ItemPrice' => (int) round($oi->price),
|
||||
'ItemTaxType' => '1',
|
||||
'ItemAmount' => (int) round($oi->subtotal ?: ($oi->price * $oi->quantity)),
|
||||
'ItemRemark' => '',
|
||||
];
|
||||
}
|
||||
if (empty($items)) {
|
||||
// 無明細時以單列總額補上,確保發票可開立
|
||||
$items[] = [
|
||||
'ItemSeq' => 1,
|
||||
'ItemName' => '商品',
|
||||
'ItemCount' => 1,
|
||||
'ItemWord' => '式',
|
||||
'ItemPrice' => (int) round($invoice->amount),
|
||||
'ItemTaxType' => '1',
|
||||
'ItemAmount' => (int) round($invoice->amount),
|
||||
'ItemRemark' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'RelateNumber' => $invoice->relate_number,
|
||||
'CustomerID' => $machine->serial_no,
|
||||
'Print' => empty($businessTaxId) ? '0' : '1',
|
||||
'CustomerIdentifier' => $businessTaxId,
|
||||
'CustomerName' => $businessTaxId,
|
||||
'CustomerAddr' => $businessTaxId,
|
||||
'CustomerPhone' => '',
|
||||
'CustomerEmail' => $donation === '1'
|
||||
? ($invoice->relate_number . substr($cfg['email'], strpos($cfg['email'], '@') ?: 0))
|
||||
: ($cfg['email'] ?: ''),
|
||||
'ClearanceMark' => '',
|
||||
'Donation' => $donation,
|
||||
'LoveCode' => $donation === '1' ? $loveCode : '',
|
||||
'CarrierType' => $carrierType,
|
||||
'CarrierNum' => ($carrierType === '2' || $carrierType === '3') ? str_replace('+', ' ', $carrier) : '',
|
||||
'TaxType' => '1',
|
||||
'SpecialTaxType' => 0,
|
||||
'SalesAmount' => (int) round($invoice->amount),
|
||||
'InvoiceRemark' => '發票備註:無',
|
||||
'InvType' => '07',
|
||||
'vat' => '1',
|
||||
'Items' => $items,
|
||||
];
|
||||
|
||||
return $this->issue($machine, $payload);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user