Compare commits
725
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4583c54568 | ||
|
|
5dad1bb0e3 | ||
|
|
19bc66ba45 | ||
|
|
076e892df9 | ||
|
|
07508e677b | ||
|
|
bab304abd3 | ||
|
|
6a9a8bddd6 | ||
|
|
c2c5078ee6 | ||
|
|
ef7ea3c190 | ||
|
|
61fdb6ebcd | ||
|
|
5651bcd85e | ||
|
|
17f3948dc7 | ||
|
|
85f99ed757 | ||
|
|
b8b1631d46 | ||
|
|
97d73d835b | ||
|
|
a51d4e10aa | ||
|
|
33c24129b4 | ||
|
|
063727a7d8 | ||
|
|
6e2656f999 | ||
|
|
a81f177cb6 | ||
|
|
77e8c78cb6 | ||
|
|
9b34f9e1ba | ||
|
|
0fb3ee50e3 | ||
|
|
d259004f9d | ||
|
|
9561f94074 | ||
|
|
afcb4e9ebe | ||
|
|
0506fe7e8a | ||
|
|
545a4c5fc1 | ||
|
|
d37bb41948 | ||
|
|
f76ef8a6e5 | ||
|
|
41e8d6f7c4 | ||
|
|
7aa685840f | ||
|
|
bd669ea77e | ||
|
|
96468f3c48 | ||
|
|
11a46bf1eb | ||
|
|
d67965aae2 | ||
|
|
dd00dba39c | ||
|
|
92c59d67b4 | ||
|
|
9325f8cf61 | ||
|
|
ac8e471aca | ||
|
|
03fcc01d6d | ||
|
|
3ceee48c00 | ||
|
|
19dffbaa49 | ||
|
|
be2226e4ca | ||
|
|
48dcf991dc | ||
|
|
d355cd0640 | ||
|
|
c825c170e7 | ||
|
|
9de7f5456e | ||
|
|
e2fee1d9c5 | ||
|
|
80d238d31d | ||
|
|
526c9029c8 | ||
|
|
b545e5987c | ||
|
|
d85c70a860 | ||
|
|
b76a7d65be | ||
|
|
d998378e12 | ||
|
|
90c1b83a54 | ||
|
|
75f00a6472 | ||
|
|
241dd2861c | ||
|
|
d29a18db86 | ||
|
|
3a148b2985 | ||
|
|
6c8b8025d7 | ||
|
|
209fb7bff3 | ||
|
|
e5ae28f0c7 | ||
|
|
0402f797c0 | ||
|
|
b042266c5b | ||
|
|
5e38dc1eca | ||
|
|
00a315b61c | ||
|
|
35532eceee | ||
|
|
a148961b55 | ||
|
|
6cd22b218d | ||
|
|
3ecd8f4a74 | ||
|
|
06d021807b | ||
|
|
ecde2ba07d | ||
|
|
b9d981d46b | ||
|
|
2b5f2f939c | ||
|
|
7ba336169a | ||
|
|
79f9c8785b | ||
|
|
87f1e9a4eb | ||
|
|
a7a5a914f1 | ||
|
|
920b432491 | ||
|
|
b592aaec2e | ||
|
|
012527ed35 | ||
|
|
9d7a72673d | ||
|
|
5ce18e56a0 | ||
|
|
f045841f1a | ||
|
|
82b8a9c1b2 | ||
|
|
6eafec3fa9 | ||
|
|
321dd3ca43 | ||
|
|
4e586e905b | ||
|
|
bbc24b7c73 | ||
|
|
42dde4b0bc | ||
|
|
3950b5c334 | ||
|
|
a2125374a8 | ||
|
|
6e190665c0 | ||
|
|
f040f579d1 | ||
|
|
e16afdee95 | ||
|
|
e56d8d7f51 | ||
|
|
0398cb3897 | ||
|
|
ee95036666 | ||
|
|
b217ffcdca | ||
|
|
e4ec917a03 | ||
|
|
63f1d7c2f9 | ||
|
|
c767373a8f | ||
|
|
8dd816ee1d | ||
|
|
1c53bcd226 | ||
|
|
f5c514c520 | ||
|
|
f5daab0d44 | ||
|
|
a7b8560f5f | ||
|
|
3354e66d82 | ||
|
|
20a8bf538a | ||
|
|
66e1c8334c | ||
|
|
be22c37196 | ||
|
|
9c61f36db8 | ||
|
|
cc843535f5 | ||
|
|
d08f03ab03 | ||
|
|
6d56572f8b | ||
|
|
618d8fe21c | ||
|
|
6ec4ec6730 | ||
|
|
5fd5550e0d | ||
|
|
13ac45d10d | ||
|
|
0efcd6a6e5 | ||
|
|
08150167a8 | ||
|
|
6c66eb164e | ||
|
|
99e71096ac | ||
|
|
0ab0774e8a | ||
|
|
aa231ea5f8 | ||
|
|
bee2c0d193 | ||
|
|
60efc5d5d3 | ||
|
|
e0ef78925a | ||
|
|
56f379dd0c | ||
|
|
c22c9467f5 | ||
|
|
22a6e1f4e4 | ||
|
|
efbf8f6ac9 | ||
|
|
8cebb8e0b8 | ||
|
|
1c225ec89d | ||
|
|
ad18204eb9 | ||
|
|
9c264d7942 | ||
|
|
aeadaa9919 | ||
|
|
50400ad19c | ||
|
|
edabf70287 | ||
|
|
d676bfadd7 | ||
|
|
eee70e417c | ||
|
|
da35921f58 | ||
|
|
8618141c2f | ||
|
|
af992b5680 | ||
|
|
cc0b217c89 | ||
|
|
727002761d | ||
|
|
a5bf4252b9 | ||
|
|
a6f56f3453 | ||
|
|
c71bb5a11c | ||
|
|
d2bb1e0fa1 | ||
|
|
b8b20dcebe | ||
|
|
a1be31c694 | ||
|
|
06a30ff658 | ||
|
|
97bf67c962 | ||
|
|
f659ec602e | ||
|
|
8c0cbb8ab8 | ||
|
|
75bae974e4 | ||
|
|
d72e28959d | ||
|
|
ce9d836194 | ||
|
|
55e321f266 | ||
|
|
54ec8e1cd1 | ||
|
|
e0dc7f4cee | ||
|
|
f761c7021e | ||
|
|
06255d1e41 | ||
|
|
0927a514d2 | ||
|
|
0c4a2408ee | ||
|
|
a3a2ffcce0 | ||
|
|
7a750e7e81 | ||
|
|
b28ed3e280 | ||
|
|
702ff0e074 | ||
|
|
81bda0cb79 | ||
|
|
02d1e16036 | ||
|
|
3f40caa1cd | ||
|
|
337d31bbe8 | ||
|
|
7e930c77fd | ||
|
|
34bf9aa0dc | ||
|
|
c5c7e42feb | ||
|
|
8453c9c805 | ||
|
|
e8e278be86 | ||
|
|
99bd4749ea | ||
|
|
d20b9f5a20 | ||
|
|
3018d05bc4 | ||
|
|
7a6d804cfa | ||
|
|
a47a7db7dc | ||
|
|
f5c999f633 | ||
|
|
8c82f6a6dd | ||
|
|
28990e66be | ||
|
|
5a24f3b433 | ||
|
|
b2ea79710c | ||
|
|
daace3ff74 | ||
|
|
8ef8981d71 | ||
|
|
b0a4b94d7d | ||
|
|
db31990ebe | ||
|
|
2d3472f5c4 | ||
|
|
f5fdfc6770 | ||
|
|
416b1cefa2 | ||
|
|
842aa0847f | ||
|
|
ea5fba9dc2 | ||
|
|
628a20dbbd | ||
|
|
5025aa09c2 | ||
|
|
34c8a1a62d | ||
|
|
da63c228f5 | ||
|
|
de132a7c04 | ||
|
|
674084c511 | ||
|
|
8b42cf1b90 | ||
|
|
60ad9de191 | ||
|
|
32c742c1ed | ||
|
|
3edfb9dd86 | ||
|
|
8cbbd3548b | ||
|
|
c860e39363 | ||
|
|
eb8a76d8d2 | ||
|
|
d4477f357d | ||
|
|
70fb2219de | ||
|
|
9444945add | ||
|
|
bce0a15772 | ||
|
|
df5206454f | ||
|
|
5d0e3d904a | ||
|
|
500a85465f | ||
|
|
f1dcafd33d | ||
|
|
df827e4344 | ||
|
|
28b10663ee | ||
|
|
5546b7aa73 | ||
|
|
e194e1ad04 | ||
|
|
e6763241d1 | ||
|
|
e521d781e3 | ||
|
|
fac7b87728 | ||
|
|
55ed0d8929 | ||
|
|
776031cb4c | ||
|
|
884d2f4a41 | ||
|
|
1e12783a41 | ||
|
|
38f9822d49 | ||
|
|
0da75fb855 | ||
|
|
4904508d9f | ||
|
|
8f00db9400 | ||
|
|
702c8a2f12 | ||
|
|
e69ddd0d32 | ||
|
|
85b315a0cf | ||
|
|
26fd37e6f9 | ||
|
|
86ba0c2cd0 | ||
|
|
f092b64a33 | ||
|
|
971af93132 | ||
|
|
04eb1e81bd | ||
|
|
68f653eb51 | ||
|
|
57d6241804 | ||
|
|
5dd545ba5c | ||
|
|
468a7c5e12 | ||
|
|
6d542a0acf | ||
|
|
66fc507b1a | ||
|
|
dbdabdce24 | ||
|
|
719bf20c5b | ||
|
|
95a28d5f0f | ||
|
|
a6138d60bc | ||
|
|
818b809614 | ||
|
|
af5c6e725d | ||
|
|
e7f2dfa4d4 | ||
|
|
f005370a1e | ||
|
|
910f61ed33 | ||
|
|
fc14c51348 | ||
|
|
69e76b85ab | ||
|
|
e027b5abc4 | ||
|
|
cf86c585b7 | ||
|
|
98ad5370dc | ||
|
|
be98dbe7ac | ||
|
|
ed37a31654 | ||
|
|
56fd44388b | ||
|
|
c1f9a56404 | ||
|
|
70c92fb33f | ||
|
|
db75ca6deb | ||
|
|
f447db7681 | ||
|
|
f8b0e2de11 | ||
|
|
cf4b8c0e56 | ||
|
|
d5bfb895a7 | ||
|
|
d5ad755695 | ||
|
|
edaddc9da1 | ||
|
|
32c01a4c69 | ||
|
|
1bc44cab13 | ||
|
|
c35e4ca3d2 | ||
|
|
47f873a3e6 | ||
|
|
4849b40594 | ||
|
|
33004fe9b9 | ||
|
|
f73428dcfd | ||
|
|
e7677d1661 | ||
|
|
a9112414c9 | ||
|
|
fdd2c1fbc3 | ||
|
|
c404c168fe | ||
|
|
4e71a13165 | ||
|
|
a328b9ff2d | ||
|
|
2ff5bc09f9 | ||
|
|
aed7a29c1f | ||
|
|
8827ec3804 | ||
|
|
ca3d464761 | ||
|
|
4928e00423 | ||
|
|
b9b4cc2673 | ||
|
|
8248e5ef15 | ||
|
|
3cc112c16c | ||
|
|
a1b95b1f0b | ||
|
|
55e1306eba | ||
|
|
c66bfc75c0 | ||
|
|
ea8420b648 | ||
|
|
8d90457cdc | ||
|
|
a4f00b2839 | ||
|
|
66e442e1e8 | ||
|
|
56ddc212a7 | ||
|
|
6a5d93165b | ||
|
|
f7c5a7477e | ||
|
|
38b11cce35 | ||
|
|
9d4a11339b | ||
|
|
a9b6a73823 | ||
|
|
68eb46c47d | ||
|
|
8d296b3da0 | ||
|
|
8029477e90 | ||
|
|
d5e5ae52a0 | ||
|
|
97c241c1b3 | ||
|
|
412ec0771f | ||
|
|
5d456a8696 | ||
|
|
0be6545078 | ||
|
|
a882bb185e | ||
|
|
e24b3dbadf | ||
|
|
679285dcc6 | ||
|
|
5623d0125c | ||
|
|
d294ecad84 | ||
|
|
195c89e6b7 | ||
|
|
03226cac2a | ||
|
|
2cd1194d17 | ||
|
|
2fd826dbe1 | ||
|
|
7e9155fef1 | ||
|
|
04d462ad43 | ||
|
|
2d6b367a67 | ||
|
|
bf6b080240 | ||
|
|
8a8ababcc0 | ||
|
|
431d7b9835 | ||
|
|
fdd219c1e7 | ||
|
|
9aa786a8c5 | ||
|
|
8e04d6944a | ||
|
|
139164c7d2 | ||
|
|
f110f92040 | ||
|
|
a7f946450e | ||
|
|
01e0d99a03 | ||
|
|
c6a9745c80 | ||
|
|
29abc79699 | ||
|
|
8054b2abe5 | ||
|
|
5367abf7f2 | ||
|
|
0d52442902 | ||
|
|
3d98104455 | ||
|
|
7541b0d762 | ||
|
|
8d51457dfa | ||
|
|
1ff4e842df | ||
|
|
a42c16cf93 | ||
|
|
cbfe592e47 | ||
|
|
3beb7ffb8a | ||
|
|
0ec7376989 | ||
|
|
7cd6a87203 | ||
|
|
107bf66586 | ||
|
|
38b4d6803f | ||
|
|
37e8ce52a7 | ||
|
|
32824fdbf5 | ||
|
|
416c4a0838 | ||
|
|
a2d4d3b0d5 | ||
|
|
d2290eb92c | ||
|
|
848517479d | ||
|
|
ef8a50a52b | ||
|
|
ae248961cc | ||
|
|
caca786051 | ||
|
|
562f35a839 | ||
|
|
83d417b6e6 | ||
|
|
19b0346f2d | ||
|
|
e527b15221 | ||
|
|
c7a7b6fa45 | ||
|
|
08f3b76e24 | ||
|
|
0a5bf28e58 | ||
|
|
f7ee984d04 | ||
|
|
6957952b4c | ||
|
|
e4dcb43d77 | ||
|
|
3aa8aebc29 | ||
|
|
1d3f73d3b9 | ||
|
|
ca4b9a15c5 | ||
|
|
450b30966d | ||
|
|
c591dab5ff | ||
|
|
6c75b2d139 | ||
|
|
e5128a087a | ||
|
|
8280e4bc24 | ||
|
|
3388c1efdb | ||
|
|
021abd8283 | ||
|
|
2c0bee3941 | ||
|
|
9cc66bdc80 | ||
|
|
50c41861bf | ||
|
|
6143a99133 | ||
|
|
217549884d | ||
|
|
b7e003220b | ||
|
|
742021be3c | ||
|
|
309b781c0e | ||
|
|
f880aa7d8a | ||
|
|
e9942af37f | ||
|
|
cd8ab170ba | ||
|
|
1618035e4b | ||
|
|
21e772db3a | ||
|
|
0707a4c95e | ||
|
|
4c45e52296 | ||
|
|
7645ac77d2 | ||
|
|
893ff58441 | ||
|
|
11eb9874af | ||
|
|
d60d4bd4b6 | ||
|
|
49e208c241 | ||
|
|
a78dd29f40 | ||
|
|
d0ea38c133 | ||
|
|
29e8adb943 | ||
|
|
d850fd0027 | ||
|
|
9ac29e6ad7 | ||
|
|
d2792174b5 | ||
|
|
a7fbaa8fd9 | ||
|
|
f42f78f134 | ||
|
|
c78d02a348 | ||
|
|
351a742c07 | ||
|
|
5ec7537b92 | ||
|
|
301df76c65 | ||
|
|
79f9649610 | ||
|
|
a089031994 | ||
|
|
af337562ae | ||
|
|
304469f6fa | ||
|
|
d8b61c523d | ||
|
|
f014e3f88a | ||
|
|
0f461851b5 | ||
|
|
5fc460c69a | ||
|
|
8e689ee771 | ||
|
|
00af72e11b | ||
|
|
bb81170b89 | ||
|
|
ea5bddb1dc | ||
|
|
6da51df503 | ||
|
|
4a1444526a | ||
|
|
e80dcd8519 | ||
|
|
52ec964f88 | ||
|
|
bc62ac796a | ||
|
|
4b7d026d4e | ||
|
|
15af9894bc | ||
|
|
bf8e8e219f | ||
|
|
6e48c4f372 | ||
|
|
3a6e2ca128 | ||
|
|
b6213a6e06 | ||
|
|
5cc0bd840d | ||
|
|
781102f880 | ||
|
|
9a0f48fbfd | ||
|
|
0b83fb21b9 | ||
|
|
dcf4f83ed0 | ||
|
|
b8a89a81f9 | ||
|
|
5f9d62fa04 | ||
|
|
1bdf979a70 | ||
|
|
85b0b454bf | ||
|
|
eb337e6ce3 | ||
|
|
88d863288f | ||
|
|
ac197ad90e | ||
|
|
01186adb46 | ||
|
|
df42730cc8 | ||
|
|
b79ff2d830 | ||
|
|
239998ec44 | ||
|
|
16fba5e9b4 | ||
|
|
514569e667 | ||
|
|
26fe6182df | ||
|
|
75ef98743d | ||
|
|
722f634b61 | ||
|
|
db975dac47 | ||
|
|
3a41902d2b | ||
|
|
afde67fd03 | ||
|
|
dc53c6e871 | ||
|
|
80722d76db | ||
|
|
405808f4dd | ||
|
|
e0cfd41b1f | ||
|
|
cfb7a227e3 | ||
|
|
119882953a | ||
|
|
283b8cd1e3 | ||
|
|
8c49021d38 | ||
|
|
0e1b750457 | ||
|
|
93bb28c06b | ||
|
|
6ece55a00b | ||
|
|
ba7f625865 | ||
|
|
b091751f4d | ||
|
|
e229525a61 | ||
|
|
df941a3450 | ||
|
|
9c3decc729 | ||
|
|
349019863b | ||
|
|
53e4a52b4f | ||
|
|
b2a30d78d8 | ||
|
|
804995ea45 | ||
|
|
5c10b207d2 | ||
|
|
fb625253f6 | ||
|
|
8ac1eb3d40 | ||
|
|
bf22b29d80 | ||
|
|
505bf29fdd | ||
|
|
2802ef23a6 | ||
|
|
5897b35b4e | ||
|
|
433eb5d113 | ||
|
|
828ada6ac3 | ||
|
|
ef6d0ef175 | ||
|
|
7da691e4c6 | ||
|
|
1c844398a9 | ||
|
|
e4934dc64e | ||
|
|
cbef68199b | ||
|
|
915253eefb | ||
|
|
06b1468bf1 | ||
|
|
ecdf73fe23 | ||
|
|
d5287765ec | ||
|
|
29560b743c | ||
|
|
76dab57f4e | ||
|
|
d827f55bf0 | ||
|
|
47d7a1421a | ||
|
|
6b0fb7b128 | ||
|
|
387e82426d | ||
|
|
cf4994ef53 | ||
|
|
2dac9d6fbd | ||
|
|
730cc99ae7 | ||
|
|
ec628e1b8d | ||
|
|
d310da1523 | ||
|
|
1b2896c877 | ||
|
|
8aab018558 | ||
|
|
df07458934 | ||
|
|
057029eca3 | ||
|
|
484b2471f5 | ||
|
|
fdaeb6840f | ||
|
|
ad6208a73a | ||
|
|
e83d925994 | ||
|
|
b638c787b1 | ||
|
|
a0775cf619 | ||
|
|
8a9d0a2f95 | ||
|
|
07eeb58d15 | ||
|
|
7ea11ffdcd | ||
|
|
4a1802af36 | ||
|
|
834b1a59a7 | ||
|
|
d5e61a1162 | ||
|
|
bb86eb7ad2 | ||
|
|
f0e687eec9 | ||
|
|
1b02c29114 | ||
|
|
9adff5584e | ||
|
|
1b45fbaa36 | ||
|
|
507032aedf | ||
|
|
6f849cbb36 | ||
|
|
d92472d2cd | ||
|
|
3c5fb79c30 | ||
|
|
693e8a678e | ||
|
|
80138c6388 | ||
|
|
80ef84e68f | ||
|
|
73abb71c65 | ||
|
|
c750072f6f | ||
|
|
2f02a2d0f2 | ||
|
|
efd7efbc69 | ||
|
|
89d9471f37 | ||
|
|
3533c42291 | ||
|
|
17a1f88626 | ||
|
|
8efa992e21 | ||
|
|
2bc7f353f0 | ||
|
|
25d1b3454b | ||
|
|
6dd7a95ce8 | ||
|
|
5e0a1ce0a1 | ||
|
|
ef259f1f0c | ||
|
|
169781a702 | ||
|
|
01e4b3b494 | ||
|
|
c7794f714b | ||
|
|
4420fb570e | ||
|
|
12ccc346b0 | ||
|
|
da33a4182c | ||
|
|
fd51a0bee4 | ||
|
|
0989d4ba0a | ||
|
|
e9f4350af4 | ||
|
|
031e456614 | ||
|
|
8793fa07cc | ||
|
|
cfe0e616ab | ||
|
|
8a5804aa04 | ||
|
|
e5bc54516f | ||
|
|
a9ba60cf6b | ||
|
|
6a8c3e750e | ||
|
|
ac4c3d1886 | ||
|
|
6beb243604 | ||
|
|
f874447ae2 | ||
|
|
d2de132853 | ||
|
|
86dd605ac5 | ||
|
|
f1fad2d27d | ||
|
|
e93edc77f4 | ||
|
|
3cf2f68b1a | ||
|
|
cf009de58a | ||
|
|
0dcc05dca7 | ||
|
|
7a1433a489 | ||
|
|
de22678c87 | ||
|
|
7af53fc344 | ||
|
|
7e166de4b1 | ||
|
|
542f449b37 | ||
|
|
0cbfa7bb91 | ||
|
|
e4279dc953 | ||
|
|
bfb495ee77 | ||
|
|
9193908400 | ||
|
|
0853777fdf | ||
|
|
01d4fcf12f | ||
|
|
98423aad7b | ||
|
|
9c589de6d7 | ||
|
|
9825d52bfb | ||
|
|
fbfbaa6d17 | ||
|
|
7f8c163c47 | ||
|
|
4d72231ea3 | ||
|
|
39892c6dc0 | ||
|
|
6fd23303d2 | ||
|
|
48de4be893 | ||
|
|
19c6d06fa0 | ||
|
|
a330903625 | ||
|
|
3e60ed56f0 | ||
|
|
45ada351ba | ||
|
|
a9298ac6ea | ||
|
|
4d3fed3215 | ||
|
|
06db3bc966 | ||
|
|
a3c73d76bb | ||
|
|
29b11743e7 | ||
|
|
8294361817 | ||
|
|
0101e46ed9 | ||
|
|
33bf0c32a5 | ||
|
|
14219b9a52 | ||
|
|
1ef4385608 | ||
|
|
feec1ca0bf | ||
|
|
4ebc2dbe82 | ||
|
|
3b46b10604 | ||
|
|
b3d267e9ca | ||
|
|
684f152683 | ||
|
|
958a6a8cb8 | ||
|
|
fa13440f18 | ||
|
|
493ce698df | ||
|
|
898a337f96 | ||
|
|
b22777fa2d | ||
|
|
b1b15da624 | ||
|
|
c31ef0e809 | ||
|
|
310b2efe8a | ||
|
|
f7d1004e6b | ||
|
|
835c1c0836 | ||
|
|
ef91e189dd | ||
|
|
2326f8bf99 | ||
|
|
6c4c0184d0 | ||
|
|
1b6ef504ac | ||
|
|
a035a9d6ad | ||
|
|
054991fbd0 | ||
|
|
a73ea36f9e | ||
|
|
0d59627fa3 | ||
|
|
cf61ce340d | ||
|
|
4e805f9625 | ||
|
|
d946b91dd1 | ||
|
|
947e6ae6f8 | ||
|
|
9277b9c568 | ||
|
|
51f868ea44 | ||
|
|
64fb25d3f6 | ||
|
|
9f796a7b96 | ||
|
|
563da3626b | ||
|
|
7aef0781a7 | ||
|
|
ec4b9438b4 | ||
|
|
22be6d8b36 | ||
|
|
3d88f52e2e | ||
|
|
a37bfac213 | ||
|
|
a501fb1219 | ||
|
|
cf069c74ec | ||
|
|
85322c39e4 | ||
|
|
330dd9e2ce | ||
|
|
6aa6c6e73e | ||
|
|
e760de4bd3 | ||
|
|
39d1031f6c | ||
|
|
be41e8f202 | ||
|
|
221a6fd360 | ||
|
|
b5c4ba4c4f | ||
|
|
8e98ffe950 | ||
|
|
55e4134050 | ||
|
|
0a24f707bf | ||
|
|
e6d7eecdc6 | ||
|
|
98074bf201 | ||
|
|
43d117133b | ||
|
|
1b934a2037 | ||
|
|
5d17113e1a | ||
|
|
111baacd2d | ||
|
|
20e3022f07 | ||
|
|
6445e51523 | ||
|
|
745802ce61 | ||
|
|
cccee8e2cc | ||
|
|
df973f6bdb | ||
|
|
5e9e81d28e | ||
|
|
12df6d6836 | ||
|
|
d3cc76d560 | ||
|
|
5c7372ee06 | ||
|
|
b1cb13fdfe | ||
|
|
8b18d21c88 | ||
|
|
a922ad12f4 | ||
|
|
5d15d8ad6f | ||
|
|
3928d9e118 | ||
|
|
38f18f1926 | ||
|
|
63074c0032 | ||
|
|
9e5cc9188b | ||
|
|
3317d4a077 | ||
|
|
56cee052d4 | ||
|
|
33c4408557 | ||
|
|
40d93934a9 | ||
|
|
f2e582b4dc | ||
|
|
b569643581 | ||
|
|
b105bd9b7d | ||
|
|
9978ae7724 | ||
|
|
789c6443f3 | ||
|
|
0a055557cb | ||
|
|
d4c927a8b9 | ||
|
|
45c2e3df01 | ||
|
|
d323e09894 | ||
|
|
d51bbad12d | ||
|
|
a3af039e74 | ||
|
|
f554d77e59 | ||
|
|
d620d5d857 | ||
|
|
3079d5da7e | ||
|
|
0c24165922 | ||
|
|
eae8743826 | ||
|
|
5942feb06e | ||
|
|
e6c8a9c88d | ||
|
|
bff544404f | ||
|
|
51f205e89b | ||
|
|
2e6e1f47d6 | ||
|
|
45d84d9642 | ||
|
|
babe26a23d | ||
|
|
260a461d05 | ||
|
|
1d24f1c682 | ||
|
|
19c97d148f | ||
|
|
d5b0f2d797 | ||
|
|
57487fd0dc | ||
|
|
f661d40f3a | ||
|
|
1fafb4a157 | ||
|
|
8bd45561b7 | ||
|
|
d69388573a | ||
|
|
b9193b3175 | ||
|
|
9b76aa654e | ||
|
|
7f4c9720e9 |
No files matched your search
@@ -27,3 +27,12 @@ web-admin/**/*.local
|
||||
web-admin/.env
|
||||
web-admin/.env.*
|
||||
!web-admin/.env.docker
|
||||
|
||||
mobile-oa-h5/node_modules
|
||||
mobile-oa-h5/dist
|
||||
mobile-oa-h5/.env
|
||||
mobile-oa-h5/.env.*
|
||||
!mobile-oa-h5/.env.docker
|
||||
|
||||
# 小程序原生工程不进 web 镜像构建上下文
|
||||
mobile-h5/
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
# 生产 Docker 交付包:仅 tag 触发
|
||||
# 镜像与包名固定为 v1.1.0(与现网 .env / 部署习惯一致),便于生产升级时不必改镜像标签
|
||||
# 产物:/root/zkxw-releases/zkxw-docker-deploy-v1.1.0.tar.gz
|
||||
# 脚本:./scripts/package-docker-deploy.sh
|
||||
#
|
||||
# docker.sock 与 /releases 由 runner 环境变量 DRONE_RUNNER_VOLUMES 注入,
|
||||
# 勿在本文件再声明同路径 host volume,否则会 Duplicate mount point。
|
||||
#
|
||||
# 注意:Drone 会对 commands 里的 ${VAR} 做预处理替换;自定义环境变量请用 $${VAR}
|
||||
# 或直接写死版本号,避免变成空字符串。
|
||||
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: package-prod
|
||||
|
||||
concurrency:
|
||||
limit: 1
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- tag
|
||||
|
||||
clone:
|
||||
depth: 1
|
||||
|
||||
steps:
|
||||
- name: package-docker
|
||||
image: docker:27-cli
|
||||
environment:
|
||||
DOCKER_BUILDKIT: '1'
|
||||
NODE_MAX_OLD_SPACE_SIZE: '3072'
|
||||
NPM_REGISTRY: https://registry.npmmirror.com
|
||||
PIP_INDEX_URL: https://mirrors.aliyun.com/pypi/simple/
|
||||
PIP_TRUSTED_HOST: mirrors.aliyun.com
|
||||
ZKXW_DEPLOY_VERSION: v1.1.0
|
||||
commands:
|
||||
- set -eu
|
||||
- apk add --no-cache bash git python3
|
||||
# 固定 v1.1.0;$${ } 防止 Drone 预处理把自定义变量掏空
|
||||
- export ZKXW_DEPLOY_VERSION="$${ZKXW_DEPLOY_VERSION:-v1.1.0}"
|
||||
- echo "==> git tag=${DRONE_TAG:-none} deploy_version=$${ZKXW_DEPLOY_VERSION} commit=${DRONE_COMMIT}"
|
||||
- chmod +x scripts/package-docker-deploy.sh scripts/build-docker-images.sh
|
||||
- |
|
||||
if [ -f /releases/.deploy-docker.env ]; then
|
||||
mkdir -p deploy/docker
|
||||
cp /releases/.deploy-docker.env deploy/docker/.env
|
||||
echo "using /releases/.deploy-docker.env for secret inject"
|
||||
fi
|
||||
- ./scripts/package-docker-deploy.sh "$${ZKXW_DEPLOY_VERSION}"
|
||||
- mkdir -p /releases
|
||||
- cp -v dist/zkxw-docker-deploy-"$${ZKXW_DEPLOY_VERSION}".tar.gz /releases/
|
||||
- ls -lh /releases/zkxw-docker-deploy-"$${ZKXW_DEPLOY_VERSION}".tar.gz
|
||||
- sha256sum /releases/zkxw-docker-deploy-"$${ZKXW_DEPLOY_VERSION}".tar.gz
|
||||
+21
@@ -49,10 +49,18 @@ __pycache__/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!mobile-oa-h5/.env.development
|
||||
!mobile-oa-h5/.env.docker
|
||||
!web-admin/.env.docker
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
secrets/
|
||||
生产环境信息/root密码
|
||||
生产环境信息/*_nginx/
|
||||
生产环境信息/微信生产.env.snippet
|
||||
生产环境信息/微信配置-测试号备份.txt
|
||||
生产环境信息/zkxw-v110-backend.env*
|
||||
|
||||
# 日志与临时文件
|
||||
*.log
|
||||
@@ -84,3 +92,16 @@ exports/
|
||||
|
||||
# Local git worktrees (isolated feature checkouts)
|
||||
.worktrees/
|
||||
|
||||
# 本地进程 PID(如 backend/app.pid,由 start-*.sh 写入,勿提交)
|
||||
*.pid
|
||||
|
||||
# Graphify:AST 解析缓存(体积大、可本地再生成;常见于 backend/app/、web-admin/src/ 下)
|
||||
**/graphify-out/cache/
|
||||
|
||||
# Graphify:增量检测/状态文件(commit hook 或 update-graph.sh 触发后产生)
|
||||
**/.graphify_detect.json
|
||||
**/.graphify_incremental.json
|
||||
|
||||
# 本地密钥(勿提交)
|
||||
测试环境机器的账号密码
|
||||
@@ -1 +0,0 @@
|
||||
78976
|
||||
@@ -220,3 +220,4 @@ pnpm clean:dev # strip demo data for fresh start
|
||||
- **Table column widths**: All `el-table-column` SHALL use `min-width` (not `width`) for proportional scaling when viewport resizes.
|
||||
- **Toolbar**: "已选 N 项" first, then primary action button (only one `type="primary"` per toolbar row), no `space-between` push.
|
||||
- **Database changes**: always pair with schema SQL in `docs/sql/` and evaluate API/TypeScript type impact.
|
||||
- **进程重启**:修改 bug 时,如果前端或后端需要重启,Agent 只告知用户,由用户自行重启。Agent 不得自行停止、启动或重启 `web-admin` 开发服务与 `backend` 服务。
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+45
-2
@@ -7,8 +7,10 @@ DATABASE_URL=mysql+pymysql://root:password@localhost:3306/zkxw
|
||||
# 已废弃:无角色用户不可登录,JWT role 固定为 guest(勿设为 super_admin)
|
||||
# WEB_AUTH_DEFAULT_ROLE=guest
|
||||
|
||||
# JWT配置示例
|
||||
# SECRET_KEY=your-secret-key-change-in-production
|
||||
# JWT配置(必填;启动时会拒绝空值与弱占位密钥,长度至少 32)
|
||||
# 生成:python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
# 注意:更换 SECRET_KEY 会使已签发的 JWT 全部失效,用户需重新登录;生产勿随意轮换。
|
||||
SECRET_KEY=
|
||||
# ALGORITHM=HS256
|
||||
# ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
@@ -50,6 +52,7 @@ DATABASE_URL=mysql+pymysql://root:password@localhost:3306/zkxw
|
||||
|
||||
# 微信支付配置示例
|
||||
# 小程序支付使用 JSAPI(trade_type=JSAPI)
|
||||
# 同时复用于小程序微信登录(code2session / 手机号):WXPAY_MINI_APPID + WXPAY_SECRET
|
||||
# WXPAY_MCHID=1900000109
|
||||
# WXPAY_MINI_APPID=wx1234567890abcdef
|
||||
# WXPAY_SECRET=your-miniapp-secret
|
||||
@@ -57,3 +60,43 @@ DATABASE_URL=mysql+pymysql://root:password@localhost:3306/zkxw
|
||||
# WXPAY_NOTIFY_URL=https://www.chengyantek.cn/api/payment/notify
|
||||
# WXPAY_REFUND_NOTIFY_URL=https://www.chengyantek.cn/api/payment/refund-notify
|
||||
# WXPAY_SANDBOX=false
|
||||
|
||||
# 微信服务号(OA 审批/抄送模板消息;access_token 用服务号凭证,miniprogram.appid 仍用 WXPAY_MINI_APPID)
|
||||
# 总开关:配好三套模板 ID 后改为 true
|
||||
# WX_MP_NOTIFY_ENABLED=true
|
||||
# WX_MP_APPID=wx_service_account_appid
|
||||
# WX_MP_SECRET=wx_service_account_secret
|
||||
# WX_MP_TEMPLATE_APPROVAL=ZKKnm3PrH97iKt4SDrONiw0TvpDyRbQXdmbF6wz-eak
|
||||
# WX_MP_TEMPLATE_CC=rHu7intrAqOqSlxmjqq7FfyC1GKbRi3XaP1-ZEcnvqs
|
||||
# WX_MP_TEMPLATE_APPLICANT_PROGRESS=rHu7intrAqOqSlxmjqq7Fb6ylcB3u3quKSBYUcR6NU4
|
||||
# 审批:申请事项/操作人/操作时间/当前环节/申请类型
|
||||
# WX_MP_TPL_FIELD_TITLE=thing12
|
||||
# WX_MP_TPL_FIELD_APPLICANT=thing18
|
||||
# WX_MP_TPL_FIELD_NODE=thing7
|
||||
# WX_MP_TPL_FIELD_TIME=time17
|
||||
# WX_MP_TPL_FIELD_PROCESS=thing8
|
||||
# 抄送:业务名称/申请人/申请时间/流程名称
|
||||
# WX_MP_TPL_CC_FIELD_TITLE=thing5
|
||||
# WX_MP_TPL_CC_FIELD_APPLICANT=thing2
|
||||
# WX_MP_TPL_CC_FIELD_TIME=time3
|
||||
# WX_MP_TPL_CC_FIELD_PROCESS=thing1
|
||||
# 申请人进度:业务名称/流程名称/审批人/审批结果(通过|失败|转办|其他)/审批时间
|
||||
# WX_MP_TPL_PROGRESS_FIELD_BUSINESS=thing5
|
||||
# WX_MP_TPL_PROGRESS_FIELD_PROCESS=thing1
|
||||
# WX_MP_TPL_PROGRESS_FIELD_APPROVER=thing6
|
||||
# WX_MP_TPL_PROGRESS_FIELD_RESULT=const4
|
||||
# WX_MP_TPL_PROGRESS_FIELD_TIME=time11
|
||||
# 基础实验交付日前一天提醒(出库提醒模板)
|
||||
# WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY=oQNU3t0Q5kwc4KPJ8Z6yPA-k6qlsH9pikSCIb4eAwgM
|
||||
# WX_MP_TPL_BE_DELIVERY_FIELD_CUSTOMER=thing6
|
||||
# WX_MP_TPL_BE_DELIVERY_FIELD_PRODUCT=thing10
|
||||
# WX_MP_TPL_BE_DELIVERY_FIELD_QTY=character_string8
|
||||
# WX_MP_TPL_BE_DELIVERY_FIELD_CONTACT=thing7
|
||||
# WX_MP_TPL_BE_DELIVERY_FIELD_TIME=time14
|
||||
# 协议新增样本采集通知(工单类模板)
|
||||
# WX_MP_TEMPLATE_SAMPLE_COLLECT=iOnQ9k2mavAOwFbp_6yH4lD-RCNmM_OiwyTgrJfU-_A
|
||||
# WX_MP_SAMPLE_COLLECT_ROLE_NAME=样本采集通知人员
|
||||
# WX_MP_TPL_SC_FIELD_TITLE=thing2
|
||||
# WX_MP_TPL_SC_FIELD_APPLICANT=thing8
|
||||
# WX_MP_TPL_SC_FIELD_TIME=time3
|
||||
# WX_MP_TPL_SC_FIELD_TYPE=phrase10
|
||||
@@ -1 +0,0 @@
|
||||
20826
|
||||
@@ -5,6 +5,10 @@ from app.api.admin import router as admin_router
|
||||
from app.api.workflow import router as workflow_router
|
||||
from app.api.basic_info import router as basic_info_router
|
||||
from app.api.collection_protocol import router as collection_protocol_router
|
||||
from app.api.protocol_sample_payment import router as protocol_sample_payment_router
|
||||
from app.api.protocol_refund_record import router as protocol_refund_record_router
|
||||
from app.api.presign_order import router as presign_order_router
|
||||
from app.api.sample_freight import router as sample_freight_router
|
||||
from app.api.leave import router as leave_router
|
||||
from app.api.operations import router as operations_router
|
||||
from app.api.ethnic import router as ethnic_router
|
||||
@@ -34,6 +38,13 @@ from app.api.cleanroom_garment import router as cleanroom_garment_router
|
||||
from app.api.market import router as market_router
|
||||
from app.api.pre_transit_sample import router as pre_transit_sample_router
|
||||
from app.api.oa_approval import router as oa_approval_router
|
||||
from app.api.mini_auth import router as mini_auth_router
|
||||
from app.api.mini_menu import router as mini_menu_router
|
||||
from app.api.market_mobile import router as market_mobile_router
|
||||
from app.api.sample_storage_cert_qc import router as sample_storage_cert_qc_router
|
||||
from app.api.customer_service_dict import router as customer_service_dict_router
|
||||
from app.api.sample_cert_insurance import router as sample_cert_insurance_router
|
||||
from app.api.sample_code_correction import router as sample_code_correction_router
|
||||
|
||||
__all__ = [
|
||||
"public_router",
|
||||
@@ -42,6 +53,10 @@ __all__ = [
|
||||
"workflow_router",
|
||||
"basic_info_router",
|
||||
"collection_protocol_router",
|
||||
"protocol_sample_payment_router",
|
||||
"protocol_refund_record_router",
|
||||
"presign_order_router",
|
||||
"sample_freight_router",
|
||||
"leave_router",
|
||||
"operations_router",
|
||||
"ethnic_router",
|
||||
@@ -71,4 +86,11 @@ __all__ = [
|
||||
"market_router",
|
||||
"pre_transit_sample_router",
|
||||
"oa_approval_router",
|
||||
"mini_auth_router",
|
||||
"mini_menu_router",
|
||||
"market_mobile_router",
|
||||
"sample_storage_cert_qc_router",
|
||||
"customer_service_dict_router",
|
||||
"sample_cert_insurance_router",
|
||||
"sample_code_correction_router",
|
||||
]
|
||||
+123
-46
@@ -26,8 +26,11 @@ from app.crud import menu as menu_svc
|
||||
from app.crud import role as role_svc
|
||||
from app.crud import user as user_svc
|
||||
from app.crud.collection_protocol import _lookup_attachment_by_url
|
||||
from app.models.models import BizAttachment, SysDept, SysRole, SysUser, SysUserRole
|
||||
from app.services.web_auth_roles import _map_role_name_to_web
|
||||
from app.models.models import BizAttachment, SysDept, SysRole, SysUser
|
||||
from app.services.web_auth_roles import (
|
||||
_map_role_name_to_web,
|
||||
list_user_ids_with_effective_role_name,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["运营端管理"])
|
||||
|
||||
@@ -77,7 +80,7 @@ async def list_users_for_select(
|
||||
):
|
||||
"""返回用户下拉选项(label=昵称,value=用户ID),供流程设计器审批人选择使用。
|
||||
|
||||
可选 roleName:按 sys_role.role_name 精确过滤拥有该角色的用户。
|
||||
可选 roleName:按有效角色(直接绑定 ∪ 职位继承)精确过滤。
|
||||
"""
|
||||
tenant_no = _user.get("tenant_no", "")
|
||||
q = db.query(SysUser).filter(SysUser.del_flag == 0, SysUser.status == "1")
|
||||
@@ -85,18 +88,12 @@ async def list_users_for_select(
|
||||
q = q.filter(SysUser.tenant_no == tenant_no)
|
||||
rn = (roleName or "").strip()
|
||||
if rn:
|
||||
q = (
|
||||
q.join(SysUserRole, SysUserRole.user_id == SysUser.id)
|
||||
.join(SysRole, SysRole.id == SysUserRole.role_id)
|
||||
.filter(
|
||||
SysUserRole.del_flag == 0,
|
||||
SysRole.del_flag == 0,
|
||||
SysRole.status == "1",
|
||||
SysRole.role_name == rn,
|
||||
)
|
||||
matched_ids = list_user_ids_with_effective_role_name(
|
||||
db, tenant_no=tenant_no or settings.DEFAULT_TENANT_NO, role_name=rn
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(SysUserRole.tenant_no == tenant_no, SysRole.tenant_no == tenant_no)
|
||||
if not matched_ids:
|
||||
return success_response([])
|
||||
q = q.filter(SysUser.id.in_(matched_ids))
|
||||
rows = q.order_by(SysUser.create_time.desc()).distinct().all()
|
||||
out = [{"label": (r.nick_name or r.user_no or ""), "value": r.id} for r in rows]
|
||||
return success_response(out)
|
||||
@@ -239,6 +236,25 @@ def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
return (sys_user.tenant_no if sys_user else None) or "zkxw"
|
||||
|
||||
|
||||
def _assert_user_tenant_access(db: Session, user: dict, target_user_id: str):
|
||||
"""同租户或 super_admin 才可写操作用户;返回目标用户行。"""
|
||||
row = user_svc.get_user(db, target_user_id)
|
||||
if not row:
|
||||
raise ValueError("用户不存在")
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
if row.tenant_no != tenant_no and user.get("role") != "super_admin":
|
||||
raise ValueError("无权操作其他租户的用户")
|
||||
return row
|
||||
|
||||
|
||||
def _generate_temp_password(length: int = 12) -> str:
|
||||
"""生成可读的临时密码(字母数字,避免易混淆字符)。"""
|
||||
import secrets
|
||||
|
||||
alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 用户管理
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -253,6 +269,9 @@ class UserCreateBody(BaseModel):
|
||||
supervisor_user_id: Optional[str] = Field(None, alias="supervisorUserId")
|
||||
email: Optional[str] = Field("", alias="email")
|
||||
phone_no: Optional[str] = Field("", alias="phoneNo")
|
||||
hire_date: Optional[str] = Field(None, alias="hireDate")
|
||||
probation_months: Optional[int] = Field(None, alias="probationMonths")
|
||||
last_work_day: Optional[str] = Field(None, alias="lastWorkDay")
|
||||
sex: Optional[str] = Field("0", alias="sex")
|
||||
avatar: Optional[str] = Field(None, alias="avatar")
|
||||
status: Optional[str] = Field("1", alias="status")
|
||||
@@ -269,6 +288,9 @@ class UserUpdateBody(BaseModel):
|
||||
supervisor_user_id: Optional[str] = Field(None, alias="supervisorUserId")
|
||||
email: Optional[str] = Field(None, alias="email")
|
||||
phone_no: Optional[str] = Field(None, alias="phoneNo")
|
||||
hire_date: Optional[str] = Field(None, alias="hireDate")
|
||||
probation_months: Optional[int] = Field(None, alias="probationMonths")
|
||||
last_work_day: Optional[str] = Field(None, alias="lastWorkDay")
|
||||
sex: Optional[str] = Field(None, alias="sex")
|
||||
avatar: Optional[str] = Field(None, alias="avatar")
|
||||
status: Optional[str] = Field(None, alias="status")
|
||||
@@ -311,17 +333,14 @@ async def list_users(
|
||||
query = query.filter(SysUser.status == status)
|
||||
rn = (roleName or "").strip()
|
||||
if rn:
|
||||
query = (
|
||||
query.join(SysUserRole, SysUserRole.user_id == SysUser.id)
|
||||
.join(SysRole, SysRole.id == SysUserRole.role_id)
|
||||
.filter(
|
||||
SysUserRole.del_flag == 0,
|
||||
SysRole.del_flag == 0,
|
||||
SysRole.status == "1",
|
||||
SysRole.role_name == rn,
|
||||
)
|
||||
matched_ids = list_user_ids_with_effective_role_name(
|
||||
db, tenant_no=tenant_no, role_name=rn
|
||||
)
|
||||
query = query.filter(SysUserRole.tenant_no == tenant_no, SysRole.tenant_no == tenant_no)
|
||||
if not matched_ids:
|
||||
return success_response(
|
||||
{"list": [], "total": 0, "page": page, "pageSize": pageSize, "totalPages": 0}
|
||||
)
|
||||
query = query.filter(SysUser.id.in_(matched_ids))
|
||||
query = apply_data_scope(query, SysUser, user, db, tenant_no)
|
||||
total = query.distinct().count()
|
||||
items = (
|
||||
@@ -339,6 +358,7 @@ async def list_users(
|
||||
result_list = [
|
||||
user_svc._row_to_dict(r, dept_name=dept_map.get(r.dept_id or "", "")) for r in items
|
||||
]
|
||||
user_svc.enrich_user_dicts_with_relations(db, result_list)
|
||||
tp = user_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": result_list, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
@@ -378,6 +398,9 @@ async def create_user(
|
||||
supervisor_user_id=body.supervisor_user_id,
|
||||
email=body.email,
|
||||
phone_no=body.phone_no,
|
||||
hire_date=body.hire_date,
|
||||
probation_months=body.probation_months,
|
||||
last_work_day=body.last_work_day,
|
||||
sex=body.sex,
|
||||
avatar=body.avatar,
|
||||
status=body.status,
|
||||
@@ -401,24 +424,30 @@ async def update_user(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
_assert_user_tenant_access(db, user, user_id)
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = user_svc.update_user(
|
||||
db,
|
||||
user_id,
|
||||
nick_name=payload.get("nick_name"),
|
||||
dept_id=payload.get("dept_id"),
|
||||
supervisor_user_id=payload.get("supervisor_user_id"),
|
||||
email=payload.get("email"),
|
||||
phone_no=payload.get("phone_no"),
|
||||
sex=payload.get("sex"),
|
||||
avatar=payload.get("avatar"),
|
||||
status=payload.get("status"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id_updater=user.get("user_id"),
|
||||
role_ids=payload.get("role_ids"),
|
||||
post_ids=payload.get("post_ids"),
|
||||
)
|
||||
update_kwargs: dict[str, Any] = {
|
||||
"nick_name": payload.get("nick_name"),
|
||||
"dept_id": payload.get("dept_id"),
|
||||
"supervisor_user_id": payload.get("supervisor_user_id"),
|
||||
"email": payload.get("email"),
|
||||
"phone_no": payload.get("phone_no"),
|
||||
"sex": payload.get("sex"),
|
||||
"avatar": payload.get("avatar"),
|
||||
"status": payload.get("status"),
|
||||
"order_num": payload.get("order_num"),
|
||||
"remark": payload.get("remark"),
|
||||
"user_id_updater": user.get("user_id"),
|
||||
"role_ids": payload.get("role_ids"),
|
||||
"post_ids": payload.get("post_ids"),
|
||||
}
|
||||
if "hire_date" in payload:
|
||||
update_kwargs["hire_date"] = payload.get("hire_date")
|
||||
if "probation_months" in payload:
|
||||
update_kwargs["probation_months"] = payload.get("probation_months")
|
||||
if "last_work_day" in payload:
|
||||
update_kwargs["last_work_day"] = payload.get("last_work_day")
|
||||
obj = user_svc.update_user(db, user_id, **update_kwargs)
|
||||
return success_response(user_svc._row_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
@@ -427,10 +456,11 @@ async def update_user(
|
||||
@router.delete("/users/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
_assert_user_tenant_access(db, user, user_id)
|
||||
user_svc.soft_delete_user(db, user_id)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
@@ -440,12 +470,17 @@ async def delete_user(
|
||||
@router.post("/users/{user_id}/reset-password")
|
||||
async def reset_user_password(
|
||||
user_id: str,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
user_svc.reset_password(db, user_id, "111111")
|
||||
return success_response(None, message="密码已重置为 111111")
|
||||
_assert_user_tenant_access(db, user, user_id)
|
||||
temp_password = _generate_temp_password(12)
|
||||
user_svc.reset_password(db, user_id, temp_password)
|
||||
return success_response(
|
||||
{"tempPassword": temp_password},
|
||||
message="密码已重置,请将临时密码告知用户并督促尽快修改",
|
||||
)
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
@@ -1032,6 +1067,46 @@ async def update_post_bound_users(
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
class PostRolesUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
role_ids: List[str] = Field(default_factory=list, alias="roleIds")
|
||||
|
||||
|
||||
@router.get("/posts/{post_id}/roles")
|
||||
async def list_post_bound_roles(
|
||||
post_id: str,
|
||||
user: dict = Depends(require_menu_code("system:post:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
post = post_svc.get_post(db, post_id)
|
||||
if not post or post.tenant_no != tenant_no:
|
||||
return error_response(404, "职位不存在")
|
||||
ids = post_svc.list_role_ids_for_post(db, post_id, tenant_no)
|
||||
return success_response({"roleIds": ids})
|
||||
|
||||
|
||||
@router.put("/posts/{post_id}/roles")
|
||||
async def update_post_bound_roles(
|
||||
post_id: str,
|
||||
body: PostRolesUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
post = post_svc.get_post(db, post_id)
|
||||
if not post or post.tenant_no != tenant_no:
|
||||
return error_response(404, "职位不存在")
|
||||
try:
|
||||
post_svc.sync_post_role_bindings(
|
||||
db, post_id, tenant_no, body.role_ids, user.get("user_id")
|
||||
)
|
||||
return success_response(None, message="保存成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 物品单位管理
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1595,8 +1670,10 @@ async def attachment_download_url(
|
||||
return error_response(400, "文件地址不能为空")
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
att = _lookup_attachment_by_url(db, file_url, tenant_no)
|
||||
if not att:
|
||||
return error_response(403, "无权下载该附件或附件未登记")
|
||||
download_name = (body.file_name or "").strip()
|
||||
if not download_name and att:
|
||||
if not download_name:
|
||||
download_name = (att.original_name or "").strip()
|
||||
if not download_name:
|
||||
download_name = file_url.rsplit("/", 1)[-1] or "download"
|
||||
|
||||
@@ -24,6 +24,7 @@ from app.models.models import (
|
||||
BizSampleCode,
|
||||
SysUser,
|
||||
)
|
||||
from app.services.customer_relations import build_customer_relations
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["基础信息管理"])
|
||||
|
||||
@@ -50,6 +51,10 @@ class CustomerCreateBody(BaseModel):
|
||||
sex: Optional[str] = Field("2", alias="sex")
|
||||
id_card_no: Optional[str] = Field(None, alias="idCardNo")
|
||||
age: Optional[int] = None
|
||||
birth_ym: Optional[str] = Field(None, alias="birthYm")
|
||||
ethnic: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
phone_no: Optional[str] = Field(None, alias="phoneNo")
|
||||
customer_tags: Optional[str] = Field(None, alias="customerTags")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
@@ -63,6 +68,10 @@ class CustomerUpdateBody(BaseModel):
|
||||
sex: Optional[str] = None
|
||||
id_card_no: Optional[str] = Field(None, alias="idCardNo")
|
||||
age: Optional[int] = None
|
||||
birth_ym: Optional[str] = Field(None, alias="birthYm")
|
||||
ethnic: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
phone_no: Optional[str] = Field(None, alias="phoneNo")
|
||||
customer_tags: Optional[str] = Field(None, alias="customerTags")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
@@ -119,6 +128,19 @@ async def list_customers(
|
||||
return success_response({"list": result_list, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.get("/customers/{customer_id}/relations")
|
||||
async def get_customer_relations(
|
||||
customer_id: str,
|
||||
user: dict = Depends(require_menu_code("biz:customer:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
data = build_customer_relations(db, customer_id, tenant_no)
|
||||
if not data:
|
||||
return error_response(404, "客户不存在")
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.post("/customers")
|
||||
async def create_customer(
|
||||
body: CustomerCreateBody,
|
||||
@@ -133,6 +155,10 @@ async def create_customer(
|
||||
sex=body.sex or "2",
|
||||
id_card_no=body.id_card_no,
|
||||
age=body.age,
|
||||
birth_ym=body.birth_ym,
|
||||
ethnic=body.ethnic,
|
||||
email=body.email,
|
||||
address=body.address,
|
||||
phone_no=body.phone_no,
|
||||
customer_tags=body.customer_tags,
|
||||
tenant_no=tenant_no,
|
||||
@@ -162,6 +188,10 @@ async def update_customer(
|
||||
sex=payload.get("sex"),
|
||||
id_card_no=payload.get("id_card_no"),
|
||||
age=payload.get("age"),
|
||||
birth_ym=payload.get("birth_ym"),
|
||||
ethnic=payload.get("ethnic"),
|
||||
email=payload.get("email"),
|
||||
address=payload.get("address"),
|
||||
phone_no=payload.get("phone_no"),
|
||||
customer_tags=payload.get("customer_tags"),
|
||||
order_num=payload.get("order_num"),
|
||||
@@ -360,8 +390,15 @@ async def list_collection_regions(
|
||||
items = query.order_by(BizCollectionRegion.create_time.desc()).offset((page - 1) * pageSize).limit(pageSize).all()
|
||||
region_ids = [r.id for r in items]
|
||||
hospital_map = region_svc._hospital_names_by_region_ids(db, region_ids)
|
||||
staff_map = region_svc._staff_names_by_region_ids(db, region_ids)
|
||||
result_list = [
|
||||
region_svc._region_to_dict(r, hospital_names=hospital_map.get(r.id, [])) for r in items
|
||||
region_svc._region_to_dict(
|
||||
r,
|
||||
hospital_names=hospital_map.get(r.id, []),
|
||||
staff_names=staff_map.get(r.id, ([], []))[0],
|
||||
leader_names=staff_map.get(r.id, ([], []))[1],
|
||||
)
|
||||
for r in items
|
||||
]
|
||||
tp = region_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": result_list, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
@@ -426,6 +463,17 @@ async def delete_collection_region(
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/collection-regions/by-staff-user/{user_id}")
|
||||
async def api_list_regions_by_staff_user(
|
||||
user_id: str,
|
||||
user: dict = Depends(require_menu_code("biz:collectionRegion:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items = region_svc.list_regions_by_staff_user(db, user_id=user_id, tenant_no=tenant_no)
|
||||
return success_response({"list": items})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 采集地区 — 下属医院
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -548,6 +596,122 @@ async def delete_hospital(
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 采集地区 — 工作人员
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RegionStaffCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
user_id: str = Field(..., alias="userId")
|
||||
is_leader: Optional[bool] = Field(False, alias="isLeader")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class RegionStaffUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
is_leader: Optional[bool] = Field(None, alias="isLeader")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/collection-regions/{region_id}/staff")
|
||||
async def list_region_staff(
|
||||
region_id: str,
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
user: dict = Depends(require_menu_code("biz:collectionRegion:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = region_svc.list_staff(
|
||||
db, region_id=region_id, page=page, page_size=pageSize, tenant_no=tenant_no
|
||||
)
|
||||
tp = region_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/collection-regions/{region_id}/staff")
|
||||
async def create_region_staff(
|
||||
region_id: str,
|
||||
body: RegionStaffCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = region_svc.create_staff(
|
||||
db,
|
||||
region_id=region_id,
|
||||
user_id=body.user_id,
|
||||
is_leader=bool(body.is_leader),
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id_actor=user.get("user_id"),
|
||||
)
|
||||
# enrich response with user display fields
|
||||
items, _ = region_svc.list_staff(db, region_id=region_id, page=1, page_size=100, tenant_no=tenant_no)
|
||||
matched = next((x for x in items if x["id"] == obj.id), region_svc._staff_to_dict(obj))
|
||||
return success_response(matched, message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/collection-regions/{region_id}/staff/{staff_id}")
|
||||
async def update_region_staff(
|
||||
region_id: str,
|
||||
staff_id: str,
|
||||
body: RegionStaffUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
s = region_svc.get_staff(db, staff_id, tenant_no=tenant_no)
|
||||
if not s or s.region_id != region_id:
|
||||
return error_response(400, "绑定记录不存在")
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = region_svc.update_staff(
|
||||
db,
|
||||
staff_id,
|
||||
is_leader=payload.get("is_leader"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
items, _ = region_svc.list_staff(db, region_id=region_id, page=1, page_size=100, tenant_no=tenant_no)
|
||||
matched = next((x for x in items if x["id"] == obj.id), region_svc._staff_to_dict(obj))
|
||||
return success_response(matched, message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/collection-regions/{region_id}/staff/{staff_id}")
|
||||
async def delete_region_staff(
|
||||
region_id: str,
|
||||
staff_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
s = region_svc.get_staff(db, staff_id, tenant_no=tenant_no)
|
||||
if not s or s.region_id != region_id:
|
||||
return error_response(400, "绑定记录不存在")
|
||||
region_svc.soft_delete_staff(db, staff_id, tenant_no=tenant_no)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 采集协议号池
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2,29 +2,60 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from sqlalchemy import and_ as sa_and, func as sa_func
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.data_scope import get_user_dept_id
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
from app.core.response import error_response, success_response
|
||||
from app.core.utils import parse_datetime
|
||||
from app.crud import collection_protocol as cp_svc
|
||||
from app.crud import customer as customer_crud
|
||||
from app.models.models import (
|
||||
BizCollectionProtocol,
|
||||
BizCustomer,
|
||||
BizProtocolPartyCustomer,
|
||||
BizProtocolSample,
|
||||
BizProtocolStorageCert,
|
||||
SysUser,
|
||||
)
|
||||
from app.services.party_customer_sync import sync_party_customers
|
||||
from app.services.sample_collect_wx_notify import (
|
||||
PROTOCOL_KIND_CELL,
|
||||
sample_codes_from_orm_rows,
|
||||
sample_codes_from_payload,
|
||||
schedule_sample_collect_notify,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["细胞存储"])
|
||||
|
||||
_PAYMENT_DETAIL_SENSITIVE_FIELDS = {
|
||||
"pay_status",
|
||||
"payStatus",
|
||||
"finance_confirm_attachments",
|
||||
"financeConfirmAttachments",
|
||||
"finance_confirm_attachment_files",
|
||||
"financeConfirmAttachmentFiles",
|
||||
"finance_remark",
|
||||
"financeRemark",
|
||||
"refund_amount",
|
||||
"refundAmount",
|
||||
"refund_remark",
|
||||
"refundRemark",
|
||||
"refund_attachments",
|
||||
"refundAttachments",
|
||||
"refund_attachment_files",
|
||||
"refundAttachmentFiles",
|
||||
"refund_finance_confirm_attachments",
|
||||
"refundFinanceConfirmAttachments",
|
||||
"refund_finance_confirm_attachment_files",
|
||||
"refundFinanceConfirmAttachmentFiles",
|
||||
"refund_finance_remark",
|
||||
"refundFinanceRemark",
|
||||
}
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
@@ -79,49 +110,6 @@ def _parse_datetime_fields(d: dict, camel_keys: list) -> None:
|
||||
d[snake] = None
|
||||
|
||||
|
||||
def _ensure_customer_for_mother(db: Session, party_customer: dict, tenant_no: str, user_id: str) -> dict:
|
||||
"""If mother info exists but no customerId, create a customer record and link it."""
|
||||
if not party_customer:
|
||||
return party_customer
|
||||
customer_id = party_customer.get("customer_id") or party_customer.get("customerId")
|
||||
mother_name = (party_customer.get("mother_name") or party_customer.get("motherName") or "").strip()
|
||||
if customer_id or not mother_name:
|
||||
return party_customer
|
||||
# Build customer fields from mother info
|
||||
mother_phone = (party_customer.get("mother_phone") or party_customer.get("motherPhone") or "").strip() or None
|
||||
mother_id_card = (party_customer.get("mother_id_card") or party_customer.get("motherIdCard") or "").strip() or None
|
||||
|
||||
# Check if customer with this id_card already exists
|
||||
if mother_id_card and customer_crud.id_card_taken(db, mother_id_card, tenant_no):
|
||||
existing = db.query(BizCustomer).filter(
|
||||
sa_and(
|
||||
BizCustomer.id_card_no == mother_id_card,
|
||||
BizCustomer.tenant_no == tenant_no,
|
||||
BizCustomer.del_flag == 0,
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
party_customer["customer_id"] = existing.id
|
||||
return party_customer
|
||||
|
||||
# Create new customer
|
||||
try:
|
||||
obj = customer_crud.create_customer(
|
||||
db,
|
||||
customer_name=mother_name,
|
||||
sex="1", # female (mother)
|
||||
id_card_no=mother_id_card,
|
||||
phone_no=mother_phone,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
party_customer["customer_id"] = obj.id
|
||||
except ValueError:
|
||||
# If creation fails (e.g. duplicate), try to find existing
|
||||
pass
|
||||
return party_customer
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 采集协议单
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -184,12 +172,16 @@ class ProtocolCreateBody(BaseModel):
|
||||
health_survey_form_urls: Optional[str] = Field(None, alias="healthSurveyFormUrls")
|
||||
register_date: Optional[str] = Field(None, alias="registerDate")
|
||||
collector_user_id: Optional[str] = Field(None, alias="collectorUserId")
|
||||
protocol_total_amount: Optional[float] = Field(None, alias="protocolTotalAmount")
|
||||
source_presign_order_id: Optional[str] = Field(None, alias="sourcePresignOrderId")
|
||||
payment_model_version: Optional[int] = Field(2, alias="paymentModelVersion")
|
||||
remark: Optional[str] = None
|
||||
party_customer: Optional[dict] = Field(None, alias="partyCustomer")
|
||||
collection_info: Optional[dict] = Field(None, alias="collectionInfo")
|
||||
samples: Optional[list] = Field(None, alias="samples")
|
||||
payment: Optional[dict] = Field(None, alias="payment")
|
||||
payment_details: Optional[list] = Field(None, alias="paymentDetails")
|
||||
sample_payments: Optional[list] = Field(None, alias="samplePayments")
|
||||
attachments: Optional[list] = Field(None, alias="attachments")
|
||||
consent_attachments: Optional[list] = Field(None, alias="consentAttachments")
|
||||
health_survey_attachments: Optional[list] = Field(None, alias="healthSurveyAttachments")
|
||||
@@ -215,18 +207,40 @@ class ProtocolUpdateBody(BaseModel):
|
||||
to_research_time: Optional[str] = Field(None, alias="toResearchTime")
|
||||
to_research_desc: Optional[str] = Field(None, alias="toResearchDesc")
|
||||
collector_user_id: Optional[str] = Field(None, alias="collectorUserId")
|
||||
protocol_total_amount: Optional[float] = Field(None, alias="protocolTotalAmount")
|
||||
source_presign_order_id: Optional[str] = Field(None, alias="sourcePresignOrderId")
|
||||
remark: Optional[str] = None
|
||||
party_customer: Optional[dict] = Field(None, alias="partyCustomer")
|
||||
collection_info: Optional[dict] = Field(None, alias="collectionInfo")
|
||||
samples: Optional[list] = Field(None, alias="samples")
|
||||
payment: Optional[dict] = Field(None, alias="payment")
|
||||
payment_details: Optional[list] = Field(None, alias="paymentDetails")
|
||||
sample_payments: Optional[list] = Field(None, alias="samplePayments")
|
||||
attachments: Optional[list] = Field(None, alias="attachments")
|
||||
consent_attachments: Optional[list] = Field(None, alias="consentAttachments")
|
||||
health_survey_attachments: Optional[list] = Field(None, alias="healthSurveyAttachments")
|
||||
storage_cert: Optional[dict] = Field(None, alias="storageCert")
|
||||
|
||||
|
||||
def assert_protocol_sensitive_fields(
|
||||
db: Session,
|
||||
user: dict,
|
||||
body: BaseModel,
|
||||
*,
|
||||
creating: bool,
|
||||
) -> None:
|
||||
"""防止通过普通协议保存接口注入财务确认或退费状态字段(不做菜单权限校验)。"""
|
||||
for detail in getattr(body, "payment_details", None) or []:
|
||||
normalized_detail = (
|
||||
cp_svc._normalize_fields(detail) if isinstance(detail, dict) else {}
|
||||
)
|
||||
if _PAYMENT_DETAIL_SENSITIVE_FIELDS.intersection(normalized_detail.keys()):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="普通协议保存不允许提交财务确认或退费状态字段",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_protocol_attachments(
|
||||
protocol_attachments: Optional[str],
|
||||
attachments: Optional[list],
|
||||
@@ -268,28 +282,78 @@ def _persist_protocol_sub_resources(
|
||||
samples: Optional[list],
|
||||
payment: Optional[dict],
|
||||
payment_details: Optional[list],
|
||||
sample_payments: Optional[list] = None,
|
||||
storage_cert: Optional[dict],
|
||||
payment_model_version: int = 2,
|
||||
commit: bool = True,
|
||||
) -> None:
|
||||
"""持久化协议子资源。
|
||||
|
||||
version=1:缴费(含明细校验)须在样本之前,避免校验失败后已占用样本编码。
|
||||
version=2:样本先落库占码,再按 sample_code 挂样本缴费方案。
|
||||
|
||||
默认整段子资源共用一次事务:内部一律 commit=False,成功后再统一 commit,
|
||||
避免样本已提交、缴费校验失败导致「提示失败但部分已保存」。
|
||||
"""
|
||||
if party_customer:
|
||||
_parse_datetime_fields(party_customer, ["newbornDeliveryTime"])
|
||||
party_customer = _ensure_customer_for_mother(db, party_customer, tenant_no, user_id)
|
||||
cp_svc.upsert_party_customer(db, protocol_id, tenant_no, user_id, **party_customer)
|
||||
party_customer = sync_party_customers(
|
||||
db,
|
||||
party_customer,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
commit=False,
|
||||
)
|
||||
cp_svc.upsert_party_customer(
|
||||
db, protocol_id, tenant_no, user_id, commit=False, **party_customer
|
||||
)
|
||||
if collection_info:
|
||||
_parse_datetime_fields(collection_info, ["sampleCollectTime", "maternalBloodCollectTime"])
|
||||
cp_svc.upsert_collection_info(db, protocol_id, tenant_no, user_id, **collection_info)
|
||||
if samples is not None:
|
||||
cp_svc.save_samples(db, protocol_id, tenant_no, user_id, samples)
|
||||
pay = None
|
||||
if payment:
|
||||
pay = cp_svc.upsert_payment(db, protocol_id, tenant_no, user_id, **payment)
|
||||
if payment_details is not None:
|
||||
if not pay and payment:
|
||||
pay = cp_svc.upsert_payment(db, protocol_id, tenant_no, user_id, **payment)
|
||||
if pay:
|
||||
aligned_details = cp_svc.align_fq_payment_details_payload(pay, payment_details)
|
||||
cp_svc.save_payment_details(db, pay.id, tenant_no, user_id, aligned_details)
|
||||
cp_svc.upsert_collection_info(
|
||||
db, protocol_id, tenant_no, user_id, commit=False, **collection_info
|
||||
)
|
||||
|
||||
version = int(payment_model_version or 2)
|
||||
if version >= 2:
|
||||
if samples is not None:
|
||||
cp_svc.save_samples(
|
||||
db, protocol_id, tenant_no, user_id, samples, commit=False
|
||||
)
|
||||
if sample_payments is not None:
|
||||
from app.crud import protocol_sample_payment as sp_pay_svc
|
||||
|
||||
sp_pay_svc.save_sample_payments_for_protocol(
|
||||
db,
|
||||
collection_protocol_id=protocol_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
sample_payments=sample_payments,
|
||||
commit=False,
|
||||
)
|
||||
else:
|
||||
# 旧模型:缴费先于样本
|
||||
if payment or payment_details is not None:
|
||||
cp_svc.save_payment_with_details(
|
||||
db,
|
||||
protocol_id,
|
||||
tenant_no,
|
||||
user_id,
|
||||
payment=payment,
|
||||
payment_details=payment_details,
|
||||
commit=False,
|
||||
)
|
||||
if samples is not None:
|
||||
cp_svc.save_samples(
|
||||
db, protocol_id, tenant_no, user_id, samples, commit=False
|
||||
)
|
||||
|
||||
if storage_cert:
|
||||
cp_svc.upsert_storage_cert(db, protocol_id, tenant_no, user_id, **storage_cert)
|
||||
cp_svc.upsert_storage_cert(
|
||||
db, protocol_id, tenant_no, user_id, commit=False, **storage_cert
|
||||
)
|
||||
|
||||
if commit:
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/collection-protocols")
|
||||
@@ -298,8 +362,10 @@ async def create_collection_protocol(
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
assert_protocol_sensitive_fields(db, user, body, creating=True)
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
protocol_id: Optional[str] = None
|
||||
try:
|
||||
protocol_attachments = _resolve_protocol_attachments(
|
||||
body.protocol_attachments,
|
||||
@@ -318,6 +384,7 @@ async def create_collection_protocol(
|
||||
collector = user_id
|
||||
obj = cp_svc.create_collection_protocol(
|
||||
db,
|
||||
create_dept_id=get_user_dept_id(db, user_id) or "",
|
||||
protocol_no=body.protocol_no or "",
|
||||
protocol_no_id=body.protocol_no_id,
|
||||
protocol_status=body.protocol_status,
|
||||
@@ -326,9 +393,15 @@ async def create_collection_protocol(
|
||||
health_survey_form_urls=health_survey_form_urls,
|
||||
register_date=body.register_date,
|
||||
collector_user_id=collector,
|
||||
protocol_total_amount=body.protocol_total_amount,
|
||||
source_presign_order_id=body.source_presign_order_id,
|
||||
payment_model_version=body.payment_model_version
|
||||
if body.payment_model_version is not None
|
||||
else 2,
|
||||
remark=body.remark,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
commit=False,
|
||||
)
|
||||
protocol_id = obj.id
|
||||
_persist_protocol_sub_resources(
|
||||
@@ -341,10 +414,28 @@ async def create_collection_protocol(
|
||||
samples=body.samples,
|
||||
payment=body.payment,
|
||||
payment_details=body.payment_details,
|
||||
sample_payments=body.sample_payments,
|
||||
storage_cert=body.storage_cert,
|
||||
payment_model_version=int(
|
||||
getattr(obj, "payment_model_version", None) or 2
|
||||
),
|
||||
commit=True,
|
||||
)
|
||||
db.refresh(obj)
|
||||
schedule_sample_collect_notify(
|
||||
tenant_no=tenant_no,
|
||||
protocol_id=protocol_id,
|
||||
protocol_kind=PROTOCOL_KIND_CELL,
|
||||
before_codes=[],
|
||||
submitted_codes=(
|
||||
sample_codes_from_payload(body.samples)
|
||||
if body.samples is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
return success_response(cp_svc._protocol_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
db.rollback()
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@@ -355,6 +446,7 @@ async def update_collection_protocol(
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
assert_protocol_sensitive_fields(db, user, body, creating=False)
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
if not _protocol_accessible(db, protocol_id, tenant_no, user):
|
||||
@@ -363,7 +455,7 @@ async def update_collection_protocol(
|
||||
payload = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
sub_keys = {
|
||||
"party_customer", "collection_info", "samples",
|
||||
"payment", "payment_details", "attachments",
|
||||
"payment", "payment_details", "sample_payments", "attachments",
|
||||
"consent_attachments", "health_survey_attachments", "storage_cert",
|
||||
}
|
||||
main_payload = {k: v for k, v in payload.items() if k not in sub_keys}
|
||||
@@ -388,12 +480,20 @@ async def update_collection_protocol(
|
||||
)
|
||||
if resolved is not None:
|
||||
main_payload["health_survey_form_urls"] = resolved
|
||||
before_codes = sample_codes_from_orm_rows(
|
||||
cp_svc.list_samples(db, protocol_id, tenant_no)
|
||||
)
|
||||
obj = cp_svc.update_collection_protocol(
|
||||
db,
|
||||
protocol_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
**{k: v for k, v in main_payload.items() if v is not None},
|
||||
commit=False,
|
||||
**{
|
||||
k: v
|
||||
for k, v in main_payload.items()
|
||||
if v is not None or k == "protocol_total_amount"
|
||||
},
|
||||
)
|
||||
_persist_protocol_sub_resources(
|
||||
db,
|
||||
@@ -405,10 +505,28 @@ async def update_collection_protocol(
|
||||
samples=body.samples,
|
||||
payment=body.payment,
|
||||
payment_details=body.payment_details,
|
||||
sample_payments=body.sample_payments,
|
||||
storage_cert=body.storage_cert,
|
||||
payment_model_version=int(
|
||||
getattr(obj, "payment_model_version", None) or 1
|
||||
),
|
||||
commit=True,
|
||||
)
|
||||
db.refresh(obj)
|
||||
schedule_sample_collect_notify(
|
||||
tenant_no=tenant_no,
|
||||
protocol_id=protocol_id,
|
||||
protocol_kind=PROTOCOL_KIND_CELL,
|
||||
before_codes=before_codes,
|
||||
submitted_codes=(
|
||||
sample_codes_from_payload(body.samples)
|
||||
if body.samples is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
return success_response(cp_svc._protocol_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
db.rollback()
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@@ -479,6 +597,8 @@ async def submit_collection_protocol_audit(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
if not _protocol_accessible(db, protocol_id, tenant_no, user):
|
||||
return error_response(403, "无权访问该协议或协议不存在")
|
||||
try:
|
||||
cp_svc.create_audit(
|
||||
db,
|
||||
@@ -495,6 +615,23 @@ async def submit_collection_protocol_audit(
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/collection-protocols/{protocol_id}/status-changes")
|
||||
async def list_collection_protocol_status_changes(
|
||||
protocol_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
if not _protocol_accessible(db, protocol_id, tenant_no, user):
|
||||
return error_response(403, "无权访问该协议或协议不存在")
|
||||
items = cp_svc.list_protocol_status_changes(
|
||||
db,
|
||||
collection_protocol_id=protocol_id,
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response({"list": items})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 收款确认
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1069,6 +1206,94 @@ async def update_fullmoon_todo_insurance(
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
class StorageCertHandleBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
handle_time: Optional[str] = Field(None, alias="handleTime")
|
||||
handler_user_id: Optional[str] = Field(None, alias="handlerUserId")
|
||||
cert_image_urls: Optional[str] = Field(None, alias="certImageUrls")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class FullmoonHandleBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
handle_time: Optional[str] = Field(None, alias="handleTime")
|
||||
handler_user_id: Optional[str] = Field(None, alias="handlerUserId")
|
||||
insurance_attachments: Optional[str] = Field(None, alias="insuranceAttachments")
|
||||
handle_remark: Optional[str] = Field(None, alias="handleRemark")
|
||||
|
||||
|
||||
def _attach_handler_name(db: Session, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
uid = (payload.get("handlerUserId") or "").strip()
|
||||
name = ""
|
||||
if uid:
|
||||
u = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
|
||||
name = (u.nick_name or "") if u else ""
|
||||
payload["handlerUserName"] = name
|
||||
return payload
|
||||
|
||||
|
||||
@router.put("/storage-certs/{protocol_id}")
|
||||
async def update_storage_cert_handle(
|
||||
protocol_id: str,
|
||||
body: StorageCertHandleBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
if not _protocol_accessible(db, protocol_id, tenant_no, user):
|
||||
return error_response(404, "协议单不存在")
|
||||
try:
|
||||
ht = parse_datetime(body.handle_time) if body.handle_time else None
|
||||
sc = cp_svc.upsert_storage_cert_handle(
|
||||
db,
|
||||
protocol_id,
|
||||
tenant_no,
|
||||
user_id,
|
||||
handle_time=ht,
|
||||
handler_user_id=body.handler_user_id,
|
||||
cert_image_urls=body.cert_image_urls,
|
||||
remark=body.remark,
|
||||
)
|
||||
return success_response(
|
||||
_attach_handler_name(db, cp_svc._storage_cert_to_dict(sc)), message="更新成功"
|
||||
)
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/fullmoon-todos/{protocol_id}/handle")
|
||||
async def update_fullmoon_todo_handle(
|
||||
protocol_id: str,
|
||||
body: FullmoonHandleBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
if not _protocol_accessible(db, protocol_id, tenant_no, user):
|
||||
return error_response(404, "协议单不存在")
|
||||
try:
|
||||
ht = parse_datetime(body.handle_time) if body.handle_time else None
|
||||
ft = cp_svc.upsert_fullmoon_todo_handle(
|
||||
db,
|
||||
protocol_id,
|
||||
tenant_no,
|
||||
user_id,
|
||||
handle_time=ht,
|
||||
handler_user_id=body.handler_user_id,
|
||||
insurance_attachments=body.insurance_attachments,
|
||||
handle_remark=body.handle_remark,
|
||||
)
|
||||
return success_response(
|
||||
_attach_handler_name(db, cp_svc._fullmoon_todo_to_dict(ft)), message="更新成功"
|
||||
)
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
class InvoiceRequestCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""客户服务字典 API"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_any_menu_code
|
||||
from app.core.response import success_response
|
||||
from app.crud import customer_service_dict as svc
|
||||
from app.models.models import SysUser
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["客户服务字典"])
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失,无法确定租户")
|
||||
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
|
||||
if not sys_user or not sys_user.tenant_no:
|
||||
raise PermissionError("用户租户信息缺失")
|
||||
return sys_user.tenant_no
|
||||
|
||||
|
||||
@router.get("/customer-service-dict")
|
||||
async def get_customer_service_dict(
|
||||
dictType: str = Query(...),
|
||||
user: dict = Depends(require_any_menu_code("cell-storage:sample-info:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
values = svc.list_dict_values(db, tenant_no, dictType)
|
||||
return success_response({"list": values})
|
||||
@@ -319,7 +319,7 @@ async def list_env_detect_records(
|
||||
query = apply_data_scope(query, BizEnvDetectRecord, user, db, tenant_no)
|
||||
total = query.count()
|
||||
items = (
|
||||
query.order_by(BizEnvDetectRecord.create_time.desc())
|
||||
query.order_by(BizEnvDetectRecord.detect_time.desc())
|
||||
.offset((page - 1) * pageSize)
|
||||
.limit(pageSize)
|
||||
.all()
|
||||
|
||||
@@ -139,22 +139,12 @@ async def submit_leave(
|
||||
|
||||
|
||||
@router.post("/callback")
|
||||
async def leave_workflow_callback(
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
logger.info("请假工作流回调: %s", body)
|
||||
business_id = body.get("businessId", "")
|
||||
event = body.get("event", "")
|
||||
if not business_id:
|
||||
return success_response({"received": True})
|
||||
|
||||
if event == "completed":
|
||||
leave_crud.update_leave_status(db, business_id, "approved")
|
||||
elif event == "rejected":
|
||||
leave_crud.update_leave_status(db, business_id, "rejected")
|
||||
db.commit()
|
||||
return success_response({"received": True})
|
||||
async def leave_workflow_callback_blocked():
|
||||
"""HTTP 回调已禁用:请假审批结果仅允许工作流本地回调(register_local_callback)。"""
|
||||
return error_response(
|
||||
403,
|
||||
"请假审批回调仅支持进程内本地回调,请勿通过 HTTP 调用本接口",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{leave_id}")
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+220
-110
@@ -7,6 +7,7 @@ from app.core.response import success_response
|
||||
router = APIRouter(prefix="/web/menu", tags=["菜单管理"])
|
||||
|
||||
# 与 web-admin/src/router/modules/sqxp.ts 一致:5 个一级菜单分组
|
||||
# 市场二级顺序:存储服务、科研捐赠、市场基础信息、中试服务、基础实验
|
||||
STATIC_MENU_TREE = [
|
||||
# ────────────────────────────────────────────
|
||||
# 1. 市场 (menu:market)
|
||||
@@ -25,14 +26,14 @@ STATIC_MENU_TREE = [
|
||||
"roles": ["admin"],
|
||||
},
|
||||
"children": [
|
||||
# 细胞存储
|
||||
# 存储服务(含细胞存储 / 科研捐赠统一业务入口)
|
||||
{
|
||||
"id": "CEL00000000000000000000000000001",
|
||||
"name": "CellStorage",
|
||||
"path": "cell-storage",
|
||||
"component": "",
|
||||
"meta": {
|
||||
"title": "细胞存储",
|
||||
"title": "存储服务",
|
||||
"icon": "ri:test-tube-line",
|
||||
"keepAlive": False,
|
||||
"roles": ["admin"],
|
||||
@@ -52,29 +53,82 @@ STATIC_MENU_TREE = [
|
||||
"component": "/cell-storage/protocol-audit/index",
|
||||
"meta": {"title": "采集协议单", "keepAlive": True, "menuCode": "biz:cellStorageProtocolAudit:list"},
|
||||
},
|
||||
{
|
||||
"id": "50000000000000000000000000000020",
|
||||
"name": "CellStoragePresignOrder",
|
||||
"path": "presign-order",
|
||||
"component": "/cell-storage/presign-order/index",
|
||||
"meta": {"title": "预签约订单", "keepAlive": True, "menuCode": "cell-storage:presign-order:list"},
|
||||
},
|
||||
{
|
||||
"id": "904b5173519411f1944300163e3bdd96",
|
||||
"name": "CellStoragePaymentInfo",
|
||||
"path": "payment-info",
|
||||
"component": "/cell-storage/payment-info/index",
|
||||
"meta": {
|
||||
"title": "缴费信息",
|
||||
"keepAlive": True,
|
||||
"menuCode": "cellStorage:paymentInfo:list",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "50000000000000000000000000000022",
|
||||
"name": "CellStoragePaymentChase",
|
||||
"path": "payment-chase",
|
||||
"component": "/cell-storage/payment-chase/index",
|
||||
"meta": {
|
||||
"title": "催缴信息",
|
||||
"keepAlive": True,
|
||||
"menuCode": "cellStorage:paymentChase:list",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "50000000000000000000000000000013",
|
||||
"name": "CellStorageSampleInfo",
|
||||
"path": "sample-info",
|
||||
"component": "/cell-storage/sample-info/index",
|
||||
"meta": {"title": "样本信息管理", "keepAlive": True, "menuCode": "cell-storage:sample-info:list"},
|
||||
"meta": {"title": "客户服务管理", "keepAlive": True, "menuCode": "cell-storage:sample-info:list"},
|
||||
},
|
||||
{
|
||||
"id": "50000000000000000000000000000021",
|
||||
"name": "CellStorageSampleFreight",
|
||||
"path": "sample-freight",
|
||||
"component": "/cell-storage/sample-freight/index",
|
||||
"meta": {
|
||||
"title": "运费信息",
|
||||
"keepAlive": True,
|
||||
"menuCode": "cell-storage:sample-freight:list",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "50000000000000000000000000000023",
|
||||
"name": "CellStorageSfExpressSettlement",
|
||||
"path": "sf-express-settlement",
|
||||
"component": "/cell-storage/sf-express-settlement/index",
|
||||
"meta": {
|
||||
"title": "顺丰月结",
|
||||
"keepAlive": True,
|
||||
"menuCode": "cell-storage:sf-express-settlement:list",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "50000000000000000000000000000004",
|
||||
"name": "CellStoragePaymentConfirm",
|
||||
"path": "payment-confirm",
|
||||
"component": "/cell-storage/payment-confirm/index",
|
||||
"meta": {"title": "缴退费财务确认及开票", "keepAlive": True, "menuCode": "biz:cellStoragePaymentConfirm:list"},
|
||||
},
|
||||
{
|
||||
"id": "70000000000000000000000000000011",
|
||||
"name": "CellStorageStorageReleaseReview",
|
||||
"path": "storage-release-review",
|
||||
"component": "/quality-inspection/storage-release-review/index",
|
||||
"meta": {"title": "入库放行审核", "keepAlive": True, "menuCode": "biz:storageReleaseReview:list"},
|
||||
"meta": {"title": "缴费财务确认", "keepAlive": True, "menuCode": "biz:cellStoragePaymentConfirm:list"},
|
||||
},
|
||||
# 市场侧入库放行审核(总览)暂下线:改由生产/质检入口审核
|
||||
# {
|
||||
# "id": "70000000000000000000000000000011",
|
||||
# "name": "CellStorageStorageReleaseReview",
|
||||
# "path": "storage-release-review",
|
||||
# "component": "/quality-inspection/storage-release-review/index",
|
||||
# "meta": {"title": "入库放行审核", "keepAlive": True, "menuCode": "biz:storageReleaseReview:list"},
|
||||
# },
|
||||
],
|
||||
},
|
||||
|
||||
# 科研捐赠
|
||||
{
|
||||
"id": "RSC00000000000000000000000000001",
|
||||
@@ -85,6 +139,7 @@ STATIC_MENU_TREE = [
|
||||
"title": "科研捐赠",
|
||||
"icon": "ri:hand-heart-line",
|
||||
"keepAlive": False,
|
||||
"isHide": True,
|
||||
"roles": ["admin"],
|
||||
},
|
||||
"children": [
|
||||
@@ -100,17 +155,122 @@ STATIC_MENU_TREE = [
|
||||
"name": "ResearchDonationAudit",
|
||||
"path": "audit",
|
||||
"component": "/research-donation/audit/index",
|
||||
"meta": {"title": "捐赠协议单", "keepAlive": True, "menuCode": "biz:researchDonationAudit:list"},
|
||||
"meta": {"title": "捐赠协议单", "keepAlive": True, "isHide": True, "menuCode": "biz:researchDonationAudit:list"},
|
||||
},
|
||||
{
|
||||
"id": "60000000000000000000000000000004",
|
||||
"name": "ResearchDonationSampleInfo",
|
||||
"path": "sample-info",
|
||||
"component": "/research-donation/sample-info/index",
|
||||
"meta": {"title": "样本信息管理", "keepAlive": True, "menuCode": "research-donation:sample-info:list"},
|
||||
"meta": {
|
||||
"title": "样本信息管理",
|
||||
"keepAlive": True,
|
||||
"isHide": True,
|
||||
"menuCode": "research-donation:sample-info:list",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
# 市场基础信息(前段:存储/捐赠相关;后段:中试/基础实验相关)
|
||||
{
|
||||
"id": "MKT00000000000000000000000000001",
|
||||
"name": "MarketInfo",
|
||||
"path": "market-info",
|
||||
"component": "",
|
||||
"meta": {
|
||||
"title": "市场基础信息",
|
||||
"icon": "ri:price-tag-3-line",
|
||||
"keepAlive": False,
|
||||
"roles": ["admin"],
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "122",
|
||||
"name": "BizCellSampleType",
|
||||
"path": "cell-sample-type",
|
||||
"component": "/basic-info/cell-sample-type/index",
|
||||
"meta": {"title": "细胞样本基础信息维护", "keepAlive": True, "menuCode": "biz:cellSampleType:list"},
|
||||
},
|
||||
|
||||
{
|
||||
"id": "123",
|
||||
"name": "BizCollectionRegion",
|
||||
"path": "collection-region",
|
||||
"component": "/basic-info/collection-region/index",
|
||||
"meta": {"title": "采集地区及医院", "keepAlive": True, "menuCode": "biz:collectionRegion:list"},
|
||||
},
|
||||
|
||||
{
|
||||
"id": "40000000000000000000000000000007",
|
||||
"name": "BasicInfoInsuranceCompany",
|
||||
"path": "insurance-company",
|
||||
"component": "/basic-info/insurance-company/index",
|
||||
"meta": {"title": "保险公司及险种价格", "keepAlive": True, "isHide": True, "menuCode": "basic-info:insurance-company:list"},
|
||||
},
|
||||
|
||||
{
|
||||
"id": "50000000000000000000000000000009",
|
||||
"name": "MarketMaterial",
|
||||
"path": "material",
|
||||
"component": "/cell-storage/material/index",
|
||||
"meta": {"title": "市场物料库", "keepAlive": True, "menuCode": "biz:material:list"},
|
||||
},
|
||||
|
||||
{
|
||||
"id": "95000000000000000000000000000003",
|
||||
"name": "MarketMyMaterialUsage",
|
||||
"path": "my-material-usage",
|
||||
"component": "/cell-storage/my-material-usage/index",
|
||||
"meta": {
|
||||
"title": "我的物料领用记录",
|
||||
"keepAlive": True,
|
||||
"menuCode": "market:material-usage:list",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
"id": "121",
|
||||
"name": "BizCustomer",
|
||||
"path": "customer",
|
||||
"component": "/basic-info/customer/index",
|
||||
"meta": {"title": "客户基础信息", "keepAlive": True, "menuCode": "biz:customer:list"},
|
||||
},
|
||||
|
||||
{
|
||||
"id": "80000000000000000000000000000002",
|
||||
"name": "CellProductType",
|
||||
"path": "cell-product-type",
|
||||
"component": "/pilot-service/cell-product-type/index",
|
||||
"meta": {"title": "细胞产品基础信息维护", "keepAlive": True, "menuCode": "pilot-service:cell-product-type:list"},
|
||||
},
|
||||
|
||||
{
|
||||
"id": "80000000000000000000000000000007",
|
||||
"name": "CheckupInstitution",
|
||||
"path": "checkup-institution",
|
||||
"component": "/pilot-service/checkup-institution/index",
|
||||
"meta": {"title": "体检机构及项目价格", "keepAlive": True, "isHide": True, "menuCode": "pilot-service:checkup-institution:list"},
|
||||
},
|
||||
|
||||
{
|
||||
"id": "80000000000000000000000000000006",
|
||||
"name": "Channel",
|
||||
"path": "channel",
|
||||
"component": "/pilot-service/channel/index",
|
||||
"meta": {"title": "渠道信息管理", "keepAlive": True, "menuCode": "pilot-service:channel:list"},
|
||||
},
|
||||
|
||||
{
|
||||
"id": "80000000000000000000000000000009",
|
||||
"name": "ChannelServiceOrderFinance",
|
||||
"path": "channel-service-order-finance",
|
||||
"component": "/pilot-service/channel-service-order-finance/index",
|
||||
"meta": {"title": "渠道服务订单(财务确认)", "keepAlive": True, "menuCode": "pilot-service:channel-service-order-finance:list"},
|
||||
}
|
||||
],
|
||||
},
|
||||
|
||||
# 中试服务
|
||||
{
|
||||
"id": "PIL00000000000000000000000000001",
|
||||
@@ -140,6 +300,7 @@ STATIC_MENU_TREE = [
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
# 基础实验
|
||||
{
|
||||
"id": "BEX00000000000000000000000000001",
|
||||
@@ -187,96 +348,7 @@ STATIC_MENU_TREE = [
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
# 市场基础信息
|
||||
{
|
||||
"id": "MKT00000000000000000000000000001",
|
||||
"name": "MarketInfo",
|
||||
"path": "market-info",
|
||||
"component": "",
|
||||
"meta": {
|
||||
"title": "市场基础信息",
|
||||
"icon": "ri:price-tag-3-line",
|
||||
"keepAlive": False,
|
||||
"roles": ["admin"],
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "122",
|
||||
"name": "BizCellSampleType",
|
||||
"path": "cell-sample-type",
|
||||
"component": "/basic-info/cell-sample-type/index",
|
||||
"meta": {"title": "细胞样本基础信息维护", "keepAlive": True, "menuCode": "biz:cellSampleType:list"},
|
||||
},
|
||||
{
|
||||
"id": "123",
|
||||
"name": "BizCollectionRegion",
|
||||
"path": "collection-region",
|
||||
"component": "/basic-info/collection-region/index",
|
||||
"meta": {"title": "细胞采集基础信息维护", "keepAlive": True, "menuCode": "biz:collectionRegion:list"},
|
||||
},
|
||||
{
|
||||
"id": "80000000000000000000000000000002",
|
||||
"name": "CellProductType",
|
||||
"path": "cell-product-type",
|
||||
"component": "/pilot-service/cell-product-type/index",
|
||||
"meta": {"title": "细胞产品基础信息维护", "keepAlive": True, "menuCode": "pilot-service:cell-product-type:list"},
|
||||
},
|
||||
{
|
||||
"id": "40000000000000000000000000000007",
|
||||
"name": "BasicInfoInsuranceCompany",
|
||||
"path": "insurance-company",
|
||||
"component": "/basic-info/insurance-company/index",
|
||||
"meta": {"title": "保险公司及险种价格", "keepAlive": True, "menuCode": "basic-info:insurance-company:list"},
|
||||
},
|
||||
{
|
||||
"id": "80000000000000000000000000000007",
|
||||
"name": "CheckupInstitution",
|
||||
"path": "checkup-institution",
|
||||
"component": "/pilot-service/checkup-institution/index",
|
||||
"meta": {"title": "体检机构及项目价格", "keepAlive": True, "menuCode": "pilot-service:checkup-institution:list"},
|
||||
},
|
||||
{
|
||||
"id": "50000000000000000000000000000009",
|
||||
"name": "MarketMaterial",
|
||||
"path": "material",
|
||||
"component": "/cell-storage/material/index",
|
||||
"meta": {"title": "市场物料库", "keepAlive": True, "menuCode": "biz:material:list"},
|
||||
},
|
||||
{
|
||||
"id": "95000000000000000000000000000003",
|
||||
"name": "MarketMyMaterialUsage",
|
||||
"path": "my-material-usage",
|
||||
"component": "/cell-storage/my-material-usage/index",
|
||||
"meta": {
|
||||
"title": "我的物料领用记录",
|
||||
"keepAlive": True,
|
||||
"menuCode": "market:material-usage:list",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "121",
|
||||
"name": "BizCustomer",
|
||||
"path": "customer",
|
||||
"component": "/basic-info/customer/index",
|
||||
"meta": {"title": "客户管理", "keepAlive": True, "menuCode": "biz:customer:list"},
|
||||
},
|
||||
{
|
||||
"id": "80000000000000000000000000000006",
|
||||
"name": "Channel",
|
||||
"path": "channel",
|
||||
"component": "/pilot-service/channel/index",
|
||||
"meta": {"title": "渠道信息管理", "keepAlive": True, "menuCode": "pilot-service:channel:list"},
|
||||
},
|
||||
{
|
||||
"id": "80000000000000000000000000000009",
|
||||
"name": "ChannelServiceOrderFinance",
|
||||
"path": "channel-service-order-finance",
|
||||
"component": "/pilot-service/channel-service-order-finance/index",
|
||||
"meta": {"title": "渠道服务订单(财务确认)", "keepAlive": True, "menuCode": "pilot-service:channel-service-order-finance:list"},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
|
||||
@@ -426,6 +498,13 @@ STATIC_MENU_TREE = [
|
||||
"component": "/production/production-work-order/index",
|
||||
"meta": {"title": "生产工单", "keepAlive": True, "menuCode": "production:work-order:list"},
|
||||
},
|
||||
{
|
||||
"id": "95500000000000000000000000000001",
|
||||
"name": "ProductionStorageReleaseReview",
|
||||
"path": "storage-release-review",
|
||||
"component": "/quality-inspection/storage-release-review/index",
|
||||
"meta": {"title": "入库放行审核", "keepAlive": True, "menuCode": "production:storageReleaseReview:list"},
|
||||
},
|
||||
{
|
||||
"id": "94000000000000000000000000000001",
|
||||
"name": "ProductionMyProductionTask",
|
||||
@@ -466,6 +545,17 @@ STATIC_MENU_TREE = [
|
||||
"menuCode": "production:cleanroom-garment:list",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "95300000000000000000000000000001",
|
||||
"name": "ProductionWasteOutput",
|
||||
"path": "waste-output",
|
||||
"component": "/production/waste-output/index",
|
||||
"meta": {
|
||||
"title": "废弃物管理",
|
||||
"keepAlive": True,
|
||||
"menuCode": "production:waste-output:list",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
# 生产基础信息
|
||||
@@ -589,6 +679,13 @@ STATIC_MENU_TREE = [
|
||||
"component": "/quality-inspection/qc-work-order/index",
|
||||
"meta": {"title": "质检工单", "keepAlive": True, "menuCode": "biz:qcWorkOrder:list"},
|
||||
},
|
||||
{
|
||||
"id": "70000000000000000000000000000014",
|
||||
"name": "QualityInspectionStorageReleaseReview",
|
||||
"path": "storage-release-review",
|
||||
"component": "/quality-inspection/storage-release-review/index",
|
||||
"meta": {"title": "入库放行审核", "keepAlive": True, "menuCode": "quality:storageReleaseReview:list"},
|
||||
},
|
||||
{
|
||||
"id": "71000000000000000000000000000002",
|
||||
"name": "QualityInspectionEnvDetectRecord",
|
||||
@@ -870,13 +967,6 @@ STATIC_MENU_TREE = [
|
||||
"component": "/oa-approval/apps/index",
|
||||
"meta": {"title": "审批管理", "keepAlive": True, "menuCode": "oa:apps:list"},
|
||||
},
|
||||
{
|
||||
"id": "OA000000000000000000000000000102",
|
||||
"name": "OaApprovalRoles",
|
||||
"path": "roles",
|
||||
"component": "/oa-approval/roles/index",
|
||||
"meta": {"title": "工作流角色", "keepAlive": True, "menuCode": "oa:roles:list"},
|
||||
},
|
||||
{
|
||||
"id": "OA000000000000000000000000000103",
|
||||
"name": "OaApprovalStart",
|
||||
@@ -884,6 +974,19 @@ STATIC_MENU_TREE = [
|
||||
"component": "/oa-approval/start/index",
|
||||
"meta": {"title": "发起审批", "keepAlive": True, "menuCode": "oa:start"},
|
||||
},
|
||||
{
|
||||
"id": "OA000000000000000000000000000203",
|
||||
"name": "OaApprovalHistory",
|
||||
"path": "history/:appId",
|
||||
"component": "/oa-approval/history/index",
|
||||
"meta": {
|
||||
"title": "历史记录",
|
||||
"keepAlive": True,
|
||||
"isHide": True,
|
||||
"worktabMulti": True,
|
||||
"menuCode": "oa:start",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "OA000000000000000000000000000104",
|
||||
"name": "OaApprovalPending",
|
||||
@@ -912,6 +1015,13 @@ STATIC_MENU_TREE = [
|
||||
"component": "/oa-approval/cc/index",
|
||||
"meta": {"title": "抄送我的", "keepAlive": True, "menuCode": "oa:cc"},
|
||||
},
|
||||
{
|
||||
"id": "OA000000000000000000000000000110",
|
||||
"name": "OaApprovalMention",
|
||||
"path": "mentions",
|
||||
"component": "/oa-approval/mentions/index",
|
||||
"meta": {"title": "提到我的", "keepAlive": True, "menuCode": "oa:mention"},
|
||||
},
|
||||
{
|
||||
"id": "OA000000000000000000000000000201",
|
||||
"name": "OaApprovalDesigner",
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"""小程序鉴权:微信登录 / 绑定 openid。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import get_current_user_by_token
|
||||
from app.core.database import get_db
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud.user import user_crud
|
||||
from app.services import wx_qr_session
|
||||
from app.services.issue_user_tokens import issue_tokens_for_user
|
||||
from app.services.wechat_login_service import (
|
||||
resolve_wechat_login_by_openid_only,
|
||||
resolve_wechat_login_user,
|
||||
)
|
||||
from app.services.wechat_mini_auth import (
|
||||
WechatMiniAuthError,
|
||||
code2session,
|
||||
get_phone_number,
|
||||
)
|
||||
from app.services.wechat_mp_auth import WechatMpAuthError, oauth_code_to_openid
|
||||
|
||||
|
||||
router = APIRouter(prefix="/mini/auth", tags=["小程序鉴权"])
|
||||
|
||||
|
||||
async def _resolve_user_id(request: Request, db: Session) -> str:
|
||||
authorization = request.headers.get("Authorization", "")
|
||||
if not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="未授权")
|
||||
token = authorization.replace("Bearer ", "")
|
||||
current = await get_current_user_by_token(token=token, db=db)
|
||||
user_id = current.get("user_id") or current.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="未授权")
|
||||
return user_id
|
||||
|
||||
|
||||
@router.post("/wechat-login", response_model=dict)
|
||||
async def wechat_login(body: dict, db: Session = Depends(get_db)):
|
||||
"""微信登录:openid 已绑定时可静默换票;否则须授权手机号且与账号 phone_no 一致。"""
|
||||
login_code = (body.get("loginCode") or "").strip()
|
||||
phone_code = (body.get("phoneCode") or "").strip() or None
|
||||
|
||||
try:
|
||||
sess = code2session(login_code)
|
||||
except WechatMiniAuthError as exc:
|
||||
return error_response(400, str(exc))
|
||||
|
||||
if not phone_code:
|
||||
user, err_msg, err_code = resolve_wechat_login_by_openid_only(
|
||||
db, openid=sess["openid"]
|
||||
)
|
||||
if err_msg:
|
||||
return error_response(err_code or 400, err_msg)
|
||||
else:
|
||||
try:
|
||||
phone = get_phone_number(phone_code)
|
||||
except WechatMiniAuthError as exc:
|
||||
return error_response(400, str(exc))
|
||||
|
||||
user, err_msg, err_code = resolve_wechat_login_user(
|
||||
db, openid=sess["openid"], phone=phone
|
||||
)
|
||||
if err_msg:
|
||||
return error_response(err_code or 400, err_msg)
|
||||
|
||||
unionid = (sess.get("unionid") or "").strip()
|
||||
if unionid and not (getattr(user, "wx_unionid", None) or "").strip():
|
||||
user.wx_unionid = unionid
|
||||
user.update_time = datetime.now()
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
try:
|
||||
data = await issue_tokens_for_user(db, user)
|
||||
except ValueError as exc:
|
||||
return error_response(403, str(exc))
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.post("/wechat-bind", response_model=dict)
|
||||
async def wechat_bind(
|
||||
request: Request,
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""密码登录后绑定当前微信 openid(需 Bearer)。"""
|
||||
try:
|
||||
user_id = await _resolve_user_id(request, db)
|
||||
except HTTPException:
|
||||
return error_response(401, "未授权")
|
||||
|
||||
login_code = (body.get("loginCode") or "").strip()
|
||||
try:
|
||||
sess = code2session(login_code)
|
||||
except WechatMiniAuthError as exc:
|
||||
return error_response(400, str(exc))
|
||||
|
||||
openid = sess["openid"]
|
||||
user = user_crud.get(db, user_id)
|
||||
if not user or user.del_flag != 0:
|
||||
return error_response(401, "用户不存在")
|
||||
|
||||
other = user_crud.get_by_wx_openid(db, openid)
|
||||
if other and other.id != user.id:
|
||||
return error_response(409, "该微信已绑定其他账号")
|
||||
|
||||
existing_oid = (getattr(user, "wx_openid", None) or "").strip()
|
||||
if existing_oid == openid:
|
||||
return success_response({"bound": True, "message": "已绑定"})
|
||||
|
||||
if existing_oid and existing_oid != openid:
|
||||
return error_response(409, "该账号已绑定其他微信,请联系管理员")
|
||||
|
||||
user.wx_openid = openid
|
||||
user.update_time = datetime.now()
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return success_response({"bound": True, "message": "绑定成功"})
|
||||
|
||||
|
||||
@router.get("/mp-bind-status", response_model=dict)
|
||||
async def mp_bind_status(request: Request, db: Session = Depends(get_db)):
|
||||
"""当前用户是否已绑定微信服务号 openid。"""
|
||||
try:
|
||||
user_id = await _resolve_user_id(request, db)
|
||||
except HTTPException:
|
||||
return error_response(401, "未授权")
|
||||
|
||||
user = user_crud.get(db, user_id)
|
||||
bound = bool(user and (getattr(user, "wx_mp_openid", None) or "").strip())
|
||||
return success_response({"bound": bound})
|
||||
|
||||
|
||||
@router.post("/mp-bind", response_model=dict)
|
||||
async def mp_bind(request: Request, body: dict, db: Session = Depends(get_db)):
|
||||
"""服务号网页授权 code 绑定当前用户(需 Bearer)。"""
|
||||
try:
|
||||
user_id = await _resolve_user_id(request, db)
|
||||
except HTTPException:
|
||||
return error_response(401, "未授权")
|
||||
|
||||
code = (body.get("code") or "").strip()
|
||||
try:
|
||||
info = oauth_code_to_openid(code)
|
||||
except WechatMpAuthError as exc:
|
||||
return error_response(400, str(exc))
|
||||
|
||||
mp_openid = info["openid"]
|
||||
other = user_crud.get_by_wx_mp_openid(db, mp_openid)
|
||||
if other and other.id != user_id:
|
||||
return error_response(409, "该微信服务号已绑定其他账号")
|
||||
|
||||
user = user_crud.get(db, user_id)
|
||||
if not user or user.del_flag != 0:
|
||||
return error_response(401, "用户不存在")
|
||||
|
||||
user.wx_mp_openid = mp_openid
|
||||
if info.get("unionid"):
|
||||
user.wx_unionid = info["unionid"]
|
||||
user.wx_mp_bind_time = datetime.now()
|
||||
user.update_time = datetime.now()
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return success_response({"bound": True})
|
||||
|
||||
|
||||
@router.post("/web-qr-scanned", response_model=dict)
|
||||
async def web_qr_scanned(body: dict):
|
||||
raw = (body.get("sessionId") or body.get("scene") or "").strip()
|
||||
sid = wx_qr_session.resolve_session_id(raw) or raw
|
||||
data = wx_qr_session.get_session(sid)
|
||||
if not data or data.get("status") in ("expired", "consumed"):
|
||||
return error_response(400, "二维码已过期,请在电脑上刷新后重扫")
|
||||
wx_qr_session.mark_scanned(sid)
|
||||
return success_response({"ok": True})
|
||||
|
||||
|
||||
@router.post("/web-qr-confirm", response_model=dict)
|
||||
async def web_qr_confirm(body: dict, db: Session = Depends(get_db)):
|
||||
raw = (body.get("sessionId") or body.get("scene") or "").strip()
|
||||
sid = wx_qr_session.resolve_session_id(raw) or raw
|
||||
data = wx_qr_session.get_session(sid)
|
||||
if not data or data.get("status") in ("expired", "consumed", "failed"):
|
||||
return error_response(400, "二维码已过期,请在电脑上刷新后重扫")
|
||||
if data.get("status") == "success":
|
||||
return success_response({"ok": True, "message": "电脑端即将登录"})
|
||||
|
||||
wx_qr_session.mark_scanned(sid)
|
||||
|
||||
login_code = (body.get("loginCode") or "").strip()
|
||||
phone_code = (body.get("phoneCode") or "").strip()
|
||||
if not login_code or not phone_code:
|
||||
wx_qr_session.mark_failed(sid, "需要授权手机号才能完成扫码登录")
|
||||
return error_response(400, "需要授权手机号才能完成扫码登录")
|
||||
|
||||
try:
|
||||
sess = code2session(login_code)
|
||||
phone = get_phone_number(phone_code)
|
||||
except WechatMiniAuthError as exc:
|
||||
wx_qr_session.mark_failed(sid, str(exc))
|
||||
return error_response(400, str(exc))
|
||||
|
||||
user, err_msg, err_code = resolve_wechat_login_user(
|
||||
db, openid=sess["openid"], phone=phone
|
||||
)
|
||||
if err_msg:
|
||||
wx_qr_session.mark_failed(sid, err_msg)
|
||||
return error_response(err_code or 400, err_msg)
|
||||
|
||||
unionid = (sess.get("unionid") or "").strip()
|
||||
if unionid and not (getattr(user, "wx_unionid", None) or "").strip():
|
||||
user.wx_unionid = unionid
|
||||
user.update_time = datetime.now()
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
try:
|
||||
tokens = await issue_tokens_for_user(db, user)
|
||||
except ValueError as exc:
|
||||
wx_qr_session.mark_failed(sid, str(exc))
|
||||
return error_response(403, str(exc))
|
||||
|
||||
if not wx_qr_session.mark_success(sid, tokens):
|
||||
return error_response(400, "二维码已过期,请在电脑上刷新后重扫")
|
||||
return success_response({"ok": True, "message": "电脑端即将登录,可关闭本页"})
|
||||
@@ -0,0 +1,216 @@
|
||||
"""小程序菜单:当前用户在「移动端菜单」树下有权限的入口。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Set
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import get_current_user_by_token
|
||||
from app.core.database import get_db
|
||||
from app.core.response import error_response, success_response
|
||||
from app.models.models import SysMenu, SysRoleMenu
|
||||
from app.services.web_auth_roles import get_effective_role_ids, resolve_web_role_for_user
|
||||
|
||||
router = APIRouter(prefix="/mini", tags=["小程序菜单"])
|
||||
|
||||
|
||||
async def _current_user(request: Request, db: Session) -> dict:
|
||||
authorization = request.headers.get("Authorization", "")
|
||||
if not authorization.startswith("Bearer "):
|
||||
raise ValueError("未授权")
|
||||
token = authorization.replace("Bearer ", "", 1).strip()
|
||||
return await get_current_user_by_token(token=token, db=db)
|
||||
|
||||
|
||||
def _find_mobile_root(db: Session, tenant_no: str) -> SysMenu | None:
|
||||
rows = (
|
||||
db.query(SysMenu)
|
||||
.filter(
|
||||
SysMenu.del_flag == 0,
|
||||
SysMenu.parent_id == "0",
|
||||
SysMenu.tenant_no == tenant_no,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for m in rows:
|
||||
name = (m.menu_name or "").strip()
|
||||
if "移动端" in name:
|
||||
return m
|
||||
return None
|
||||
|
||||
|
||||
def _collect_descendants(
|
||||
db: Session, root_id: str, tenant_no: str
|
||||
) -> List[SysMenu]:
|
||||
all_menus = (
|
||||
db.query(SysMenu)
|
||||
.filter(
|
||||
SysMenu.del_flag == 0,
|
||||
SysMenu.tenant_no == tenant_no,
|
||||
SysMenu.status == "1",
|
||||
SysMenu.visible == "1",
|
||||
)
|
||||
.order_by(SysMenu.order_num.asc(), SysMenu.create_time.asc())
|
||||
.all()
|
||||
)
|
||||
by_parent: Dict[str, List[SysMenu]] = {}
|
||||
for m in all_menus:
|
||||
pid = (m.parent_id or "0").strip() or "0"
|
||||
by_parent.setdefault(pid, []).append(m)
|
||||
|
||||
out: List[SysMenu] = []
|
||||
|
||||
def walk(pid: str) -> None:
|
||||
for child in by_parent.get(pid, []):
|
||||
out.append(child)
|
||||
walk(child.id)
|
||||
|
||||
walk(root_id)
|
||||
return out
|
||||
|
||||
|
||||
def _user_menu_ids(db: Session, user_id: str, tenant_no: str) -> Set[str]:
|
||||
role_ids = get_effective_role_ids(db, user_id, tenant_no=tenant_no)
|
||||
if not role_ids:
|
||||
return set()
|
||||
rows = (
|
||||
db.query(SysRoleMenu.menu_id)
|
||||
.filter(
|
||||
SysRoleMenu.del_flag == 0,
|
||||
SysRoleMenu.tenant_no == tenant_no,
|
||||
SysRoleMenu.role_id.in_(role_ids),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return {str(r[0]).strip() for r in rows if r and r[0]}
|
||||
|
||||
|
||||
def _mobile_menu_icon(path: str) -> str:
|
||||
"""按路由匹配移动端宫格图标(与 mobile-h5 van-icon 字形名对齐)。"""
|
||||
p = (path or "").lower()
|
||||
# 兼容有无前导斜杠、query 分隔写法
|
||||
if "market/webview" in p and "entry=" in p:
|
||||
# payment-chase 须先于 payment 匹配
|
||||
if "payment-chase" in p:
|
||||
return "market-payment-chase"
|
||||
if "presign" in p:
|
||||
return "market-presign"
|
||||
if "protocol" in p:
|
||||
return "market-protocol"
|
||||
if "payment" in p:
|
||||
return "market-payment"
|
||||
if "freight" in p:
|
||||
return "market-freight"
|
||||
return ""
|
||||
if "oa/start" in p:
|
||||
return "approve"
|
||||
if "oa/drafts" in p:
|
||||
return "oa-drafts"
|
||||
if "oa/pending" in p:
|
||||
return "oa-pending"
|
||||
if "oa/done" in p:
|
||||
return "oa-done"
|
||||
if "oa/mine" in p:
|
||||
return "oa-mine"
|
||||
if "oa/cc" in p:
|
||||
return "oa-cc"
|
||||
if "oa/mentions" in p:
|
||||
return "oa-mentions"
|
||||
if "oa/wechat-notify" in p:
|
||||
return "oa-wechat-notify"
|
||||
if "form-sink" in p:
|
||||
return "orders-o"
|
||||
return "apps-o"
|
||||
|
||||
|
||||
def _mobile_menu_icon_colors(path: str) -> dict:
|
||||
"""宫格图标底色/图形色(可按入口自定义,不强制品牌绿)。"""
|
||||
p = (path or "").lower()
|
||||
# OA / 市场菜单 PNG 自带颜色,宫格不套色底
|
||||
if any(
|
||||
x in p
|
||||
for x in (
|
||||
"oa/start",
|
||||
"oa/drafts",
|
||||
"oa/pending",
|
||||
"oa/done",
|
||||
"oa/mine",
|
||||
"oa/cc",
|
||||
"oa/mentions",
|
||||
"oa/wechat-notify",
|
||||
)
|
||||
) or ("market/webview" in p and "entry=" in p):
|
||||
return {"iconBg": "transparent", "iconFg": ""}
|
||||
if "form-sink" in p:
|
||||
return {"iconBg": "#eef2f1", "iconFg": "#3d524a"}
|
||||
return {"iconBg": "#eef2f1", "iconFg": "#3d524a"}
|
||||
|
||||
|
||||
def list_mobile_menu_items(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: str,
|
||||
tenant_no: str,
|
||||
is_super_admin: bool = False,
|
||||
) -> List[dict]:
|
||||
"""列出当前租户下已授权的移动端叶子入口。"""
|
||||
tenant = (tenant_no or "").strip()
|
||||
uid = (user_id or "").strip()
|
||||
if not tenant or not uid:
|
||||
return []
|
||||
|
||||
root = _find_mobile_root(db, tenant)
|
||||
if not root:
|
||||
return []
|
||||
|
||||
descendants = _collect_descendants(db, root.id, tenant)
|
||||
by_id = {menu.id: menu for menu in descendants}
|
||||
allowed_ids = None if is_super_admin else _user_menu_ids(db, uid, tenant)
|
||||
items: List[dict] = []
|
||||
for menu in descendants:
|
||||
if (menu.menu_type or "").upper() == "F":
|
||||
continue
|
||||
path = (menu.path or "").strip()
|
||||
if not path:
|
||||
continue
|
||||
if "form-sink" in path.lower() or "水槽" in str(menu.menu_name or ""):
|
||||
continue
|
||||
if allowed_ids is not None and menu.id not in allowed_ids:
|
||||
continue
|
||||
colors = _mobile_menu_icon_colors(path)
|
||||
parent = by_id.get((menu.parent_id or "").strip())
|
||||
item = {
|
||||
"id": menu.id,
|
||||
"title": menu.menu_name,
|
||||
"path": path if path.startswith("/") else f"/{path}",
|
||||
"icon": _mobile_menu_icon(path),
|
||||
"iconBg": colors["iconBg"],
|
||||
"iconFg": colors["iconFg"],
|
||||
"perms": (menu.perms or "").strip() or None,
|
||||
}
|
||||
if parent is not None and parent.id != root.id:
|
||||
item["groupTitle"] = parent.menu_name
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/menus")
|
||||
async def list_my_mobile_menus(request: Request, db: Session = Depends(get_db)):
|
||||
try:
|
||||
user = await _current_user(request, db)
|
||||
except Exception:
|
||||
return error_response(401, "未授权")
|
||||
|
||||
user_id = str(user.get("user_id") or user.get("sub") or "").strip()
|
||||
tenant_no = str(user.get("tenant_no") or "").strip()
|
||||
if not user_id or not tenant_no:
|
||||
return error_response(401, "未授权")
|
||||
web_role = resolve_web_role_for_user(db, user_id, tenant_no=tenant_no)
|
||||
items = list_mobile_menu_items(
|
||||
db,
|
||||
user_id=user_id,
|
||||
tenant_no=tenant_no,
|
||||
is_super_admin=web_role == "super_admin",
|
||||
)
|
||||
return success_response({"list": items})
|
||||
+800
-116
File diff suppressed because it is too large.
Load diff
@@ -447,6 +447,22 @@ class StorageFacilityUpdateBody(BaseModel):
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class StorageFacilityBatchCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
parent_id: str = Field("0", alias="parentId")
|
||||
name_prefix: str = Field("", alias="namePrefix")
|
||||
name_suffix: str = Field("", alias="nameSuffix")
|
||||
start_num: int = Field(..., alias="startNum", ge=0)
|
||||
end_num: int = Field(..., alias="endNum", ge=0)
|
||||
pad_width: int = Field(0, alias="padWidth", ge=0, le=8)
|
||||
code_prefix: str = Field("", alias="codePrefix")
|
||||
code_suffix: str = Field("", alias="codeSuffix")
|
||||
node_type: Optional[str] = Field(None, alias="nodeType")
|
||||
is_leaf: Optional[int] = Field(0, alias="isLeaf")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/storage-facilities")
|
||||
async def list_storage_facilities(
|
||||
keyword: Optional[str] = None,
|
||||
@@ -500,6 +516,35 @@ async def create_storage_facility(
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/storage-facilities/batch")
|
||||
async def batch_create_storage_facilities(
|
||||
body: StorageFacilityBatchCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items = storage_facility_svc.batch_create_facilities(
|
||||
db,
|
||||
parent_id=body.parent_id,
|
||||
name_prefix=body.name_prefix,
|
||||
name_suffix=body.name_suffix,
|
||||
start_num=body.start_num,
|
||||
end_num=body.end_num,
|
||||
pad_width=body.pad_width,
|
||||
code_prefix=body.code_prefix,
|
||||
code_suffix=body.code_suffix,
|
||||
node_type=body.node_type,
|
||||
is_leaf=body.is_leaf or 0,
|
||||
tenant_no=tenant_no,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(items, message=f"批量创建 {len(items)} 个子节点成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/storage-facilities/{node_id}")
|
||||
async def update_storage_facility(
|
||||
node_id: str,
|
||||
@@ -603,13 +648,21 @@ async def list_storage_slots(
|
||||
BizStorageFacilitySlot.order_num.asc(),
|
||||
BizStorageFacilitySlot.create_time.asc(),
|
||||
).all()
|
||||
facility_by_id = storage_facility_svc.load_facility_map(db, tenant_no)
|
||||
facility_by_id = storage_facility_svc.load_facility_ancestor_map(
|
||||
db, tenant_no, facilityNodeId
|
||||
)
|
||||
result_list = [storage_facility_svc._slot_to_dict(r, facility_by_id) for r in items]
|
||||
if availableOnly:
|
||||
# 仅空格场景无需 enrich 在库产品
|
||||
result_list = [
|
||||
row for row in result_list
|
||||
if (row.get("slotStatus") or "") == "空" or int(row.get("occupiedCount") or 0) == 0
|
||||
]
|
||||
else:
|
||||
# 占用格附带名称/类型(轻量批量,供库位管理货架展示)
|
||||
result_list = occupancy_svc.enrich_slots_with_stored_cell_products(
|
||||
db, tenant_no, result_list
|
||||
)
|
||||
return success_response(result_list)
|
||||
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ async def list_pilot_release_reviews(
|
||||
bizType: Optional[str] = None,
|
||||
infusionTimeStart: Optional[str] = None,
|
||||
infusionTimeEnd: Optional[str] = None,
|
||||
reviewPhase: Optional[str] = Query(None),
|
||||
readyForReviewOnly: Optional[bool] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:outboundReleaseReview:list")),
|
||||
):
|
||||
@@ -62,6 +64,8 @@ async def list_pilot_release_reviews(
|
||||
biz_type=bizType,
|
||||
infusion_time_start=infusionTimeStart,
|
||||
infusion_time_end=infusionTimeEnd,
|
||||
review_phase=reviewPhase,
|
||||
ready_for_review_only=readyForReviewOnly,
|
||||
page=page,
|
||||
page_size=pageSize,
|
||||
user_info=user,
|
||||
|
||||
@@ -151,6 +151,8 @@ class ScheduleProductNestedBody(BaseModel):
|
||||
project_name: Optional[str] = Field(None, alias="projectName")
|
||||
cell_type_name: Optional[str] = Field(None, alias="cellTypeName")
|
||||
spec_text: Optional[str] = Field(None, alias="specText")
|
||||
package_id: Optional[str] = Field(None, alias="packageId")
|
||||
package_text: Optional[str] = Field(None, alias="packageText")
|
||||
agreement_price: Optional[float] = Field(None, alias="agreementPrice")
|
||||
actual_price: Optional[float] = Field(None, alias="actualPrice")
|
||||
quantity: Optional[int] = 1
|
||||
@@ -167,6 +169,8 @@ class InfusionScheduleNestedBody(BaseModel):
|
||||
infusion_location: Optional[str] = Field(None, alias="infusionLocation")
|
||||
infusion_method: Optional[str] = Field(None, alias="infusionMethod")
|
||||
amount: Optional[float] = None
|
||||
package_id: Optional[str] = Field(None, alias="packageId")
|
||||
package_text: Optional[str] = Field(None, alias="packageText")
|
||||
cell_products: Optional[list[ScheduleProductNestedBody]] = Field(None, alias="cellProducts")
|
||||
|
||||
|
||||
@@ -500,6 +504,8 @@ class SaleScheduleProductLineBody(BaseModel):
|
||||
project_name: Optional[str] = Field(None, alias="projectName")
|
||||
cell_type_name: Optional[str] = Field(None, alias="cellTypeName")
|
||||
spec_text: Optional[str] = Field(None, alias="specText")
|
||||
package_id: Optional[str] = Field(None, alias="packageId")
|
||||
package_text: Optional[str] = Field(None, alias="packageText")
|
||||
agreement_price: Optional[float] = Field(None, alias="agreementPrice")
|
||||
actual_price: Optional[float] = Field(None, alias="actualPrice")
|
||||
quantity: Optional[int] = 1
|
||||
|
||||
@@ -228,6 +228,108 @@ async def delete_cell_product_spec(
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2b. 细胞产品包装
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CellProductPackageCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
package_code: str = Field(..., alias="packageCode")
|
||||
package_name: str = Field(..., alias="packageName")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class CellProductPackageUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
package_code: Optional[str] = Field(None, alias="packageCode")
|
||||
package_name: Optional[str] = Field(None, alias="packageName")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/cell-product-packages")
|
||||
async def list_cell_product_packages(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = ps_svc.list_cell_product_packages(
|
||||
db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no, user_info=user
|
||||
)
|
||||
tp = ps_svc.total_pages(total, pageSize)
|
||||
return success_response(
|
||||
{"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cell-product-packages")
|
||||
async def create_cell_product_package(
|
||||
body: CellProductPackageCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = ps_svc.create_cell_product_package(
|
||||
db,
|
||||
package_code=body.package_code,
|
||||
package_name=body.package_name,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(ps_svc._row_to_dict_cell_product_package(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/cell-product-packages/{row_id}")
|
||||
async def update_cell_product_package(
|
||||
row_id: str,
|
||||
body: CellProductPackageUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = ps_svc.update_cell_product_package(
|
||||
db,
|
||||
row_id,
|
||||
package_code=payload.get("package_code"),
|
||||
package_name=payload.get("package_name"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response(ps_svc._row_to_dict_cell_product_package(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/cell-product-packages/{row_id}")
|
||||
async def delete_cell_product_package(
|
||||
row_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
ps_svc.soft_delete_cell_product_package(db, row_id, tenant_no=tenant_no)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. 细胞产品价格
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_menu_code
|
||||
from app.core.menu_guard import require_any_menu_code
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud import pre_transit_sample as pts_svc
|
||||
from app.models.models import SysUser
|
||||
@@ -44,7 +44,12 @@ async def list_in_transit_samples(
|
||||
sampleCollectTimeStart: Optional[str] = Query(None, alias="sampleCollectTimeStart"),
|
||||
sampleCollectTimeEnd: Optional[str] = Query(None, alias="sampleCollectTimeEnd"),
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:preTransitSample:list")),
|
||||
user: dict = Depends(
|
||||
require_any_menu_code(
|
||||
"biz:preTransitSample:list",
|
||||
"production:preTransitSample:list",
|
||||
)
|
||||
),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
@@ -79,7 +84,12 @@ async def list_pre_delivery_samples(
|
||||
sampleCollectTimeStart: Optional[str] = Query(None, alias="sampleCollectTimeStart"),
|
||||
sampleCollectTimeEnd: Optional[str] = Query(None, alias="sampleCollectTimeEnd"),
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:preTransitSample:list")),
|
||||
user: dict = Depends(
|
||||
require_any_menu_code(
|
||||
"biz:preTransitSample:list",
|
||||
"production:preTransitSample:list",
|
||||
)
|
||||
),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
"""预签约订单 / 意向金转账 / 转正式 API"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.data_scope import get_user_dept_id
|
||||
from app.core.menu_guard import require_admin_or_super_admin, require_strict_menu_code
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud import presign_order as po_svc
|
||||
from app.models.models import SysUser
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["细胞存储-预签约订单"])
|
||||
|
||||
PAYMENT_CONFIRM_MENU = "biz:cellStoragePaymentConfirm:list"
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失,无法确定租户")
|
||||
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
|
||||
if not sys_user or not sys_user.tenant_no:
|
||||
raise PermissionError("用户租户信息缺失")
|
||||
return sys_user.tenant_no
|
||||
|
||||
|
||||
def _get_current_user_id(user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失")
|
||||
return str(user_id)
|
||||
|
||||
|
||||
class PresignOrderBody(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
order_date: Optional[str] = Field(None, alias="orderDate")
|
||||
protocol_no: Optional[str] = Field(None, alias="protocolNo")
|
||||
mother_name: Optional[str] = Field(None, alias="motherName")
|
||||
expected_delivery_date: Optional[str] = Field(None, alias="expectedDeliveryDate")
|
||||
region_id: Optional[str] = Field(None, alias="regionId")
|
||||
hospital_id: Optional[str] = Field(None, alias="hospitalId")
|
||||
sample_collect_time: Optional[str] = Field(None, alias="sampleCollectTime")
|
||||
pay_mode: Optional[str] = Field(None, alias="payMode")
|
||||
contract_amount: Optional[float] = Field(None, alias="contractAmount")
|
||||
collector_user_id: Optional[str] = Field(None, alias="collectorUserId")
|
||||
sales_user_id: Optional[str] = Field(None, alias="salesUserId")
|
||||
region_manager_user_id: Optional[str] = Field(None, alias="regionManagerUserId")
|
||||
order_status: Optional[str] = Field(None, alias="orderStatus")
|
||||
remark: Optional[str] = None
|
||||
sample_type_ids: Optional[List[str]] = Field(None, alias="sampleTypeIds")
|
||||
transfers: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
@field_validator("order_date", "expected_delivery_date")
|
||||
@classmethod
|
||||
def validate_ymd(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value in (None, ""):
|
||||
return value
|
||||
text = value.strip()
|
||||
if len(text) != 8 or not text.isdigit():
|
||||
raise ValueError("日期必须使用 YYYYMMDD 格式")
|
||||
try:
|
||||
datetime.strptime(text, "%Y%m%d")
|
||||
except ValueError as exc:
|
||||
raise ValueError("日期必须是有效的 YYYYMMDD") from exc
|
||||
return text
|
||||
|
||||
|
||||
class PresignTransferBody(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
transfer_amount: Optional[float] = Field(None, alias="transferAmount")
|
||||
transfer_time: Optional[str] = Field(None, alias="transferTime")
|
||||
transfer_type: Optional[str] = Field(None, alias="transferType")
|
||||
transfer_attachments: Optional[str] = Field(None, alias="transferAttachments")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class FinanceConfirmBody(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
finance_confirm_attachments: Optional[str] = Field(
|
||||
None, alias="financeConfirmAttachments"
|
||||
)
|
||||
finance_remark: Optional[str] = Field(None, alias="financeRemark")
|
||||
|
||||
|
||||
@router.get("/presign-orders")
|
||||
async def list_presign_orders(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
orderStatus: Optional[str] = Query(None, alias="orderStatus"),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = po_svc.list_presign_orders(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
page=page,
|
||||
page_size=pageSize,
|
||||
keyword=keyword,
|
||||
order_status=orderStatus,
|
||||
user_info=user,
|
||||
)
|
||||
return success_response(
|
||||
{"list": items, "total": total, "page": page, "pageSize": pageSize}
|
||||
)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
|
||||
|
||||
@router.get("/presign-orders/{order_id}")
|
||||
async def get_presign_order(
|
||||
order_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
po_svc.assert_presign_data_access(db, order_id, tenant_no, user)
|
||||
data = po_svc.get_presign_order(db, order_id=order_id, tenant_no=tenant_no)
|
||||
if not data:
|
||||
return error_response(404, "预签约订单不存在")
|
||||
return success_response(data)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
|
||||
|
||||
@router.post("/presign-orders")
|
||||
async def create_presign_order(
|
||||
body: PresignOrderBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
dumped = body.model_dump(by_alias=False)
|
||||
obj = po_svc.create_presign_order(
|
||||
db,
|
||||
create_dept_id=get_user_dept_id(db, user_id) or "",
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
fields=dumped,
|
||||
sample_type_ids=body.sample_type_ids,
|
||||
transfers=body.transfers,
|
||||
)
|
||||
return success_response(
|
||||
po_svc.order_to_dict(db, obj, tenant_no, with_children=True),
|
||||
message="创建成功",
|
||||
)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/presign-orders/{order_id}")
|
||||
async def update_presign_order(
|
||||
order_id: str,
|
||||
body: PresignOrderBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
po_svc.assert_presign_data_access(db, order_id, tenant_no, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
dumped = body.model_dump(by_alias=False, exclude_unset=True)
|
||||
sample_type_ids = dumped.pop("sample_type_ids", None)
|
||||
dumped.pop("transfers", None)
|
||||
obj = po_svc.update_presign_order(
|
||||
db,
|
||||
create_dept_id=get_user_dept_id(db, user_id) or "",
|
||||
order_id=order_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
fields=dumped,
|
||||
sample_type_ids=sample_type_ids,
|
||||
)
|
||||
return success_response(
|
||||
po_svc.order_to_dict(db, obj, tenant_no, with_children=True),
|
||||
message="保存成功",
|
||||
)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/presign-orders/{order_id}")
|
||||
async def delete_presign_order(
|
||||
order_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
po_svc.assert_presign_data_access(db, order_id, tenant_no, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
po_svc.soft_delete_presign_order(
|
||||
db, order_id=order_id, tenant_no=tenant_no, user_id=user_id
|
||||
)
|
||||
return success_response(None, message="删除成功")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/presign-orders/{order_id}/convert")
|
||||
async def convert_presign_order(
|
||||
order_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
po_svc.assert_presign_data_access(db, order_id, tenant_no, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
data = po_svc.convert_to_protocol(
|
||||
db, order_id=order_id, tenant_no=tenant_no, user_id=user_id,
|
||||
create_dept_id=get_user_dept_id(db, user_id) or "",
|
||||
)
|
||||
return success_response(data, message="已转正式协议")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/presign-orders/{order_id}/transfers")
|
||||
async def create_presign_transfer(
|
||||
order_id: str,
|
||||
body: PresignTransferBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
po_svc.assert_presign_data_access(db, order_id, tenant_no, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
obj = po_svc.add_transfer(
|
||||
db,
|
||||
create_dept_id=get_user_dept_id(db, user_id) or "",
|
||||
presign_order_id=order_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
fields=body.model_dump(by_alias=False),
|
||||
)
|
||||
return success_response(
|
||||
po_svc._transfer_to_dict(db, obj, tenant_no), message="添加成功"
|
||||
)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/presign-transfers/{transfer_id}")
|
||||
async def update_presign_transfer(
|
||||
transfer_id: str,
|
||||
body: PresignTransferBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
po_svc.assert_presign_data_access(
|
||||
db, transfer_id, tenant_no, user, transfer=True
|
||||
)
|
||||
user_id = _get_current_user_id(user)
|
||||
obj = po_svc.update_transfer_draft(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
fields=body.model_dump(by_alias=False, exclude_unset=True),
|
||||
)
|
||||
return success_response(
|
||||
po_svc._transfer_to_dict(db, obj, tenant_no), message="保存成功"
|
||||
)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/presign-transfers/{transfer_id}/submit-receipt-finance")
|
||||
async def submit_presign_transfer_finance(
|
||||
transfer_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
po_svc.assert_presign_data_access(
|
||||
db, transfer_id, tenant_no, user, transfer=True
|
||||
)
|
||||
user_id = _get_current_user_id(user)
|
||||
obj = po_svc.submit_receipt_finance(
|
||||
db, transfer_id=transfer_id, tenant_no=tenant_no, user_id=user_id
|
||||
)
|
||||
return success_response(
|
||||
po_svc._transfer_to_dict(db, obj, tenant_no), message="已提交财务确认"
|
||||
)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/presign-transfers/{transfer_id}/withdraw")
|
||||
async def withdraw_presign_transfer(
|
||||
transfer_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
po_svc.assert_presign_data_access(
|
||||
db, transfer_id, tenant_no, user, transfer=True
|
||||
)
|
||||
user_id = _get_current_user_id(user)
|
||||
obj = po_svc.withdraw_receipt(
|
||||
db, transfer_id=transfer_id, tenant_no=tenant_no, user_id=user_id
|
||||
)
|
||||
return success_response(
|
||||
po_svc._transfer_to_dict(db, obj, tenant_no), message="已撤回"
|
||||
)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/presign-transfers/receipt-confirm")
|
||||
async def list_presign_receipt_confirm(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = po_svc.list_presign_transfers_for_finance(
|
||||
db,
|
||||
pay_status="jfqrz",
|
||||
tenant_no=tenant_no,
|
||||
keyword=keyword,
|
||||
page=page,
|
||||
page_size=pageSize,
|
||||
user_info=user,
|
||||
)
|
||||
return success_response(
|
||||
{"list": items, "total": total, "page": page, "pageSize": pageSize}
|
||||
)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
|
||||
|
||||
@router.get("/presign-transfers/receipt-confirmed")
|
||||
async def list_presign_receipt_confirmed(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = po_svc.list_presign_transfers_for_finance(
|
||||
db,
|
||||
pay_status="yjf",
|
||||
tenant_no=tenant_no,
|
||||
keyword=keyword,
|
||||
page=page,
|
||||
page_size=pageSize,
|
||||
user_info=user,
|
||||
)
|
||||
return success_response(
|
||||
{"list": items, "total": total, "page": page, "pageSize": pageSize}
|
||||
)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
|
||||
|
||||
@router.post("/presign-transfers/{transfer_id}/receipt-confirm")
|
||||
async def confirm_presign_transfer(
|
||||
transfer_id: str,
|
||||
body: Optional[FinanceConfirmBody] = None,
|
||||
user: dict = Depends(require_strict_menu_code(PAYMENT_CONFIRM_MENU)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
po_svc.assert_presign_data_access(
|
||||
db, transfer_id, tenant_no, user, transfer=True
|
||||
)
|
||||
user_id = _get_current_user_id(user)
|
||||
obj = po_svc.confirm_receipt(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
finance_confirm_attachments=body.finance_confirm_attachments if body else None,
|
||||
finance_remark=body.finance_remark if body else None,
|
||||
)
|
||||
return success_response(
|
||||
po_svc._transfer_to_dict(db, obj, tenant_no), message="确认收款成功"
|
||||
)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/presign-transfers/{transfer_id}/receipt-reject")
|
||||
async def reject_presign_transfer(
|
||||
transfer_id: str,
|
||||
user: dict = Depends(require_strict_menu_code(PAYMENT_CONFIRM_MENU)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
po_svc.assert_presign_data_access(
|
||||
db, transfer_id, tenant_no, user, transfer=True
|
||||
)
|
||||
user_id = _get_current_user_id(user)
|
||||
obj = po_svc.reject_receipt(
|
||||
db, transfer_id=transfer_id, tenant_no=tenant_no, user_id=user_id
|
||||
)
|
||||
return success_response(
|
||||
po_svc._transfer_to_dict(db, obj, tenant_no), message="已退回"
|
||||
)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
+379
-13
@@ -19,6 +19,7 @@ from app.crud import sop_document as sop_document_svc
|
||||
from app.crud import process_category as process_category_svc
|
||||
from app.crud import equipment as equipment_svc
|
||||
from app.crud import output_type as output_type_svc
|
||||
from app.crud import production_product_type as production_product_type_svc
|
||||
from app.crud import consumable_pack as consumable_pack_svc
|
||||
from app.crud import process as process_svc
|
||||
from app.crud import process_route as route_svc
|
||||
@@ -38,6 +39,8 @@ from app.schemas.process_route import (
|
||||
from app.models.models import SysUser
|
||||
from app.crud import production_work_order as production_work_order_svc
|
||||
from app.crud import production_task as production_task_svc
|
||||
from app.crud import production_task_set as production_task_set_svc
|
||||
from app.crud import production_waste_output as production_waste_output_svc
|
||||
from app.crud import cell_product as cell_product_svc
|
||||
from app.crud import qc_work_order as qc_work_order_svc
|
||||
from app.schemas.production_work_order import (
|
||||
@@ -47,6 +50,12 @@ from app.schemas.production_work_order import (
|
||||
ProductionWorkOrderConfigureBody,
|
||||
ProductionWorkOrderUpdate,
|
||||
)
|
||||
from app.schemas.production_task_set import (
|
||||
ProductionTaskSetCreateBody,
|
||||
ProductionTaskSetFromWorkOrderBody,
|
||||
ProductionTaskSetUpdateBody,
|
||||
ProductionTasksFromSetBody,
|
||||
)
|
||||
from app.schemas.production_task import (
|
||||
ProductionTaskCreate,
|
||||
ProductionTaskUpdate,
|
||||
@@ -1014,6 +1023,110 @@ async def delete_output_type(
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5b. 生产产品类型(生产专用,解耦中试 biz_cell_product_type)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProductionProductTypeCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
product_type_code: str = Field(..., alias="productTypeCode")
|
||||
product_type_name: str = Field(..., alias="productTypeName")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class ProductionProductTypeUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
product_type_code: Optional[str] = Field(None, alias="productTypeCode")
|
||||
product_type_name: Optional[str] = Field(None, alias="productTypeName")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/production-product-types")
|
||||
async def list_production_product_types(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = production_product_type_svc.list_production_product_types(
|
||||
db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no, user_info=user
|
||||
)
|
||||
tp = production_product_type_svc.total_pages(total, pageSize)
|
||||
return success_response(
|
||||
{"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/production-product-types")
|
||||
async def create_production_product_type(
|
||||
body: ProductionProductTypeCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = production_product_type_svc.create_production_product_type(
|
||||
db,
|
||||
product_type_code=body.product_type_code,
|
||||
product_type_name=body.product_type_name,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(production_product_type_svc._row_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/production-product-types/{row_id}")
|
||||
async def update_production_product_type(
|
||||
row_id: str,
|
||||
body: ProductionProductTypeUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = production_product_type_svc.update_production_product_type(
|
||||
db,
|
||||
row_id,
|
||||
product_type_code=payload.get("product_type_code"),
|
||||
product_type_name=payload.get("product_type_name"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response(production_product_type_svc._row_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/production-product-types/{row_id}")
|
||||
async def delete_production_product_type(
|
||||
row_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
production_product_type_svc.soft_delete_production_product_type(
|
||||
db, row_id, tenant_no=tenant_no
|
||||
)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. 耗材包(主 + 子项)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1209,6 +1322,7 @@ class ProcessCreateBody(BaseModel):
|
||||
equipment_ids: Optional[List[str]] = Field(None, alias="equipmentIds")
|
||||
estimated_duration: Optional[str] = Field(None, alias="estimatedDuration")
|
||||
sop_ids: Optional[str] = Field(None, alias="sopIds")
|
||||
sop_content: Optional[str] = Field(None, alias="sopContent")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
reagents: List[ProcessReagentInput] = []
|
||||
@@ -1226,6 +1340,7 @@ class ProcessUpdateBody(BaseModel):
|
||||
equipment_ids: Optional[List[str]] = Field(None, alias="equipmentIds")
|
||||
estimated_duration: Optional[str] = Field(None, alias="estimatedDuration")
|
||||
sop_ids: Optional[str] = Field(None, alias="sopIds")
|
||||
sop_content: Optional[str] = Field(None, alias="sopContent")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
reagents: Optional[List[ProcessReagentInput]] = None
|
||||
@@ -1298,6 +1413,7 @@ async def create_process(
|
||||
equipment_ids=body.equipment_ids,
|
||||
estimated_duration=body.estimated_duration,
|
||||
sop_ids=body.sop_ids,
|
||||
sop_content=body.sop_content,
|
||||
reagents=reagents_data,
|
||||
consumables=consumables_data,
|
||||
outputs=outputs_data,
|
||||
@@ -1339,6 +1455,7 @@ async def update_process(
|
||||
"description": payload.get("description"),
|
||||
"estimated_duration": payload.get("estimated_duration"),
|
||||
"sop_ids": payload.get("sop_ids"),
|
||||
"sop_content": payload.get("sop_content"),
|
||||
"reagents": reagents_data,
|
||||
"consumables": consumables_data,
|
||||
"outputs": outputs_data,
|
||||
@@ -1437,16 +1554,19 @@ async def update_process_route(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
route_svc.assert_route_writable(db, route_id, user)
|
||||
payload = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = route_svc.update_route(
|
||||
db, route_id,
|
||||
user_id=user.get("user_id"),
|
||||
route_code=payload.get("route_code"),
|
||||
route_name=payload.get("route_name"),
|
||||
product_type_id=payload.get("product_type_id"),
|
||||
version=payload.get("version"),
|
||||
remark=payload.get("remark"),
|
||||
)
|
||||
update_kwargs = {
|
||||
"user_id": user.get("user_id"),
|
||||
"route_code": payload.get("route_code"),
|
||||
"route_name": payload.get("route_name"),
|
||||
"product_type_id": payload.get("product_type_id"),
|
||||
"version": payload.get("version"),
|
||||
"remark": payload.get("remark"),
|
||||
}
|
||||
if "equipment_ids" in payload:
|
||||
update_kwargs["equipment_ids"] = payload.get("equipment_ids")
|
||||
obj = route_svc.update_route(db, route_id, **update_kwargs)
|
||||
return success_response(route_svc._route_row_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
@@ -1459,6 +1579,7 @@ async def set_current_process_route(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
route_svc.assert_route_writable(db, body.route_id, user)
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = route_svc.set_current_route(db, body.route_id, tenant_no)
|
||||
return success_response(route_svc._route_row_to_dict(obj), message="设置成功")
|
||||
@@ -1469,10 +1590,11 @@ async def set_current_process_route(
|
||||
@router.delete("/process-routes/{route_id}")
|
||||
async def delete_process_route(
|
||||
route_id: str,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
route_svc.assert_route_writable(db, route_id, user)
|
||||
route_svc.soft_delete_route(db, route_id)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
@@ -1488,6 +1610,7 @@ async def add_route_step(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
route_svc.assert_route_writable(db, body.route_id, user)
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
step_data = route_svc.add_step(
|
||||
db,
|
||||
@@ -1509,6 +1632,7 @@ async def copy_route_step(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
route_svc.assert_step_route_writable(db, body.step_id, user)
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
step_data = route_svc.copy_step(
|
||||
db,
|
||||
@@ -1528,6 +1652,7 @@ async def update_route_step(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
route_svc.assert_step_route_writable(db, body.step_id, user)
|
||||
payload = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
payload.pop("step_id", None)
|
||||
obj = route_svc.update_step(db, body.step_id, user_id=user.get("user_id"), **payload)
|
||||
@@ -1539,10 +1664,11 @@ async def update_route_step(
|
||||
@router.put("/process-routes/step/reorder")
|
||||
async def reorder_route_steps(
|
||||
body: StepReorderBody,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
route_svc.assert_route_writable(db, body.route_id, user)
|
||||
route_svc.reorder_steps(db, body.route_id, body.step_ids)
|
||||
return success_response(None, message="排序成功")
|
||||
except ValueError as e:
|
||||
@@ -1552,10 +1678,11 @@ async def reorder_route_steps(
|
||||
@router.delete("/process-routes/step/{step_id}")
|
||||
async def delete_route_step(
|
||||
step_id: str,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
route_svc.assert_step_route_writable(db, step_id, user)
|
||||
route_svc.soft_delete_step(db, step_id)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
@@ -1571,6 +1698,7 @@ async def update_step_reagents(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
route_svc.assert_step_route_writable(db, body.step_id, user)
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items_data = [it.model_dump(by_alias=False) for it in body.items]
|
||||
route_svc.update_step_reagents(db, body.step_id, items_data, tenant_no, user.get("user_id"))
|
||||
@@ -1586,6 +1714,7 @@ async def update_step_consumables(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
route_svc.assert_step_route_writable(db, body.step_id, user)
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items_data = [it.model_dump(by_alias=False) for it in body.items]
|
||||
route_svc.update_step_consumables(db, body.step_id, items_data, tenant_no, user.get("user_id"))
|
||||
@@ -1601,6 +1730,7 @@ async def update_step_outputs(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
route_svc.assert_step_route_writable(db, body.step_id, user)
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items_data = [it.model_dump(by_alias=False) for it in body.items]
|
||||
route_svc.update_step_outputs(db, body.step_id, items_data, tenant_no, user.get("user_id"))
|
||||
@@ -1616,6 +1746,7 @@ async def update_step_data_collections(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
route_svc.assert_step_route_writable(db, body.step_id, user)
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items_data = [it.model_dump(by_alias=False) for it in body.items]
|
||||
route_svc.update_step_data_collections(db, body.step_id, items_data, tenant_no, user.get("user_id"))
|
||||
@@ -1623,7 +1754,6 @@ async def update_step_data_collections(
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. 入库生产工单
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1810,6 +1940,25 @@ async def complete_production_work_order(
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/production-work-orders/{wo_id}/void")
|
||||
async def void_production_work_order_api(
|
||||
wo_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
wo = production_work_order_svc.void_production_work_order(
|
||||
db, wo_id, tenant_no, user.get("user_id"),
|
||||
)
|
||||
return success_response(
|
||||
production_work_order_svc._wo_to_response_dict(db, wo),
|
||||
message="工单已作废",
|
||||
)
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/production-work-orders/{wo_id}")
|
||||
async def delete_production_work_order_api(
|
||||
wo_id: str,
|
||||
@@ -1916,6 +2065,155 @@ async def create_qc_work_order_from_production_api(
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 生产任务集合
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/production-task-sets")
|
||||
async def list_production_task_sets(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
user: dict = Depends(require_menu_code("production:task-set:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = production_task_set_svc.list_sets(
|
||||
db, tenant_no=tenant_no, page=page, page_size=pageSize, keyword=keyword, status=status,
|
||||
)
|
||||
tp = production_task_set_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.get("/production-task-sets/active")
|
||||
async def list_active_production_task_sets(
|
||||
user: dict = Depends(require_any_menu_code("production:task-set:list", "production:work-order:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items = production_task_set_svc.list_active_sets(db, tenant_no=tenant_no)
|
||||
return success_response({"list": items})
|
||||
|
||||
|
||||
@router.post("/production-task-sets/from-work-order")
|
||||
async def create_production_task_set_from_work_order(
|
||||
body: ProductionTaskSetFromWorkOrderBody,
|
||||
user: dict = Depends(
|
||||
require_any_menu_code("production:work-order:list", "production:task-set:add")
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
detail = production_task_set_svc.create_set_from_work_order(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id"),
|
||||
work_order_id=body.work_order_id,
|
||||
set_name=body.set_name,
|
||||
remark=body.remark,
|
||||
items=(
|
||||
[x.model_dump(by_alias=False) for x in body.items]
|
||||
if body.items is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
return success_response(detail, message="保存成功")
|
||||
except ValueError as e:
|
||||
db.rollback()
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/production-task-sets/{set_id}")
|
||||
async def get_production_task_set(
|
||||
set_id: str,
|
||||
user: dict = Depends(require_any_menu_code("production:task-set:list", "production:work-order:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
detail = production_task_set_svc.get_set(db, set_id, tenant_no)
|
||||
if not detail:
|
||||
return error_response(404, "集合不存在")
|
||||
return success_response(detail)
|
||||
|
||||
|
||||
@router.post("/production-task-sets")
|
||||
async def create_production_task_set(
|
||||
body: ProductionTaskSetCreateBody,
|
||||
user: dict = Depends(require_menu_code("production:task-set:add")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
detail = production_task_set_svc.create_set(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id"),
|
||||
set_name=body.set_name,
|
||||
status=body.status or "1",
|
||||
remark=body.remark,
|
||||
items=[x.model_dump(by_alias=False) for x in body.items],
|
||||
)
|
||||
db.commit()
|
||||
return success_response(detail, message="创建成功")
|
||||
except ValueError as e:
|
||||
db.rollback()
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/production-task-sets/{set_id}")
|
||||
async def update_production_task_set(
|
||||
set_id: str,
|
||||
body: ProductionTaskSetUpdateBody,
|
||||
user: dict = Depends(require_menu_code("production:task-set:edit")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
production_task_set_svc.assert_set_writable(db, set_id, tenant_no, user)
|
||||
data = body.model_dump(by_alias=False, exclude_unset=True)
|
||||
items = None
|
||||
if "items" in data:
|
||||
items = [x.model_dump(by_alias=False) for x in (body.items or [])]
|
||||
detail = production_task_set_svc.update_set(
|
||||
db,
|
||||
set_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id"),
|
||||
set_name=data.get("set_name"),
|
||||
status=data.get("status"),
|
||||
remark=data.get("remark"),
|
||||
items=items,
|
||||
)
|
||||
db.commit()
|
||||
return success_response(detail, message="更新成功")
|
||||
except ValueError as e:
|
||||
db.rollback()
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/production-task-sets/{set_id}")
|
||||
async def delete_production_task_set(
|
||||
set_id: str,
|
||||
user: dict = Depends(require_menu_code("production:task-set:remove")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
production_task_set_svc.assert_set_writable(db, set_id, tenant_no, user)
|
||||
production_task_set_svc.delete_set(
|
||||
db, set_id, tenant_no=tenant_no, user_id=user.get("user_id")
|
||||
)
|
||||
db.commit()
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
db.rollback()
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. 生产任务
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1943,6 +2241,34 @@ async def create_production_task(
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/production-tasks/from-set")
|
||||
async def create_production_tasks_from_set(
|
||||
body: ProductionTasksFromSetBody,
|
||||
user: dict = Depends(require_menu_code("production:work-order:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
result = production_task_set_svc.create_tasks_from_set(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id"),
|
||||
create_dept_id="",
|
||||
work_order_id=body.work_order_id,
|
||||
set_id=body.set_id,
|
||||
items=(
|
||||
[x.model_dump(by_alias=False) for x in body.items]
|
||||
if body.items is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
return success_response(result, message="创建成功")
|
||||
except ValueError as e:
|
||||
db.rollback()
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/production-tasks")
|
||||
async def list_production_tasks(
|
||||
page: int = Query(1, ge=1),
|
||||
@@ -1971,6 +2297,33 @@ async def list_production_tasks(
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.get("/production-waste-outputs")
|
||||
async def list_production_waste_outputs(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
createTimeStart: Optional[str] = Query(None, alias="createTimeStart"),
|
||||
createTimeEnd: Optional[str] = Query(None, alias="createTimeEnd"),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = production_waste_output_svc.list_production_waste_outputs(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
page=page,
|
||||
page_size=pageSize,
|
||||
keyword=keyword,
|
||||
create_time_start=createTimeStart,
|
||||
create_time_end=createTimeEnd,
|
||||
user_info=user,
|
||||
)
|
||||
tp = production_waste_output_svc.total_pages(total, pageSize)
|
||||
return success_response(
|
||||
{"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/production-tasks/list-by-work-orders")
|
||||
async def list_production_tasks_by_work_orders(
|
||||
body: ProductionTaskListByWorkOrdersBody,
|
||||
@@ -2693,6 +3046,19 @@ async def get_cell_product_chain(
|
||||
return success_response(chain)
|
||||
|
||||
|
||||
@router.get("/cell-products/{product_id}/lineage-qc")
|
||||
async def get_cell_product_lineage_qc(
|
||||
product_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
data = cell_product_svc.get_product_lineage_qc(db, product_id, tenant_no)
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="细胞产品不存在")
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.post("/cell-products")
|
||||
async def create_cell_product(
|
||||
body: CellProductCreate,
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""协议级退费登记 API"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud import protocol_refund_record as rr_svc
|
||||
from app.models.models import (
|
||||
BizCollectionProtocol,
|
||||
BizProtocolRefundRecord,
|
||||
SysUser,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["细胞存储-退费登记"])
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失,无法确定租户")
|
||||
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
|
||||
if not sys_user or not sys_user.tenant_no:
|
||||
raise PermissionError("用户租户信息缺失")
|
||||
return sys_user.tenant_no
|
||||
|
||||
|
||||
def _get_current_user_id(user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失")
|
||||
return str(user_id)
|
||||
|
||||
|
||||
def _protocol_accessible(db: Session, protocol_id: str, tenant_no: str, user: dict) -> bool:
|
||||
from app.core.data_scope import apply_data_scope
|
||||
|
||||
q = db.query(BizCollectionProtocol).filter(
|
||||
BizCollectionProtocol.id == protocol_id,
|
||||
BizCollectionProtocol.del_flag == 0,
|
||||
BizCollectionProtocol.tenant_no == tenant_no,
|
||||
)
|
||||
q = apply_data_scope(q, BizCollectionProtocol, user, db, tenant_no)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
class RefundRecordBody(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
refund_amount: float = Field(..., alias="refundAmount")
|
||||
refund_time: Optional[str] = Field(None, alias="refundTime")
|
||||
refund_remark: Optional[str] = Field(None, alias="refundRemark")
|
||||
refund_attachments: Optional[str] = Field(None, alias="refundAttachments")
|
||||
workflow_instance_id: Optional[str] = Field(None, alias="workflowInstanceId")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class RefundRecordUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
refund_amount: Optional[float] = Field(None, alias="refundAmount")
|
||||
refund_time: Optional[str] = Field(None, alias="refundTime")
|
||||
refund_remark: Optional[str] = Field(None, alias="refundRemark")
|
||||
refund_attachments: Optional[str] = Field(None, alias="refundAttachments")
|
||||
workflow_instance_id: Optional[str] = Field(None, alias="workflowInstanceId")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/collection-protocols/{protocol_id}/refund-records")
|
||||
async def list_protocol_refund_records(
|
||||
protocol_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
if not _protocol_accessible(db, protocol_id, tenant_no, user):
|
||||
return error_response(404, "协议单不存在或无权查看")
|
||||
data = rr_svc.list_refund_records(
|
||||
db, collection_protocol_id=protocol_id, tenant_no=tenant_no
|
||||
)
|
||||
return success_response(data)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
|
||||
|
||||
@router.post("/collection-protocols/{protocol_id}/refund-records")
|
||||
async def create_protocol_refund_record(
|
||||
protocol_id: str,
|
||||
body: RefundRecordBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
if not _protocol_accessible(db, protocol_id, tenant_no, user):
|
||||
return error_response(404, "协议单不存在或无权操作")
|
||||
obj = rr_svc.create_refund_record(
|
||||
db,
|
||||
collection_protocol_id=protocol_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
refund_amount=body.refund_amount,
|
||||
refund_time=body.refund_time,
|
||||
refund_remark=body.refund_remark,
|
||||
refund_attachments=body.refund_attachments,
|
||||
workflow_instance_id=body.workflow_instance_id,
|
||||
remark=body.remark,
|
||||
)
|
||||
return success_response(
|
||||
rr_svc._record_to_dict(db, obj, tenant_no), message="登记成功"
|
||||
)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/protocol-refund-records/{record_id}")
|
||||
async def update_protocol_refund_record(
|
||||
record_id: str,
|
||||
body: RefundRecordUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
existing = (
|
||||
db.query(BizProtocolRefundRecord)
|
||||
.filter(
|
||||
BizProtocolRefundRecord.id == record_id,
|
||||
BizProtocolRefundRecord.tenant_no == tenant_no,
|
||||
BizProtocolRefundRecord.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not existing:
|
||||
return error_response(404, "退费登记不存在")
|
||||
if not _protocol_accessible(
|
||||
db, existing.collection_protocol_id, tenant_no, user
|
||||
):
|
||||
return error_response(403, "无权操作该协议")
|
||||
dumped = body.model_dump(by_alias=False, exclude_unset=True)
|
||||
obj = rr_svc.update_refund_record(
|
||||
db,
|
||||
record_id=record_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
refund_amount=dumped.get("refund_amount"),
|
||||
refund_time=dumped.get("refund_time"),
|
||||
refund_remark=dumped.get("refund_remark"),
|
||||
refund_attachments=dumped.get("refund_attachments"),
|
||||
workflow_instance_id=dumped.get("workflow_instance_id"),
|
||||
remark=dumped.get("remark"),
|
||||
fields_set=set(dumped.keys()),
|
||||
)
|
||||
return success_response(
|
||||
rr_svc._record_to_dict(db, obj, tenant_no), message="保存成功"
|
||||
)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/protocol-refund-records/{record_id}")
|
||||
async def delete_protocol_refund_record(
|
||||
record_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
existing = (
|
||||
db.query(BizProtocolRefundRecord)
|
||||
.filter(
|
||||
BizProtocolRefundRecord.id == record_id,
|
||||
BizProtocolRefundRecord.tenant_no == tenant_no,
|
||||
BizProtocolRefundRecord.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not existing:
|
||||
return error_response(404, "退费登记不存在")
|
||||
if not _protocol_accessible(
|
||||
db, existing.collection_protocol_id, tenant_no, user
|
||||
):
|
||||
return error_response(403, "无权操作该协议")
|
||||
rr_svc.soft_delete_refund_record(
|
||||
db, record_id=record_id, tenant_no=tenant_no, user_id=user_id
|
||||
)
|
||||
return success_response(None, message="删除成功")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
@@ -1,15 +1,19 @@
|
||||
"""采集协议样本采集信息 API"""
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_any_menu_code
|
||||
from app.core.response import error_response, success_response
|
||||
from app.core.utils import parse_date, parse_datetime
|
||||
from app.crud import protocol_sample_collection as svc
|
||||
from app.crud import sample_info_quick_register as quick_svc
|
||||
from app.crud.collection_protocol import total_pages
|
||||
from app.models.models import SysUser
|
||||
from app.services.sample_customer_service_register import has_any_customer_service_payload
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["采集协议样本采集信息"])
|
||||
|
||||
@@ -17,6 +21,11 @@ _SAMPLE_INFO_MENU = require_any_menu_code(
|
||||
"cell-storage:sample-info:list",
|
||||
)
|
||||
|
||||
_QUICK_REGISTER_MENU = require_any_menu_code(
|
||||
"cell-storage:sample-info:list",
|
||||
"research-donation:sample-info:list",
|
||||
)
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
@@ -44,6 +53,12 @@ async def list_protocol_sample_collections(
|
||||
pageSize: int = Query(10, ge=1, le=100),
|
||||
keyword: Optional[str] = None,
|
||||
sampleStatus: Optional[str] = Query(None, alias="sampleStatus"),
|
||||
collectionFlag: Optional[str] = Query(None, alias="collectionFlag"),
|
||||
receptionFlag: Optional[str] = Query(None, alias="receptionFlag"),
|
||||
qcFlag: Optional[str] = Query(None, alias="qcFlag"),
|
||||
storageFlag: Optional[str] = Query(None, alias="storageFlag"),
|
||||
storageCertHandled: Optional[str] = Query(None, alias="storageCertHandled"),
|
||||
insuranceHandled: Optional[str] = Query(None, alias="insuranceHandled"),
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(_SAMPLE_INFO_MENU),
|
||||
):
|
||||
@@ -56,6 +71,12 @@ async def list_protocol_sample_collections(
|
||||
tenant_no=tenant_no,
|
||||
keyword=keyword,
|
||||
sample_status=sampleStatus,
|
||||
collection_flag=collectionFlag,
|
||||
reception_flag=receptionFlag,
|
||||
qc_flag=qcFlag,
|
||||
storage_flag=storageFlag,
|
||||
storage_cert_handled=storageCertHandled,
|
||||
insurance_handled=insuranceHandled,
|
||||
user_info=user,
|
||||
)
|
||||
return success_response(_page_payload(items, total, page, pageSize))
|
||||
@@ -63,3 +84,321 @@ async def list_protocol_sample_collections(
|
||||
return error_response(403, str(e))
|
||||
except Exception as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
def _get_current_user_id(user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失")
|
||||
return str(user_id)
|
||||
|
||||
|
||||
class QuickRegisterItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
sample_code: str = Field(..., alias="sampleCode")
|
||||
protocol_id: str = Field(..., alias="protocolId")
|
||||
|
||||
|
||||
class QuickRegisterBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
kind: str
|
||||
reception_source: str = Field(..., alias="receptionSource")
|
||||
items: List[QuickRegisterItem]
|
||||
handle_time: Optional[str] = Field(None, alias="handleTime")
|
||||
handler_user_id: Optional[str] = Field(None, alias="handlerUserId")
|
||||
attachments: Optional[str] = None
|
||||
remark: Optional[str] = None
|
||||
mail_cert_time: Optional[str] = Field(None, alias="mailCertTime")
|
||||
policy_send_time: Optional[str] = Field(None, alias="policySendTime")
|
||||
storage_handle_time: Optional[str] = Field(None, alias="storageHandleTime")
|
||||
storage_handler_user_id: Optional[str] = Field(None, alias="storageHandlerUserId")
|
||||
storage_attachments: Optional[str] = Field(None, alias="storageAttachments")
|
||||
storage_remark: Optional[str] = Field(None, alias="storageRemark")
|
||||
cert_handle_time: Optional[str] = Field(None, alias="certHandleTime")
|
||||
cert_handler_user_id: Optional[str] = Field(None, alias="certHandlerUserId")
|
||||
cert_attachments: Optional[str] = Field(None, alias="certAttachments")
|
||||
cert_remark: Optional[str] = Field(None, alias="certRemark")
|
||||
insurance_handle_time: Optional[str] = Field(None, alias="insuranceHandleTime")
|
||||
insurance_handler_user_id: Optional[str] = Field(None, alias="insuranceHandlerUserId")
|
||||
insurance_attachments: Optional[str] = Field(None, alias="insuranceAttachments")
|
||||
insurance_remark: Optional[str] = Field(None, alias="insuranceRemark")
|
||||
sms_send_date: Optional[str] = Field(None, alias="smsSendDate")
|
||||
sms_send_status: Optional[str] = Field(None, alias="smsSendStatus")
|
||||
microbe_followup_date: Optional[str] = Field(None, alias="microbeFollowupDate")
|
||||
microbe_followup_status: Optional[str] = Field(None, alias="microbeFollowupStatus")
|
||||
fullmoon_followup_date: Optional[str] = Field(None, alias="fullmoonFollowupDate")
|
||||
fullmoon_followup_status: Optional[str] = Field(None, alias="fullmoonFollowupStatus")
|
||||
household_registered: Optional[str] = Field(None, alias="householdRegistered")
|
||||
einvoice_send_time: Optional[str] = Field(None, alias="einvoiceSendTime")
|
||||
effective_address: Optional[str] = Field(None, alias="effectiveAddress")
|
||||
|
||||
|
||||
def _optional_dt(raw: Optional[str]):
|
||||
if raw is None:
|
||||
return None, False
|
||||
text = str(raw).strip()
|
||||
if not text:
|
||||
return None, False
|
||||
return parse_datetime(text), True
|
||||
|
||||
|
||||
def _customer_service_incoming_from_body(body: QuickRegisterBody) -> dict:
|
||||
return {
|
||||
"sms_send_date": parse_date(body.sms_send_date) if body.sms_send_date else None,
|
||||
"sms_send_status": body.sms_send_status,
|
||||
"microbe_followup_date": parse_date(body.microbe_followup_date)
|
||||
if body.microbe_followup_date
|
||||
else None,
|
||||
"microbe_followup_status": body.microbe_followup_status,
|
||||
"fullmoon_followup_date": parse_date(body.fullmoon_followup_date)
|
||||
if body.fullmoon_followup_date
|
||||
else None,
|
||||
"fullmoon_followup_status": body.fullmoon_followup_status,
|
||||
"household_registered": body.household_registered,
|
||||
"einvoice_send_time": parse_datetime(body.einvoice_send_time)
|
||||
if body.einvoice_send_time
|
||||
else None,
|
||||
"effective_address": body.effective_address,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/sample-info/quick-register")
|
||||
async def quick_register_sample_info(
|
||||
body: QuickRegisterBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(_QUICK_REGISTER_MENU),
|
||||
):
|
||||
kind = (body.kind or "").strip()
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
ht = parse_datetime(body.handle_time) if body.handle_time else None
|
||||
items = [
|
||||
{"sampleCode": it.sample_code, "protocolId": it.protocol_id} for it in (body.items or [])
|
||||
]
|
||||
source = (body.reception_source or "").strip()
|
||||
attachments = body.attachments or ""
|
||||
remark = body.remark or ""
|
||||
mail_t, update_mail = _optional_dt(body.mail_cert_time)
|
||||
policy_t, update_policy = _optional_dt(body.policy_send_time)
|
||||
cs_incoming = _customer_service_incoming_from_body(body)
|
||||
|
||||
if kind == "storage":
|
||||
if ht is None:
|
||||
return error_response(400, "请填写办理时间")
|
||||
result = quick_svc.batch_quick_register_storage(
|
||||
db,
|
||||
items=items,
|
||||
reception_source=source,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
handle_time=ht,
|
||||
handler_user_id=body.handler_user_id,
|
||||
attachments=attachments,
|
||||
remark=remark,
|
||||
)
|
||||
elif kind == "storage_cert":
|
||||
if source != "cell_storage":
|
||||
return error_response(400, "储存证仅支持细胞存储样本")
|
||||
if ht is None:
|
||||
return error_response(400, "请填写办理时间")
|
||||
result = quick_svc.batch_handle_storage_certs(
|
||||
db,
|
||||
items=items,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
handle_time=ht,
|
||||
handler_user_id=body.handler_user_id,
|
||||
attachments=attachments,
|
||||
remark=remark,
|
||||
mail_cert_time=mail_t,
|
||||
update_mail_cert_time=update_mail,
|
||||
)
|
||||
elif kind == "insurance":
|
||||
if source != "cell_storage":
|
||||
return error_response(400, "保险仅支持细胞存储样本")
|
||||
if ht is None:
|
||||
return error_response(400, "请填写办理时间")
|
||||
result = quick_svc.batch_handle_insurance(
|
||||
db,
|
||||
items=items,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
handle_time=ht,
|
||||
handler_user_id=body.handler_user_id,
|
||||
attachments=attachments,
|
||||
remark=remark,
|
||||
policy_send_time=policy_t,
|
||||
update_policy_send_time=update_policy,
|
||||
)
|
||||
elif kind in ("sms", "followup", "fulfillment"):
|
||||
if source != "cell_storage":
|
||||
return error_response(400, "客户服务登记仅支持细胞存储样本")
|
||||
result = quick_svc.batch_register_customer_service(
|
||||
db,
|
||||
items=items,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
incoming=cs_incoming,
|
||||
)
|
||||
elif kind == "all":
|
||||
if source != "cell_storage":
|
||||
return error_response(400, "登记全部仅支持细胞存储样本")
|
||||
split = any(
|
||||
value is not None
|
||||
for value in (
|
||||
body.storage_handle_time,
|
||||
body.storage_handler_user_id,
|
||||
body.storage_attachments,
|
||||
body.storage_remark,
|
||||
body.cert_handle_time,
|
||||
body.insurance_handle_time,
|
||||
body.cert_attachments,
|
||||
body.insurance_attachments,
|
||||
body.cert_remark,
|
||||
body.insurance_remark,
|
||||
body.cert_handler_user_id,
|
||||
body.insurance_handler_user_id,
|
||||
)
|
||||
)
|
||||
if split:
|
||||
storage_ht = (
|
||||
parse_datetime(body.storage_handle_time)
|
||||
if (body.storage_handle_time or "").strip()
|
||||
else None
|
||||
)
|
||||
storage_handler = body.storage_handler_user_id
|
||||
storage_atts = body.storage_attachments or ""
|
||||
storage_remark = body.storage_remark or ""
|
||||
cert_ht = (
|
||||
parse_datetime(body.cert_handle_time)
|
||||
if (body.cert_handle_time or "").strip()
|
||||
else None
|
||||
)
|
||||
ins_ht = (
|
||||
parse_datetime(body.insurance_handle_time)
|
||||
if (body.insurance_handle_time or "").strip()
|
||||
else None
|
||||
)
|
||||
cert_handler = body.cert_handler_user_id
|
||||
ins_handler = body.insurance_handler_user_id
|
||||
cert_atts = body.cert_attachments or ""
|
||||
ins_atts = body.insurance_attachments or ""
|
||||
cert_remark = body.cert_remark or ""
|
||||
ins_remark = body.insurance_remark or ""
|
||||
else:
|
||||
storage_ht = None
|
||||
storage_handler = None
|
||||
storage_atts = ""
|
||||
storage_remark = ""
|
||||
cert_ht = ins_ht = ht
|
||||
cert_handler = ins_handler = body.handler_user_id
|
||||
cert_atts = ins_atts = attachments
|
||||
cert_remark = ins_remark = remark
|
||||
storage_side = bool(
|
||||
storage_ht is not None
|
||||
or (storage_atts or "").strip()
|
||||
or (storage_remark or "").strip()
|
||||
)
|
||||
cert_side = bool(
|
||||
cert_ht is not None
|
||||
or update_mail
|
||||
or (cert_atts or "").strip()
|
||||
or (cert_remark or "").strip()
|
||||
)
|
||||
ins_side = bool(
|
||||
ins_ht is not None
|
||||
or update_policy
|
||||
or (ins_atts or "").strip()
|
||||
or (ins_remark or "").strip()
|
||||
)
|
||||
if storage_side and storage_ht is None:
|
||||
return error_response(400, "请填写入库办理时间")
|
||||
if cert_side and cert_ht is None:
|
||||
return error_response(400, "请填写储存证办理时间")
|
||||
if ins_side and ins_ht is None:
|
||||
return error_response(400, "请填写保险办理时间")
|
||||
wrote_any = False
|
||||
success = 0
|
||||
fail = 0
|
||||
if storage_ht is not None:
|
||||
storage_r = quick_svc.batch_quick_register_storage(
|
||||
db,
|
||||
items=items,
|
||||
reception_source=source,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
handle_time=storage_ht,
|
||||
handler_user_id=storage_handler,
|
||||
attachments=storage_atts,
|
||||
remark=storage_remark,
|
||||
do_commit=False,
|
||||
)
|
||||
success = max(success, storage_r.get("success", 0))
|
||||
fail = max(fail, storage_r.get("fail", 0))
|
||||
wrote_any = True
|
||||
if cert_ht is not None:
|
||||
cert_r = quick_svc.batch_handle_storage_certs(
|
||||
db,
|
||||
items=items,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
handle_time=cert_ht,
|
||||
handler_user_id=cert_handler,
|
||||
attachments=cert_atts,
|
||||
remark=cert_remark,
|
||||
mail_cert_time=mail_t,
|
||||
update_mail_cert_time=update_mail,
|
||||
do_commit=False,
|
||||
)
|
||||
success = max(success, cert_r.get("success", 0))
|
||||
fail = max(fail, cert_r.get("fail", 0))
|
||||
wrote_any = True
|
||||
if ins_ht is not None:
|
||||
ins_r = quick_svc.batch_handle_insurance(
|
||||
db,
|
||||
items=items,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
handle_time=ins_ht,
|
||||
handler_user_id=ins_handler,
|
||||
attachments=ins_atts,
|
||||
remark=ins_remark,
|
||||
policy_send_time=policy_t,
|
||||
update_policy_send_time=update_policy,
|
||||
do_commit=False,
|
||||
)
|
||||
success = max(success, ins_r.get("success", 0))
|
||||
fail = max(fail, ins_r.get("fail", 0))
|
||||
wrote_any = True
|
||||
if has_any_customer_service_payload(cs_incoming):
|
||||
cs_r = quick_svc.batch_register_customer_service(
|
||||
db,
|
||||
items=items,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
incoming=cs_incoming,
|
||||
do_commit=False,
|
||||
)
|
||||
success = max(success, cs_r.get("success", 0))
|
||||
fail = max(fail, cs_r.get("fail", 0))
|
||||
wrote_any = True
|
||||
if not wrote_any:
|
||||
return error_response(400, "请至少填写一项登记信息")
|
||||
db.commit()
|
||||
result = {"success": success, "fail": fail}
|
||||
else:
|
||||
return error_response(400, "登记类型无效")
|
||||
return success_response(result, message="登记完成")
|
||||
except PermissionError as e:
|
||||
db.rollback()
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
db.rollback()
|
||||
return error_response(400, str(e))
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return error_response(400, str(e))
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
"""按样本缴费方案 / 转账笔财务确认 API(payment_model_version=2)"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin, require_strict_menu_code
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud import protocol_sample_payment as psp_svc
|
||||
from app.models.models import BizCollectionProtocol, SysUser
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["细胞存储-按样本缴费"])
|
||||
|
||||
PAYMENT_CONFIRM_MENU = "biz:cellStoragePaymentConfirm:list"
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失,无法确定租户")
|
||||
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
|
||||
if not sys_user or not sys_user.tenant_no:
|
||||
raise PermissionError("用户租户信息缺失")
|
||||
return sys_user.tenant_no
|
||||
|
||||
|
||||
def _get_current_user_id(user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失")
|
||||
return str(user_id)
|
||||
|
||||
|
||||
def _protocol_accessible(db: Session, protocol_id: str, tenant_no: str, user: dict) -> bool:
|
||||
from app.core.data_scope import apply_data_scope
|
||||
|
||||
q = db.query(BizCollectionProtocol).filter(
|
||||
BizCollectionProtocol.id == protocol_id,
|
||||
BizCollectionProtocol.del_flag == 0,
|
||||
BizCollectionProtocol.tenant_no == tenant_no,
|
||||
)
|
||||
q = apply_data_scope(q, BizCollectionProtocol, user, db, tenant_no)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
class ScheduleBody(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
id: Optional[str] = None
|
||||
schedule_no: Optional[int] = Field(None, alias="scheduleNo")
|
||||
due_amount: Optional[float] = Field(None, alias="dueAmount")
|
||||
due_date: Optional[str] = Field(None, alias="dueDate")
|
||||
schedule_status: Optional[str] = Field(None, alias="scheduleStatus")
|
||||
remark: Optional[str] = None
|
||||
transfers: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
|
||||
class SamplePaymentBody(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
id: Optional[str] = None
|
||||
sample_code: str = Field(..., alias="sampleCode")
|
||||
protocol_sample_id: Optional[str] = Field(None, alias="protocolSampleId")
|
||||
pay_mode: str = Field(..., alias="payMode")
|
||||
contract_amount: Optional[float] = Field(None, alias="contractAmount")
|
||||
installment_count: Optional[int] = Field(None, alias="installmentCount")
|
||||
installment_interval_months: Optional[float] = Field(
|
||||
None, alias="installmentIntervalMonths"
|
||||
)
|
||||
installment_start_date: Optional[str] = Field(None, alias="installmentStartDate")
|
||||
installment_advance_days: Optional[int] = Field(None, alias="installmentAdvanceDays")
|
||||
installment_ratios: Optional[Any] = Field(None, alias="installmentRatios")
|
||||
lump_sum_deadline: Optional[str] = Field(None, alias="lumpSumDeadline")
|
||||
remark: Optional[str] = None
|
||||
schedules: Optional[List[ScheduleBody]] = None
|
||||
|
||||
|
||||
class SamplePaymentsPutBody(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
sample_payments: List[SamplePaymentBody] = Field(
|
||||
default_factory=list, alias="samplePayments"
|
||||
)
|
||||
|
||||
|
||||
class TransferCreateBody(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
schedule_id: str = Field(..., alias="scheduleId")
|
||||
transfer_amount: Optional[float] = Field(None, alias="transferAmount")
|
||||
transfer_time: Optional[str] = Field(None, alias="transferTime")
|
||||
transfer_type: Optional[str] = Field(None, alias="transferType")
|
||||
transfer_attachments: Optional[str] = Field(None, alias="transferAttachments")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class TransferDraftBody(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
transfer_amount: Optional[float] = Field(None, alias="transferAmount")
|
||||
transfer_time: Optional[str] = Field(None, alias="transferTime")
|
||||
transfer_type: Optional[str] = Field(None, alias="transferType")
|
||||
transfer_attachments: Optional[str] = Field(None, alias="transferAttachments")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class FinanceConfirmBody(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
finance_confirm_attachments: Optional[str] = Field(
|
||||
None, alias="financeConfirmAttachments"
|
||||
)
|
||||
finance_remark: Optional[str] = Field(None, alias="financeRemark")
|
||||
|
||||
|
||||
def _ratios_to_str(ratios: Any) -> Optional[str]:
|
||||
if ratios is None:
|
||||
return None
|
||||
if isinstance(ratios, str):
|
||||
return ratios.strip() or None
|
||||
import json
|
||||
|
||||
try:
|
||||
return json.dumps(ratios, ensure_ascii=False)
|
||||
except Exception:
|
||||
return str(ratios)
|
||||
|
||||
|
||||
def _save_sample_payments(
|
||||
db: Session,
|
||||
*,
|
||||
protocol_id: str,
|
||||
tenant_no: str,
|
||||
user_id: str,
|
||||
payments: List[SamplePaymentBody],
|
||||
) -> List[Dict[str, Any]]:
|
||||
for item in payments:
|
||||
prev = psp_svc.find_active_sample_payment(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
sample_payment_id=item.id,
|
||||
collection_protocol_id=protocol_id,
|
||||
sample_code=item.sample_code,
|
||||
)
|
||||
prev_mode = (prev.pay_mode or "").strip() if prev else ""
|
||||
sp = psp_svc.upsert_sample_payment(
|
||||
db,
|
||||
collection_protocol_id=protocol_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
sample_code=item.sample_code,
|
||||
pay_mode=item.pay_mode,
|
||||
contract_amount=item.contract_amount,
|
||||
protocol_sample_id=item.protocol_sample_id,
|
||||
installment_count=item.installment_count,
|
||||
installment_interval_months=item.installment_interval_months,
|
||||
installment_start_date=item.installment_start_date,
|
||||
installment_advance_days=item.installment_advance_days,
|
||||
installment_ratios=_ratios_to_str(item.installment_ratios),
|
||||
lump_sum_deadline=item.lump_sum_deadline,
|
||||
remark=item.remark,
|
||||
sample_payment_id=item.id,
|
||||
)
|
||||
next_mode = (item.pay_mode or "").strip()
|
||||
if (
|
||||
prev_mode == "fq"
|
||||
and next_mode in ("fgd", "dj")
|
||||
and psp_svc.sample_payment_has_yjf(
|
||||
db, sample_payment_id=sp.id, tenant_no=tenant_no
|
||||
)
|
||||
):
|
||||
psp_svc.apply_fq_to_fgd_merge(
|
||||
db,
|
||||
sample_payment_id=sp.id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
target_pay_mode=next_mode,
|
||||
)
|
||||
continue
|
||||
if item.schedules is not None:
|
||||
schedule_payloads = [s.model_dump(by_alias=False) for s in item.schedules]
|
||||
psp_svc.sync_schedules_and_transfers(
|
||||
db,
|
||||
sample_payment_id=sp.id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
schedules=schedule_payloads,
|
||||
)
|
||||
db.commit()
|
||||
return psp_svc.get_sample_payments_bundle(db, protocol_id, tenant_no)
|
||||
|
||||
|
||||
@router.get("/payment-chase")
|
||||
async def list_payment_chase(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
chaseType: Optional[str] = Query(None, alias="chaseType"),
|
||||
payMode: Optional[str] = Query(None, alias="payMode"),
|
||||
salesUserId: Optional[str] = Query(None, alias="salesUserId"),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""催缴信息:逾期未清 ∪ 未来 30 天内应缴未清(一行一期)。"""
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = psp_svc.list_payment_chase_page(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
keyword=keyword,
|
||||
chase_type=chaseType,
|
||||
pay_mode=payMode,
|
||||
sales_user_id=salesUserId,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
user_info=user,
|
||||
)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": page_size})
|
||||
except Exception as e:
|
||||
return error_response(500, str(e))
|
||||
|
||||
|
||||
@router.get("/sample-payments")
|
||||
async def list_sample_payments(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
payMode: Optional[str] = Query(None, alias="payMode"),
|
||||
progressStatus: Optional[str] = Query(None, alias="progressStatus"),
|
||||
installmentDueDateStart: Optional[str] = Query(None, alias="installmentDueDateStart"),
|
||||
installmentDueDateEnd: Optional[str] = Query(None, alias="installmentDueDateEnd"),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""按样本缴费方案分页列表(独立缴费信息菜单)。"""
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = psp_svc.list_sample_payments_page(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
keyword=keyword,
|
||||
pay_mode=payMode,
|
||||
progress_status=progressStatus,
|
||||
due_date_start=installmentDueDateStart,
|
||||
due_date_end=installmentDueDateEnd,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
user_info=user,
|
||||
)
|
||||
return success_response(
|
||||
{"list": items, "total": total, "page": page, "pageSize": page_size}
|
||||
)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/sample-payments/{sample_payment_id}")
|
||||
async def get_sample_payment(
|
||||
sample_payment_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
data = psp_svc.get_sample_payment_detail(
|
||||
db,
|
||||
sample_payment_id=sample_payment_id,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
)
|
||||
if not data:
|
||||
return error_response(403, "无权查看或记录不存在")
|
||||
return success_response(data)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/collection-protocols/{protocol_id}/sample-payments")
|
||||
async def get_sample_payments(
|
||||
protocol_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
if not _protocol_accessible(db, protocol_id, tenant_no, user):
|
||||
return error_response(404, "协议单不存在或无权查看")
|
||||
data = psp_svc.get_sample_payments_bundle(db, protocol_id, tenant_no)
|
||||
return success_response(data)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/collection-protocols/{protocol_id}/sample-payments")
|
||||
async def put_sample_payments(
|
||||
protocol_id: str,
|
||||
body: SamplePaymentsPutBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
if not _protocol_accessible(db, protocol_id, tenant_no, user):
|
||||
return error_response(404, "协议单不存在或无权操作")
|
||||
# pydantic v2: may need model_validate with populate
|
||||
payments = body.sample_payments
|
||||
if hasattr(body, "model_dump"):
|
||||
raw = body.model_dump(by_alias=True)
|
||||
payments = [
|
||||
SamplePaymentBody.model_validate(x)
|
||||
for x in (raw.get("samplePayments") or raw.get("sample_payments") or [])
|
||||
]
|
||||
data = _save_sample_payments(
|
||||
db,
|
||||
protocol_id=protocol_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
payments=payments or body.sample_payments,
|
||||
)
|
||||
return success_response(data, message="保存成功")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/payment-transfers")
|
||||
async def create_payment_transfer(
|
||||
body: TransferCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
sch = psp_svc._get_schedule_or_raise(
|
||||
db, schedule_id=body.schedule_id, tenant_no=tenant_no
|
||||
)
|
||||
if not _protocol_accessible(db, sch.collection_protocol_id, tenant_no, user):
|
||||
return error_response(403, "无权操作该协议")
|
||||
obj = psp_svc.add_transfer(
|
||||
db,
|
||||
schedule_id=body.schedule_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
transfer_amount=body.transfer_amount,
|
||||
transfer_time=body.transfer_time,
|
||||
transfer_type=body.transfer_type,
|
||||
transfer_attachments=body.transfer_attachments,
|
||||
remark=body.remark,
|
||||
)
|
||||
from app.crud.protocol_sample_payment import _transfer_to_dict
|
||||
|
||||
return success_response(
|
||||
_transfer_to_dict(db, obj, tenant_no), message="创建成功"
|
||||
)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/payment-transfers/{transfer_id}")
|
||||
async def update_payment_transfer(
|
||||
transfer_id: str,
|
||||
body: TransferDraftBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
fields = body.model_dump(by_alias=False, exclude_unset=True)
|
||||
locked_transfer = psp_svc._get_transfer_with_schedule_lock(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
)
|
||||
obj = psp_svc.update_transfer_draft(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
fields=fields,
|
||||
locked_transfer=locked_transfer,
|
||||
)
|
||||
from app.crud.protocol_sample_payment import _transfer_to_dict
|
||||
|
||||
return success_response(_transfer_to_dict(db, obj, tenant_no), message="保存成功")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/payment-transfers/{transfer_id}/submit-receipt-finance")
|
||||
async def submit_payment_transfer_finance(
|
||||
transfer_id: str,
|
||||
body: Optional[TransferDraftBody] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
draft = body.model_dump(by_alias=True, exclude_unset=True) if body else None
|
||||
locked_transfer = psp_svc._get_transfer_with_schedule_lock(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
)
|
||||
obj = psp_svc.submit_receipt_finance(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
draft_fields=draft,
|
||||
locked_transfer=locked_transfer,
|
||||
)
|
||||
from app.crud.protocol_sample_payment import _transfer_to_dict
|
||||
|
||||
return success_response(_transfer_to_dict(db, obj, tenant_no), message="已提交财务确认")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/payment-transfers/{transfer_id}/withdraw")
|
||||
async def withdraw_payment_transfer(
|
||||
transfer_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
locked_transfer = psp_svc._get_transfer_with_schedule_lock(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
)
|
||||
obj = psp_svc.withdraw_receipt(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
locked_transfer=locked_transfer,
|
||||
)
|
||||
from app.crud.protocol_sample_payment import _transfer_to_dict
|
||||
|
||||
return success_response(_transfer_to_dict(db, obj, tenant_no), message="已撤回")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/payment-transfers/{transfer_id}")
|
||||
async def delete_payment_transfer(
|
||||
transfer_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
obj = psp_svc._get_transfer_or_raise(
|
||||
db, transfer_id=transfer_id, tenant_no=tenant_no
|
||||
)
|
||||
if not _protocol_accessible(db, obj.collection_protocol_id, tenant_no, user):
|
||||
return error_response(403, "无权操作该协议")
|
||||
psp_svc.soft_delete_payment_transfer(
|
||||
db, transfer_id=transfer_id, tenant_no=tenant_no, user_id=user_id
|
||||
)
|
||||
return success_response(None, message="删除成功")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/payment-transfers")
|
||||
async def list_payment_transfers(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
payStatus: str = Query("dfj", alias="payStatus"),
|
||||
keyword: Optional[str] = None,
|
||||
installmentDueDateStart: Optional[str] = Query(None, alias="installmentDueDateStart"),
|
||||
installmentDueDateEnd: Optional[str] = Query(None, alias="installmentDueDateEnd"),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""按转账笔状态分页(缴费信息看板 / 财务共用)。"""
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = psp_svc.list_transfers_for_finance(
|
||||
db,
|
||||
pay_status=payStatus,
|
||||
tenant_no=tenant_no,
|
||||
keyword=keyword,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
user_info=user,
|
||||
due_date_start=installmentDueDateStart,
|
||||
due_date_end=installmentDueDateEnd,
|
||||
)
|
||||
return success_response(
|
||||
{"list": items, "total": total, "page": page, "pageSize": page_size}
|
||||
)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
|
||||
|
||||
@router.get("/payment-transfers/receipt-confirm")
|
||||
async def list_receipt_confirm(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
installmentDueDateStart: Optional[str] = Query(None, alias="installmentDueDateStart"),
|
||||
installmentDueDateEnd: Optional[str] = Query(None, alias="installmentDueDateEnd"),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = psp_svc.list_transfers_for_finance(
|
||||
db,
|
||||
pay_status="jfqrz",
|
||||
tenant_no=tenant_no,
|
||||
keyword=keyword,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
user_info=user,
|
||||
due_date_start=installmentDueDateStart,
|
||||
due_date_end=installmentDueDateEnd,
|
||||
)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": page_size})
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
|
||||
|
||||
@router.get("/payment-transfers/receipt-confirmed")
|
||||
async def list_receipt_confirmed(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
installmentDueDateStart: Optional[str] = Query(None, alias="installmentDueDateStart"),
|
||||
installmentDueDateEnd: Optional[str] = Query(None, alias="installmentDueDateEnd"),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = psp_svc.list_transfers_for_finance(
|
||||
db,
|
||||
pay_status="yjf",
|
||||
tenant_no=tenant_no,
|
||||
keyword=keyword,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
user_info=user,
|
||||
due_date_start=installmentDueDateStart,
|
||||
due_date_end=installmentDueDateEnd,
|
||||
)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": page_size})
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
|
||||
|
||||
@router.post("/payment-transfers/{transfer_id}/receipt-confirm")
|
||||
async def confirm_payment_transfer(
|
||||
transfer_id: str,
|
||||
body: Optional[FinanceConfirmBody] = None,
|
||||
user: dict = Depends(require_strict_menu_code(PAYMENT_CONFIRM_MENU)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
locked_transfer = psp_svc._get_transfer_with_schedule_lock(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
)
|
||||
obj = psp_svc.confirm_receipt(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
finance_confirm_attachments=body.finance_confirm_attachments if body else None,
|
||||
finance_remark=body.finance_remark if body else None,
|
||||
locked_transfer=locked_transfer,
|
||||
)
|
||||
from app.crud.protocol_sample_payment import _transfer_to_dict
|
||||
|
||||
return success_response(_transfer_to_dict(db, obj, tenant_no), message="确认收款成功")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/payment-transfers/{transfer_id}/receipt-reject")
|
||||
async def reject_payment_transfer(
|
||||
transfer_id: str,
|
||||
user: dict = Depends(require_strict_menu_code(PAYMENT_CONFIRM_MENU)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
locked_transfer = psp_svc._get_transfer_with_schedule_lock(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
)
|
||||
obj = psp_svc.reject_receipt(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
locked_transfer=locked_transfer,
|
||||
)
|
||||
from app.crud.protocol_sample_payment import _transfer_to_dict
|
||||
|
||||
return success_response(_transfer_to_dict(db, obj, tenant_no), message="已退回")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
+96
-39
@@ -1,8 +1,10 @@
|
||||
"""公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/zkxw.sql 基线)"""
|
||||
import base64
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, File
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -37,38 +39,13 @@ from app.services.web_auth_roles import (
|
||||
)
|
||||
from app.schemas.auth import ProfileUpdateRequest
|
||||
from app.crud.user import update_user, change_password
|
||||
from app.services.issue_user_tokens import build_user_info_payload
|
||||
from app.services import wx_qr_session
|
||||
from app.services.wechat_mini_auth import get_unlimited_wxacode, WechatMiniAuthError
|
||||
|
||||
router = APIRouter(prefix="", tags=["公共接口"])
|
||||
|
||||
|
||||
def _avatar_url_field(user: SysUser) -> Optional[str]:
|
||||
"""返回库中原始头像路径,由前端按 VITE_MINIO_BASE_URL 拼展示 URL。"""
|
||||
raw = getattr(user, "avatar", None)
|
||||
if not raw:
|
||||
return None
|
||||
s = str(raw).strip()
|
||||
return s if s else None
|
||||
|
||||
|
||||
def _build_user_info_payload(user: SysUser, web_role: str, role_names: list = None) -> dict:
|
||||
"""与 web-admin `userInfo` 字段约定一致。"""
|
||||
phone = (getattr(user, "phone_no", None) or "").strip() or None
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.user_no,
|
||||
"realName": user.nick_name,
|
||||
"phone": phone,
|
||||
"email": (getattr(user, "email", None) or "").strip() or None,
|
||||
"sex": getattr(user, "sex", None) or "0",
|
||||
"remark": (getattr(user, "remark", None) or "").strip() or None,
|
||||
"role": web_role,
|
||||
"roleNames": role_names or [],
|
||||
"regionId": user.dept_id,
|
||||
"regionName": None,
|
||||
"avatarUrl": _avatar_url_field(user),
|
||||
}
|
||||
|
||||
|
||||
def _build_current_user_payload(current_user: dict) -> dict:
|
||||
"""组装 current-user 接口响应。"""
|
||||
au = current_user.get("avatar_url")
|
||||
@@ -85,7 +62,9 @@ def _build_current_user_payload(current_user: dict) -> dict:
|
||||
"roleNames": current_user.get("role_names") or current_user.get("roleNames") or [],
|
||||
"isSuperAdmin": current_user.get("role") == "super_admin",
|
||||
"regionId": current_user.get("region_id"),
|
||||
"regionName": None,
|
||||
"regionName": current_user.get("region_name") or current_user.get("dept_name"),
|
||||
"deptName": current_user.get("dept_name") or current_user.get("region_name"),
|
||||
"postNames": current_user.get("post_names") or [],
|
||||
"regionIds": [],
|
||||
"isActive": current_user.get("is_active"),
|
||||
"avatarUrl": avatar_url if avatar_url else None,
|
||||
@@ -141,11 +120,14 @@ async def web_login(
|
||||
if user.status != "1":
|
||||
return error_response(403, "账号已被停用,请联系管理员")
|
||||
|
||||
if not user_has_active_roles(db, user.id):
|
||||
tenant_no = (getattr(user, "tenant_no", None) or "").strip() or None
|
||||
if not user_has_active_roles(db, user.id, tenant_no=tenant_no):
|
||||
return error_response(403, "账号未分配角色,请联系管理员")
|
||||
|
||||
web_role = resolve_web_role_for_user(db, user.id)
|
||||
role_names = get_user_role_names(db, user.id)
|
||||
web_role = resolve_web_role_for_user(
|
||||
db, user.id, tenant_no=tenant_no
|
||||
)
|
||||
role_names = get_user_role_names(db, user.id, tenant_no=tenant_no)
|
||||
|
||||
session_id = generate_uuid()
|
||||
access_jti = generate_uuid()
|
||||
@@ -174,11 +156,54 @@ async def web_login(
|
||||
"token": access_token,
|
||||
"refreshToken": refresh_token,
|
||||
"expiresIn": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
"userInfo": _build_user_info_payload(user, web_role, role_names),
|
||||
"userInfo": build_user_info_payload(user, web_role, role_names, db=db),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/web/auth/wx-qr/create", response_model=dict)
|
||||
async def wx_qr_create():
|
||||
session_id, short = wx_qr_session.create_session()
|
||||
page = (settings.WX_QR_PAGE or "pages/login/web-qr/index").strip()
|
||||
try:
|
||||
raw = get_unlimited_wxacode(
|
||||
scene=short,
|
||||
page=page,
|
||||
env_version=settings.WX_QR_ENV_VERSION,
|
||||
)
|
||||
except WechatMiniAuthError as exc:
|
||||
wx_qr_session.invalidate(session_id)
|
||||
return error_response(400, str(exc))
|
||||
b64 = base64.b64encode(raw).decode("ascii")
|
||||
mime = "image/jpeg" if raw[:2] == b"\xff\xd8" else "image/png"
|
||||
return success_response(
|
||||
{
|
||||
"sessionId": session_id,
|
||||
"expiresIn": settings.WX_QR_SESSION_TTL_SEC,
|
||||
"qrImageBase64": f"data:{mime};base64,{b64}",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/web/auth/wx-qr/status", response_model=dict)
|
||||
async def wx_qr_status(sessionId: str = ""):
|
||||
sid = (sessionId or "").strip()
|
||||
data = wx_qr_session.get_session(sid)
|
||||
if not data:
|
||||
return success_response({"status": "expired", "message": "二维码已过期,请刷新"})
|
||||
status = data.get("status") or "expired"
|
||||
if status == "success":
|
||||
tokens = wx_qr_session.consume_success(sid)
|
||||
if not tokens:
|
||||
return success_response({"status": "expired", "message": "登录凭证已失效,请刷新二维码"})
|
||||
return success_response({"status": "success", **tokens})
|
||||
if status == "failed":
|
||||
return success_response({"status": "failed", "message": data.get("message") or "登录失败"})
|
||||
if status in ("expired", "consumed"):
|
||||
return success_response({"status": "expired", "message": "二维码已过期,请刷新"})
|
||||
return success_response({"status": status})
|
||||
|
||||
|
||||
@router.post("/web/auth/refresh-token", response_model=dict)
|
||||
async def refresh_token(body: dict, db: Session = Depends(get_db)):
|
||||
"""刷新 Token"""
|
||||
@@ -205,7 +230,10 @@ async def refresh_token(body: dict, db: Session = Depends(get_db)):
|
||||
if not user or user.del_flag != 0 or user.status != "1":
|
||||
return error_response(401, "用户不存在或已禁用")
|
||||
|
||||
web_role = resolve_web_role_for_user(db, user.id)
|
||||
tenant_no = (getattr(user, "tenant_no", None) or "").strip() or None
|
||||
web_role = resolve_web_role_for_user(
|
||||
db, user.id, tenant_no=tenant_no
|
||||
)
|
||||
|
||||
await revoke_refresh_token(refresh_jti=refresh_jti)
|
||||
await extend_session(session_id=session_id)
|
||||
@@ -298,9 +326,14 @@ async def update_current_user_profile(
|
||||
user = user_crud.get(db, user_id)
|
||||
if not user:
|
||||
return error_response(404, "用户不存在")
|
||||
web_role = resolve_web_role_for_user(db, user.id)
|
||||
role_names = get_user_role_names(db, user.id)
|
||||
return success_response(_build_user_info_payload(user, web_role, role_names))
|
||||
tenant_no = (getattr(user, "tenant_no", None) or "").strip() or None
|
||||
web_role = resolve_web_role_for_user(
|
||||
db, user.id, tenant_no=tenant_no
|
||||
)
|
||||
role_names = get_user_role_names(
|
||||
db, user.id, tenant_no=tenant_no
|
||||
)
|
||||
return success_response(build_user_info_payload(user, web_role, role_names, db=db))
|
||||
except HTTPException:
|
||||
return error_response(401, "Token无效或已过期")
|
||||
except ValueError as exc:
|
||||
@@ -397,7 +430,11 @@ async def get_web_auth_menu_codes(request: Request, db: Session = Depends(get_db
|
||||
)
|
||||
|
||||
try:
|
||||
codes = iter_user_menu_perm_codes(db, current_user.get("user_id") or "")
|
||||
codes = iter_user_menu_perm_codes(
|
||||
db,
|
||||
current_user.get("user_id") or "",
|
||||
tenant_no=current_user.get("tenant_no"),
|
||||
)
|
||||
menu_codes = sorted(codes)
|
||||
except OperationalError:
|
||||
menu_codes = []
|
||||
@@ -411,3 +448,23 @@ async def get_web_auth_menu_codes(request: Request, db: Session = Depends(get_db
|
||||
)
|
||||
except HTTPException:
|
||||
return error_response(401, "Token无效或已过期")
|
||||
|
||||
|
||||
@router.get("/cn-holiday/range", response_model=dict)
|
||||
def cn_holiday_range(
|
||||
start: str = Query(..., description="YYYY-MM-DD"),
|
||||
end: str = Query(..., description="YYYY-MM-DD"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""查询闭区间内每日节假日/休息日标志(只读本地缓存表)。"""
|
||||
try:
|
||||
start_d = datetime.strptime(start.strip(), "%Y-%m-%d").date()
|
||||
end_d = datetime.strptime(end.strip(), "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="start/end 须为 YYYY-MM-DD")
|
||||
if end_d < start_d:
|
||||
raise HTTPException(status_code=400, detail="end 不能早于 start")
|
||||
from app.services.cn_holiday import list_day_flags
|
||||
|
||||
days = list_day_flags(db, start_d, end_d)
|
||||
return success_response({"days": days})
|
||||
@@ -25,6 +25,7 @@ QC_THIRD_PARTY_FEE_FIELD_NAMES = ("feeAmount", "thirdPartyFeeTotal")
|
||||
from app.core.response import error_response, success_response
|
||||
from app.core.utils import parse_datetime
|
||||
from app.crud import qc_lab_test_item as lab_svc
|
||||
from app.crud import qc_inspection_item_template as iit_svc
|
||||
from app.crud import qc_item_param as param_svc
|
||||
from app.crud import qc_task_result as result_svc
|
||||
from app.crud import qc_task as task_svc
|
||||
@@ -35,6 +36,8 @@ from app.crud import qc_material_stock_batch as batch_svc
|
||||
from app.crud import qc_material_stock_check as stock_check_svc
|
||||
from app.crud import qc_material_usage as mu_svc
|
||||
from app.crud import qc_material_amount_auth as amount_auth_svc
|
||||
from app.crud import qc_material_month_template as month_tpl_svc
|
||||
from app.crud.qc_material_stock_batch import _parse_date
|
||||
from app.core.qc_material_amount_auth_constants import QC_MATERIAL_AMOUNT_AUTH_LIST_PERM
|
||||
from app.crud import inbound_sample_special as inbound_sample_special_svc
|
||||
from app.crud import qc_work_order as wo_svc
|
||||
@@ -398,6 +401,31 @@ class LabTestItemUpdateBody(BaseModel):
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
# ---------- 检测项目模版(项目组合) ----------
|
||||
|
||||
|
||||
class InspectionItemTemplateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
template_name: str = Field(..., alias="templateName")
|
||||
lab_item_ids: List[str] = Field(default_factory=list, alias="labItemIds")
|
||||
third_party_item_ids: List[str] = Field(default_factory=list, alias="thirdPartyItemIds")
|
||||
status: Optional[str] = "1"
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class InspectionItemTemplateUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
template_name: Optional[str] = Field(None, alias="templateName")
|
||||
lab_item_ids: Optional[List[str]] = Field(None, alias="labItemIds")
|
||||
third_party_item_ids: Optional[List[str]] = Field(None, alias="thirdPartyItemIds")
|
||||
status: Optional[str] = None
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
# ---------- 检测项目参数模板 ----------
|
||||
|
||||
|
||||
@@ -533,6 +561,148 @@ async def delete_qc_lab_test_item(
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/qc-inspection-item-templates")
|
||||
async def list_qc_inspection_item_templates(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100),
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcInspectionItemTemplate:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = iit_svc.list_templates(
|
||||
db,
|
||||
page=page,
|
||||
page_size=pageSize,
|
||||
keyword=keyword,
|
||||
status=status,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
)
|
||||
return success_response(_page_payload(items, total, page, pageSize))
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except Exception as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/qc-inspection-item-templates/active")
|
||||
async def list_qc_inspection_item_templates_active(
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(
|
||||
require_any_menu_code(
|
||||
"biz:qcInspectionItemTemplate:list", "biz:qcWorkOrder:list"
|
||||
)
|
||||
),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
return success_response(
|
||||
iit_svc.list_active_templates(db, tenant_no, user_info=user)
|
||||
)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except Exception as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/qc-inspection-item-templates/{template_id}")
|
||||
async def get_qc_inspection_item_template(
|
||||
template_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcInspectionItemTemplate:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = iit_svc.get_template(db, template_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
return error_response(404, "模版不存在")
|
||||
return success_response(iit_svc._row_to_dict(obj))
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except Exception as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/qc-inspection-item-templates")
|
||||
async def create_qc_inspection_item_template(
|
||||
body: InspectionItemTemplateBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcInspectionItemTemplate:add")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = iit_svc.create_template(
|
||||
db,
|
||||
template_name=body.template_name,
|
||||
lab_item_ids=body.lab_item_ids,
|
||||
third_party_item_ids=body.third_party_item_ids,
|
||||
status=body.status or "1",
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=_get_current_user_id(user),
|
||||
create_dept_id=user.get("dept_id", "") or "",
|
||||
)
|
||||
return success_response(iit_svc._row_to_dict(obj), message="创建成功")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
except Exception as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/qc-inspection-item-templates/{template_id}")
|
||||
async def update_qc_inspection_item_template(
|
||||
template_id: str,
|
||||
body: InspectionItemTemplateUpdateBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcInspectionItemTemplate:edit")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = iit_svc.update_template(
|
||||
db,
|
||||
template_id,
|
||||
template_name=body.template_name,
|
||||
lab_item_ids=body.lab_item_ids,
|
||||
third_party_item_ids=body.third_party_item_ids,
|
||||
status=body.status,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=_get_current_user_id(user),
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response(iit_svc._row_to_dict(obj), message="更新成功")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
except Exception as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/qc-inspection-item-templates/{template_id}")
|
||||
async def delete_qc_inspection_item_template(
|
||||
template_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcInspectionItemTemplate:remove")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
iit_svc.soft_delete_template(db, template_id, tenant_no=tenant_no)
|
||||
return success_response(None, message="删除成功")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
except Exception as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------- 检测项目参数模板 ----------
|
||||
|
||||
|
||||
@@ -617,6 +787,7 @@ async def create_qc_work_order(
|
||||
user_id=user.get("user_id"),
|
||||
create_dept_id=user.get("dept_id", ""),
|
||||
cell_product_id=body.cell_product_id or "",
|
||||
external_target_code=body.external_target_code or "",
|
||||
remark=body.remark or "",
|
||||
)
|
||||
else:
|
||||
@@ -655,6 +826,9 @@ async def list_qc_work_orders(
|
||||
infusionTimeEnd: Optional[str] = Query(None, alias="infusionTimeEnd"),
|
||||
triggerSource: Optional[str] = Query(None, alias="triggerSource"),
|
||||
sampleTypeId: Optional[str] = Query(None, alias="sampleTypeId"),
|
||||
protocolStatus: Optional[str] = Query(
|
||||
None, alias="protocolStatus", description="协议/捐赠状态,多个用英文逗号分隔"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcWorkOrder:list")),
|
||||
):
|
||||
@@ -671,6 +845,7 @@ async def list_qc_work_orders(
|
||||
reception_source=receptionSource,
|
||||
trigger_source=triggerSource,
|
||||
sample_type_id=sampleTypeId,
|
||||
protocol_status=protocolStatus,
|
||||
create_time_start=createTimeStart,
|
||||
create_time_end=createTimeEnd,
|
||||
infusion_time_start=infusionTimeStart,
|
||||
@@ -799,7 +974,9 @@ async def update_qc_work_order(
|
||||
tenant_no=tenant_no,
|
||||
user_id=_get_current_user_id(user),
|
||||
cell_product_id=body.cell_product_id,
|
||||
external_target_code=body.external_target_code,
|
||||
work_order_status=body.work_order_status,
|
||||
qc_conclusion=body.qc_conclusion,
|
||||
remark=body.remark,
|
||||
)
|
||||
db.commit()
|
||||
@@ -1394,6 +1571,55 @@ class BatchStockInBody(BaseModel):
|
||||
stock_in_time: Optional[str] = Field(None, alias="stockInTime")
|
||||
|
||||
|
||||
class QcMonthTemplateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
material_id: Optional[str] = Field(None, alias="materialId")
|
||||
material_code: Optional[str] = Field(None, alias="materialCode")
|
||||
material_name: Optional[str] = Field(None, alias="materialName")
|
||||
spec_model: Optional[str] = Field(None, alias="specModel")
|
||||
unit: Optional[str] = None
|
||||
catalog_no: Optional[str] = Field(None, alias="catalogNo")
|
||||
batch_no: Optional[str] = Field(None, alias="batchNo")
|
||||
manufacturer: Optional[str] = None
|
||||
unit_price: Optional[float] = Field(None, alias="unitPrice")
|
||||
amount: Optional[float] = None
|
||||
in_qty: Optional[float] = Field(None, alias="inQty")
|
||||
expire_date: Optional[str] = Field(None, alias="expireDate")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class QcMonthTemplatePreviewBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
stock_in_date: str = Field(..., alias="stockInDate")
|
||||
|
||||
|
||||
class QcMonthTemplateConfirmItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
material_id: Optional[str] = Field(None, alias="materialId")
|
||||
material_code: str = Field(..., alias="materialCode")
|
||||
material_name: str = Field(..., alias="materialName")
|
||||
spec_model: Optional[str] = Field(None, alias="specModel")
|
||||
unit: Optional[str] = None
|
||||
stock_in_no: str = Field(..., alias="stockInNo")
|
||||
expire_date: str = Field(..., alias="expireDate")
|
||||
catalog_no: str = Field(..., alias="catalogNo")
|
||||
batch_no: str = Field(..., alias="batchNo")
|
||||
manufacturer: str
|
||||
unit_price: float = Field(..., alias="unitPrice")
|
||||
amount: float
|
||||
qty: float = Field(..., gt=0)
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class QcMonthTemplateConfirmBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
stock_in_date: str = Field(..., alias="stockInDate")
|
||||
items: List[QcMonthTemplateConfirmItem] = Field(..., min_length=1)
|
||||
remark: Optional[str] = None
|
||||
stock_in_time: Optional[str] = Field(None, alias="stockInTime")
|
||||
|
||||
|
||||
class BatchStockOutItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
batch_id: str = Field(..., alias="batchId")
|
||||
@@ -1455,6 +1681,7 @@ async def list_qc_material_batches(
|
||||
keyword: Optional[str] = None,
|
||||
materialId: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
stockStatus: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
|
||||
):
|
||||
@@ -1467,6 +1694,7 @@ async def list_qc_material_batches(
|
||||
keyword=keyword,
|
||||
material_id=materialId,
|
||||
status=status,
|
||||
stock_status=stockStatus,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
)
|
||||
@@ -1887,6 +2115,174 @@ async def batch_discard_qc_material_batches(
|
||||
return await batch_discard_qc_material(body, db, user)
|
||||
|
||||
|
||||
# ---------- 月初入库模板 ----------
|
||||
|
||||
|
||||
@router.get("/qc-material-month-templates")
|
||||
async def list_qc_month_templates(
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
rows = month_tpl_svc.list_templates(db, tenant_no=tenant_no)
|
||||
return success_response(rows)
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
except Exception as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/qc-material-month-templates")
|
||||
async def create_qc_month_template(
|
||||
body: QcMonthTemplateBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
unit_price, amount = _resolve_stock_in_amounts(
|
||||
db, user, body.unit_price, body.amount
|
||||
)
|
||||
data = body.model_dump(by_alias=False)
|
||||
data["unit_price"] = unit_price if unit_price is not None else 0
|
||||
data["amount"] = amount if amount is not None else 0
|
||||
row = month_tpl_svc.create_template(
|
||||
db, data=data, tenant_no=tenant_no, user_id=user_id
|
||||
)
|
||||
return success_response(row, message="新增成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
except Exception as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/qc-material-month-templates/{row_id}")
|
||||
async def update_qc_month_template(
|
||||
row_id: str,
|
||||
body: QcMonthTemplateBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
unit_price, amount = _resolve_stock_in_amounts(
|
||||
db, user, body.unit_price, body.amount
|
||||
)
|
||||
data = body.model_dump(by_alias=False)
|
||||
data["unit_price"] = unit_price if unit_price is not None else 0
|
||||
data["amount"] = amount if amount is not None else 0
|
||||
row = month_tpl_svc.update_template(
|
||||
db, row_id, data=data, tenant_no=tenant_no, user_id=user_id
|
||||
)
|
||||
return success_response(row, message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
except Exception as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/qc-material-month-templates/{row_id}")
|
||||
async def delete_qc_month_template(
|
||||
row_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
month_tpl_svc.soft_delete_template(db, row_id, tenant_no=tenant_no)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
except Exception as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/qc-material-month-templates/preview")
|
||||
async def preview_qc_month_templates(
|
||||
body: QcMonthTemplatePreviewBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
stock_in_date = _parse_date(body.stock_in_date)
|
||||
rows = month_tpl_svc.preview_templates(
|
||||
db, tenant_no=tenant_no, stock_in_date=stock_in_date
|
||||
)
|
||||
return success_response(rows)
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
except Exception as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/qc-material-month-templates/confirm")
|
||||
async def confirm_qc_month_templates(
|
||||
body: QcMonthTemplateConfirmBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
operate_time = datetime.now()
|
||||
if body.stock_in_time:
|
||||
rt = parse_datetime(body.stock_in_time)
|
||||
if not rt:
|
||||
return error_response(400, "入库时间格式无效")
|
||||
operate_time = rt
|
||||
stock_in_date = _parse_date(body.stock_in_date)
|
||||
items = []
|
||||
for x in body.items:
|
||||
unit_price, amount = _resolve_stock_in_amounts(
|
||||
db, user, x.unit_price, x.amount
|
||||
)
|
||||
items.append(
|
||||
{
|
||||
"material_id": x.material_id,
|
||||
"materialId": x.material_id,
|
||||
"material_code": x.material_code,
|
||||
"materialCode": x.material_code,
|
||||
"material_name": x.material_name,
|
||||
"materialName": x.material_name,
|
||||
"spec_model": x.spec_model,
|
||||
"specModel": x.spec_model,
|
||||
"unit": x.unit,
|
||||
"stock_in_no": x.stock_in_no,
|
||||
"stockInNo": x.stock_in_no,
|
||||
"expire_date": x.expire_date,
|
||||
"expireDate": x.expire_date,
|
||||
"catalog_no": x.catalog_no,
|
||||
"catalogNo": x.catalog_no,
|
||||
"batch_no": x.batch_no,
|
||||
"batchNo": x.batch_no,
|
||||
"manufacturer": x.manufacturer,
|
||||
"unit_price": unit_price,
|
||||
"unitPrice": unit_price,
|
||||
"amount": amount,
|
||||
"qty": x.qty,
|
||||
"remark": x.remark,
|
||||
}
|
||||
)
|
||||
count = month_tpl_svc.confirm_month_import(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
stock_in_date=stock_in_date,
|
||||
items=items,
|
||||
remark=body.remark,
|
||||
operate_time=operate_time,
|
||||
)
|
||||
return success_response({"count": count}, message=f"月初导入成功,共 {count} 项")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
except Exception as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------- 物料变动记录 ----------
|
||||
|
||||
|
||||
|
||||
@@ -6,14 +6,27 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
from app.core.menu_guard import (
|
||||
assert_strict_menu_code,
|
||||
require_admin_or_super_admin,
|
||||
require_strict_menu_code,
|
||||
)
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud import research_donation as rd_svc
|
||||
from app.models.models import SysUser
|
||||
from app.models.research_donation import BizResearchDonation
|
||||
from app.services.sample_collect_wx_notify import (
|
||||
PROTOCOL_KIND_DONATION,
|
||||
sample_codes_from_orm_rows,
|
||||
sample_codes_from_payload,
|
||||
schedule_sample_collect_notify,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["科研捐赠管理"])
|
||||
|
||||
PROTOCOL_LIST_MENU = "biz:cellStorageProtocol:list"
|
||||
PROTOCOL_AUDIT_MENU = "biz:cellStorageProtocolAudit:list"
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
@@ -119,6 +132,33 @@ class ConvertFromStorageBody(BaseModel):
|
||||
protocol_id: str = Field(..., alias="protocolId")
|
||||
|
||||
|
||||
def assert_donation_sensitive_fields(
|
||||
db: Session,
|
||||
user: dict,
|
||||
body: BaseModel,
|
||||
*,
|
||||
creating: bool,
|
||||
) -> None:
|
||||
"""防止通过普通保存接口注入状态或协议互转来源。"""
|
||||
submitted = set(body.model_fields_set)
|
||||
if creating:
|
||||
status_value = str(getattr(body, "donation_status", None) or "dsx").strip()
|
||||
source_value = str(getattr(body, "source_type", None) or "direct").strip()
|
||||
requires_audit = (
|
||||
status_value != "dsx"
|
||||
or source_value != "direct"
|
||||
or bool(getattr(body, "source_protocol_id", None))
|
||||
)
|
||||
else:
|
||||
requires_audit = bool(
|
||||
submitted.intersection(
|
||||
{"donation_status", "source_type", "source_protocol_id"}
|
||||
)
|
||||
)
|
||||
if requires_audit:
|
||||
assert_strict_menu_code(db, user, PROTOCOL_AUDIT_MENU)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 列表
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -226,9 +266,10 @@ async def get_research_donation_detail(
|
||||
@router.post("/research-donations")
|
||||
async def create_research_donation(
|
||||
body: DonationCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
user: dict = Depends(require_strict_menu_code(PROTOCOL_LIST_MENU)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
assert_donation_sensitive_fields(db, user, body, creating=True)
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
try:
|
||||
@@ -242,6 +283,17 @@ async def create_research_donation(
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
schedule_sample_collect_notify(
|
||||
tenant_no=tenant_no,
|
||||
protocol_id=obj.id,
|
||||
protocol_kind=PROTOCOL_KIND_DONATION,
|
||||
before_codes=[],
|
||||
submitted_codes=(
|
||||
sample_codes_from_payload(body.samples)
|
||||
if body.samples is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
return success_response(
|
||||
rd_svc._donation_to_dict(obj, db, tenant_no),
|
||||
message="创建成功",
|
||||
@@ -259,13 +311,18 @@ async def create_research_donation(
|
||||
async def update_research_donation(
|
||||
donation_id: str,
|
||||
body: DonationUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
user: dict = Depends(require_strict_menu_code(PROTOCOL_LIST_MENU)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
assert_donation_sensitive_fields(db, user, body, creating=False)
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
try:
|
||||
data = body.model_dump(exclude_unset=True, by_alias=True)
|
||||
before_codes = sample_codes_from_orm_rows(
|
||||
rd_svc.list_samples(db, donation_id, tenant_no)
|
||||
)
|
||||
unset_fields = body.model_dump(exclude_unset=True)
|
||||
obj = rd_svc.update_donation(
|
||||
db,
|
||||
donation_id,
|
||||
@@ -273,6 +330,17 @@ async def update_research_donation(
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
schedule_sample_collect_notify(
|
||||
tenant_no=tenant_no,
|
||||
protocol_id=obj.id,
|
||||
protocol_kind=PROTOCOL_KIND_DONATION,
|
||||
before_codes=before_codes,
|
||||
submitted_codes=(
|
||||
sample_codes_from_payload(body.samples)
|
||||
if "samples" in unset_fields
|
||||
else None
|
||||
),
|
||||
)
|
||||
return success_response(
|
||||
rd_svc._donation_to_dict(obj, db, tenant_no),
|
||||
message="更新成功",
|
||||
@@ -397,6 +465,32 @@ async def change_donation_status(
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/research-donations/{donation_id}/status-changes")
|
||||
async def list_research_donation_status_changes(
|
||||
donation_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = (
|
||||
db.query(BizResearchDonation)
|
||||
.filter(
|
||||
BizResearchDonation.id == donation_id,
|
||||
BizResearchDonation.tenant_no == tenant_no,
|
||||
BizResearchDonation.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
return error_response(404, "捐赠单不存在")
|
||||
items = rd_svc.list_donation_status_changes(
|
||||
db,
|
||||
research_donation_id=donation_id,
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response({"list": items})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. 细胞存储 → 科研捐赠
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -44,6 +44,10 @@ async def list_donation_sample_collections(
|
||||
pageSize: int = Query(10, ge=1, le=100),
|
||||
keyword: Optional[str] = None,
|
||||
sampleStatus: Optional[str] = Query(None, alias="sampleStatus"),
|
||||
collectionFlag: Optional[str] = Query(None, alias="collectionFlag"),
|
||||
receptionFlag: Optional[str] = Query(None, alias="receptionFlag"),
|
||||
qcFlag: Optional[str] = Query(None, alias="qcFlag"),
|
||||
storageFlag: Optional[str] = Query(None, alias="storageFlag"),
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(_SAMPLE_INFO_MENU),
|
||||
):
|
||||
@@ -56,6 +60,10 @@ async def list_donation_sample_collections(
|
||||
tenant_no=tenant_no,
|
||||
keyword=keyword,
|
||||
sample_status=sampleStatus,
|
||||
collection_flag=collectionFlag,
|
||||
reception_flag=receptionFlag,
|
||||
qc_flag=qcFlag,
|
||||
storage_flag=storageFlag,
|
||||
user_info=user,
|
||||
)
|
||||
return success_response(_page_payload(items, total, page, pageSize))
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""样本级储存证 / 保险办理 API"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_any_menu_code, require_strict_menu_code
|
||||
from app.core.response import error_response, success_response
|
||||
from app.core.utils import parse_datetime
|
||||
from app.crud import sample_insurance as insurance_crud
|
||||
from app.crud import sample_storage_cert as cert_crud
|
||||
from app.models.models import BizProtocolSample, SysUser
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["样本储存证保险"])
|
||||
|
||||
_MENU = require_any_menu_code("cell-storage:sample-info:list")
|
||||
_WRITE_MENU = require_strict_menu_code("cell-storage:storage-cert:list")
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失,无法确定租户")
|
||||
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
|
||||
if not sys_user or not sys_user.tenant_no:
|
||||
raise PermissionError("用户租户信息缺失")
|
||||
return sys_user.tenant_no
|
||||
|
||||
|
||||
def _get_current_user_id(user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失")
|
||||
return str(user_id)
|
||||
|
||||
|
||||
def _resolve_protocol_id(db: Session, tenant_no: str, sample_code: str) -> Optional[str]:
|
||||
row = (
|
||||
db.query(BizProtocolSample)
|
||||
.filter(
|
||||
BizProtocolSample.tenant_no == tenant_no,
|
||||
BizProtocolSample.sample_code == sample_code,
|
||||
BizProtocolSample.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return (row.collection_protocol_id or "").strip() or None if row else None
|
||||
|
||||
|
||||
class SampleHandleBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
handle_time: Optional[str] = Field(None, alias="handleTime")
|
||||
handler_user_id: Optional[str] = Field(None, alias="handlerUserId")
|
||||
cert_image_urls: Optional[str] = Field(None, alias="certImageUrls")
|
||||
insurance_attachments: Optional[str] = Field(None, alias="insuranceAttachments")
|
||||
remark: Optional[str] = None
|
||||
handle_remark: Optional[str] = Field(None, alias="handleRemark")
|
||||
mail_cert_time: Optional[str] = Field(None, alias="mailCertTime")
|
||||
policy_send_time: Optional[str] = Field(None, alias="policySendTime")
|
||||
collection_protocol_id: Optional[str] = Field(None, alias="collectionProtocolId")
|
||||
|
||||
|
||||
@router.get("/sample-storage-certs/{sample_code}")
|
||||
async def get_sample_storage_cert(
|
||||
sample_code: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(_MENU),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
code = (sample_code or "").strip()
|
||||
row = cert_crud.get_by_sample_code(db, tenant_no, code)
|
||||
if row is None:
|
||||
return success_response(
|
||||
{
|
||||
"sampleCode": code,
|
||||
"storageCertHandled": False,
|
||||
"handleTime": "",
|
||||
"handlerUserId": "",
|
||||
"certImageUrls": "",
|
||||
"mailCertTime": "",
|
||||
"remark": "",
|
||||
}
|
||||
)
|
||||
return success_response(cert_crud.row_to_dict(row))
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except Exception as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/sample-storage-certs/{sample_code}")
|
||||
async def put_sample_storage_cert(
|
||||
sample_code: str,
|
||||
body: SampleHandleBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(_WRITE_MENU),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
code = (sample_code or "").strip()
|
||||
pid = (body.collection_protocol_id or "").strip() or _resolve_protocol_id(
|
||||
db, tenant_no, code
|
||||
)
|
||||
ht = parse_datetime(body.handle_time) if body.handle_time else None
|
||||
mail_raw = body.mail_cert_time
|
||||
update_mail = mail_raw is not None
|
||||
mail_t = parse_datetime(mail_raw) if mail_raw and str(mail_raw).strip() else None
|
||||
row = cert_crud.upsert_handle(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
sample_code=code,
|
||||
collection_protocol_id=pid,
|
||||
user_id=user_id,
|
||||
handle_time=ht,
|
||||
handler_user_id=body.handler_user_id,
|
||||
cert_image_urls=body.cert_image_urls or "",
|
||||
remark=body.remark or "",
|
||||
mail_cert_time=mail_t,
|
||||
update_mail_cert_time=update_mail,
|
||||
)
|
||||
db.commit()
|
||||
return success_response(cert_crud.row_to_dict(row), message="保存成功")
|
||||
except PermissionError as e:
|
||||
db.rollback()
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
db.rollback()
|
||||
return error_response(400, str(e))
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/sample-insurances/{sample_code}")
|
||||
async def get_sample_insurance(
|
||||
sample_code: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(_MENU),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
code = (sample_code or "").strip()
|
||||
row = insurance_crud.get_by_sample_code(db, tenant_no, code)
|
||||
if row is None:
|
||||
return success_response(
|
||||
{
|
||||
"sampleCode": code,
|
||||
"insuranceHandled": False,
|
||||
"handleTime": "",
|
||||
"handlerUserId": "",
|
||||
"insuranceAttachments": "",
|
||||
"policySendTime": "",
|
||||
"handleRemark": "",
|
||||
}
|
||||
)
|
||||
return success_response(insurance_crud.row_to_dict(row))
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except Exception as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/sample-insurances/{sample_code}")
|
||||
async def put_sample_insurance(
|
||||
sample_code: str,
|
||||
body: SampleHandleBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(_WRITE_MENU),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
code = (sample_code or "").strip()
|
||||
pid = (body.collection_protocol_id or "").strip() or _resolve_protocol_id(
|
||||
db, tenant_no, code
|
||||
)
|
||||
ht = parse_datetime(body.handle_time) if body.handle_time else None
|
||||
policy_raw = body.policy_send_time
|
||||
update_policy = policy_raw is not None
|
||||
policy_t = (
|
||||
parse_datetime(policy_raw) if policy_raw and str(policy_raw).strip() else None
|
||||
)
|
||||
row = insurance_crud.upsert_handle(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
sample_code=code,
|
||||
collection_protocol_id=pid,
|
||||
user_id=user_id,
|
||||
handle_time=ht,
|
||||
handler_user_id=body.handler_user_id,
|
||||
insurance_attachments=body.insurance_attachments or "",
|
||||
handle_remark=body.handle_remark or body.remark or "",
|
||||
policy_send_time=policy_t,
|
||||
update_policy_send_time=update_policy,
|
||||
)
|
||||
db.commit()
|
||||
return success_response(insurance_crud.row_to_dict(row), message="保存成功")
|
||||
except PermissionError as e:
|
||||
db.rollback()
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
db.rollback()
|
||||
return error_response(400, str(e))
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return error_response(400, str(e))
|
||||
@@ -0,0 +1,98 @@
|
||||
"""样本编码全链路纠错 API"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
from app.core.response import error_response, success_response
|
||||
from app.models.models import SysUser
|
||||
from app.services import sample_code_correction as svc
|
||||
from app.services.sample_code_correction import SampleCodeCorrectionError
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["样本编码纠错"])
|
||||
|
||||
_MENU = require_admin_or_super_admin
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失,无法确定租户")
|
||||
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
|
||||
if not sys_user or not sys_user.tenant_no:
|
||||
raise PermissionError("用户租户信息缺失")
|
||||
return sys_user.tenant_no
|
||||
|
||||
|
||||
def _get_user_id(user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失")
|
||||
return str(user_id)
|
||||
|
||||
|
||||
class SampleCodeCorrectionBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
reception_source: str = Field(..., alias="receptionSource")
|
||||
old_sample_code: str = Field(..., alias="oldSampleCode")
|
||||
new_sample_code: str = Field(..., alias="newSampleCode")
|
||||
new_sample_code_id: Optional[str] = Field(None, alias="newSampleCodeId")
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/sample-code-corrections/preview")
|
||||
async def preview_sample_code_correction(
|
||||
body: SampleCodeCorrectionBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(_MENU),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
data = svc.preview_correction(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
reception_source=body.reception_source,
|
||||
old_sample_code=body.old_sample_code,
|
||||
new_sample_code=body.new_sample_code,
|
||||
new_sample_code_id=body.new_sample_code_id,
|
||||
)
|
||||
return success_response(data)
|
||||
except PermissionError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(f"预览失败: {e}")
|
||||
|
||||
|
||||
@router.post("/sample-code-corrections/apply")
|
||||
async def apply_sample_code_correction(
|
||||
body: SampleCodeCorrectionBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(_MENU),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_user_id(user)
|
||||
data = svc.apply_correction(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
reception_source=body.reception_source,
|
||||
old_sample_code=body.old_sample_code,
|
||||
new_sample_code=body.new_sample_code,
|
||||
new_sample_code_id=body.new_sample_code_id,
|
||||
reason=body.reason,
|
||||
user_id=user_id,
|
||||
)
|
||||
return success_response(data, message="样本编码已更正")
|
||||
except SampleCodeCorrectionError as e:
|
||||
db.rollback()
|
||||
return error_response(str(e))
|
||||
except PermissionError as e:
|
||||
db.rollback()
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return error_response(f"更正失败: {e}")
|
||||
@@ -0,0 +1,412 @@
|
||||
"""样本运费批次 API"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud import sample_freight as freight_svc
|
||||
from app.crud import sf_express_settlement as sf_crud
|
||||
from app.models.models import SysUser
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["样本运费批次"])
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失,无法确定租户")
|
||||
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
|
||||
if not sys_user or not sys_user.tenant_no:
|
||||
raise PermissionError("用户租户信息缺失")
|
||||
return sys_user.tenant_no
|
||||
|
||||
|
||||
def _get_current_user_id(user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失")
|
||||
return str(user_id)
|
||||
|
||||
|
||||
class SampleFreightItemBody(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
sample_source: str = Field(..., alias="sampleSource")
|
||||
sample_id: str = Field(..., alias="sampleId")
|
||||
protocol_id: Optional[str] = Field(None, alias="protocolId")
|
||||
sample_code: Optional[str] = Field(None, alias="sampleCode")
|
||||
|
||||
|
||||
class SampleFreightCreateBody(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
transport_mode: str = Field(..., alias="transportMode")
|
||||
freight_amount: Optional[float] = Field(None, alias="freightAmount")
|
||||
waybill_no: Optional[str] = Field(None, alias="waybillNo")
|
||||
items: Optional[List[SampleFreightItemBody]] = None
|
||||
|
||||
|
||||
class SampleFreightUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
transport_mode: Optional[str] = Field(None, alias="transportMode")
|
||||
freight_amount: Optional[float] = Field(None, alias="freightAmount")
|
||||
waybill_no: Optional[str] = Field(None, alias="waybillNo")
|
||||
|
||||
|
||||
class SampleFreightItemsBody(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
items: List[SampleFreightItemBody] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SfSettlementSettleBody(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
ids: List[str] = Field(default_factory=list)
|
||||
freight_amount: Optional[float] = Field(None, alias="freightAmount")
|
||||
|
||||
|
||||
class SfSettlementFillBody(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
freight_amount: float = Field(..., alias="freightAmount")
|
||||
|
||||
|
||||
@router.get("/sample-freights")
|
||||
async def list_sample_freights(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
transportMode: Optional[str] = None,
|
||||
sampleSource: Optional[str] = None,
|
||||
createTimeFrom: Optional[str] = None,
|
||||
createTimeTo: Optional[str] = None,
|
||||
includeItems: bool = Query(False),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = freight_svc.list_freights(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
page=page,
|
||||
page_size=pageSize,
|
||||
keyword=keyword,
|
||||
transport_mode=transportMode,
|
||||
sample_source=sampleSource,
|
||||
create_time_from=createTimeFrom,
|
||||
create_time_to=createTimeTo,
|
||||
include_items=includeItems,
|
||||
)
|
||||
return success_response(
|
||||
{"list": items, "total": total, "page": page, "pageSize": pageSize}
|
||||
)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/sample-freights/sample-options")
|
||||
async def list_sample_freight_options(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(20, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
sampleSource: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = freight_svc.list_sample_options(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
page=page,
|
||||
page_size=pageSize,
|
||||
keyword=keyword,
|
||||
sample_source=sampleSource,
|
||||
)
|
||||
return success_response(
|
||||
{"list": items, "total": total, "page": page, "pageSize": pageSize}
|
||||
)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/sample-freights/sf-settlements")
|
||||
async def list_sf_express_settlements(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
waybillNo: Optional[str] = None,
|
||||
settleStatus: Optional[str] = None,
|
||||
createTimeFrom: Optional[str] = None,
|
||||
createTimeTo: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
rows, total = sf_crud.list_sf_settlements(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
page=page,
|
||||
page_size=pageSize,
|
||||
waybill_no=waybillNo,
|
||||
settle_status=settleStatus,
|
||||
create_time_from=createTimeFrom,
|
||||
create_time_to=createTimeTo,
|
||||
)
|
||||
return success_response(
|
||||
{"list": rows, "total": total, "page": page, "pageSize": pageSize}
|
||||
)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/sample-freights/sf-settlements/export")
|
||||
async def export_sf_express_settlements(
|
||||
waybillNo: Optional[str] = None,
|
||||
settleStatus: Optional[str] = None,
|
||||
createTimeFrom: Optional[str] = None,
|
||||
createTimeTo: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
rows = sf_crud.list_sf_settlements_for_export(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
waybill_no=waybillNo,
|
||||
settle_status=settleStatus,
|
||||
create_time_from=createTimeFrom,
|
||||
create_time_to=createTimeTo,
|
||||
)
|
||||
return success_response({"list": rows})
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/sample-freights/sf-settlements/settle")
|
||||
async def settle_sf_express_settlements(
|
||||
body: SfSettlementSettleBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
data = sf_crud.settle_sf_freights(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
user_info=user,
|
||||
ids=body.ids,
|
||||
freight_amount=body.freight_amount,
|
||||
)
|
||||
return success_response(data, message="结算成功")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/sample-freights/sf-settlements/{freight_id}/fill-amount")
|
||||
async def fill_sf_express_settlement_amount(
|
||||
freight_id: str,
|
||||
body: SfSettlementFillBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
data = sf_crud.fill_sf_freight_amount(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
user_info=user,
|
||||
freight_id=freight_id,
|
||||
freight_amount=body.freight_amount,
|
||||
)
|
||||
return success_response(data, message="补填成功")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/sample-freights/{freight_id}")
|
||||
async def get_sample_freight(
|
||||
freight_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
data = freight_svc.get_freight_detail(db, freight_id, tenant_no, user_info=user)
|
||||
return success_response(data)
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(404, str(e))
|
||||
|
||||
|
||||
@router.post("/sample-freights")
|
||||
async def create_sample_freight(
|
||||
body: SampleFreightCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
items = [it.model_dump(by_alias=True) for it in (body.items or [])]
|
||||
data = freight_svc.create_freight(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
user_id=user_id,
|
||||
transport_mode_raw=body.transport_mode,
|
||||
freight_amount=body.freight_amount,
|
||||
waybill_no=body.waybill_no,
|
||||
waybill_supplied=True,
|
||||
amount_supplied=True,
|
||||
items=items,
|
||||
)
|
||||
return success_response(data, message="创建成功")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/sample-freights/{freight_id}")
|
||||
async def update_sample_freight(
|
||||
freight_id: str,
|
||||
body: SampleFreightUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
data = freight_svc.update_freight(
|
||||
db,
|
||||
freight_id,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
user_id=user_id,
|
||||
transport_mode_raw=body.transport_mode,
|
||||
freight_amount=body.freight_amount,
|
||||
waybill_no=body.waybill_no,
|
||||
waybill_supplied="waybill_no" in body.model_fields_set,
|
||||
amount_supplied="freight_amount" in body.model_fields_set,
|
||||
)
|
||||
return success_response(data, message="更新成功")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
msg = str(e)
|
||||
code = 404 if "不存在" in msg else 400
|
||||
return error_response(code, msg)
|
||||
|
||||
|
||||
@router.post("/sample-freights/{freight_id}/link")
|
||||
async def link_sample_freight(
|
||||
freight_id: str,
|
||||
body: SampleFreightItemsBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
items = [it.model_dump(by_alias=True) for it in (body.items or [])]
|
||||
data = freight_svc.link_samples(
|
||||
db,
|
||||
freight_id,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
user_id=user_id,
|
||||
items=items,
|
||||
)
|
||||
return success_response(data, message="关联成功")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
msg = str(e)
|
||||
code = 404 if "不存在" in msg else 400
|
||||
return error_response(code, msg)
|
||||
|
||||
|
||||
@router.post("/sample-freights/{freight_id}/unlink")
|
||||
async def unlink_sample_freight(
|
||||
freight_id: str,
|
||||
body: SampleFreightItemsBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
items = [it.model_dump(by_alias=True) for it in (body.items or [])]
|
||||
data = freight_svc.unlink_samples(
|
||||
db,
|
||||
freight_id,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
user_id=user_id,
|
||||
items=items,
|
||||
)
|
||||
return success_response(data, message="已取消关联")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
msg = str(e)
|
||||
code = 404 if "不存在" in msg else 400
|
||||
return error_response(code, msg)
|
||||
|
||||
|
||||
@router.delete("/sample-freights/{freight_id}")
|
||||
async def delete_sample_freight(
|
||||
freight_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
data = freight_svc.delete_freight(
|
||||
db,
|
||||
freight_id,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
user_id=user_id,
|
||||
)
|
||||
return success_response(data, message="删除成功")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
msg = str(e)
|
||||
code = 404 if "不存在" in msg else 400
|
||||
return error_response(code, msg)
|
||||
@@ -61,7 +61,7 @@ class ProductionReceptionBody(BaseModel):
|
||||
infectious_four_test_result: Optional[str] = Field(None, alias="infectiousFourTestResult")
|
||||
enriched_blood_weight_g: Optional[float] = Field(None, alias="enrichedBloodWeightG")
|
||||
cryopreservation_volume_ml: Optional[float] = Field(None, alias="cryopreservationVolumeMl")
|
||||
inoculation_amount: Optional[float] = Field(None, alias="inoculationAmount")
|
||||
inoculation_amount: Optional[str] = Field(None, alias="inoculationAmount")
|
||||
cryopreservation_time: Optional[str] = Field(None, alias="cryopreservationTime")
|
||||
storage_total_tubes: Optional[int] = Field(None, alias="storageTotalTubes")
|
||||
available_cell_count_1e7: Optional[float] = Field(None, alias="availableCellCount1e7")
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""样本储存证质检信息 API"""
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_any_menu_code, require_strict_any_menu_code
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud import sample_storage_cert_qc as svc
|
||||
from app.models.models import SysUser
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["储存证质检信息"])
|
||||
|
||||
# 市场 / 质检 / 生产三端共用读写(方案 A:完整表后写覆盖)
|
||||
READ_MENUS = (
|
||||
"biz:qcWorkOrder:list",
|
||||
"biz:myQcTask:list",
|
||||
"production:work-order:list",
|
||||
"production:my-task:list",
|
||||
"cell-storage:sample-info:list",
|
||||
)
|
||||
WRITE_MENUS = READ_MENUS
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失,无法确定租户")
|
||||
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
|
||||
if not sys_user or not sys_user.tenant_no:
|
||||
raise PermissionError("用户租户信息缺失")
|
||||
return sys_user.tenant_no
|
||||
|
||||
|
||||
def _get_current_user_id(user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失")
|
||||
return str(user_id)
|
||||
|
||||
|
||||
class UpsertBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||
qc_work_order_id: Optional[str] = Field(None, alias="qcWorkOrderId")
|
||||
|
||||
|
||||
@router.get("/sample-storage-cert-qc/{sample_code}")
|
||||
async def get_sample_storage_cert_qc(
|
||||
sample_code: str,
|
||||
user: dict = Depends(require_any_menu_code(*READ_MENUS)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
detail = svc.get_detail(db, tenant_no, sample_code)
|
||||
if detail is None:
|
||||
return error_response(404, "样本不存在")
|
||||
return success_response(detail)
|
||||
|
||||
|
||||
@router.put("/sample-storage-cert-qc/{sample_code}")
|
||||
async def put_sample_storage_cert_qc(
|
||||
sample_code: str,
|
||||
body: UpsertBody,
|
||||
user: dict = Depends(require_strict_any_menu_code(*WRITE_MENUS)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
try:
|
||||
detail = svc.upsert(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
sample_code=sample_code,
|
||||
payload=body.payload,
|
||||
qc_work_order_id=body.qc_work_order_id,
|
||||
)
|
||||
return success_response(detail, message="保存成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
@@ -4,7 +4,11 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin, require_menu_code
|
||||
from app.core.menu_guard import (
|
||||
require_admin_or_super_admin,
|
||||
require_any_menu_code,
|
||||
user_has_menu_perm,
|
||||
)
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud import storage_release_review as srr_svc
|
||||
from app.models.models import SysUser
|
||||
@@ -34,6 +38,32 @@ def _get_current_user_id(user: dict) -> str:
|
||||
return str(user_id)
|
||||
|
||||
|
||||
STORAGE_RELEASE_READ_MENU_CODES = (
|
||||
"biz:storageReleaseReview:list",
|
||||
"production:storageReleaseReview:list",
|
||||
"quality:storageReleaseReview:list",
|
||||
)
|
||||
STORAGE_RELEASE_WRITE_MENU_CODES = (
|
||||
"production:storageReleaseReview:list",
|
||||
"quality:storageReleaseReview:list",
|
||||
)
|
||||
|
||||
|
||||
def _ensure_review_side_write(db: Session, user: dict, review_side: str) -> str:
|
||||
side = srr_svc.normalize_review_side(review_side)
|
||||
if side not in ("production", "qc"):
|
||||
raise ValueError("审核侧无效")
|
||||
menu = srr_svc.review_side_required_menu(side)
|
||||
if not menu or not user_has_menu_perm(db, user, menu):
|
||||
raise PermissionError("无权提交该侧审核")
|
||||
return side
|
||||
|
||||
|
||||
def _ensure_storage_write(db: Session, user: dict) -> None:
|
||||
if not any(user_has_menu_perm(db, user, code) for code in STORAGE_RELEASE_WRITE_MENU_CODES):
|
||||
raise PermissionError("无权执行入库操作")
|
||||
|
||||
|
||||
@router.get("/storage-release-reviews")
|
||||
async def list_storage_release_reviews(
|
||||
page: int = Query(1, ge=1),
|
||||
@@ -44,8 +74,12 @@ async def list_storage_release_reviews(
|
||||
storageStatus: Optional[str] = None,
|
||||
reviewPhase: Optional[str] = None,
|
||||
qcWorkOrderCompleted: Optional[bool] = Query(None),
|
||||
readyForReviewOnly: Optional[bool] = Query(None),
|
||||
reviewSide: Optional[str] = None,
|
||||
prodReviewConclusion: Optional[str] = None,
|
||||
qcReviewConclusion: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:storageReleaseReview:list")),
|
||||
user: dict = Depends(require_any_menu_code(*STORAGE_RELEASE_READ_MENU_CODES)),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
@@ -60,6 +94,10 @@ async def list_storage_release_reviews(
|
||||
keyword=keyword,
|
||||
review_phase=reviewPhase,
|
||||
qc_work_order_completed=qcWorkOrderCompleted,
|
||||
ready_for_review_only=readyForReviewOnly,
|
||||
review_side=reviewSide,
|
||||
prod_review_conclusion=prodReviewConclusion,
|
||||
qc_review_conclusion=qcReviewConclusion,
|
||||
user_info=user,
|
||||
)
|
||||
return success_response(result)
|
||||
@@ -73,7 +111,7 @@ async def list_storage_release_reviews(
|
||||
async def get_storage_release_review_detail(
|
||||
review_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:storageReleaseReview:list")),
|
||||
user: dict = Depends(require_any_menu_code(*STORAGE_RELEASE_READ_MENU_CODES)),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
@@ -97,9 +135,11 @@ async def submit_single_review(
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
side = _ensure_review_side_write(db, user, body.reviewSide)
|
||||
rev = srr_svc.submit_review(
|
||||
db,
|
||||
review_id,
|
||||
review_side=side,
|
||||
review_conclusion=body.reviewConclusion,
|
||||
review_remark=body.reviewRemark,
|
||||
review_attachments=body.reviewAttachments,
|
||||
@@ -125,9 +165,11 @@ async def batch_review(
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
side = _ensure_review_side_write(db, user, body.reviewSide)
|
||||
result = srr_svc.batch_submit_review(
|
||||
db,
|
||||
body.ids,
|
||||
review_side=side,
|
||||
review_conclusion=body.reviewConclusion,
|
||||
review_remark=body.reviewRemark,
|
||||
review_attachments=body.reviewAttachments,
|
||||
@@ -153,6 +195,7 @@ async def store_single_sample(
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
_ensure_storage_write(db, user)
|
||||
rev = srr_svc.store_sample(
|
||||
db,
|
||||
review_id,
|
||||
@@ -178,6 +221,7 @@ async def batch_store_samples(
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
_ensure_storage_write(db, user)
|
||||
result = srr_svc.batch_store_samples(
|
||||
db,
|
||||
body.ids,
|
||||
|
||||
@@ -467,8 +467,11 @@ async def approve_task(
|
||||
):
|
||||
try:
|
||||
tenant_no = user.get("tenant_no", "zkxw")
|
||||
actor_user_id = user.get("user_id") or user.get("id")
|
||||
engine = WorkflowEngine(db)
|
||||
instance = engine.approve_task(task_id, body.comment or "", tenant_no)
|
||||
instance = engine.approve_task(
|
||||
task_id, body.comment or "", tenant_no, actor_user_id=actor_user_id
|
||||
)
|
||||
return success_response({"instanceId": instance.id, "status": instance.status}, message="审批成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
@@ -483,8 +486,11 @@ async def reject_task(
|
||||
):
|
||||
try:
|
||||
tenant_no = user.get("tenant_no", "zkxw")
|
||||
actor_user_id = user.get("user_id") or user.get("id")
|
||||
engine = WorkflowEngine(db)
|
||||
instance = engine.reject_task(task_id, body.comment or "", tenant_no)
|
||||
instance = engine.reject_task(
|
||||
task_id, body.comment or "", tenant_no, actor_user_id=actor_user_id
|
||||
)
|
||||
return success_response({"instanceId": instance.id, "status": instance.status}, message="已驳回")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
@@ -221,8 +221,11 @@ async def get_current_user_by_token(
|
||||
detail="用户已被禁用",
|
||||
)
|
||||
|
||||
web_role = resolve_web_role_for_user(db, user.id)
|
||||
role_names = get_user_role_names(db, user.id)
|
||||
tenant_no = (getattr(user, "tenant_no", None) or "").strip() or None
|
||||
web_role = resolve_web_role_for_user(
|
||||
db, user.id, tenant_no=tenant_no
|
||||
)
|
||||
role_names = get_user_role_names(db, user.id, tenant_no=tenant_no)
|
||||
|
||||
phone = (getattr(user, "phone_no", None) or "").strip() or None
|
||||
email = (getattr(user, "email", None) or "").strip() or None
|
||||
@@ -232,6 +235,10 @@ async def get_current_user_by_token(
|
||||
s = str(avatar_raw).strip()
|
||||
avatar_stored = s if s else None
|
||||
|
||||
from app.services.issue_user_tokens import resolve_user_org_labels
|
||||
|
||||
dept_name, post_names = resolve_user_org_labels(db, user)
|
||||
|
||||
current_user_dept_id.set(user.dept_id or "")
|
||||
|
||||
return {
|
||||
@@ -240,7 +247,11 @@ async def get_current_user_by_token(
|
||||
"real_name": user.nick_name,
|
||||
"role": web_role,
|
||||
"role_names": role_names,
|
||||
"tenant_no": tenant_no,
|
||||
"region_id": user.dept_id,
|
||||
"region_name": dept_name,
|
||||
"dept_name": dept_name,
|
||||
"post_names": post_names,
|
||||
"phone": phone,
|
||||
"email": email,
|
||||
"sex": getattr(user, "sex", None) or "0",
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
# 已知弱/占位 JWT 密钥:启动时一律拒绝(勿再当作默认值)
|
||||
_INSECURE_JWT_SECRET_KEYS = frozenset(
|
||||
{
|
||||
"",
|
||||
"secret",
|
||||
"changeme",
|
||||
"change-me",
|
||||
"your-secret-key",
|
||||
"your-secret-key-change-in-production",
|
||||
"your-secret-key-change-in-production-please-use-random-string",
|
||||
}
|
||||
)
|
||||
_MIN_JWT_SECRET_KEY_LEN = 32
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用配置"""
|
||||
@@ -15,10 +30,27 @@ class Settings(BaseSettings):
|
||||
# 数据库配置
|
||||
DATABASE_URL: str = "mysql+pymysql://root:password@localhost:3306/sanqiang"
|
||||
|
||||
# JWT配置
|
||||
SECRET_KEY: str = "your-secret-key-change-in-production-please-use-random-string"
|
||||
# JWT配置(必须通过环境变量配置;禁止弱默认值)
|
||||
SECRET_KEY: str = ""
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 * 24 # 30天
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 * 24 * 60 # 30天
|
||||
|
||||
@field_validator("SECRET_KEY")
|
||||
@classmethod
|
||||
def secret_key_must_be_strong(cls, value: str) -> str:
|
||||
key = (value or "").strip()
|
||||
if key.lower() in _INSECURE_JWT_SECRET_KEYS or key in _INSECURE_JWT_SECRET_KEYS:
|
||||
raise ValueError(
|
||||
"SECRET_KEY 未配置或仍为弱占位值。"
|
||||
"请在环境文件中设置至少 32 字符的随机密钥,例如:"
|
||||
'python -c "import secrets; print(secrets.token_urlsafe(48))"'
|
||||
)
|
||||
if len(key) < _MIN_JWT_SECRET_KEY_LEN:
|
||||
raise ValueError(
|
||||
f"SECRET_KEY 长度不足(当前 {len(key)},至少 {_MIN_JWT_SECRET_KEY_LEN})。"
|
||||
"请使用 secrets.token_urlsafe(48) 等方式生成。"
|
||||
)
|
||||
return key
|
||||
|
||||
# CORS配置
|
||||
BACKEND_CORS_ORIGINS: list = [
|
||||
@@ -100,6 +132,53 @@ class Settings(BaseSettings):
|
||||
WX_TEMPLATE_REFUND_RESULT: str = "" # 退款结果通知模板ID
|
||||
WX_TEMPLATE_DELIVERY_WARNING: str = "" # 配送时效预警模板ID
|
||||
|
||||
# 微信服务号(OA 审批/抄送通知)
|
||||
# 总开关:false 时不发送审批/抄送/申请人进度/评论@提到/基础实验交付提醒/样本采集通知模板消息
|
||||
WX_MP_NOTIFY_ENABLED: bool = False
|
||||
WX_MP_APPID: str = ""
|
||||
WX_MP_SECRET: str = ""
|
||||
WX_MP_TEMPLATE_APPROVAL: str = ""
|
||||
WX_MP_TEMPLATE_CC: str = ""
|
||||
WX_MP_TEMPLATE_APPLICANT_PROGRESS: str = ""
|
||||
# 基础实验交付日前一天提醒(出库提醒模板)
|
||||
WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY: str = (
|
||||
"oQNU3t0Q5kwc4KPJ8Z6yPA-k6qlsH9pikSCIb4eAwgM"
|
||||
)
|
||||
# 协议新增样本采集通知(工单类模板)
|
||||
WX_MP_TEMPLATE_SAMPLE_COLLECT: str = (
|
||||
"iOnQ9k2mavAOwFbp_6yH4lD-RCNmM_OiwyTgrJfU-_A"
|
||||
)
|
||||
WX_MP_SAMPLE_COLLECT_ROLE_NAME: str = "样本采集通知人员"
|
||||
# 审批模板:申请事项/操作人(申请人)/当前环节/操作时间/申请类型(流程名)
|
||||
# 生产字段:thing12 / thing18 / thing7 / time17 / thing8
|
||||
WX_MP_TPL_FIELD_TITLE: str = "thing12"
|
||||
WX_MP_TPL_FIELD_APPLICANT: str = "thing18"
|
||||
WX_MP_TPL_FIELD_NODE: str = "thing7"
|
||||
WX_MP_TPL_FIELD_TIME: str = "time17"
|
||||
WX_MP_TPL_FIELD_PROCESS: str = "thing8"
|
||||
# 抄送模板「OA抄送通知」:业务名称/申请人/申请时间/流程名称(无当前环节)
|
||||
WX_MP_TPL_CC_FIELD_TITLE: str = "thing5"
|
||||
WX_MP_TPL_CC_FIELD_APPLICANT: str = "thing2"
|
||||
WX_MP_TPL_CC_FIELD_TIME: str = "time3"
|
||||
WX_MP_TPL_CC_FIELD_PROCESS: str = "thing1"
|
||||
# 申请人进度:业务名称/流程名称/审批人/审批结果(const)/审批时间
|
||||
WX_MP_TPL_PROGRESS_FIELD_BUSINESS: str = "thing5"
|
||||
WX_MP_TPL_PROGRESS_FIELD_PROCESS: str = "thing1"
|
||||
WX_MP_TPL_PROGRESS_FIELD_APPROVER: str = "thing6"
|
||||
WX_MP_TPL_PROGRESS_FIELD_RESULT: str = "const4"
|
||||
WX_MP_TPL_PROGRESS_FIELD_TIME: str = "time11"
|
||||
# 基础实验交付提醒:客户名称/产品名称/件数/收货联系人/出库时间
|
||||
WX_MP_TPL_BE_DELIVERY_FIELD_CUSTOMER: str = "thing6"
|
||||
WX_MP_TPL_BE_DELIVERY_FIELD_PRODUCT: str = "thing10"
|
||||
WX_MP_TPL_BE_DELIVERY_FIELD_QTY: str = "character_string8"
|
||||
WX_MP_TPL_BE_DELIVERY_FIELD_CONTACT: str = "thing7"
|
||||
WX_MP_TPL_BE_DELIVERY_FIELD_TIME: str = "time14"
|
||||
# 样本采集通知:工单标题/发起人/发起时间/任务类型
|
||||
WX_MP_TPL_SC_FIELD_TITLE: str = "thing2"
|
||||
WX_MP_TPL_SC_FIELD_APPLICANT: str = "thing8"
|
||||
WX_MP_TPL_SC_FIELD_TIME: str = "time3"
|
||||
WX_MP_TPL_SC_FIELD_TYPE: str = "phrase10"
|
||||
|
||||
# 与 docs/sql/zkxw.sql 基线一致:登录按租户 + sys_user.user_no
|
||||
DEFAULT_TENANT_NO: str = "zkxw"
|
||||
# 已废弃:无角色用户固定为 guest,不可通过环境变量提升为 super_admin
|
||||
@@ -126,6 +205,9 @@ class Settings(BaseSettings):
|
||||
API_ENCRYPT_FIXED_SECRET: str = ""
|
||||
API_ENCRYPT_TS_SKEW_SEC: int = 300
|
||||
API_ENCRYPT_NONCE_TTL_SEC: int = 300
|
||||
WX_QR_SESSION_TTL_SEC: int = 180
|
||||
WX_QR_ENV_VERSION: str = "release" # release | trial | develop
|
||||
WX_QR_PAGE: str = "pages/login/web-qr/index"
|
||||
API_ENCRYPT_EXCLUDE_PATHS: str = (
|
||||
"/,"
|
||||
"/health,"
|
||||
@@ -134,14 +216,24 @@ class Settings(BaseSettings):
|
||||
"/api/web/auth/login-rsa-public-key,"
|
||||
"/api/web/auth/login,"
|
||||
"/api/web/auth/refresh-token,"
|
||||
"/api/mini/auth/wechat-login,"
|
||||
"/api/web/admin/attachment/upload,"
|
||||
"/api/web/admin/products/upload-image,"
|
||||
"/api/web/admin/categories/upload-image,"
|
||||
"/api/web/admin/users/upload-avatar,"
|
||||
"/api/web/auth/upload-avatar,"
|
||||
"/api/invoice/download"
|
||||
"/api/invoice/download,"
|
||||
"/api/web/auth/wx-qr/create,"
|
||||
"/api/web/auth/wx-qr/status,"
|
||||
"/api/mini/auth/web-qr-scanned,"
|
||||
"/api/mini/auth/web-qr-confirm"
|
||||
)
|
||||
|
||||
# 中国节假日日历(ailcc)同步
|
||||
CN_HOLIDAY_API_BASE: str = "https://holiday.ailcc.com"
|
||||
CN_HOLIDAY_HTTP_TIMEOUT_SEC: int = 15
|
||||
CN_HOLIDAY_HTTP_RETRIES: int = 2
|
||||
|
||||
class Config:
|
||||
# 支持通过 ENV_FILE 指定环境文件;未指定时回退到 backend/.env
|
||||
_default_env_file = Path(__file__).resolve().parents[2] / ".env"
|
||||
|
||||
@@ -20,7 +20,6 @@ from app.models.models import (
|
||||
SysRole,
|
||||
SysRoleDataScopeDetail,
|
||||
SysUser,
|
||||
SysUserRole,
|
||||
)
|
||||
|
||||
|
||||
@@ -62,17 +61,20 @@ def _get_child_dept_ids(db: Session, dept_id: str, tenant_no: str) -> List[str]:
|
||||
def _resolve_user_role_scopes(
|
||||
db: Session, user_id: str, tenant_no: str
|
||||
) -> List[dict]:
|
||||
"""获取用户所有角色的数据权限配置。
|
||||
"""获取用户有效角色的数据权限配置(直接角色 ∪ 职位继承角色)。
|
||||
|
||||
返回: [{"data_scope": str, "details": [SysRoleDataScopeDetail, ...]}]
|
||||
"""
|
||||
from app.services.web_auth_roles import get_effective_role_ids
|
||||
|
||||
role_ids = get_effective_role_ids(db, user_id, tenant_no=tenant_no)
|
||||
if not role_ids:
|
||||
return []
|
||||
|
||||
roles = (
|
||||
db.query(SysRole)
|
||||
.join(SysUserRole, SysUserRole.role_id == SysRole.id)
|
||||
.filter(
|
||||
SysUserRole.user_id == user_id,
|
||||
SysUserRole.del_flag == 0,
|
||||
SysUserRole.tenant_no == tenant_no,
|
||||
SysRole.id.in_(role_ids),
|
||||
SysRole.del_flag == 0,
|
||||
SysRole.status == "1",
|
||||
SysRole.tenant_no == tenant_no,
|
||||
@@ -83,7 +85,6 @@ def _resolve_user_role_scopes(
|
||||
return []
|
||||
|
||||
result: List[dict] = []
|
||||
role_ids = [r.id for r in roles]
|
||||
|
||||
# 批量查所有自定义权限明细
|
||||
all_details = (
|
||||
|
||||
@@ -5,6 +5,8 @@ from fastapi import Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import get_current_user
|
||||
from app.core.database import get_db
|
||||
from app.models.models import SysUser
|
||||
from app.services.web_auth_roles import iter_user_menu_perm_codes
|
||||
|
||||
ADMIN_ROLES = {"admin", "super_admin"}
|
||||
@@ -57,3 +59,92 @@ def require_any_menu_code(*menu_codes: str) -> Callable:
|
||||
"""接口菜单权限守卫(已统一放行 menuCode 校验,仅保留运营端登录)。"""
|
||||
_ = menu_codes
|
||||
return require_admin_or_super_admin
|
||||
|
||||
|
||||
def _strict_user_identity(db: Session, user: dict) -> tuple[str, str]:
|
||||
"""从用户表读取当前租户,拒绝信任 JWT/请求中的租户覆盖。"""
|
||||
user_id = str((user or {}).get("user_id") or "").strip()
|
||||
if not user_id:
|
||||
return "", ""
|
||||
row = (
|
||||
db.query(SysUser.id, SysUser.tenant_no)
|
||||
.filter(
|
||||
SysUser.id == user_id,
|
||||
SysUser.del_flag == 0,
|
||||
SysUser.status == "1",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not row:
|
||||
return "", ""
|
||||
return str(row[0] or "").strip(), str(row[1] or "").strip()
|
||||
|
||||
|
||||
def strict_user_has_any_menu_perm(
|
||||
db: Session,
|
||||
user: dict,
|
||||
menu_codes: Iterable[str],
|
||||
) -> bool:
|
||||
"""按用户真实租户严格判断任一菜单权限,含直接与职位继承角色。"""
|
||||
required = {
|
||||
str(code or "").strip() for code in menu_codes if str(code or "").strip()
|
||||
}
|
||||
if not required:
|
||||
return False
|
||||
user_id, tenant_no = _strict_user_identity(db, user)
|
||||
if not user_id or not tenant_no:
|
||||
return False
|
||||
granted = iter_user_menu_perm_codes(db, user_id, tenant_no=tenant_no)
|
||||
return bool(required.intersection(granted))
|
||||
|
||||
|
||||
def strict_user_has_menu_perm(db: Session, user: dict, menu_code: str) -> bool:
|
||||
"""按用户真实租户严格判断单个菜单权限。"""
|
||||
return strict_user_has_any_menu_perm(db, user, (menu_code,))
|
||||
|
||||
|
||||
def assert_strict_menu_code(
|
||||
db: Session,
|
||||
user: dict,
|
||||
menu_code: str,
|
||||
) -> None:
|
||||
"""路由内部字段级权限断言。"""
|
||||
if not strict_user_has_menu_perm(db, user, menu_code):
|
||||
raise HTTPException(status_code=403, detail="无对应菜单权限")
|
||||
|
||||
|
||||
def require_strict_menu_code(menu_code: str) -> Callable:
|
||||
"""租户化的精确菜单权限依赖;与历史宽松守卫并存。"""
|
||||
required = str(menu_code or "").strip()
|
||||
|
||||
async def dependency(
|
||||
user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
assert_strict_menu_code(db, user, required)
|
||||
return user
|
||||
|
||||
dependency.strict_menu_codes = (required,)
|
||||
return dependency
|
||||
|
||||
|
||||
def require_strict_any_menu_code(*menu_codes: str) -> Callable:
|
||||
"""租户化的任一菜单权限依赖。"""
|
||||
required = tuple(
|
||||
dict.fromkeys(
|
||||
str(code or "").strip()
|
||||
for code in menu_codes
|
||||
if str(code or "").strip()
|
||||
)
|
||||
)
|
||||
|
||||
async def dependency(
|
||||
user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
if not strict_user_has_any_menu_perm(db, user, required):
|
||||
raise HTTPException(status_code=403, detail="无对应菜单权限")
|
||||
return user
|
||||
|
||||
dependency.strict_menu_codes = required
|
||||
return dependency
|
||||
@@ -0,0 +1,76 @@
|
||||
"""移动市场接口的严格菜单权限守卫。"""
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import get_current_user
|
||||
from app.core.database import get_db
|
||||
from app.services.web_auth_roles import (
|
||||
iter_user_menu_perm_codes,
|
||||
resolve_web_role_for_user,
|
||||
)
|
||||
|
||||
|
||||
def require_market_menu_permission(
|
||||
*,
|
||||
user_id: str,
|
||||
tenant_no: str,
|
||||
required: str,
|
||||
granted: Iterable[str],
|
||||
) -> bool:
|
||||
"""严格判断当前租户用户是否拥有指定移动市场权限码。"""
|
||||
uid = (user_id or "").strip()
|
||||
tenant = (tenant_no or "").strip()
|
||||
code = (required or "").strip()
|
||||
if not uid or not tenant or not code:
|
||||
return False
|
||||
return code in {(item or "").strip() for item in granted if item}
|
||||
|
||||
|
||||
def user_has_tenant_menu_perm(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: str,
|
||||
tenant_no: str,
|
||||
perm_code: str,
|
||||
) -> bool:
|
||||
"""按显式租户读取用户权限,不回退默认租户。"""
|
||||
tenant = (tenant_no or "").strip()
|
||||
uid = (user_id or "").strip()
|
||||
if not tenant or not uid:
|
||||
return False
|
||||
granted = iter_user_menu_perm_codes(db, uid, tenant_no=tenant)
|
||||
return require_market_menu_permission(
|
||||
user_id=uid,
|
||||
tenant_no=tenant,
|
||||
required=perm_code,
|
||||
granted=granted,
|
||||
)
|
||||
|
||||
|
||||
def require_market_menu_code(menu_code: str) -> Callable:
|
||||
"""为移动市场 API 提供租户化、精确匹配的菜单权限依赖。"""
|
||||
required = (menu_code or "").strip()
|
||||
|
||||
async def dependency(
|
||||
user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
tenant_no = str(user.get("tenant_no") or "").strip()
|
||||
user_id = str(user.get("user_id") or "").strip()
|
||||
if not tenant_no or not user_id:
|
||||
raise HTTPException(status_code=403, detail="用户租户或身份信息缺失")
|
||||
tenant_role = resolve_web_role_for_user(
|
||||
db, user_id, tenant_no=tenant_no
|
||||
)
|
||||
if tenant_role != "super_admin" and not user_has_tenant_menu_perm(
|
||||
db,
|
||||
user_id=user_id,
|
||||
tenant_no=tenant_no,
|
||||
perm_code=required,
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="无移动端菜单权限")
|
||||
return user
|
||||
|
||||
return dependency
|
||||
@@ -1,7 +1,7 @@
|
||||
"""协议状态展示(含作废类型后缀)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Optional
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -12,6 +12,27 @@ VOID_TYPE_DICT = "xyzflx"
|
||||
# 协议/捐赠单无效态(与样本接收、生产工单列表过滤口径一致)
|
||||
INACTIVE_PROTOCOL_STATUSES = ("yzf", "sdfq")
|
||||
|
||||
# 科研捐赠质检列表/角标默认协议状态:待生效、生效中
|
||||
DEFAULT_QC_DONATION_PROTOCOL_STATUSES = ("dsx", "sxz")
|
||||
|
||||
|
||||
def resolve_research_donation_protocol_status_filter(
|
||||
protocol_status: Optional[str],
|
||||
) -> Tuple[str, List[str]]:
|
||||
"""解析科研捐赠质检列表的协议状态筛选语义。
|
||||
|
||||
Returns:
|
||||
("in", statuses) — 按所选状态 IN 过滤
|
||||
("none", []) — 显式空选择,无匹配结果
|
||||
("default", DEFAULT_QC_DONATION_PROTOCOL_STATUSES) — 未传参,与列表默认一致
|
||||
"""
|
||||
if protocol_status is None:
|
||||
return ("default", list(DEFAULT_QC_DONATION_PROTOCOL_STATUSES))
|
||||
statuses = [s.strip() for s in str(protocol_status).split(",") if s.strip()]
|
||||
if not statuses:
|
||||
return ("none", [])
|
||||
return ("in", statuses)
|
||||
|
||||
_PROTOCOL_STATUS_LABELS = {
|
||||
"dsx": "待生效",
|
||||
"sxz": "生效中",
|
||||
@@ -34,6 +55,16 @@ _VOID_TYPE_FALLBACK_LABELS = {
|
||||
"jcbhg": "检测不合格",
|
||||
}
|
||||
|
||||
# 非法来源状态视为终态,不允许再转出
|
||||
_PROTOCOL_STATUS_TRANSITIONS = {
|
||||
"dsx": ("sxz", "yzf", "sdfq"),
|
||||
"sxz": ("ygq", "yzf", "zky", "sdfq"),
|
||||
}
|
||||
_DONATION_STATUS_TRANSITIONS = {
|
||||
"dsx": ("sxz", "yzf", "sdfq"),
|
||||
"sxz": ("ygq", "yzf", "zckc", "sdfq"),
|
||||
}
|
||||
|
||||
|
||||
def resolve_void_type_label(
|
||||
db: Optional[Session],
|
||||
@@ -59,6 +90,23 @@ def resolve_void_type_label(
|
||||
return _VOID_TYPE_FALLBACK_LABELS.get(code, code)
|
||||
|
||||
|
||||
def status_transition_denied_message(current: str, target: str, *, kind: str = "protocol") -> str:
|
||||
"""状态流转被拒绝时的提示,状态码展示为中文名称。"""
|
||||
current_label = format_protocol_status_label(current, kind=kind)
|
||||
target_label = format_protocol_status_label(target, kind=kind)
|
||||
return f"状态{current_label}不可转为{target_label}"
|
||||
|
||||
|
||||
def assert_protocol_status_transition(current: str, target: str, *, kind: str = "protocol") -> None:
|
||||
cur = (current or "").strip()
|
||||
nxt = (target or "").strip()
|
||||
if cur == nxt:
|
||||
return
|
||||
table = _DONATION_STATUS_TRANSITIONS if kind == "donation" else _PROTOCOL_STATUS_TRANSITIONS
|
||||
if nxt not in table.get(cur, ()):
|
||||
raise ValueError(status_transition_denied_message(cur, nxt, kind=kind))
|
||||
|
||||
|
||||
def format_protocol_status_label(
|
||||
status: str,
|
||||
void_type: Optional[str] = None,
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import os
|
||||
import uuid
|
||||
import base64
|
||||
import mimetypes
|
||||
from io import BytesIO
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import quote
|
||||
@@ -33,6 +34,25 @@ from app.core.config import settings
|
||||
# HEIC 格式的 MIME 类型
|
||||
HEIC_TYPES = {"image/heic", "image/heif"}
|
||||
|
||||
# 扩展名 → MIME(补齐部分系统 mimetypes 缺失,尤其 SVG)
|
||||
_EXTRA_CONTENT_TYPES = {
|
||||
".svg": "image/svg+xml",
|
||||
".svgz": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
".heic": "image/heic",
|
||||
".heif": "image/heif",
|
||||
}
|
||||
|
||||
|
||||
def guess_content_type(filename: str) -> str:
|
||||
"""按文件名推断 Content-Type,供对象存储写入元数据(SVG 回显依赖正确 MIME)。"""
|
||||
name = (filename or "").strip()
|
||||
ext = os.path.splitext(name)[1].lower()
|
||||
if ext in _EXTRA_CONTENT_TYPES:
|
||||
return _EXTRA_CONTENT_TYPES[ext]
|
||||
guessed, _ = mimetypes.guess_type(name)
|
||||
return guessed or "application/octet-stream"
|
||||
|
||||
|
||||
def convert_heic_to_jpeg(content: bytes) -> bytes:
|
||||
"""将 HEIC/HEIF 格式转换为 JPEG 格式"""
|
||||
@@ -176,8 +196,9 @@ class QiniuStorage(BaseStorage):
|
||||
|
||||
key = self._generate_filename(filename)
|
||||
token = self.qiniu.upload_token(self.bucket, key, 3600)
|
||||
mime = guess_content_type(filename)
|
||||
|
||||
ret, info = put_data(token, key, file_content)
|
||||
ret, info = put_data(token, key, file_content, mime_type=mime)
|
||||
if ret is not None:
|
||||
return self.get_url(ret["key"])
|
||||
raise Exception(f"七牛云上传失败: {info}")
|
||||
@@ -231,7 +252,8 @@ class OSSStorage(BaseStorage):
|
||||
file_content, filename = process_image_content(file_content, filename)
|
||||
|
||||
key = self._generate_filename(filename)
|
||||
result = self.bucket.put_object(key, file_content)
|
||||
headers = {"Content-Type": guess_content_type(filename)}
|
||||
result = self.bucket.put_object(key, file_content, headers=headers)
|
||||
if result.status == 200:
|
||||
return self.get_url(key)
|
||||
raise Exception(f"OSS上传失败: {result.status}")
|
||||
@@ -294,11 +316,13 @@ class MinIOStorage(BaseStorage):
|
||||
if folder:
|
||||
key = f"{folder.strip('/')}/{key}"
|
||||
data = BytesIO(file_content)
|
||||
# 必须写入正确 MIME:SVG 若为 octet-stream,浏览器 <img> 会拒绝回显
|
||||
self.client.put_object(
|
||||
bucket_name=self.bucket_name,
|
||||
object_name=key,
|
||||
data=data,
|
||||
length=len(file_content),
|
||||
content_type=guess_content_type(filename),
|
||||
)
|
||||
return self.get_url(key)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.protocol_donation_convert import assert_biz_no_available
|
||||
from app.models.models import BizBizNoSequence
|
||||
|
||||
PREFIX_COLLECTION = "XBCC"
|
||||
PREFIX_DONATION = "JZCC"
|
||||
@@ -24,6 +25,46 @@ def _format_biz_no(prefix: str, biz_date: str, seq: int) -> str:
|
||||
return f"{prefix}{biz_date}{seq:0{SEQ_DIGITS}d}"
|
||||
|
||||
|
||||
def _allocate_sqlite_sequence(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
prefix: str,
|
||||
biz_date: str,
|
||||
user_id: str,
|
||||
now: datetime,
|
||||
) -> int:
|
||||
"""SQLite 测试环境使用 ORM 分配;生产 MySQL 仍走原子 SQL。"""
|
||||
row = db.query(BizBizNoSequence).filter(
|
||||
BizBizNoSequence.tenant_no == tenant_no,
|
||||
BizBizNoSequence.prefix == prefix,
|
||||
BizBizNoSequence.biz_date == biz_date,
|
||||
BizBizNoSequence.del_flag == 0,
|
||||
).first()
|
||||
if row:
|
||||
row.last_seq = int(row.last_seq or 0) + 1
|
||||
row.update_by = user_id
|
||||
row.update_time = now
|
||||
else:
|
||||
row = BizBizNoSequence(
|
||||
id=generate_uuid(),
|
||||
tenant_no=tenant_no,
|
||||
prefix=prefix,
|
||||
biz_date=biz_date,
|
||||
last_seq=1,
|
||||
order_num=0,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return int(row.last_seq)
|
||||
|
||||
|
||||
def allocate_next_biz_no(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -41,6 +82,27 @@ def allocate_next_biz_no(
|
||||
date_str = now.strftime("%Y%m%d")
|
||||
uid = user_id or ""
|
||||
|
||||
dialect_name = getattr(getattr(db.get_bind(), "dialect", None), "name", "")
|
||||
if dialect_name == "sqlite":
|
||||
seq_int = _allocate_sqlite_sequence(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
prefix=prefix,
|
||||
biz_date=date_str,
|
||||
user_id=uid,
|
||||
now=now,
|
||||
)
|
||||
if seq_int > MAX_SEQ:
|
||||
raise ValueError(f"当日{prefix}协议号序号已用尽(最大 {MAX_SEQ})")
|
||||
biz_no = _format_biz_no(prefix, date_str, seq_int)
|
||||
assert_biz_no_available(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
biz_no=biz_no,
|
||||
for_donation=(kind == "donation"),
|
||||
)
|
||||
return biz_no
|
||||
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
|
||||
@@ -16,11 +16,12 @@ from app.models.models import BizStorageSlotOccupancy
|
||||
from app.models.models import (
|
||||
BizCellProduct,
|
||||
BizCellProductSpec,
|
||||
BizCellProductType,
|
||||
BizProductionProductType,
|
||||
BizStorageFacility,
|
||||
BizStorageFacilitySlot,
|
||||
)
|
||||
from app.services.storage_facility_code import build_full_slot_code
|
||||
from app.crud.storage_facility import search_slot_ids_by_keyword
|
||||
|
||||
_CELL_PRODUCT_STATUSES = frozenset(
|
||||
{"frozen", "in_production", "sold", "used", "voided", "thawed"}
|
||||
@@ -148,10 +149,10 @@ def _enrich_product_dicts(
|
||||
type_map: Dict[str, str] = {}
|
||||
if type_ids:
|
||||
rows = (
|
||||
db.query(BizCellProductType)
|
||||
db.query(BizProductionProductType)
|
||||
.filter(
|
||||
BizCellProductType.id.in_(type_ids),
|
||||
BizCellProductType.del_flag == 0,
|
||||
BizProductionProductType.id.in_(type_ids),
|
||||
BizProductionProductType.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
@@ -289,20 +290,27 @@ def list_cell_products(
|
||||
source_type: Optional[str] = None,
|
||||
user_info=None,
|
||||
) -> Dict[str, Any]:
|
||||
kw = (keyword or "").strip()
|
||||
matched_slot_ids: List[str] = (
|
||||
search_slot_ids_by_keyword(db, tenant_no, kw) if kw else []
|
||||
)
|
||||
|
||||
def _filtered():
|
||||
q0 = db.query(BizCellProduct).filter(
|
||||
BizCellProduct.del_flag == 0,
|
||||
BizCellProduct.tenant_no == tenant_no,
|
||||
)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(
|
||||
BizCellProduct.product_code.ilike(like),
|
||||
BizCellProduct.product_name.ilike(like),
|
||||
keyword_conds = [
|
||||
BizCellProduct.product_code.ilike(like),
|
||||
BizCellProduct.product_name.ilike(like),
|
||||
]
|
||||
if matched_slot_ids:
|
||||
keyword_conds.append(
|
||||
BizCellProduct.storage_slot_id.in_(matched_slot_ids)
|
||||
)
|
||||
)
|
||||
q0 = q0.filter(or_(*keyword_conds))
|
||||
pc = (product_code or "").strip()
|
||||
if pc:
|
||||
q0 = q0.filter(BizCellProduct.product_code.like(f"%{pc}%"))
|
||||
@@ -1410,6 +1418,62 @@ def _build_descendant_chain(
|
||||
return result
|
||||
|
||||
|
||||
def _lineage_node_key(node: Dict[str, Any]) -> str:
|
||||
nt = (node.get("nodeType") or "").strip()
|
||||
nid = (node.get("id") or "").strip()
|
||||
if nt == "sample":
|
||||
code = (node.get("sampleCode") or "").strip()
|
||||
return f"sample:{(nid or code)}"
|
||||
return f"{nt}:{nid}"
|
||||
|
||||
|
||||
def _walk_ancestor_link(
|
||||
link: Any,
|
||||
out: List[Dict[str, Any]],
|
||||
seen: set,
|
||||
) -> None:
|
||||
if not link:
|
||||
return
|
||||
if isinstance(link, list):
|
||||
for item in link:
|
||||
_walk_ancestor_link(item, out, seen)
|
||||
return
|
||||
if not isinstance(link, dict):
|
||||
return
|
||||
parent = link.get("parent")
|
||||
_walk_ancestor_link(parent, out, seen)
|
||||
node = link.get("node")
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
key = _lineage_node_key(node)
|
||||
if not key or key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
out.append(dict(node))
|
||||
|
||||
|
||||
def _flatten_lineage_nodes(chain: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""将 get_product_chain 结果展平为上游→下游节点列表(含 current,不含 descendants)。"""
|
||||
out: List[Dict[str, Any]] = []
|
||||
seen: set = set()
|
||||
for link in chain.get("ancestors") or []:
|
||||
_walk_ancestor_link(link, out, seen)
|
||||
current = chain.get("current")
|
||||
if isinstance(current, dict):
|
||||
key = _lineage_node_key(current)
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
node = dict(current)
|
||||
node["isCurrent"] = True
|
||||
out.append(node)
|
||||
elif key:
|
||||
for n in out:
|
||||
if _lineage_node_key(n) == key:
|
||||
n["isCurrent"] = True
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def get_product_chain(db: Session, product_id: str, tenant_no: str) -> Optional[Dict[str, Any]]:
|
||||
product = get_cell_product(db, product_id, tenant_no)
|
||||
if not product:
|
||||
@@ -1422,3 +1486,208 @@ def get_product_chain(db: Session, product_id: str, tenant_no: str) -> Optional[
|
||||
db, product.id, product.product_code or "", 0, tenant_no
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _wo_status_label(status: str) -> str:
|
||||
from app.crud.qc_work_order import _WORK_ORDER_STATUS_LABELS
|
||||
|
||||
return _WORK_ORDER_STATUS_LABELS.get(status or "", status or "")
|
||||
|
||||
|
||||
def _slim_task_for_lineage(
|
||||
task: Dict[str, Any], results: List[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": task.get("id") or "",
|
||||
"taskNo": task.get("taskNo") or "",
|
||||
"itemName": task.get("itemName") or "",
|
||||
"itemCode": task.get("itemCode") or "",
|
||||
"taskType": task.get("taskType") or "",
|
||||
"taskStatus": task.get("taskStatus") or "",
|
||||
"taskStatusLabel": task.get("taskStatusLabel") or "",
|
||||
"conclusion": task.get("conclusion") or "",
|
||||
"conclusionLabel": task.get("conclusionLabel") or "",
|
||||
"remark": task.get("remark") or "",
|
||||
"completedTime": task.get("completedTime") or "",
|
||||
"companyName": task.get("companyName") or "",
|
||||
"results": [
|
||||
{
|
||||
"paramName": r.get("paramName") or "",
|
||||
"paramValue": r.get("paramValue") or "",
|
||||
"paramUnit": r.get("paramUnit") or "",
|
||||
}
|
||||
for r in (results or [])
|
||||
],
|
||||
"attachmentFiles": task.get("attachmentFiles") or [],
|
||||
}
|
||||
|
||||
|
||||
def _load_lineage_qc_for_nodes(
|
||||
db: Session, nodes: List[Dict[str, Any]], tenant_no: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
from app.models.models import BizQcWorkOrder
|
||||
from app.crud import qc_task as task_svc
|
||||
from app.crud import qc_task_result as result_svc
|
||||
from app.crud.qc_work_order import list_work_orders_by_sample
|
||||
|
||||
product_ids = [
|
||||
(n.get("id") or "").strip()
|
||||
for n in nodes
|
||||
if (n.get("nodeType") or "") == "cell_product" and (n.get("id") or "").strip()
|
||||
]
|
||||
wo_by_product: Dict[str, List[BizQcWorkOrder]] = {pid: [] for pid in product_ids}
|
||||
if product_ids:
|
||||
rows = (
|
||||
db.query(BizQcWorkOrder)
|
||||
.filter(
|
||||
BizQcWorkOrder.cell_product_id.in_(product_ids),
|
||||
BizQcWorkOrder.tenant_no == tenant_no,
|
||||
BizQcWorkOrder.del_flag == 0,
|
||||
)
|
||||
.order_by(BizQcWorkOrder.create_time.desc())
|
||||
.all()
|
||||
)
|
||||
for wo in rows:
|
||||
pid = (wo.cell_product_id or "").strip()
|
||||
if pid in wo_by_product:
|
||||
wo_by_product[pid].append(wo)
|
||||
|
||||
sample_wo_cache: Dict[Tuple[str, str], List[Dict[str, Any]]] = {}
|
||||
|
||||
node_work_orders_orm: List[List[BizQcWorkOrder]] = []
|
||||
for n in nodes:
|
||||
if (n.get("nodeType") or "") == "cell_product":
|
||||
node_work_orders_orm.append(wo_by_product.get((n.get("id") or "").strip(), []))
|
||||
else:
|
||||
node_work_orders_orm.append([])
|
||||
|
||||
sample_wo_dicts: Dict[int, List[Dict[str, Any]]] = {}
|
||||
for idx, n in enumerate(nodes):
|
||||
if (n.get("nodeType") or "") != "sample":
|
||||
continue
|
||||
code = (n.get("sampleCode") or "").strip()
|
||||
rs = (n.get("receptionSource") or "").strip()
|
||||
if not code or not rs:
|
||||
sample_wo_dicts[idx] = []
|
||||
continue
|
||||
key = (code, rs)
|
||||
if key not in sample_wo_cache:
|
||||
sample_wo_cache[key] = list_work_orders_by_sample(
|
||||
db,
|
||||
sample_code=code,
|
||||
reception_source=rs,
|
||||
tenant_no=tenant_no,
|
||||
user_info=None,
|
||||
)
|
||||
sample_wo_dicts[idx] = sample_wo_cache[key]
|
||||
|
||||
pending_wo_ids: List[str] = []
|
||||
for wos in node_work_orders_orm:
|
||||
for wo in wos:
|
||||
pending_wo_ids.append(wo.id)
|
||||
for wos in sample_wo_dicts.values():
|
||||
for wo in wos:
|
||||
wid = (wo.get("id") or "").strip()
|
||||
if wid:
|
||||
pending_wo_ids.append(wid)
|
||||
|
||||
unique_wo_ids = list({(x or "").strip() for x in pending_wo_ids if (x or "").strip()})
|
||||
tasks_by_wo: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for i in range(0, len(unique_wo_ids), 100):
|
||||
chunk = unique_wo_ids[i : i + 100]
|
||||
chunk_tasks = task_svc.list_tasks_by_work_order_ids(db, chunk, tenant_no)
|
||||
for wo_id, tasks in chunk_tasks.items():
|
||||
tasks_by_wo.setdefault(wo_id, []).extend(tasks)
|
||||
|
||||
all_task_ids = [
|
||||
(t.get("id") or "").strip()
|
||||
for tasks in tasks_by_wo.values()
|
||||
for t in tasks
|
||||
if (t.get("id") or "").strip()
|
||||
]
|
||||
results_by_task = result_svc.list_saved_results_by_task_ids(db, all_task_ids, tenant_no)
|
||||
|
||||
def pack_orm_wos(wos: List[BizQcWorkOrder]) -> List[Dict[str, Any]]:
|
||||
packed = []
|
||||
for wo in wos:
|
||||
tasks = tasks_by_wo.get(wo.id, [])
|
||||
packed.append(
|
||||
{
|
||||
"id": wo.id,
|
||||
"workOrderNo": wo.work_order_no or "",
|
||||
"workOrderType": wo.work_order_type or "",
|
||||
"workOrderStatus": wo.work_order_status or "",
|
||||
"workOrderStatusLabel": _wo_status_label(wo.work_order_status or ""),
|
||||
"tasks": [
|
||||
_slim_task_for_lineage(
|
||||
t, results_by_task.get(t.get("id") or "", [])
|
||||
)
|
||||
for t in tasks
|
||||
],
|
||||
}
|
||||
)
|
||||
return packed
|
||||
|
||||
def pack_dict_wos(wos: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
packed = []
|
||||
for wo in wos:
|
||||
wid = (wo.get("id") or "").strip()
|
||||
tasks = tasks_by_wo.get(wid, [])
|
||||
packed.append(
|
||||
{
|
||||
"id": wid,
|
||||
"workOrderNo": wo.get("workOrderNo") or "",
|
||||
"workOrderType": wo.get("workOrderType") or "",
|
||||
"workOrderStatus": wo.get("workOrderStatus") or "",
|
||||
"workOrderStatusLabel": wo.get("workOrderStatusLabel")
|
||||
or _wo_status_label(wo.get("workOrderStatus") or ""),
|
||||
"tasks": [
|
||||
_slim_task_for_lineage(
|
||||
t, results_by_task.get(t.get("id") or "", [])
|
||||
)
|
||||
for t in tasks
|
||||
],
|
||||
}
|
||||
)
|
||||
return packed
|
||||
|
||||
out_nodes: List[Dict[str, Any]] = []
|
||||
for idx, n in enumerate(nodes):
|
||||
nt = n.get("nodeType") or ""
|
||||
if nt == "sample":
|
||||
label = (n.get("sampleCode") or n.get("id") or "").strip()
|
||||
work_orders = pack_dict_wos(sample_wo_dicts.get(idx, []))
|
||||
else:
|
||||
label = (n.get("productCode") or n.get("id") or "").strip()
|
||||
work_orders = pack_orm_wos(node_work_orders_orm[idx])
|
||||
out_nodes.append(
|
||||
{
|
||||
"nodeType": nt,
|
||||
"id": n.get("id") or "",
|
||||
"label": label,
|
||||
"productName": n.get("productName") or "",
|
||||
"passage": n.get("passage") or None,
|
||||
"receptionSource": n.get("receptionSource") or None,
|
||||
"receptionSourceLabel": n.get("receptionSourceLabel") or None,
|
||||
"isCurrent": bool(n.get("isCurrent")),
|
||||
"workOrders": work_orders,
|
||||
}
|
||||
)
|
||||
return out_nodes
|
||||
|
||||
|
||||
def get_product_lineage_qc(
|
||||
db: Session, product_id: str, tenant_no: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
chain = get_product_chain(db, product_id, tenant_no)
|
||||
if not chain:
|
||||
return None
|
||||
current = chain.get("current") or {}
|
||||
flat = _flatten_lineage_nodes(chain)
|
||||
nodes = _load_lineage_qc_for_nodes(db, flat, tenant_no)
|
||||
return {
|
||||
"productId": current.get("id") or product_id,
|
||||
"productCode": current.get("productCode") or "",
|
||||
"productName": current.get("productName") or "",
|
||||
"nodes": nodes,
|
||||
}
|
||||
@@ -405,7 +405,7 @@ def submit_refund(
|
||||
) -> BizPilotPayment:
|
||||
row = _get_or_raise(db, payment_id=payment_id, tenant_no=tenant_no)
|
||||
if row.pay_status != "yjf":
|
||||
raise ValueError("仅已缴费状态可发起退费")
|
||||
raise ValueError("仅付款完成状态可发起退费")
|
||||
if refund_amount is None or Decimal(str(refund_amount)) <= 0:
|
||||
raise ValueError("请填写有效的退费金额")
|
||||
row.pay_status = "tfqrz"
|
||||
|
||||
+1073
-150
File diff suppressed because it is too large.
Load diff
@@ -9,14 +9,43 @@ from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizCollectionHospital, BizCollectionRegion
|
||||
from app.models.models import (
|
||||
BizCollectionHospital,
|
||||
BizCollectionRegion,
|
||||
BizCollectionRegionStaff,
|
||||
SysDept,
|
||||
SysUser,
|
||||
)
|
||||
|
||||
|
||||
def _region_to_dict(r: BizCollectionRegion, hospital_names: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
def staff_display_name(nick_name: Optional[str], user_no: Optional[str]) -> str:
|
||||
n = (nick_name or "").strip()
|
||||
if n:
|
||||
return n
|
||||
return (user_no or "").strip()
|
||||
|
||||
|
||||
def build_staff_leader_name_lists(rows: List[Dict[str, Any]]) -> Tuple[List[str], List[str]]:
|
||||
"""rows: dicts with nickName/isLeader (already sorted leaders-first preferred)."""
|
||||
leaders = [staff_display_name(r.get("nickName"), r.get("userNo")) for r in rows if r.get("isLeader")]
|
||||
# Keep leader-first order if caller sorted; otherwise partition
|
||||
non_leaders = [staff_display_name(r.get("nickName"), r.get("userNo")) for r in rows if not r.get("isLeader")]
|
||||
staff_names = leaders + non_leaders
|
||||
return staff_names, leaders
|
||||
|
||||
|
||||
def _region_to_dict(
|
||||
r: BizCollectionRegion,
|
||||
hospital_names: Optional[List[str]] = None,
|
||||
staff_names: Optional[List[str]] = None,
|
||||
leader_names: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"regionName": r.region_name,
|
||||
"hospitalNames": hospital_names or [],
|
||||
"staffNames": staff_names or [],
|
||||
"leaderNames": leader_names or [],
|
||||
"tenantNo": r.tenant_no,
|
||||
"orderNum": r.order_num if r.order_num is not None else 0,
|
||||
"remark": r.remark or "",
|
||||
@@ -46,6 +75,43 @@ def _hospital_names_by_region_ids(db: Session, region_ids: List[str]) -> Dict[st
|
||||
return hospital_map
|
||||
|
||||
|
||||
def _staff_names_by_region_ids(
|
||||
db: Session, region_ids: List[str]
|
||||
) -> Dict[str, Tuple[List[str], List[str]]]:
|
||||
"""Return region_id -> (staffNames, leaderNames). Leaders first in staffNames."""
|
||||
out: Dict[str, Tuple[List[str], List[str]]] = {rid: ([], []) for rid in region_ids}
|
||||
if not region_ids:
|
||||
return out
|
||||
rows = (
|
||||
db.query(BizCollectionRegionStaff, SysUser)
|
||||
.outerjoin(SysUser, SysUser.id == BizCollectionRegionStaff.user_id)
|
||||
.filter(
|
||||
and_(
|
||||
BizCollectionRegionStaff.region_id.in_(region_ids),
|
||||
BizCollectionRegionStaff.del_flag == 0,
|
||||
)
|
||||
)
|
||||
.order_by(
|
||||
BizCollectionRegionStaff.is_leader.desc(),
|
||||
BizCollectionRegionStaff.order_num.asc(),
|
||||
BizCollectionRegionStaff.create_time.desc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
buckets: Dict[str, List[Dict[str, Any]]] = {rid: [] for rid in region_ids}
|
||||
for staff, user in rows:
|
||||
buckets.setdefault(staff.region_id, []).append(
|
||||
{
|
||||
"nickName": (user.nick_name if user else "") or "",
|
||||
"userNo": (user.user_no if user else "") or "",
|
||||
"isLeader": bool(staff.is_leader),
|
||||
}
|
||||
)
|
||||
for rid, items in buckets.items():
|
||||
out[rid] = build_staff_leader_name_lists(items)
|
||||
return out
|
||||
|
||||
|
||||
def _hospital_to_dict(h: BizCollectionHospital) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": h.id,
|
||||
@@ -59,6 +125,30 @@ def _hospital_to_dict(h: BizCollectionHospital) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _staff_to_dict(
|
||||
s: BizCollectionRegionStaff,
|
||||
*,
|
||||
nick_name: str = "",
|
||||
user_no: str = "",
|
||||
dept_name: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": s.id,
|
||||
"regionId": s.region_id,
|
||||
"userId": s.user_id,
|
||||
"userName": staff_display_name(nick_name, user_no),
|
||||
"nickName": nick_name or "",
|
||||
"userNo": user_no or "",
|
||||
"deptName": dept_name or "",
|
||||
"isLeader": bool(s.is_leader),
|
||||
"tenantNo": s.tenant_no,
|
||||
"orderNum": s.order_num if s.order_num is not None else 0,
|
||||
"remark": s.remark or "",
|
||||
"createTime": s.create_time.strftime("%Y-%m-%d %H:%M:%S") if s.create_time else "",
|
||||
"updateTime": s.update_time.strftime("%Y-%m-%d %H:%M:%S") if s.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_collection_regions(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -90,7 +180,16 @@ def list_collection_regions(
|
||||
|
||||
region_ids = [r.id for r in rows]
|
||||
hospital_map = _hospital_names_by_region_ids(db, region_ids)
|
||||
return [_region_to_dict(x, hospital_names=hospital_map.get(x.id, [])) for x in rows], int(total)
|
||||
staff_map = _staff_names_by_region_ids(db, region_ids)
|
||||
return [
|
||||
_region_to_dict(
|
||||
x,
|
||||
hospital_names=hospital_map.get(x.id, []),
|
||||
staff_names=staff_map.get(x.id, ([], []))[0],
|
||||
leader_names=staff_map.get(x.id, ([], []))[1],
|
||||
)
|
||||
for x in rows
|
||||
], int(total)
|
||||
|
||||
|
||||
def get_collection_region(db: Session, region_id: str, tenant_no: Optional[str] = None) -> Optional[BizCollectionRegion]:
|
||||
@@ -183,6 +282,20 @@ def soft_delete_collection_region(db: Session, region_id: str, tenant_no: Option
|
||||
h.del_flag = 1
|
||||
h.update_time = now
|
||||
db.add(h)
|
||||
staff_subs = (
|
||||
db.query(BizCollectionRegionStaff)
|
||||
.filter(
|
||||
and_(
|
||||
BizCollectionRegionStaff.region_id == region_id,
|
||||
BizCollectionRegionStaff.del_flag == 0,
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for s in staff_subs:
|
||||
s.del_flag = 1
|
||||
s.update_time = now
|
||||
db.add(s)
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -343,6 +456,217 @@ def soft_delete_hospital(db: Session, hospital_id: str, tenant_no: Optional[str]
|
||||
db.commit()
|
||||
|
||||
|
||||
def map_staff_region_rows_to_list(rows: List[Tuple[str, str]]) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
for rid, name in rows:
|
||||
rid = (rid or "").strip()
|
||||
if not rid:
|
||||
continue
|
||||
out.append({"id": rid, "regionName": (name or "").strip()})
|
||||
return out
|
||||
|
||||
|
||||
def list_regions_by_staff_user(
|
||||
db: Session, user_id: str, tenant_no: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
uid = (user_id or "").strip()
|
||||
if not uid:
|
||||
return []
|
||||
q = (
|
||||
db.query(BizCollectionRegionStaff.region_id, BizCollectionRegion.region_name)
|
||||
.join(
|
||||
BizCollectionRegion,
|
||||
BizCollectionRegion.id == BizCollectionRegionStaff.region_id,
|
||||
)
|
||||
.filter(
|
||||
BizCollectionRegionStaff.user_id == uid,
|
||||
BizCollectionRegionStaff.del_flag == 0,
|
||||
BizCollectionRegion.del_flag == 0,
|
||||
)
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(
|
||||
BizCollectionRegionStaff.tenant_no == tenant_no,
|
||||
BizCollectionRegion.tenant_no == tenant_no,
|
||||
)
|
||||
q = q.order_by(
|
||||
BizCollectionRegionStaff.order_num.asc(),
|
||||
BizCollectionRegionStaff.create_time.desc(),
|
||||
)
|
||||
return map_staff_region_rows_to_list([(r[0], r[1]) for r in q.all()])
|
||||
|
||||
|
||||
def list_staff(
|
||||
db: Session,
|
||||
*,
|
||||
region_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
region = get_collection_region(db, region_id, tenant_no=tenant_no)
|
||||
if not region:
|
||||
raise ValueError("采集地区不存在")
|
||||
|
||||
def _filtered():
|
||||
q0 = db.query(BizCollectionRegionStaff).filter(
|
||||
and_(BizCollectionRegionStaff.region_id == region_id, BizCollectionRegionStaff.del_flag == 0)
|
||||
)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizCollectionRegionStaff.tenant_no == tenant_no)
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizCollectionRegionStaff.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(
|
||||
BizCollectionRegionStaff.is_leader.desc(),
|
||||
BizCollectionRegionStaff.order_num.asc(),
|
||||
BizCollectionRegionStaff.create_time.desc(),
|
||||
)
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
user_ids = [r.user_id for r in rows]
|
||||
user_map: Dict[str, SysUser] = {}
|
||||
dept_map: Dict[str, str] = {}
|
||||
if user_ids:
|
||||
for u in db.query(SysUser).filter(SysUser.id.in_(user_ids)).all():
|
||||
user_map[u.id] = u
|
||||
dept_ids = [u.dept_id for u in user_map.values() if u.dept_id]
|
||||
if dept_ids:
|
||||
for d in db.query(SysDept).filter(SysDept.id.in_(dept_ids), SysDept.del_flag == 0).all():
|
||||
dept_map[d.id] = d.dept_name or ""
|
||||
items: List[Dict[str, Any]] = []
|
||||
for s in rows:
|
||||
u = user_map.get(s.user_id)
|
||||
items.append(
|
||||
_staff_to_dict(
|
||||
s,
|
||||
nick_name=(u.nick_name if u else "") or "",
|
||||
user_no=(u.user_no if u else "") or "",
|
||||
dept_name=dept_map.get((u.dept_id if u else "") or "", ""),
|
||||
)
|
||||
)
|
||||
return items, int(total)
|
||||
|
||||
|
||||
def get_staff(db: Session, staff_id: str, tenant_no: Optional[str] = None) -> Optional[BizCollectionRegionStaff]:
|
||||
q = db.query(BizCollectionRegionStaff).filter(
|
||||
and_(BizCollectionRegionStaff.id == staff_id, BizCollectionRegionStaff.del_flag == 0)
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(BizCollectionRegionStaff.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def create_staff(
|
||||
db: Session,
|
||||
*,
|
||||
region_id: str,
|
||||
user_id: str,
|
||||
is_leader: bool = False,
|
||||
tenant_no: str = "zkxw",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id_actor: Optional[str] = None,
|
||||
) -> BizCollectionRegionStaff:
|
||||
region = get_collection_region(db, region_id, tenant_no=tenant_no)
|
||||
if not region:
|
||||
raise ValueError("采集地区不存在")
|
||||
uid = (user_id or "").strip()
|
||||
if not uid:
|
||||
raise ValueError("请选择系统人员")
|
||||
user = (
|
||||
db.query(SysUser)
|
||||
.filter(
|
||||
and_(
|
||||
SysUser.id == uid,
|
||||
SysUser.del_flag == 0,
|
||||
SysUser.tenant_no == tenant_no,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not user:
|
||||
raise ValueError("用户不存在")
|
||||
if (user.status or "") != "1":
|
||||
raise ValueError("用户已停用,无法绑定")
|
||||
exists = (
|
||||
db.query(BizCollectionRegionStaff)
|
||||
.filter(
|
||||
and_(
|
||||
BizCollectionRegionStaff.region_id == region_id,
|
||||
BizCollectionRegionStaff.user_id == uid,
|
||||
BizCollectionRegionStaff.del_flag == 0,
|
||||
BizCollectionRegionStaff.tenant_no == tenant_no,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if exists:
|
||||
raise ValueError("该人员已绑定到此地区")
|
||||
obj = BizCollectionRegionStaff(
|
||||
id=generate_uuid(),
|
||||
region_id=region_id,
|
||||
user_id=uid,
|
||||
is_leader=1 if is_leader else 0,
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id_actor,
|
||||
create_dept_id="",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id_actor,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_staff(
|
||||
db: Session,
|
||||
staff_id: str,
|
||||
*,
|
||||
is_leader: Optional[bool] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> BizCollectionRegionStaff:
|
||||
obj = get_staff(db, staff_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("绑定记录不存在")
|
||||
if is_leader is not None:
|
||||
obj.is_leader = 1 if is_leader else 0
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = remark.strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_staff(db: Session, staff_id: str, tenant_no: Optional[str] = None) -> None:
|
||||
obj = get_staff(db, staff_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("绑定记录不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if total <= 0:
|
||||
return 0
|
||||
|
||||
@@ -19,6 +19,10 @@ def _row_to_dict(c: BizCustomer) -> Dict[str, Any]:
|
||||
"sex": (c.sex or "2").strip() if c.sex else "2",
|
||||
"idCardNo": c.id_card_no or "",
|
||||
"age": c.age,
|
||||
"birthYm": c.birth_ym or "",
|
||||
"ethnic": c.ethnic or "",
|
||||
"email": c.email or "",
|
||||
"address": c.address or "",
|
||||
"phoneNo": c.phone_no or "",
|
||||
"customerTags": c.customer_tags or "",
|
||||
"tenantNo": c.tenant_no,
|
||||
@@ -110,12 +114,18 @@ def create_customer(
|
||||
sex: str = "2",
|
||||
id_card_no: Optional[str] = None,
|
||||
age: Optional[int] = None,
|
||||
birth_ym: Optional[str] = None,
|
||||
ethnic: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
address: Optional[str] = None,
|
||||
phone_no: Optional[str] = None,
|
||||
customer_tags: Optional[str] = None,
|
||||
tenant_no: str = "zkxw",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
commit: bool = True,
|
||||
create_dept_id: str = "",
|
||||
) -> BizCustomer:
|
||||
name = (customer_name or "").strip()
|
||||
if not name:
|
||||
@@ -129,21 +139,28 @@ def create_customer(
|
||||
sex=(sex or "2")[:1] if sex else "2",
|
||||
id_card_no=ic,
|
||||
age=age,
|
||||
birth_ym=(birth_ym or "").strip()[:6] or None,
|
||||
ethnic=(ethnic or "").strip() or None,
|
||||
email=(email or "").strip() or None,
|
||||
address=(address or "").strip() or None,
|
||||
phone_no=(phone_no or "").strip() or None,
|
||||
customer_tags=(customer_tags or "").strip() or None,
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_dept_id=create_dept_id,
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
else:
|
||||
db.flush()
|
||||
return obj
|
||||
|
||||
|
||||
@@ -155,6 +172,10 @@ def update_customer(
|
||||
sex: Optional[str] = None,
|
||||
id_card_no: Optional[str] = None,
|
||||
age: Optional[int] = None,
|
||||
birth_ym: Optional[str] = None,
|
||||
ethnic: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
address: Optional[str] = None,
|
||||
phone_no: Optional[str] = None,
|
||||
customer_tags: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
@@ -179,6 +200,14 @@ def update_customer(
|
||||
obj.id_card_no = ic
|
||||
if age is not None:
|
||||
obj.age = age
|
||||
if birth_ym is not None:
|
||||
obj.birth_ym = birth_ym.strip()[:6] or None
|
||||
if ethnic is not None:
|
||||
obj.ethnic = ethnic.strip() or None
|
||||
if email is not None:
|
||||
obj.email = email.strip() or None
|
||||
if address is not None:
|
||||
obj.address = address.strip() or None
|
||||
if phone_no is not None:
|
||||
obj.phone_no = phone_no.strip() or None
|
||||
if customer_tags is not None:
|
||||
@@ -195,14 +224,23 @@ def update_customer(
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_customer(db: Session, customer_id: str, tenant_no: Optional[str] = None) -> None:
|
||||
def soft_delete_customer(
|
||||
db: Session,
|
||||
customer_id: str,
|
||||
tenant_no: Optional[str] = None,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> None:
|
||||
obj = get_customer(db, customer_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("客户不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""客户服务字典 CRUD。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizCustomerServiceDict
|
||||
from app.services.sample_customer_service_register import normalize_dict_value
|
||||
|
||||
|
||||
def list_dict_values(db: Session, tenant_no: str, dict_type: str) -> List[str]:
|
||||
dtype = (dict_type or "").strip()
|
||||
if not dtype:
|
||||
return []
|
||||
rows = (
|
||||
db.query(BizCustomerServiceDict)
|
||||
.filter(
|
||||
BizCustomerServiceDict.tenant_no == tenant_no,
|
||||
BizCustomerServiceDict.dict_type == dtype,
|
||||
BizCustomerServiceDict.del_flag == 0,
|
||||
)
|
||||
.order_by(
|
||||
BizCustomerServiceDict.order_num.asc(),
|
||||
BizCustomerServiceDict.dict_value.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return [row.dict_value for row in rows if row.dict_value]
|
||||
|
||||
|
||||
def ensure_dict_value(
|
||||
db: Session,
|
||||
tenant_no: str,
|
||||
dict_type: str,
|
||||
value,
|
||||
*,
|
||||
user_id: Optional[str],
|
||||
is_system: bool = False,
|
||||
) -> Optional[str]:
|
||||
normalized = normalize_dict_value(value)
|
||||
if normalized is None:
|
||||
return None
|
||||
|
||||
dtype = (dict_type or "").strip()
|
||||
if not dtype:
|
||||
return None
|
||||
|
||||
existing = (
|
||||
db.query(BizCustomerServiceDict)
|
||||
.filter(
|
||||
BizCustomerServiceDict.tenant_no == tenant_no,
|
||||
BizCustomerServiceDict.dict_type == dtype,
|
||||
BizCustomerServiceDict.dict_value == normalized,
|
||||
BizCustomerServiceDict.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
return existing.dict_value
|
||||
|
||||
now = datetime.now()
|
||||
row = BizCustomerServiceDict(
|
||||
id=generate_uuid(),
|
||||
dict_type=dtype,
|
||||
dict_value=normalized,
|
||||
is_system="1" if is_system else "0",
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return normalized
|
||||
@@ -170,7 +170,7 @@ def list_env_detect_records(
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizEnvDetectRecord.create_time.desc())
|
||||
.order_by(BizEnvDetectRecord.detect_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
|
||||
+111
-338
@@ -1,16 +1,15 @@
|
||||
"""OA 审批 CRUD(工作流角色、分组、应用、草稿等)"""
|
||||
"""OA 审批 CRUD(分组、应用、草稿等)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db_collation import eq_unicode
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import OaApp, OaAppGroup, OaAppVersion, OaRole, OaRoleUser, SysUser
|
||||
from app.models.models import OaApp, OaAppGroup, OaAppVersion
|
||||
|
||||
DEFAULT_ADVANCED_SETTINGS: Dict[str, Any] = {
|
||||
"supervisorFallbackToDeptLeader": True,
|
||||
@@ -19,6 +18,7 @@ DEFAULT_ADVANCED_SETTINGS: Dict[str, Any] = {
|
||||
"commentRequired": False,
|
||||
"allowAddSign": True,
|
||||
"allowReturn": True,
|
||||
"autoPassWhenApproverIsInitiator": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -40,322 +40,6 @@ def _loads(text: Optional[str], default: Any = None) -> Any:
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
PRESET_ROLES = (
|
||||
("GM", "总经理"),
|
||||
("CHAIRMAN", "董事长"),
|
||||
)
|
||||
|
||||
|
||||
def _role_to_dict(r: OaRole, *, member_count: int = 0) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"roleName": r.role_name,
|
||||
"roleCode": r.role_code,
|
||||
"roleKind": r.role_kind,
|
||||
"status": r.status,
|
||||
"tenantNo": r.tenant_no,
|
||||
"orderNum": r.order_num if r.order_num is not None else 0,
|
||||
"remark": r.remark or "",
|
||||
"memberCount": member_count,
|
||||
"createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
|
||||
"updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def ensure_presets(db: Session, tenant_no: str, user_id: Optional[str] = None) -> List[OaRole]:
|
||||
"""幂等写入预置角色 GM / CHAIRMAN。"""
|
||||
now = datetime.now()
|
||||
created: List[OaRole] = []
|
||||
for code, name in PRESET_ROLES:
|
||||
exists = (
|
||||
db.query(OaRole)
|
||||
.filter(
|
||||
OaRole.tenant_no == tenant_no,
|
||||
OaRole.role_code == code,
|
||||
OaRole.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if exists:
|
||||
continue
|
||||
obj = OaRole(
|
||||
id=generate_uuid(),
|
||||
role_name=name,
|
||||
role_code=code,
|
||||
role_kind="preset",
|
||||
status="1",
|
||||
tenant_no=tenant_no,
|
||||
order_num=0 if code == "GM" else 1,
|
||||
remark="系统预置工作流角色",
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
created.append(obj)
|
||||
if created:
|
||||
db.commit()
|
||||
for obj in created:
|
||||
db.refresh(obj)
|
||||
return created
|
||||
|
||||
|
||||
def _member_count_map(db: Session, role_ids: List[str], tenant_no: str) -> Dict[str, int]:
|
||||
if not role_ids:
|
||||
return {}
|
||||
rows = (
|
||||
db.query(OaRoleUser.oa_role_id, OaRoleUser.id)
|
||||
.filter(
|
||||
OaRoleUser.oa_role_id.in_(role_ids),
|
||||
OaRoleUser.tenant_no == tenant_no,
|
||||
OaRoleUser.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
counts: Dict[str, int] = {rid: 0 for rid in role_ids}
|
||||
for rid, _ in rows:
|
||||
counts[rid] = counts.get(rid, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def list_roles(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
ensure_presets(db, tenant_no)
|
||||
q = db.query(OaRole).filter(OaRole.tenant_no == tenant_no, OaRole.del_flag == 0)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q = q.filter((OaRole.role_name.like(like)) | (OaRole.role_code.like(like)))
|
||||
if status:
|
||||
q = q.filter(OaRole.status == status)
|
||||
rows = q.order_by(OaRole.role_kind.desc(), OaRole.order_num.asc(), OaRole.create_time.desc()).all()
|
||||
counts = _member_count_map(db, [r.id for r in rows], tenant_no)
|
||||
return [_role_to_dict(r, member_count=counts.get(r.id, 0)) for r in rows]
|
||||
|
||||
|
||||
def get_role(db: Session, role_id: str, tenant_no: str) -> Optional[OaRole]:
|
||||
return (
|
||||
db.query(OaRole)
|
||||
.filter(OaRole.id == role_id, OaRole.tenant_no == tenant_no, OaRole.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def create_role(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
role_name: str,
|
||||
role_code: str,
|
||||
status: str = "1",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> OaRole:
|
||||
name = (role_name or "").strip()
|
||||
code = (role_code or "").strip().upper()
|
||||
if not name:
|
||||
raise ValueError("角色名称不能为空")
|
||||
if not code:
|
||||
raise ValueError("角色编码不能为空")
|
||||
if code in {c for c, _ in PRESET_ROLES}:
|
||||
raise ValueError("该编码为系统预置,请勿占用")
|
||||
exists = (
|
||||
db.query(OaRole)
|
||||
.filter(OaRole.tenant_no == tenant_no, OaRole.role_code == code, OaRole.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
if exists:
|
||||
raise ValueError("角色编码已存在")
|
||||
now = datetime.now()
|
||||
obj = OaRole(
|
||||
id=generate_uuid(),
|
||||
role_name=name,
|
||||
role_code=code,
|
||||
role_kind="custom",
|
||||
status=status or "1",
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 10,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_role(
|
||||
db: Session,
|
||||
role_id: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
role_name: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> OaRole:
|
||||
obj = get_role(db, role_id, tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("角色不存在")
|
||||
if role_name is not None:
|
||||
name = (role_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("角色名称不能为空")
|
||||
obj.role_name = name
|
||||
if status is not None:
|
||||
obj.status = status
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = (remark or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def delete_role(db: Session, role_id: str, *, tenant_no: str, user_id: Optional[str] = None) -> None:
|
||||
obj = get_role(db, role_id, tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("角色不存在")
|
||||
if obj.role_kind == "preset":
|
||||
raise ValueError("预置角色不可删除")
|
||||
obj.del_flag = 1
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
def list_role_users(db: Session, role_id: str, tenant_no: str) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(OaRoleUser, SysUser)
|
||||
.outerjoin(
|
||||
SysUser,
|
||||
and_(eq_unicode(SysUser.id, OaRoleUser.user_id), SysUser.del_flag == 0),
|
||||
)
|
||||
.filter(
|
||||
OaRoleUser.oa_role_id == role_id,
|
||||
OaRoleUser.tenant_no == tenant_no,
|
||||
OaRoleUser.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
out: List[Dict[str, Any]] = []
|
||||
for ru, u in rows:
|
||||
out.append(
|
||||
{
|
||||
"id": ru.id,
|
||||
"userId": ru.user_id,
|
||||
"nickName": (u.nick_name if u else "") or "",
|
||||
"userNo": (u.user_no if u else "") or "",
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def replace_role_users(
|
||||
db: Session,
|
||||
role_id: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_ids: List[str],
|
||||
operator_id: Optional[str] = None,
|
||||
) -> None:
|
||||
obj = get_role(db, role_id, tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("角色不存在")
|
||||
now = datetime.now()
|
||||
existing = (
|
||||
db.query(OaRoleUser)
|
||||
.filter(
|
||||
OaRoleUser.oa_role_id == role_id,
|
||||
OaRoleUser.tenant_no == tenant_no,
|
||||
OaRoleUser.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for row in existing:
|
||||
row.del_flag = 1
|
||||
row.update_by = operator_id
|
||||
row.update_time = now
|
||||
db.add(row)
|
||||
seen = set()
|
||||
for uid in user_ids or []:
|
||||
uid = (uid or "").strip()
|
||||
if not uid or uid in seen:
|
||||
continue
|
||||
seen.add(uid)
|
||||
db.add(
|
||||
OaRoleUser(
|
||||
id=generate_uuid(),
|
||||
oa_role_id=role_id,
|
||||
user_id=uid,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark=None,
|
||||
create_by=operator_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=operator_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def list_oa_role_user_ids(db: Session, role_ids: List[str], tenant_no: str) -> List[str]:
|
||||
if not role_ids:
|
||||
return []
|
||||
enabled = {
|
||||
r.id
|
||||
for r in db.query(OaRole)
|
||||
.filter(
|
||||
OaRole.id.in_(role_ids),
|
||||
OaRole.tenant_no == tenant_no,
|
||||
OaRole.del_flag == 0,
|
||||
OaRole.status == "1",
|
||||
)
|
||||
.all()
|
||||
}
|
||||
if not enabled:
|
||||
return []
|
||||
rows = (
|
||||
db.query(OaRoleUser.user_id, OaRoleUser.oa_role_id)
|
||||
.filter(
|
||||
OaRoleUser.oa_role_id.in_(list(enabled)),
|
||||
OaRoleUser.tenant_no == tenant_no,
|
||||
OaRoleUser.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
out: List[str] = []
|
||||
seen = set()
|
||||
for uid, rid in rows:
|
||||
if rid in enabled and uid and uid not in seen:
|
||||
seen.add(uid)
|
||||
out.append(uid)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 分组 oa_app_group
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -525,6 +209,9 @@ def list_apps(
|
||||
like = f"%{kw}%"
|
||||
q = q.filter(OaApp.app_name.like(like))
|
||||
rows = q.order_by(OaApp.order_num.asc(), OaApp.create_time.desc()).all()
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
group_ids = list({r.group_id for r in rows if r.group_id})
|
||||
group_names: Dict[str, str] = {}
|
||||
if group_ids:
|
||||
@@ -534,37 +221,46 @@ def list_apps(
|
||||
.all()
|
||||
):
|
||||
group_names[g.id] = g.group_name
|
||||
out: List[Dict[str, Any]] = []
|
||||
for r in rows:
|
||||
draft = (
|
||||
db.query(OaAppVersion.id)
|
||||
|
||||
app_ids = [r.id for r in rows]
|
||||
# 批量取草稿/最新发布版本号,避免 N+1,且不拉取 form/flow 大 JSON
|
||||
draft_app_ids = {
|
||||
app_id
|
||||
for (app_id,) in (
|
||||
db.query(OaAppVersion.app_id)
|
||||
.filter(
|
||||
OaAppVersion.app_id == r.id,
|
||||
OaAppVersion.app_id.in_(app_ids),
|
||||
OaAppVersion.tenant_no == tenant_no,
|
||||
OaAppVersion.status == "draft",
|
||||
OaAppVersion.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
pub = (
|
||||
db.query(OaAppVersion)
|
||||
}
|
||||
latest_published: Dict[str, int] = {
|
||||
app_id: version_no
|
||||
for app_id, version_no in (
|
||||
db.query(OaAppVersion.app_id, OaAppVersion.version_no)
|
||||
.filter(
|
||||
OaAppVersion.app_id == r.id,
|
||||
OaAppVersion.app_id.in_(app_ids),
|
||||
OaAppVersion.tenant_no == tenant_no,
|
||||
OaAppVersion.is_latest_published == "1",
|
||||
OaAppVersion.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
.all()
|
||||
)
|
||||
out.append(
|
||||
_app_to_dict(
|
||||
r,
|
||||
group_name=group_names.get(r.group_id or "", ""),
|
||||
has_draft=bool(draft),
|
||||
latest_published=pub.version_no if pub else None,
|
||||
)
|
||||
}
|
||||
|
||||
return [
|
||||
_app_to_dict(
|
||||
r,
|
||||
group_name=group_names.get(r.group_id or "", ""),
|
||||
has_draft=r.id in draft_app_ids,
|
||||
latest_published=latest_published.get(r.id),
|
||||
)
|
||||
return out
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _create_version(
|
||||
@@ -668,6 +364,83 @@ def create_app(
|
||||
return obj
|
||||
|
||||
|
||||
def _copied_app_name(source_name: str) -> str:
|
||||
"""原名称 +「复制」;超 50 字时截断前缀并保留后缀。"""
|
||||
suffix = "复制"
|
||||
base = (source_name or "").strip()
|
||||
candidate = f"{base}{suffix}"
|
||||
if len(candidate) <= 50:
|
||||
return candidate
|
||||
max_base = max(0, 50 - len(suffix))
|
||||
return f"{base[:max_base]}{suffix}"
|
||||
|
||||
|
||||
def copy_app(
|
||||
db: Session,
|
||||
source_app_id: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> OaApp:
|
||||
"""复制应用:基础信息 + 草稿/最新发布版的表单·流程·高级设置,结果为未发布草稿。"""
|
||||
src = get_app(db, source_app_id, tenant_no)
|
||||
if not src:
|
||||
raise ValueError("应用不存在")
|
||||
|
||||
source_ver = _find_draft(db, source_app_id, tenant_no) or _find_latest_published(
|
||||
db, source_app_id, tenant_no
|
||||
)
|
||||
if source_ver:
|
||||
form_schema = copy.deepcopy(_loads(source_ver.form_schema_json, {}) or {})
|
||||
flow_tree = copy.deepcopy(_loads(source_ver.flow_tree_json, {}) or {})
|
||||
advanced = copy.deepcopy(
|
||||
_loads(source_ver.advanced_settings_json, DEFAULT_ADVANCED_SETTINGS)
|
||||
or DEFAULT_ADVANCED_SETTINGS
|
||||
)
|
||||
else:
|
||||
form_schema = {}
|
||||
flow_tree = {}
|
||||
advanced = dict(DEFAULT_ADVANCED_SETTINGS)
|
||||
|
||||
now = datetime.now()
|
||||
obj = OaApp(
|
||||
id=generate_uuid(),
|
||||
group_id=src.group_id,
|
||||
app_name=_copied_app_name(src.app_name),
|
||||
icon_url=src.icon_url,
|
||||
description=src.description,
|
||||
initiator_scope=src.initiator_scope or "all",
|
||||
initiator_user_ids=src.initiator_user_ids,
|
||||
admin_scope=src.admin_scope or "all_oa_admin",
|
||||
admin_user_ids=src.admin_user_ids,
|
||||
status=src.status or "1",
|
||||
tenant_no=tenant_no,
|
||||
order_num=src.order_num if src.order_num is not None else 0,
|
||||
remark=src.remark,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
_create_version(
|
||||
db,
|
||||
app_id=obj.id,
|
||||
tenant_no=tenant_no,
|
||||
version_no=1,
|
||||
status="draft",
|
||||
form_schema=form_schema,
|
||||
flow_tree=flow_tree,
|
||||
advanced=advanced,
|
||||
user_id=user_id,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_app(
|
||||
db: Session,
|
||||
app_id: str,
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
"""OA 发起审批草稿 CRUD(oa_instance_draft)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud import oa_approval as oa_crud
|
||||
from app.models.models import OaApp, OaAppGroup, OaAppVersion, OaInstanceDraft
|
||||
from app.services.oa_related_approval import flatten_form_fields_for_summary
|
||||
|
||||
|
||||
def _loads(raw: Any, default: Any) -> Any:
|
||||
if raw is None or raw == "":
|
||||
return default
|
||||
if isinstance(raw, (dict, list)):
|
||||
return raw
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return default
|
||||
|
||||
|
||||
def _dumps(obj: Any) -> Optional[str]:
|
||||
if obj is None:
|
||||
return None
|
||||
return json.dumps(obj, ensure_ascii=False)
|
||||
|
||||
|
||||
def row_to_dict(
|
||||
d: OaInstanceDraft,
|
||||
*,
|
||||
app_name: str = "",
|
||||
version_no: Optional[int] = None,
|
||||
group_id: str = "",
|
||||
group_name: str = "",
|
||||
include_payload: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
out: Dict[str, Any] = {
|
||||
"id": d.id,
|
||||
"appId": d.app_id,
|
||||
"appVersionId": d.app_version_id,
|
||||
"appName": app_name or "",
|
||||
"versionNo": version_no,
|
||||
"groupId": group_id or "",
|
||||
"groupName": group_name or "",
|
||||
"title": d.title or "",
|
||||
"ownerUserId": d.owner_user_id,
|
||||
"initiatorDeptId": d.initiator_dept_id or "",
|
||||
"status": "draft",
|
||||
"createTime": d.create_time.strftime("%Y-%m-%d %H:%M:%S") if d.create_time else "",
|
||||
"updateTime": d.update_time.strftime("%Y-%m-%d %H:%M:%S") if d.update_time else "",
|
||||
}
|
||||
if include_payload:
|
||||
out["formData"] = _loads(d.form_data_json, {}) or {}
|
||||
out["initiatorSelectByNode"] = _loads(d.initiator_select_json, {}) or {}
|
||||
return out
|
||||
|
||||
|
||||
class DraftAccessError(ValueError):
|
||||
"""草稿不存在或无权访问"""
|
||||
|
||||
|
||||
def get_owned_draft(
|
||||
db: Session,
|
||||
draft_id: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
owner_user_id: str,
|
||||
) -> OaInstanceDraft:
|
||||
row = (
|
||||
db.query(OaInstanceDraft)
|
||||
.filter(
|
||||
OaInstanceDraft.id == draft_id,
|
||||
OaInstanceDraft.tenant_no == tenant_no,
|
||||
OaInstanceDraft.owner_user_id == owner_user_id,
|
||||
OaInstanceDraft.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not row:
|
||||
raise DraftAccessError("草稿不存在或无权访问")
|
||||
return row
|
||||
|
||||
|
||||
def _enrich(db: Session, d: OaInstanceDraft, *, include_payload: bool = False) -> Dict[str, Any]:
|
||||
app = (
|
||||
db.query(OaApp)
|
||||
.filter(OaApp.id == d.app_id, OaApp.tenant_no == d.tenant_no, OaApp.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
ver = (
|
||||
db.query(OaAppVersion)
|
||||
.filter(OaAppVersion.id == d.app_version_id, OaAppVersion.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
group_name = ""
|
||||
group_id = ""
|
||||
if app and app.group_id:
|
||||
group_id = app.group_id
|
||||
g = (
|
||||
db.query(OaAppGroup)
|
||||
.filter(OaAppGroup.id == app.group_id, OaAppGroup.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
if g:
|
||||
group_name = g.group_name or ""
|
||||
out = row_to_dict(
|
||||
d,
|
||||
app_name=(app.app_name if app else "") or "",
|
||||
version_no=ver.version_no if ver else None,
|
||||
group_id=group_id,
|
||||
group_name=group_name,
|
||||
include_payload=include_payload,
|
||||
)
|
||||
if include_payload and "formData" in out:
|
||||
from app.services.oa_datetime_normalize import normalize_datetime_form_values
|
||||
|
||||
schema = _loads(getattr(ver, "form_schema_json", None), {}) if ver else {}
|
||||
out["formData"] = normalize_datetime_form_values(schema, out.get("formData") or {})
|
||||
return out
|
||||
|
||||
|
||||
def count_drafts(db: Session, *, tenant_no: str, owner_user_id: str) -> int:
|
||||
return (
|
||||
db.query(OaInstanceDraft)
|
||||
.filter(
|
||||
OaInstanceDraft.tenant_no == tenant_no,
|
||||
OaInstanceDraft.owner_user_id == owner_user_id,
|
||||
OaInstanceDraft.del_flag == 0,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
|
||||
def list_drafts(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
owner_user_id: str,
|
||||
keyword: Optional[str] = None,
|
||||
group_id: Optional[str] = None,
|
||||
update_time_from: Optional[datetime] = None,
|
||||
update_time_to: Optional[datetime] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
q = (
|
||||
db.query(OaInstanceDraft, OaApp, OaAppVersion, OaAppGroup)
|
||||
.outerjoin(
|
||||
OaApp,
|
||||
(OaApp.id == OaInstanceDraft.app_id)
|
||||
& (OaApp.tenant_no == OaInstanceDraft.tenant_no)
|
||||
& (OaApp.del_flag == 0),
|
||||
)
|
||||
.outerjoin(
|
||||
OaAppVersion,
|
||||
(OaAppVersion.id == OaInstanceDraft.app_version_id) & (OaAppVersion.del_flag == 0),
|
||||
)
|
||||
.outerjoin(
|
||||
OaAppGroup,
|
||||
(OaAppGroup.id == OaApp.group_id) & (OaAppGroup.del_flag == 0),
|
||||
)
|
||||
.filter(
|
||||
OaInstanceDraft.tenant_no == tenant_no,
|
||||
OaInstanceDraft.owner_user_id == owner_user_id,
|
||||
OaInstanceDraft.del_flag == 0,
|
||||
)
|
||||
)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q = q.filter(or_(OaApp.app_name.like(like), OaInstanceDraft.title.like(like)))
|
||||
gid = (group_id or "").strip()
|
||||
if gid:
|
||||
q = q.filter(OaApp.group_id == gid)
|
||||
if update_time_from is not None:
|
||||
q = q.filter(OaInstanceDraft.update_time >= update_time_from)
|
||||
if update_time_to is not None:
|
||||
q = q.filter(OaInstanceDraft.update_time <= update_time_to)
|
||||
|
||||
total = q.order_by(None).with_entities(func.count(OaInstanceDraft.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
q.order_by(OaInstanceDraft.update_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
items: List[Dict[str, Any]] = []
|
||||
for d, app, ver, group in rows:
|
||||
item = row_to_dict(
|
||||
d,
|
||||
app_name=(app.app_name if app else "") or "",
|
||||
version_no=ver.version_no if ver else None,
|
||||
group_id=(app.group_id if app else "") or "",
|
||||
group_name=(group.group_name if group else "") or "",
|
||||
)
|
||||
schema = _loads(getattr(ver, "form_schema_json", None), {}) if ver else {}
|
||||
form_data = _loads(d.form_data_json, {}) or {}
|
||||
preview = [
|
||||
{"label": f.get("label") or "", "value": f.get("value") or ""}
|
||||
for f in flatten_form_fields_for_summary(schema, form_data)[:3]
|
||||
]
|
||||
item["previewFields"] = preview
|
||||
items.append(item)
|
||||
return items, int(total)
|
||||
|
||||
|
||||
def get_draft_detail(
|
||||
db: Session,
|
||||
draft_id: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
owner_user_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
d = get_owned_draft(db, draft_id, tenant_no=tenant_no, owner_user_id=owner_user_id)
|
||||
return _enrich(db, d, include_payload=True)
|
||||
|
||||
|
||||
def create_draft(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
owner_user_id: str,
|
||||
app_id: str,
|
||||
form_data: Optional[Dict[str, Any]] = None,
|
||||
initiator_select_by_node: Optional[Dict[str, Any]] = None,
|
||||
initiator_dept_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
create_dept_id: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
app = oa_crud.get_app(db, app_id, tenant_no)
|
||||
if not app or app.status != "1":
|
||||
raise ValueError("应用不存在或已停用")
|
||||
ver = oa_crud._find_latest_published(db, app_id, tenant_no)
|
||||
if not ver:
|
||||
raise ValueError("应用尚未发布,无法暂存草稿")
|
||||
now = datetime.now()
|
||||
row = OaInstanceDraft(
|
||||
id=generate_uuid(),
|
||||
app_id=app_id,
|
||||
app_version_id=ver.id,
|
||||
title=(title or app.app_name or "")[:200] or None,
|
||||
form_data_json=_dumps(form_data or {}),
|
||||
initiator_select_json=_dumps(initiator_select_by_node or {}),
|
||||
initiator_dept_id=(initiator_dept_id or "").strip() or None,
|
||||
owner_user_id=owner_user_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=owner_user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
create_time=now,
|
||||
update_by=owner_user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return _enrich(db, row, include_payload=True)
|
||||
|
||||
|
||||
def update_draft(
|
||||
db: Session,
|
||||
draft_id: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
owner_user_id: str,
|
||||
form_data: Optional[Dict[str, Any]] = None,
|
||||
initiator_select_by_node: Optional[Dict[str, Any]] = None,
|
||||
initiator_dept_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
d = get_owned_draft(db, draft_id, tenant_no=tenant_no, owner_user_id=owner_user_id)
|
||||
ver = oa_crud._find_latest_published(db, d.app_id, tenant_no)
|
||||
if ver:
|
||||
d.app_version_id = ver.id
|
||||
if form_data is not None:
|
||||
d.form_data_json = _dumps(form_data)
|
||||
if initiator_select_by_node is not None:
|
||||
d.initiator_select_json = _dumps(initiator_select_by_node)
|
||||
if initiator_dept_id is not None:
|
||||
d.initiator_dept_id = (initiator_dept_id or "").strip() or None
|
||||
if title is not None:
|
||||
d.title = (title or "")[:200] or None
|
||||
d.update_by = owner_user_id
|
||||
d.update_time = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(d)
|
||||
return _enrich(db, d, include_payload=True)
|
||||
|
||||
|
||||
def soft_delete_draft(
|
||||
db: Session,
|
||||
draft_id: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
owner_user_id: str,
|
||||
) -> None:
|
||||
d = get_owned_draft(db, draft_id, tenant_no=tenant_no, owner_user_id=owner_user_id)
|
||||
d.del_flag = 1
|
||||
d.update_by = owner_user_id
|
||||
d.update_time = datetime.now()
|
||||
db.commit()
|
||||
|
||||
|
||||
def batch_soft_delete_drafts(
|
||||
db: Session,
|
||||
ids: List[str],
|
||||
*,
|
||||
tenant_no: str,
|
||||
owner_user_id: str,
|
||||
) -> int:
|
||||
cleaned = [str(x).strip() for x in (ids or []) if str(x).strip()]
|
||||
if not cleaned:
|
||||
raise ValueError("请选择要删除的草稿")
|
||||
rows = (
|
||||
db.query(OaInstanceDraft)
|
||||
.filter(
|
||||
OaInstanceDraft.id.in_(cleaned),
|
||||
OaInstanceDraft.tenant_no == tenant_no,
|
||||
OaInstanceDraft.owner_user_id == owner_user_id,
|
||||
OaInstanceDraft.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if len(rows) != len(set(cleaned)):
|
||||
raise DraftAccessError("草稿不存在或无权访问")
|
||||
now = datetime.now()
|
||||
for d in rows:
|
||||
d.del_flag = 1
|
||||
d.update_by = owner_user_id
|
||||
d.update_time = now
|
||||
db.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
def submit_draft(
|
||||
db: Session,
|
||||
draft_id: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
owner_user_id: str,
|
||||
form_data: Optional[Dict[str, Any]] = None,
|
||||
initiator_select_by_node: Optional[Dict[str, Any]] = None,
|
||||
initiator_dept_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""提交草稿:走 start_instance,成功后软删草稿。"""
|
||||
from app.services.oa_engine import start_instance
|
||||
|
||||
d = get_owned_draft(db, draft_id, tenant_no=tenant_no, owner_user_id=owner_user_id)
|
||||
payload_form = form_data if form_data is not None else (_loads(d.form_data_json, {}) or {})
|
||||
payload_select = (
|
||||
initiator_select_by_node
|
||||
if initiator_select_by_node is not None
|
||||
else (_loads(d.initiator_select_json, {}) or {})
|
||||
)
|
||||
dept = initiator_dept_id if initiator_dept_id is not None else d.initiator_dept_id
|
||||
use_title = title if title is not None else d.title
|
||||
|
||||
data = start_instance(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
app_id=d.app_id,
|
||||
initiator_user_id=owner_user_id,
|
||||
initiator_dept_id=dept,
|
||||
form_data=payload_form if isinstance(payload_form, dict) else {},
|
||||
title=use_title,
|
||||
initiator_select_by_node=payload_select if isinstance(payload_select, dict) else {},
|
||||
)
|
||||
d.del_flag = 1
|
||||
d.update_by = owner_user_id
|
||||
d.update_time = datetime.now()
|
||||
db.commit()
|
||||
return data
|
||||
@@ -0,0 +1,227 @@
|
||||
"""OA 收款账户 CRUD(oa_receive_account)"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import OaReceiveAccount, SysUser
|
||||
|
||||
|
||||
def row_to_dict(r: OaReceiveAccount) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"accountType": r.account_type,
|
||||
"accountName": r.account_name,
|
||||
"accountNo": r.account_no,
|
||||
"bankName": r.bank_name or "",
|
||||
"bankProvince": r.bank_province or "",
|
||||
"bankCity": r.bank_city or "",
|
||||
"bankBranch": r.bank_branch or "",
|
||||
"tenantNo": r.tenant_no,
|
||||
"orderNum": r.order_num if r.order_num is not None else 0,
|
||||
"remark": r.remark or "",
|
||||
"createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
|
||||
"updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
|
||||
"ownerUserId": (r.create_by or "").strip(),
|
||||
}
|
||||
|
||||
|
||||
def _owner_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]:
|
||||
ids = [item for item in {uid.strip() for uid in user_ids if uid and uid.strip()}]
|
||||
if not ids:
|
||||
return {}
|
||||
rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(ids)).all()
|
||||
return {row.id: (row.nick_name or "").strip() for row in rows}
|
||||
|
||||
|
||||
def _attach_owner_names(db: Session, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
names = _owner_name_map(db, [item.get("ownerUserId") or "" for item in items])
|
||||
for item in items:
|
||||
uid = item.get("ownerUserId") or ""
|
||||
item["ownerName"] = names.get(uid) or "—"
|
||||
return items
|
||||
|
||||
|
||||
def _filtered_query(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
keyword: Optional[str] = None,
|
||||
account_type: Optional[str] = None,
|
||||
):
|
||||
q = db.query(OaReceiveAccount).filter(
|
||||
OaReceiveAccount.tenant_no == tenant_no,
|
||||
OaReceiveAccount.del_flag == 0,
|
||||
)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q = q.filter(
|
||||
or_(
|
||||
OaReceiveAccount.account_name.like(like),
|
||||
OaReceiveAccount.account_no.like(like),
|
||||
OaReceiveAccount.bank_name.like(like),
|
||||
)
|
||||
)
|
||||
if account_type:
|
||||
q = q.filter(OaReceiveAccount.account_type == account_type.strip())
|
||||
return q
|
||||
|
||||
|
||||
def list_accounts(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
keyword: Optional[str] = None,
|
||||
account_type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
base = _filtered_query(db, tenant_no=tenant_no, keyword=keyword, account_type=account_type)
|
||||
total = base.order_by(None).with_entities(func.count(OaReceiveAccount.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
base.order_by(OaReceiveAccount.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return _attach_owner_names(db, [row_to_dict(x) for x in rows]), int(total)
|
||||
|
||||
|
||||
def list_options(db: Session, *, tenant_no: str, user_id: str) -> List[Dict[str, Any]]:
|
||||
owner_id = (user_id or "").strip()
|
||||
if not owner_id:
|
||||
return []
|
||||
rows = (
|
||||
db.query(OaReceiveAccount)
|
||||
.filter(
|
||||
OaReceiveAccount.tenant_no == tenant_no,
|
||||
OaReceiveAccount.del_flag == 0,
|
||||
func.trim(OaReceiveAccount.create_by) == owner_id,
|
||||
)
|
||||
.order_by(OaReceiveAccount.create_time.desc())
|
||||
.all()
|
||||
)
|
||||
return _attach_owner_names(db, [row_to_dict(x) for x in rows])
|
||||
|
||||
|
||||
def get_account(db: Session, account_id: str, tenant_no: str) -> Optional[Dict[str, Any]]:
|
||||
row = (
|
||||
db.query(OaReceiveAccount)
|
||||
.filter(
|
||||
OaReceiveAccount.id == account_id,
|
||||
OaReceiveAccount.tenant_no == tenant_no,
|
||||
OaReceiveAccount.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return _attach_owner_names(db, [row_to_dict(row)])[0] if row else None
|
||||
|
||||
|
||||
def create_account(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
dept_id: str,
|
||||
fields: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
now = datetime.now()
|
||||
obj = OaReceiveAccount(
|
||||
id=generate_uuid(),
|
||||
account_type=fields["account_type"],
|
||||
account_name=fields["account_name"],
|
||||
account_no=fields["account_no"],
|
||||
bank_name=fields.get("bank_name") or None,
|
||||
bank_province=fields.get("bank_province") or None,
|
||||
bank_city=fields.get("bank_city") or None,
|
||||
bank_branch=fields.get("bank_branch") or None,
|
||||
tenant_no=tenant_no,
|
||||
order_num=fields.get("order_num") if fields.get("order_num") is not None else 0,
|
||||
remark=(fields.get("remark") or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id=dept_id or "",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return _attach_owner_names(db, [row_to_dict(obj)])[0]
|
||||
|
||||
|
||||
def update_account(
|
||||
db: Session,
|
||||
account_id: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
fields: Dict[str, Any],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
obj = (
|
||||
db.query(OaReceiveAccount)
|
||||
.filter(
|
||||
OaReceiveAccount.id == account_id,
|
||||
OaReceiveAccount.tenant_no == tenant_no,
|
||||
OaReceiveAccount.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
return None
|
||||
obj.account_type = fields["account_type"]
|
||||
obj.account_name = fields["account_name"]
|
||||
obj.account_no = fields["account_no"]
|
||||
obj.bank_name = fields.get("bank_name") or None
|
||||
obj.bank_province = fields.get("bank_province") or None
|
||||
obj.bank_city = fields.get("bank_city") or None
|
||||
obj.bank_branch = fields.get("bank_branch") or None
|
||||
if fields.get("order_num") is not None:
|
||||
obj.order_num = fields["order_num"]
|
||||
if fields.get("remark") is not None:
|
||||
obj.remark = (fields.get("remark") or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return _attach_owner_names(db, [row_to_dict(obj)])[0]
|
||||
|
||||
|
||||
def soft_delete_account(
|
||||
db: Session, account_id: str, tenant_no: str, user_id: Optional[str]
|
||||
) -> bool:
|
||||
obj = (
|
||||
db.query(OaReceiveAccount)
|
||||
.filter(
|
||||
OaReceiveAccount.id == account_id,
|
||||
OaReceiveAccount.tenant_no == tenant_no,
|
||||
OaReceiveAccount.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
return False
|
||||
obj.del_flag = 1
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def soft_delete_accounts(
|
||||
db: Session, ids: List[str], tenant_no: str, user_id: Optional[str]
|
||||
) -> int:
|
||||
count = 0
|
||||
for account_id in ids:
|
||||
if soft_delete_account(db, account_id, tenant_no, user_id):
|
||||
count += 1
|
||||
return count
|
||||
@@ -6,9 +6,10 @@ from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy import and_, exists, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db_collation import eq_unicode
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import (
|
||||
BizOutboundReleaseReview,
|
||||
@@ -68,6 +69,124 @@ def _total_pages(total: int, page_size: int) -> int:
|
||||
return int(math.ceil(total / float(page_size)))
|
||||
|
||||
|
||||
def normalize_outbound_review_phase(review_phase: Optional[str]) -> Optional[str]:
|
||||
p = (review_phase or "").strip().lower()
|
||||
if p in ("pending", "reviewed"):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def normalize_outbound_ready_for_review_only(raw) -> Optional[bool]:
|
||||
"""Return True when ready-only filter should apply; None means no filter."""
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, bool):
|
||||
return True if raw else None
|
||||
s = str(raw).strip().lower()
|
||||
if s in ("true", "1", "yes"):
|
||||
return True
|
||||
return None
|
||||
|
||||
|
||||
def outbound_work_orders_all_void(statuses) -> bool:
|
||||
"""True when ≥1 non-empty status and every one equals yzf."""
|
||||
cleaned = [(s or "").strip() for s in (statuses or [])]
|
||||
if not cleaned or not any(s for s in cleaned):
|
||||
return False
|
||||
return all(s.lower() == "yzf" for s in cleaned)
|
||||
|
||||
|
||||
def _outbound_qc_unfinished_exists_for_schedule(tenant_no: str):
|
||||
"""Any unfinished outbound schedule QC (dpz/ypz) for this infusion schedule."""
|
||||
return (
|
||||
exists()
|
||||
.where(
|
||||
and_(
|
||||
BizQcWorkOrder.tenant_no == tenant_no,
|
||||
BizQcWorkOrder.del_flag == 0,
|
||||
BizQcWorkOrder.qc_biz_type == "outbound",
|
||||
BizQcWorkOrder.trigger_source == "schedule",
|
||||
# 跨表 id 排序规则可能不同(general_ci vs unicode_ci)
|
||||
eq_unicode(
|
||||
BizQcWorkOrder.infusion_schedule_id, BizPilotInfusionSchedule.id
|
||||
),
|
||||
BizQcWorkOrder.work_order_status.in_(("dpz", "ypz")),
|
||||
)
|
||||
)
|
||||
.correlate(BizPilotInfusionSchedule)
|
||||
)
|
||||
|
||||
|
||||
def _outbound_production_unfinished_exists_for_schedule(tenant_no: str):
|
||||
return (
|
||||
exists()
|
||||
.where(
|
||||
and_(
|
||||
BizProductionWorkOrder.tenant_no == tenant_no,
|
||||
BizProductionWorkOrder.del_flag == 0,
|
||||
BizProductionWorkOrder.work_order_type == "outbound",
|
||||
eq_unicode(
|
||||
BizProductionWorkOrder.infusion_schedule_id,
|
||||
BizPilotInfusionSchedule.id,
|
||||
),
|
||||
BizProductionWorkOrder.work_order_status.in_(("dpz", "jxz")),
|
||||
)
|
||||
)
|
||||
.correlate(BizPilotInfusionSchedule)
|
||||
)
|
||||
|
||||
|
||||
def _outbound_qc_all_void_for_schedule(tenant_no: str):
|
||||
base = [
|
||||
BizQcWorkOrder.tenant_no == tenant_no,
|
||||
BizQcWorkOrder.del_flag == 0,
|
||||
BizQcWorkOrder.qc_biz_type == "outbound",
|
||||
BizQcWorkOrder.trigger_source == "schedule",
|
||||
eq_unicode(BizQcWorkOrder.infusion_schedule_id, BizPilotInfusionSchedule.id),
|
||||
]
|
||||
any_wo = exists().where(and_(*base)).correlate(BizPilotInfusionSchedule)
|
||||
any_non_void = (
|
||||
exists()
|
||||
.where(
|
||||
and_(
|
||||
*base,
|
||||
or_(
|
||||
BizQcWorkOrder.work_order_status.is_(None),
|
||||
BizQcWorkOrder.work_order_status != "yzf",
|
||||
),
|
||||
)
|
||||
)
|
||||
.correlate(BizPilotInfusionSchedule)
|
||||
)
|
||||
return and_(any_wo, ~any_non_void)
|
||||
|
||||
|
||||
def _outbound_production_all_void_for_schedule(tenant_no: str):
|
||||
base = [
|
||||
BizProductionWorkOrder.tenant_no == tenant_no,
|
||||
BizProductionWorkOrder.del_flag == 0,
|
||||
BizProductionWorkOrder.work_order_type == "outbound",
|
||||
eq_unicode(
|
||||
BizProductionWorkOrder.infusion_schedule_id, BizPilotInfusionSchedule.id
|
||||
),
|
||||
]
|
||||
any_wo = exists().where(and_(*base)).correlate(BizPilotInfusionSchedule)
|
||||
any_non_void = (
|
||||
exists()
|
||||
.where(
|
||||
and_(
|
||||
*base,
|
||||
or_(
|
||||
BizProductionWorkOrder.work_order_status.is_(None),
|
||||
BizProductionWorkOrder.work_order_status != "yzf",
|
||||
),
|
||||
)
|
||||
)
|
||||
.correlate(BizPilotInfusionSchedule)
|
||||
)
|
||||
return and_(any_wo, ~any_non_void)
|
||||
|
||||
|
||||
def _get_user_name(db: Session, user_id: str) -> str:
|
||||
u = db.query(SysUser).filter(SysUser.id == user_id).first()
|
||||
return (u.nick_name or "") if u else ""
|
||||
@@ -132,6 +251,8 @@ def list_reviews_paginated(
|
||||
biz_type: Optional[str] = None,
|
||||
infusion_time_start: Optional[str] = None,
|
||||
infusion_time_end: Optional[str] = None,
|
||||
review_phase: Optional[str] = None,
|
||||
ready_for_review_only=None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
user_info=None,
|
||||
@@ -246,24 +367,45 @@ def list_reviews_paginated(
|
||||
if time_bounds:
|
||||
q0 = q0.filter(or_(and_(*time_bounds), BizPilotInfusionSchedule.infusion_time.is_(None)))
|
||||
|
||||
# ── review_conclusion 过滤 ──
|
||||
# ── reviewPhase / review_conclusion 过滤 ──
|
||||
phase = normalize_outbound_review_phase(review_phase)
|
||||
rc = (review_conclusion or "").strip()
|
||||
if rc == "pending":
|
||||
# 待审核:不存在审核记录
|
||||
if phase == "pending" or (phase is None and rc == "pending"):
|
||||
reviewed_schedule_ids = (
|
||||
db.query(BizOutboundReleaseReview.infusion_schedule_id)
|
||||
.filter(
|
||||
BizOutboundReleaseReview.tenant_no == tenant_no,
|
||||
BizOutboundReleaseReview.del_flag == 0,
|
||||
BizOutboundReleaseReview.review_conclusion.isnot(None),
|
||||
BizOutboundReleaseReview.review_conclusion != "",
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
q0 = q0.filter(
|
||||
~BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids)
|
||||
q0 = q0.filter(~BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids))
|
||||
elif phase == "reviewed":
|
||||
reviewed_schedule_ids = (
|
||||
db.query(BizOutboundReleaseReview.infusion_schedule_id)
|
||||
.filter(
|
||||
BizOutboundReleaseReview.tenant_no == tenant_no,
|
||||
BizOutboundReleaseReview.del_flag == 0,
|
||||
BizOutboundReleaseReview.review_conclusion.isnot(None),
|
||||
BizOutboundReleaseReview.review_conclusion != "",
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
elif rc:
|
||||
# 有具体结论
|
||||
q0 = q0.filter(BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids))
|
||||
if rc in ("tg", "btg"):
|
||||
conclusion_ids = (
|
||||
db.query(BizOutboundReleaseReview.infusion_schedule_id)
|
||||
.filter(
|
||||
BizOutboundReleaseReview.tenant_no == tenant_no,
|
||||
BizOutboundReleaseReview.del_flag == 0,
|
||||
BizOutboundReleaseReview.review_conclusion == rc,
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
q0 = q0.filter(BizPilotInfusionSchedule.id.in_(conclusion_ids))
|
||||
elif rc in ("tg", "btg"):
|
||||
reviewed_schedule_ids = (
|
||||
db.query(BizOutboundReleaseReview.infusion_schedule_id)
|
||||
.filter(
|
||||
@@ -273,10 +415,22 @@ def list_reviews_paginated(
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
q0 = q0.filter(BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids))
|
||||
|
||||
if normalize_outbound_ready_for_review_only(ready_for_review_only) is True:
|
||||
q0 = q0.filter(
|
||||
BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids)
|
||||
~_outbound_qc_unfinished_exists_for_schedule(tenant_no),
|
||||
~_outbound_production_unfinished_exists_for_schedule(tenant_no),
|
||||
)
|
||||
|
||||
# 出库质检/生产「有工单且全部 yzf」→ 列表隐藏(两 Tab 常驻)
|
||||
q0 = q0.filter(
|
||||
~or_(
|
||||
_outbound_qc_all_void_for_schedule(tenant_no),
|
||||
_outbound_production_all_void_for_schedule(tenant_no),
|
||||
)
|
||||
)
|
||||
|
||||
# ── 数据权限 ──
|
||||
if user_info:
|
||||
from app.core.data_scope import apply_data_scope
|
||||
@@ -370,6 +524,8 @@ def list_reviews_paginated(
|
||||
"projectName": product.project_name or "",
|
||||
"cellTypeName": product.cell_type_name or "",
|
||||
"specText": product.spec_text or "",
|
||||
"packageId": getattr(product, "package_id", None) or "",
|
||||
"packageText": getattr(product, "package_text", None) or "",
|
||||
"agreementPrice": _dec(product.agreement_price),
|
||||
"actualPrice": _dec(product.actual_price),
|
||||
"quantity": product.quantity,
|
||||
@@ -450,6 +606,8 @@ def list_reviews_paginated(
|
||||
"patientName": (patient.patient_name or "") if patient else "",
|
||||
"cellType": s.cell_type or (plan.cell_type or "") if plan else (s.cell_type or ""),
|
||||
"specText": s.spec_text or (plan.spec_text or "") if plan else (s.spec_text or ""),
|
||||
"packageId": getattr(s, "package_id", None) or "",
|
||||
"packageText": getattr(s, "package_text", None) or "",
|
||||
"infusionTime": _fmt_dt(s.infusion_time),
|
||||
"scheduleStatus": s.schedule_status or "",
|
||||
"scheduleStatusLabel": _SCHEDULE_STATUS_LABELS.get(
|
||||
|
||||
@@ -272,6 +272,8 @@ def _row_to_dict_schedule_product(row) -> Dict[str, Any]:
|
||||
"projectName": row.project_name or "",
|
||||
"cellTypeName": row.cell_type_name or "",
|
||||
"specText": row.spec_text or "",
|
||||
"packageId": getattr(row, "package_id", None) or "",
|
||||
"packageText": getattr(row, "package_text", None) or "",
|
||||
"agreementPrice": float(row.agreement_price) if row.agreement_price is not None else None,
|
||||
"actualPrice": float(row.actual_price) if row.actual_price is not None else None,
|
||||
"quantity": row.quantity if row.quantity is not None else 1,
|
||||
@@ -289,6 +291,8 @@ def _row_to_dict_infusion_schedule(row, cell_products: Optional[List[Dict[str, A
|
||||
"infusionPlanId": row.infusion_plan_id or "",
|
||||
"cellType": row.cell_type or "",
|
||||
"specText": row.spec_text or "",
|
||||
"packageId": getattr(row, "package_id", None) or "",
|
||||
"packageText": getattr(row, "package_text", None) or "",
|
||||
"infusionTime": infusion_time,
|
||||
"infusionLocation": row.infusion_location or "",
|
||||
"infusionMethod": row.infusion_method or "",
|
||||
@@ -1218,6 +1222,8 @@ def _create_schedule_products(
|
||||
project_name=item.get("projectName") or "",
|
||||
cell_type_name=item.get("cellTypeName") or "",
|
||||
spec_text=item.get("specText") or "",
|
||||
package_id=item.get("packageId") or "",
|
||||
package_text=item.get("packageText") or "",
|
||||
agreement_price=item.get("agreementPrice"),
|
||||
actual_price=item.get("actualPrice"),
|
||||
quantity=item.get("quantity") if item.get("quantity") is not None else 1,
|
||||
@@ -1273,6 +1279,8 @@ def create_schedule(
|
||||
infusion_plan_id=plan_id,
|
||||
cell_type=data.get("cellType") or first_product.get("cellTypeName") or "",
|
||||
spec_text=data.get("specText") or first_product.get("specText") or "",
|
||||
package_id=data.get("packageId") or first_product.get("packageId") or "",
|
||||
package_text=data.get("packageText") or first_product.get("packageText") or "",
|
||||
infusion_time=_parse_infusion_datetime(data.get("infusionTime")),
|
||||
infusion_location=data.get("infusionLocation") or "",
|
||||
infusion_method=data.get("infusionMethod") or "",
|
||||
@@ -1364,6 +1372,10 @@ def _update_schedule_editable(
|
||||
if cell_products:
|
||||
row.cell_type = data.get("cellType") or first_product.get("cellTypeName") or row.cell_type
|
||||
row.spec_text = data.get("specText") or first_product.get("specText") or row.spec_text
|
||||
row.package_id = data.get("packageId") or first_product.get("packageId") or row.package_id
|
||||
row.package_text = (
|
||||
data.get("packageText") or first_product.get("packageText") or row.package_text
|
||||
)
|
||||
if data.get("remark") is not None:
|
||||
row.remark = data.get("remark") or ""
|
||||
row.update_by = user_id
|
||||
@@ -1407,6 +1419,8 @@ def _upsert_schedule_products(
|
||||
prow.project_name = item.get("projectName") or ""
|
||||
prow.cell_type_name = item.get("cellTypeName") or ""
|
||||
prow.spec_text = item.get("specText") or ""
|
||||
prow.package_id = item.get("packageId") or ""
|
||||
prow.package_text = item.get("packageText") or ""
|
||||
if item.get("agreementPrice") is not None:
|
||||
prow.agreement_price = item.get("agreementPrice")
|
||||
if item.get("actualPrice") is not None:
|
||||
@@ -1429,6 +1443,8 @@ def _upsert_schedule_products(
|
||||
project_name=item.get("projectName") or "",
|
||||
cell_type_name=item.get("cellTypeName") or "",
|
||||
spec_text=item.get("specText") or "",
|
||||
package_id=item.get("packageId") or "",
|
||||
package_text=item.get("packageText") or "",
|
||||
agreement_price=item.get("agreementPrice"),
|
||||
actual_price=item.get("actualPrice"),
|
||||
quantity=item.get("quantity") if item.get("quantity") is not None else 1,
|
||||
@@ -1645,6 +1661,8 @@ def create_basic_experiment_sale_schedules(
|
||||
schedule_data = {
|
||||
"cellType": line.get("cellTypeName") or "",
|
||||
"specText": line.get("specText") or "",
|
||||
"packageId": line.get("packageId") or "",
|
||||
"packageText": line.get("packageText") or "",
|
||||
"infusionTime": line.get("pickupTime"),
|
||||
"amount": line.get("amount"),
|
||||
"sortNo": idx + 1,
|
||||
@@ -1655,6 +1673,8 @@ def create_basic_experiment_sale_schedules(
|
||||
"projectName": line.get("projectName"),
|
||||
"cellTypeName": line.get("cellTypeName"),
|
||||
"specText": line.get("specText"),
|
||||
"packageId": line.get("packageId"),
|
||||
"packageText": line.get("packageText"),
|
||||
"agreementPrice": line.get("agreementPrice"),
|
||||
"actualPrice": line.get("actualPrice"),
|
||||
"quantity": line.get("quantity") or 1,
|
||||
|
||||
@@ -69,6 +69,8 @@ def _row_to_dict_schedule(
|
||||
"totalCourseCount": course.get("totalCourseCount"),
|
||||
"cellType": row.cell_type or "",
|
||||
"specText": row.spec_text or "",
|
||||
"packageId": getattr(row, "package_id", None) or "",
|
||||
"packageText": getattr(row, "package_text", None) or "",
|
||||
"infusionTime": row.infusion_time.strftime("%Y-%m-%d %H:%M:%S") if row.infusion_time else "",
|
||||
"infusionLocation": row.infusion_location or "",
|
||||
"infusionMethod": row.infusion_method or "",
|
||||
@@ -142,6 +144,8 @@ def _row_to_dict_product(row) -> Dict[str, Any]:
|
||||
"projectName": row.project_name or "",
|
||||
"cellTypeName": row.cell_type_name or "",
|
||||
"specText": row.spec_text or "",
|
||||
"packageId": getattr(row, "package_id", None) or "",
|
||||
"packageText": getattr(row, "package_text", None) or "",
|
||||
"agreementPrice": float(row.agreement_price) if row.agreement_price is not None else None,
|
||||
"actualPrice": float(row.actual_price) if row.actual_price is not None else None,
|
||||
"quantity": row.quantity if row.quantity is not None else 1,
|
||||
@@ -516,6 +520,8 @@ def get_infusion_schedule_detail(
|
||||
sched["totalCourseCount"] = flat.get("totalCourseCount")
|
||||
sched["cellType"] = flat.get("cellType") or sched.get("cellType") or ""
|
||||
sched["specText"] = flat.get("specText") or sched.get("specText") or ""
|
||||
sched["packageId"] = flat.get("packageId") or sched.get("packageId") or ""
|
||||
sched["packageText"] = flat.get("packageText") or sched.get("packageText") or ""
|
||||
sched["infusionTime"] = flat.get("infusionTime") or sched.get("infusionTime") or ""
|
||||
sched["infusionLocation"] = flat.get("infusionLocation") or sched.get("infusionLocation") or ""
|
||||
sched["infusionMethod"] = flat.get("infusionMethod") or sched.get("infusionMethod") or ""
|
||||
@@ -1114,6 +1120,8 @@ def update_infusion_schedule_mgmt(
|
||||
fields = {
|
||||
"cellType": "cell_type",
|
||||
"specText": "spec_text",
|
||||
"packageId": "package_id",
|
||||
"packageText": "package_text",
|
||||
"infusionLocation": "infusion_location",
|
||||
"infusionMethod": "infusion_method",
|
||||
}
|
||||
@@ -1153,6 +1161,8 @@ def update_infusion_schedule_mgmt(
|
||||
project_name=item.get("projectName") or item.get("project_name") or "",
|
||||
cell_type_name=item.get("cellTypeName") or item.get("cell_type_name") or "",
|
||||
spec_text=item.get("specText") or item.get("spec_text") or "",
|
||||
package_id=item.get("packageId") or item.get("package_id") or "",
|
||||
package_text=item.get("packageText") or item.get("package_text") or "",
|
||||
agreement_price=item.get("agreementPrice") if item.get("agreementPrice") is not None else (
|
||||
item.get("agreement_price") if item.get("agreement_price") is not None else None
|
||||
),
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.crud.collection_protocol import urls_to_attachment_list
|
||||
from app.models.models import (
|
||||
BizCellProductType,
|
||||
BizCellProductSpec,
|
||||
BizCellProductPackage,
|
||||
BizCellProductPrice,
|
||||
BizCellProductProjectSystem,
|
||||
BizCellProductProjectSystemItem,
|
||||
@@ -281,6 +282,141 @@ def soft_delete_cell_product_spec(db, spec_id, tenant_no=None):
|
||||
db.commit()
|
||||
|
||||
|
||||
# ────────────────────────────────────────────
|
||||
# Entity 2b: BizCellProductPackage 细胞产品包装
|
||||
# ────────────────────────────────────────────
|
||||
|
||||
def _row_to_dict_cell_product_package(row) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"packageCode": row.package_code or "",
|
||||
"packageName": row.package_name or "",
|
||||
"tenantNo": row.tenant_no or "",
|
||||
"orderNum": row.order_num if row.order_num is not None else 0,
|
||||
"remark": row.remark or "",
|
||||
"createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
|
||||
"updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_cell_product_packages(db, *, page=1, page_size=10, keyword=None, tenant_no=None, user_info=None):
|
||||
def _filtered():
|
||||
q0 = db.query(BizCellProductPackage).filter(BizCellProductPackage.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizCellProductPackage.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(
|
||||
BizCellProductPackage.package_code.like(like),
|
||||
BizCellProductPackage.package_name.like(like),
|
||||
)
|
||||
)
|
||||
if user_info:
|
||||
from app.core.data_scope import apply_data_scope
|
||||
q0 = apply_data_scope(q0, BizCellProductPackage, user_info, db, tenant_no)
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizCellProductPackage.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizCellProductPackage.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict_cell_product_package(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def get_cell_product_package(db, package_id, tenant_no=None):
|
||||
q = db.query(BizCellProductPackage).filter(
|
||||
and_(BizCellProductPackage.id == package_id, BizCellProductPackage.del_flag == 0)
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(BizCellProductPackage.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def cell_product_package_code_exists(db, code, tenant_no, exclude_id=None):
|
||||
q = db.query(BizCellProductPackage).filter(
|
||||
and_(BizCellProductPackage.package_code == code, BizCellProductPackage.del_flag == 0)
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(BizCellProductPackage.tenant_no == tenant_no)
|
||||
if exclude_id:
|
||||
q = q.filter(BizCellProductPackage.id != exclude_id)
|
||||
return db.query(q.exists()).scalar()
|
||||
|
||||
|
||||
def create_cell_product_package(
|
||||
db, *, package_code, package_name, tenant_no="zkxw", order_num=None, remark=None, user_id=None
|
||||
):
|
||||
if cell_product_package_code_exists(db, package_code, tenant_no):
|
||||
raise ValueError(f"包装编码 {package_code} 已存在")
|
||||
obj = BizCellProductPackage(
|
||||
id=generate_uuid(),
|
||||
package_code=package_code,
|
||||
package_name=package_name,
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num or 0,
|
||||
remark=remark or "",
|
||||
create_by=user_id,
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_cell_product_package(
|
||||
db,
|
||||
package_id,
|
||||
*,
|
||||
package_code=None,
|
||||
package_name=None,
|
||||
order_num=None,
|
||||
remark=None,
|
||||
user_id=None,
|
||||
tenant_no=None,
|
||||
):
|
||||
obj = get_cell_product_package(db, package_id, tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("记录不存在")
|
||||
if package_code is not None and package_code != obj.package_code:
|
||||
if cell_product_package_code_exists(db, package_code, tenant_no, exclude_id=package_id):
|
||||
raise ValueError(f"包装编码 {package_code} 已存在")
|
||||
obj.package_code = package_code
|
||||
if package_name is not None:
|
||||
obj.package_name = package_name
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = remark
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_cell_product_package(db, package_id, tenant_no=None):
|
||||
obj = get_cell_product_package(db, package_id, tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("记录不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ────────────────────────────────────────────
|
||||
# Entity 3: BizCellProductPrice 细胞产品价格
|
||||
# ────────────────────────────────────────────
|
||||
|
||||
+121
-4
@@ -9,10 +9,52 @@ from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import SysPost
|
||||
from app.models.models import SysPost, SysPostRole, SysRole, SysUser, SysUserPost
|
||||
|
||||
|
||||
def _row_to_dict(p: SysPost) -> Dict[str, Any]:
|
||||
def _user_display_name(nick_name: Optional[str], user_no: Optional[str]) -> str:
|
||||
n = (nick_name or "").strip()
|
||||
if n:
|
||||
return n
|
||||
return (user_no or "").strip()
|
||||
|
||||
|
||||
def _bound_user_names_by_post_ids(
|
||||
db: Session, post_ids: List[str], tenant_no: str
|
||||
) -> Dict[str, List[str]]:
|
||||
"""批量查询职位关联用户显示名(nick_name 优先,否则 user_no)。"""
|
||||
out: Dict[str, List[str]] = {}
|
||||
ids = [pid for pid in (post_ids or []) if pid]
|
||||
tenant = (tenant_no or "").strip()
|
||||
if not ids or not tenant:
|
||||
return out
|
||||
rows = (
|
||||
db.query(SysUserPost.post_id, SysUser.nick_name, SysUser.user_no)
|
||||
.join(
|
||||
SysUser,
|
||||
and_(
|
||||
SysUser.id == SysUserPost.user_id,
|
||||
SysUser.del_flag == 0,
|
||||
),
|
||||
)
|
||||
.filter(
|
||||
and_(
|
||||
SysUserPost.post_id.in_(ids),
|
||||
SysUserPost.tenant_no == tenant,
|
||||
SysUserPost.del_flag == 0,
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for post_id, nick_name, user_no in rows:
|
||||
name = _user_display_name(nick_name, user_no)
|
||||
if not name:
|
||||
continue
|
||||
out.setdefault(post_id, []).append(name)
|
||||
return out
|
||||
|
||||
|
||||
def _row_to_dict(p: SysPost, bound_user_names: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": p.id,
|
||||
"postName": p.post_name,
|
||||
@@ -21,6 +63,7 @@ def _row_to_dict(p: SysPost) -> Dict[str, Any]:
|
||||
"tenantNo": p.tenant_no,
|
||||
"orderNum": p.order_num if p.order_num is not None else 0,
|
||||
"remark": p.remark or "",
|
||||
"boundUserNames": list(bound_user_names or []),
|
||||
"createTime": p.create_time.strftime("%Y-%m-%d %H:%M:%S") if p.create_time else "",
|
||||
"updateTime": p.update_time.strftime("%Y-%m-%d %H:%M:%S") if p.update_time else "",
|
||||
}
|
||||
@@ -58,7 +101,10 @@ def list_posts(
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows], int(total)
|
||||
name_map = _bound_user_names_by_post_ids(db, [x.id for x in rows], tenant_no or "")
|
||||
return [
|
||||
_row_to_dict(x, bound_user_names=name_map.get(x.id, [])) for x in rows
|
||||
], int(total)
|
||||
|
||||
|
||||
def get_post(db: Session, post_id: str) -> Optional[SysPost]:
|
||||
@@ -154,9 +200,80 @@ def soft_delete_post(db: Session, post_id: str) -> None:
|
||||
obj = get_post(db, post_id)
|
||||
if not obj:
|
||||
raise ValueError("职位不存在")
|
||||
now = datetime.now()
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
obj.update_time = now
|
||||
db.add(obj)
|
||||
db.query(SysPostRole).filter(
|
||||
and_(SysPostRole.post_id == post_id, SysPostRole.del_flag == 0)
|
||||
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
|
||||
db.commit()
|
||||
|
||||
|
||||
def list_role_ids_for_post(db: Session, post_id: str, tenant_no: str) -> List[str]:
|
||||
rows = (
|
||||
db.query(SysPostRole.role_id)
|
||||
.filter(
|
||||
and_(
|
||||
SysPostRole.post_id == post_id,
|
||||
SysPostRole.tenant_no == tenant_no,
|
||||
SysPostRole.del_flag == 0,
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def sync_post_role_bindings(
|
||||
db: Session,
|
||||
post_id: str,
|
||||
tenant_no: str,
|
||||
role_ids: List[str],
|
||||
updater_id: Optional[str],
|
||||
) -> None:
|
||||
"""将本职位绑定的角色集合全量替换为 role_ids。"""
|
||||
ordered = list(dict.fromkeys([rid for rid in (role_ids or []) if rid]))
|
||||
for rid in ordered:
|
||||
role = (
|
||||
db.query(SysRole)
|
||||
.filter(
|
||||
and_(
|
||||
SysRole.id == rid,
|
||||
SysRole.del_flag == 0,
|
||||
SysRole.tenant_no == tenant_no,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not role:
|
||||
raise ValueError("存在无效角色或不属于当前租户")
|
||||
|
||||
now = datetime.now()
|
||||
db.query(SysPostRole).filter(
|
||||
and_(
|
||||
SysPostRole.post_id == post_id,
|
||||
SysPostRole.tenant_no == tenant_no,
|
||||
SysPostRole.del_flag == 0,
|
||||
)
|
||||
).update({"del_flag": 1, "update_time": now, "update_by": updater_id}, synchronize_session=False)
|
||||
for rid in ordered:
|
||||
db.add(
|
||||
SysPostRole(
|
||||
id=generate_uuid(),
|
||||
post_id=post_id,
|
||||
role_id=rid,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark=None,
|
||||
create_by=updater_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=updater_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -82,6 +82,7 @@ def _row_to_dict(p: BizProcess) -> Dict[str, Any]:
|
||||
"description": p.description or "",
|
||||
"estimatedDuration": p.estimated_duration or "",
|
||||
"sopIds": p.sop_ids or "",
|
||||
"sopContent": p.sop_content or "",
|
||||
"tenantNo": p.tenant_no,
|
||||
"orderNum": p.order_num if p.order_num is not None else 0,
|
||||
"remark": p.remark or "",
|
||||
@@ -309,6 +310,7 @@ def create_process(
|
||||
equipment_ids: Optional[Union[str, List[str]]] = None,
|
||||
estimated_duration: Optional[str] = None,
|
||||
sop_ids: Optional[str] = None,
|
||||
sop_content: Optional[str] = None,
|
||||
reagents: Optional[List[Dict[str, Any]]] = None,
|
||||
consumables: Optional[List[Dict[str, Any]]] = None,
|
||||
outputs: Optional[List[Dict[str, Any]]] = None,
|
||||
@@ -334,6 +336,7 @@ def create_process(
|
||||
equipment_name=None,
|
||||
estimated_duration=(estimated_duration or "").strip() or None,
|
||||
sop_ids=(sop_ids or "").strip() or None,
|
||||
sop_content=(sop_content or "").strip() or None,
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or None,
|
||||
@@ -388,6 +391,7 @@ def update_process(
|
||||
equipment_ids: Any = _UNSET,
|
||||
estimated_duration: Optional[str] = None,
|
||||
sop_ids: Optional[str] = None,
|
||||
sop_content: Optional[str] = None,
|
||||
reagents: Optional[List[Dict[str, Any]]] = None,
|
||||
consumables: Optional[List[Dict[str, Any]]] = None,
|
||||
outputs: Optional[List[Dict[str, Any]]] = None,
|
||||
@@ -417,6 +421,8 @@ def update_process(
|
||||
obj.estimated_duration = (estimated_duration or "").strip() or None
|
||||
if sop_ids is not None:
|
||||
obj.sop_ids = (sop_ids or "").strip() or None
|
||||
if sop_content is not None:
|
||||
obj.sop_content = (sop_content or "").strip() or None
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
|
||||
@@ -9,8 +9,9 @@ from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.equipment_snapshot import parse_equipment_ids, resolve_equipment_list_snapshot
|
||||
from app.models.models import (
|
||||
BizCellProductType,
|
||||
BizProductionProductType,
|
||||
BizProcess,
|
||||
BizProcessReagent,
|
||||
BizProcessConsumable,
|
||||
@@ -37,9 +38,12 @@ def _route_row_to_dict(r: BizProcessRoute) -> Dict[str, Any]:
|
||||
"productTypeId": r.product_type_id or "",
|
||||
"version": r.version or "",
|
||||
"isCurrent": r.is_current if r.is_current is not None else "0",
|
||||
"equipmentIds": parse_equipment_ids(getattr(r, "equipment_ids", None)),
|
||||
"equipmentName": getattr(r, "equipment_name", None) or "",
|
||||
"tenantNo": r.tenant_no,
|
||||
"orderNum": r.order_num if r.order_num is not None else 0,
|
||||
"remark": r.remark or "",
|
||||
"isBuiltin": getattr(r, "is_builtin", None) or "0",
|
||||
"createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
|
||||
"updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
|
||||
}
|
||||
@@ -57,6 +61,7 @@ def _step_row_to_dict(s: BizProcessRouteStep) -> Dict[str, Any]:
|
||||
"description": s.description or "",
|
||||
"estimatedDuration": s.estimated_duration or "",
|
||||
"sopIds": s.sop_ids or "",
|
||||
"sopContent": s.sop_content or "",
|
||||
"remark": s.remark or "",
|
||||
}
|
||||
|
||||
@@ -144,15 +149,15 @@ def list_routes(db: Session, *, page: int = 1, page_size: int = 10,
|
||||
rows = (
|
||||
_filtered()
|
||||
.outerjoin(
|
||||
BizCellProductType,
|
||||
BizProductionProductType,
|
||||
and_(
|
||||
BizProcessRoute.product_type_id == BizCellProductType.id,
|
||||
BizCellProductType.del_flag == 0,
|
||||
BizProcessRoute.product_type_id == BizProductionProductType.id,
|
||||
BizProductionProductType.del_flag == 0,
|
||||
),
|
||||
)
|
||||
.order_by(
|
||||
BizCellProductType.product_type_name.is_(None),
|
||||
BizCellProductType.product_type_name.asc(),
|
||||
BizProductionProductType.product_type_name.is_(None),
|
||||
BizProductionProductType.product_type_name.asc(),
|
||||
BizProcessRoute.create_time.desc(),
|
||||
)
|
||||
.offset((page - 1) * page_size)
|
||||
@@ -168,6 +173,38 @@ def get_route(db: Session, route_id: str) -> Optional[BizProcessRoute]:
|
||||
).first()
|
||||
|
||||
|
||||
def get_step(db: Session, step_id: str) -> Optional[BizProcessRouteStep]:
|
||||
return db.query(BizProcessRouteStep).filter(
|
||||
and_(BizProcessRouteStep.id == step_id, BizProcessRouteStep.del_flag == 0)
|
||||
).first()
|
||||
|
||||
|
||||
def is_super_admin_user(user: Optional[Dict[str, Any]]) -> bool:
|
||||
return bool(user) and (user.get("role") or "") == "super_admin"
|
||||
|
||||
|
||||
def assert_route_writable(
|
||||
db: Session, route_id: str, user: Optional[Dict[str, Any]] = None
|
||||
) -> BizProcessRoute:
|
||||
"""内置模版仅超级管理员可写;返回路线对象。"""
|
||||
route = get_route(db, route_id)
|
||||
if not route:
|
||||
raise ValueError("路线不存在")
|
||||
if (getattr(route, "is_builtin", None) or "0") == "1" and not is_super_admin_user(user):
|
||||
raise ValueError("内置模版仅超级管理员可修改")
|
||||
return route
|
||||
|
||||
|
||||
def assert_step_route_writable(
|
||||
db: Session, step_id: str, user: Optional[Dict[str, Any]] = None
|
||||
) -> BizProcessRouteStep:
|
||||
step = get_step(db, step_id)
|
||||
if not step:
|
||||
raise ValueError("步骤不存在")
|
||||
assert_route_writable(db, step.route_id, user)
|
||||
return step
|
||||
|
||||
|
||||
def get_route_detail(db: Session, route_id: str) -> Optional[Dict[str, Any]]:
|
||||
route = get_route(db, route_id)
|
||||
if not route:
|
||||
@@ -224,6 +261,7 @@ def create_route(db: Session, *, route_code: str, route_name: str,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark=(remark or "").strip() or None,
|
||||
is_builtin="0",
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
@@ -245,6 +283,12 @@ def update_route(db: Session, route_id: str, *, user_id: Optional[str] = None, *
|
||||
val = kwargs.get(field)
|
||||
if val is not None:
|
||||
setattr(obj, field, (val or "").strip() or None if field != "route_code" else (val or "").strip())
|
||||
if "equipment_ids" in kwargs:
|
||||
eids, enames = resolve_equipment_list_snapshot(
|
||||
db, kwargs.get("equipment_ids"), obj.tenant_no or "zkxw"
|
||||
)
|
||||
obj.equipment_ids = eids
|
||||
obj.equipment_name = enames
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
@@ -376,6 +420,7 @@ def add_step(db: Session, *, route_id: str, process_id: str, step_order: Optiona
|
||||
process_name=proc.process_name, description=proc.description,
|
||||
estimated_duration=proc.estimated_duration,
|
||||
sop_ids=proc.sop_ids,
|
||||
sop_content=proc.sop_content,
|
||||
tenant_no=tenant_no, order_num=0,
|
||||
remark=None, create_by=user_id, create_dept_id="",
|
||||
create_time=now, update_by=user_id, update_time=now, del_flag=0,
|
||||
@@ -432,10 +477,13 @@ def update_step(db: Session, step_id: str, *, user_id: Optional[str] = None, **k
|
||||
if not step:
|
||||
raise ValueError("步骤不存在")
|
||||
for field in ["process_code", "category_id", "process_name", "description",
|
||||
"estimated_duration", "sop_ids"]:
|
||||
"estimated_duration", "sop_ids", "sop_content"]:
|
||||
val = kwargs.get(field)
|
||||
if val is not None:
|
||||
setattr(step, field, (val or "").strip() or None)
|
||||
if field == "sop_content":
|
||||
setattr(step, field, val if str(val).strip() else None)
|
||||
else:
|
||||
setattr(step, field, (val or "").strip() or None)
|
||||
step.update_by = user_id
|
||||
step.update_time = datetime.now()
|
||||
db.add(step)
|
||||
@@ -494,6 +542,7 @@ def copy_step(db: Session, step_id: str, *, tenant_no: str = "zkxw",
|
||||
process_name=new_name, description=src.description,
|
||||
estimated_duration=src.estimated_duration,
|
||||
sop_ids=src.sop_ids,
|
||||
sop_content=src.sop_content,
|
||||
tenant_no=tenant_no, order_num=0,
|
||||
remark=src.remark, create_by=user_id, create_dept_id="",
|
||||
create_time=now, update_by=user_id, update_time=now, del_flag=0,
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""生产产品类型 CRUD(biz_production_product_type)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizProductionProductType
|
||||
|
||||
|
||||
def _row_to_dict(row: BizProductionProductType) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"productTypeCode": row.product_type_code or "",
|
||||
"productTypeName": row.product_type_name or "",
|
||||
"tenantNo": row.tenant_no or "",
|
||||
"orderNum": row.order_num if row.order_num is not None else 0,
|
||||
"remark": row.remark or "",
|
||||
"createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
|
||||
"updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_production_product_types(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
user_info=None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizProductionProductType).filter(BizProductionProductType.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizProductionProductType.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(
|
||||
BizProductionProductType.product_type_code.like(like),
|
||||
BizProductionProductType.product_type_name.like(like),
|
||||
)
|
||||
)
|
||||
if user_info:
|
||||
from app.core.data_scope import apply_data_scope
|
||||
|
||||
q0 = apply_data_scope(q0, BizProductionProductType, user_info, db, tenant_no)
|
||||
return q0
|
||||
|
||||
total = (
|
||||
_filtered().order_by(None).with_entities(func.count(BizProductionProductType.id)).scalar() or 0
|
||||
)
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizProductionProductType.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def get_production_product_type(
|
||||
db: Session, type_id: str, tenant_no: Optional[str] = None
|
||||
) -> Optional[BizProductionProductType]:
|
||||
q = db.query(BizProductionProductType).filter(
|
||||
and_(BizProductionProductType.id == type_id, BizProductionProductType.del_flag == 0)
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(BizProductionProductType.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def product_type_code_exists(
|
||||
db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None
|
||||
) -> bool:
|
||||
q = db.query(BizProductionProductType).filter(
|
||||
and_(
|
||||
BizProductionProductType.product_type_code == code,
|
||||
BizProductionProductType.del_flag == 0,
|
||||
)
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(BizProductionProductType.tenant_no == tenant_no)
|
||||
if exclude_id:
|
||||
q = q.filter(BizProductionProductType.id != exclude_id)
|
||||
return db.query(q.exists()).scalar()
|
||||
|
||||
|
||||
def create_production_product_type(
|
||||
db: Session,
|
||||
*,
|
||||
product_type_code: str,
|
||||
product_type_name: str,
|
||||
tenant_no: str = "zkxw",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizProductionProductType:
|
||||
code = (product_type_code or "").strip()
|
||||
if not code:
|
||||
raise ValueError("产品类型编码不能为空")
|
||||
if product_type_code_exists(db, code, tenant_no):
|
||||
raise ValueError(f"产品类型编码 {code} 已存在")
|
||||
obj = BizProductionProductType(
|
||||
id=generate_uuid(),
|
||||
product_type_code=code,
|
||||
product_type_name=(product_type_name or "").strip() or code,
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or "",
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_production_product_type(
|
||||
db: Session,
|
||||
type_id: str,
|
||||
*,
|
||||
product_type_code: Optional[str] = None,
|
||||
product_type_name: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> BizProductionProductType:
|
||||
obj = get_production_product_type(db, type_id, tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("记录不存在")
|
||||
if product_type_code is not None and product_type_code != obj.product_type_code:
|
||||
new_code = product_type_code.strip()
|
||||
if not new_code:
|
||||
raise ValueError("产品类型编码不能为空")
|
||||
if product_type_code_exists(db, new_code, tenant_no or obj.tenant_no, exclude_id=type_id):
|
||||
raise ValueError(f"产品类型编码 {new_code} 已存在")
|
||||
obj.product_type_code = new_code
|
||||
if product_type_name is not None:
|
||||
obj.product_type_name = (product_type_name or "").strip() or obj.product_type_name
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = (remark or "").strip() or ""
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_production_product_type(
|
||||
db: Session, type_id: str, tenant_no: Optional[str] = None
|
||||
) -> None:
|
||||
obj = get_production_product_type(db, type_id, tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("记录不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if total <= 0:
|
||||
return 0
|
||||
return int(math.ceil(total / float(page_size)))
|
||||
@@ -309,6 +309,7 @@ def _step_to_dict(s: BizProductionTaskStep) -> Dict[str, Any]:
|
||||
"estimatedDuration": s.estimated_duration or "",
|
||||
"actualDuration": s.actual_duration or "",
|
||||
"sopIds": s.sop_ids or "",
|
||||
"sopContent": s.sop_content or "",
|
||||
"stepStatus": s.step_status or "dzx",
|
||||
"plannedStartTime": format_date(getattr(s, "planned_start_time", None)) or "",
|
||||
"startedTime": _fmt_dt(s.started_time),
|
||||
@@ -688,6 +689,7 @@ def _clone_route_to_task(
|
||||
process_name=rs.process_name, description=rs.description,
|
||||
estimated_duration=rs.estimated_duration,
|
||||
sop_ids=rs.sop_ids,
|
||||
sop_content=rs.sop_content,
|
||||
step_status="dzx",
|
||||
tenant_no=tenant_no, order_num=0,
|
||||
remark=None, create_by=user_id, create_dept_id=create_dept_id or "",
|
||||
@@ -1017,6 +1019,19 @@ def create_production_task(
|
||||
route_id = data.get("route_id")
|
||||
if route_id:
|
||||
_clone_route_to_task(db, task.id, route_id, tenant_no, user_id, create_dept_id)
|
||||
route = (
|
||||
db.query(BizProcessRoute)
|
||||
.filter(
|
||||
BizProcessRoute.id == route_id,
|
||||
BizProcessRoute.tenant_no == tenant_no,
|
||||
BizProcessRoute.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if route and (getattr(route, "equipment_ids", None) or getattr(route, "equipment_name", None)):
|
||||
task.equipment_ids = route.equipment_ids
|
||||
task.equipment_name = route.equipment_name
|
||||
db.add(task)
|
||||
|
||||
from app.crud import production_work_order as production_work_order_svc
|
||||
|
||||
@@ -1168,17 +1183,21 @@ def list_production_tasks(
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
keyword_conditions = [
|
||||
BizProductionTask.task_no.like(like),
|
||||
BizProductionWorkOrder.work_order_no.like(like),
|
||||
BizProductionWorkOrder.sample_code.like(like),
|
||||
]
|
||||
if tenant_no:
|
||||
from app.crud.production_work_order import _outbound_work_order_ids_by_keyword
|
||||
wo_type = (work_order_type or "").strip()
|
||||
if wo_type == "temporary":
|
||||
keyword_conditions = [BizProductionWorkOrder.remark.like(like)]
|
||||
else:
|
||||
keyword_conditions = [
|
||||
BizProductionTask.task_no.like(like),
|
||||
BizProductionWorkOrder.work_order_no.like(like),
|
||||
BizProductionWorkOrder.sample_code.like(like),
|
||||
]
|
||||
if tenant_no:
|
||||
from app.crud.production_work_order import _outbound_work_order_ids_by_keyword
|
||||
|
||||
outbound_ids = _outbound_work_order_ids_by_keyword(db, tenant_no, kw)
|
||||
if outbound_ids:
|
||||
keyword_conditions.append(BizProductionWorkOrder.id.in_(outbound_ids))
|
||||
outbound_ids = _outbound_work_order_ids_by_keyword(db, tenant_no, kw)
|
||||
if outbound_ids:
|
||||
keyword_conditions.append(BizProductionWorkOrder.id.in_(outbound_ids))
|
||||
q = q.outerjoin(
|
||||
BizProductionWorkOrder,
|
||||
and_(
|
||||
@@ -1955,6 +1974,37 @@ def _consume_task_input_cell_products(
|
||||
pass
|
||||
|
||||
|
||||
def _force_complete_task_steps(
|
||||
db: Session,
|
||||
task_id: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
now: datetime,
|
||||
) -> int:
|
||||
"""将任务下未完成步骤置为 ywc;不 commit。返回更新条数。"""
|
||||
steps = (
|
||||
db.query(BizProductionTaskStep)
|
||||
.filter(
|
||||
and_(
|
||||
BizProductionTaskStep.task_id == task_id,
|
||||
BizProductionTaskStep.tenant_no == tenant_no,
|
||||
BizProductionTaskStep.del_flag == 0,
|
||||
BizProductionTaskStep.step_status != "ywc",
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for step in steps:
|
||||
step.step_status = "ywc"
|
||||
if user_id:
|
||||
step.completed_by = user_id
|
||||
step.completed_time = now
|
||||
step.update_by = user_id
|
||||
step.update_time = now
|
||||
db.add(step)
|
||||
return len(steps)
|
||||
|
||||
|
||||
def update_task_status(
|
||||
db: Session,
|
||||
task_id: str,
|
||||
@@ -1986,15 +2036,7 @@ def update_task_status(
|
||||
if concl not in ("cg", "sb"):
|
||||
raise ValueError("须选择生产结论(成功或失败)")
|
||||
if previous_status != "ywc":
|
||||
incomplete_step_count = db.query(BizProductionTaskStep).filter(
|
||||
and_(
|
||||
BizProductionTaskStep.task_id == task_id,
|
||||
BizProductionTaskStep.del_flag == 0,
|
||||
BizProductionTaskStep.step_status != "ywc",
|
||||
)
|
||||
).count()
|
||||
if incomplete_step_count > 0:
|
||||
raise ValueError("尚有未完成的工艺步骤,请完成全部步骤后再完成任务")
|
||||
_force_complete_task_steps(db, task_id, tenant_no, user_id, now)
|
||||
task.conclusion = concl
|
||||
if remark is not None:
|
||||
task.remark = (remark or "").strip() or None
|
||||
@@ -2055,6 +2097,7 @@ def add_task_step(
|
||||
description=proc.description,
|
||||
estimated_duration=proc.estimated_duration,
|
||||
sop_ids=proc.sop_ids,
|
||||
sop_content=proc.sop_content,
|
||||
step_status="dzx",
|
||||
tenant_no=tenant_no, order_num=0,
|
||||
remark=None,
|
||||
@@ -2135,6 +2178,7 @@ def add_task_step(
|
||||
estimated_duration=(data.get("estimated_duration") or "").strip() or None,
|
||||
actual_duration=(data.get("actual_duration") or "").strip() or None,
|
||||
sop_ids=(data.get("sop_ids") or "").strip() or None,
|
||||
sop_content=(data.get("sop_content") if str(data.get("sop_content") or "").strip() else None),
|
||||
step_status=(data.get("step_status") or "dzx"),
|
||||
tenant_no=tenant_no, order_num=0,
|
||||
remark=(data.get("remark") or "").strip() or None,
|
||||
@@ -2186,12 +2230,14 @@ def update_task_step(
|
||||
for field in [
|
||||
"step_order", "process_code", "category_id", "process_name",
|
||||
"description", "estimated_duration", "actual_duration",
|
||||
"sop_ids", "step_status", "remark",
|
||||
"sop_ids", "sop_content", "step_status", "remark",
|
||||
]:
|
||||
val = data.get(field)
|
||||
if val is not None:
|
||||
if field == "step_order":
|
||||
setattr(step, field, int(val))
|
||||
elif field == "sop_content":
|
||||
setattr(step, field, val if str(val).strip() else None)
|
||||
else:
|
||||
setattr(step, field, (val or "").strip() or None)
|
||||
if data.get("started_time"):
|
||||
@@ -2765,7 +2811,9 @@ def set_task_outputs(
|
||||
BizCellProduct.id == cp_id, BizCellProduct.del_flag == 0
|
||||
).first()
|
||||
if product:
|
||||
# 作废并软删:避免同编码再入库时留下 voided+frozen 双行,污染各类选择器
|
||||
product.product_status = "voided"
|
||||
product.del_flag = 1
|
||||
product.update_by = user_id
|
||||
product.update_time = now
|
||||
db.add(product)
|
||||
@@ -2911,6 +2959,7 @@ def copy_task_step(db: Session, step_id: str, *, tenant_no: str = "zkxw",
|
||||
process_name=new_name, description=src.description,
|
||||
estimated_duration=src.estimated_duration,
|
||||
actual_duration=src.actual_duration, sop_ids=src.sop_ids,
|
||||
sop_content=src.sop_content,
|
||||
step_status="dzx",
|
||||
tenant_no=tenant_no, order_num=0,
|
||||
remark=src.remark, create_by=user_id, create_dept_id="",
|
||||
|
||||
@@ -0,0 +1,816 @@
|
||||
"""生产任务集合 CRUD"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import (
|
||||
BizProcessRoute,
|
||||
BizProcessRouteStep,
|
||||
BizProductionTask,
|
||||
BizProductionTaskSet,
|
||||
BizProductionTaskSetItem,
|
||||
BizProductionWorkOrder,
|
||||
)
|
||||
from app.utils.production_task_name_placeholders import (
|
||||
apply_task_name_placeholders,
|
||||
revert_task_name_placeholders,
|
||||
)
|
||||
|
||||
|
||||
def resolve_work_order_lab_storage_code(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
wo: BizProductionWorkOrder,
|
||||
) -> str:
|
||||
"""解析工单对应实验室入库编码(样本接收优先,样本编码回退)。"""
|
||||
from app.crud import sample_reception as sample_reception_svc
|
||||
from app.models.models import BizSampleReception
|
||||
|
||||
reception_id = (getattr(wo, "reception_id", None) or "").strip()
|
||||
if reception_id:
|
||||
rec = (
|
||||
db.query(BizSampleReception)
|
||||
.filter(
|
||||
BizSampleReception.id == reception_id,
|
||||
BizSampleReception.tenant_no == tenant_no,
|
||||
BizSampleReception.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if rec:
|
||||
code = (rec.lab_storage_code or "").strip()
|
||||
if code:
|
||||
return code
|
||||
sample_code = (getattr(wo, "sample_code", None) or "").strip()
|
||||
if not sample_code:
|
||||
return ""
|
||||
source = (getattr(wo, "reception_source", None) or "").strip() or None
|
||||
code_map = sample_reception_svc.get_lab_storage_code_map_by_sample_codes(
|
||||
db, tenant_no, [sample_code], reception_source=source
|
||||
)
|
||||
return (code_map.get(sample_code) or "").strip()
|
||||
|
||||
|
||||
def _strip_or_none(raw: Any) -> Optional[str]:
|
||||
text = str(raw or "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def normalize_item_payload(raw: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
data = raw or {}
|
||||
task_name = str(data.get("taskName") or data.get("task_name") or "").strip()
|
||||
if not task_name:
|
||||
return None
|
||||
route_raw = data.get("routeId") if "routeId" in data else data.get("route_id")
|
||||
return {
|
||||
"task_name": task_name,
|
||||
"route_id": _strip_or_none(route_raw),
|
||||
"description": _strip_or_none(data.get("description")),
|
||||
"remark": _strip_or_none(data.get("remark")),
|
||||
}
|
||||
|
||||
|
||||
def skip_empty_named_tasks(tasks: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], int]:
|
||||
kept: List[Dict[str, Any]] = []
|
||||
skipped = 0
|
||||
for row in tasks or []:
|
||||
name = str(row.get("task_name") or row.get("taskName") or "").strip()
|
||||
if not name:
|
||||
skipped += 1
|
||||
continue
|
||||
route_raw = row.get("route_id") if "route_id" in row else row.get("routeId")
|
||||
name_raw = row.get("route_name") if "route_name" in row else row.get("routeName")
|
||||
kept.append(
|
||||
{
|
||||
"task_name": name,
|
||||
"route_id": _strip_or_none(route_raw),
|
||||
"route_name": _strip_or_none(name_raw),
|
||||
"description": _strip_or_none(row.get("description")),
|
||||
"remark": _strip_or_none(row.get("remark")),
|
||||
}
|
||||
)
|
||||
return kept, skipped
|
||||
|
||||
|
||||
def collect_missing_route_ids(items: List[Dict[str, Any]], existing_ids: Set[str]) -> List[str]:
|
||||
missing: List[str] = []
|
||||
seen: Set[str] = set()
|
||||
alive = existing_ids or set()
|
||||
for item in items or []:
|
||||
rid = _strip_or_none(item.get("route_id"))
|
||||
if not rid or rid in alive or rid in seen:
|
||||
continue
|
||||
seen.add(rid)
|
||||
missing.append(rid)
|
||||
return missing
|
||||
|
||||
|
||||
def collect_route_ids_without_steps(
|
||||
items: List[Dict[str, Any]],
|
||||
route_ids_with_steps: Set[str],
|
||||
) -> List[str]:
|
||||
"""返回条目中已绑定路线但无有效工序步骤的 route_id(去重保序)。"""
|
||||
empty: List[str] = []
|
||||
seen: Set[str] = set()
|
||||
with_steps = route_ids_with_steps or set()
|
||||
for item in items or []:
|
||||
rid = _strip_or_none(item.get("route_id"))
|
||||
if not rid or rid in with_steps or rid in seen:
|
||||
continue
|
||||
seen.add(rid)
|
||||
empty.append(rid)
|
||||
return empty
|
||||
|
||||
|
||||
def _load_route_ids_with_steps(db: Session, route_ids: List[str]) -> Set[str]:
|
||||
ids = [rid for rid in route_ids if rid]
|
||||
if not ids:
|
||||
return set()
|
||||
rows = (
|
||||
db.query(BizProcessRouteStep.route_id)
|
||||
.filter(
|
||||
BizProcessRouteStep.route_id.in_(ids),
|
||||
BizProcessRouteStep.del_flag == 0,
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
return {r[0] for r in rows if r and r[0]}
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if page_size <= 0:
|
||||
return 0
|
||||
return (int(total) + int(page_size) - 1) // int(page_size)
|
||||
|
||||
|
||||
def _item_to_dict(r: BizProductionTaskSetItem) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"setId": r.set_id,
|
||||
"taskName": r.task_name or "",
|
||||
"routeId": r.route_id or "",
|
||||
"routeName": r.route_name or "",
|
||||
"description": r.description or "",
|
||||
"remark": r.remark or "",
|
||||
"orderNum": r.order_num if r.order_num is not None else 0,
|
||||
}
|
||||
|
||||
|
||||
def _set_to_dict(
|
||||
r: BizProductionTaskSet,
|
||||
item_count: int = 0,
|
||||
items: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
data: Dict[str, Any] = {
|
||||
"id": r.id,
|
||||
"setName": r.set_name or "",
|
||||
"status": r.status or "1",
|
||||
"itemCount": item_count,
|
||||
"orderNum": r.order_num if r.order_num is not None else 0,
|
||||
"remark": r.remark or "",
|
||||
"isBuiltin": getattr(r, "is_builtin", None) or "0",
|
||||
"createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
|
||||
"updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
|
||||
}
|
||||
if items is not None:
|
||||
data["items"] = items
|
||||
return data
|
||||
|
||||
|
||||
def set_name_exists(
|
||||
db: Session, name: str, tenant_no: str, exclude_id: Optional[str] = None
|
||||
) -> bool:
|
||||
n = (name or "").strip()
|
||||
if not n:
|
||||
return False
|
||||
q = db.query(BizProductionTaskSet).filter(
|
||||
BizProductionTaskSet.set_name == n,
|
||||
BizProductionTaskSet.tenant_no == tenant_no,
|
||||
BizProductionTaskSet.del_flag == 0,
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.filter(BizProductionTaskSet.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def _require_status(status: Optional[str]) -> str:
|
||||
st = (status or "1").strip()
|
||||
if st not in ("0", "1"):
|
||||
raise ValueError("状态无效")
|
||||
return st
|
||||
|
||||
|
||||
def _load_route_name_map(db: Session, tenant_no: str, route_ids: List[str]) -> Dict[str, str]:
|
||||
ids = [rid for rid in route_ids if rid]
|
||||
if not ids:
|
||||
return {}
|
||||
rows = (
|
||||
db.query(BizProcessRoute)
|
||||
.filter(
|
||||
BizProcessRoute.tenant_no == tenant_no,
|
||||
BizProcessRoute.del_flag == 0,
|
||||
BizProcessRoute.id.in_(ids),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return {r.id: (r.route_name or "") for r in rows}
|
||||
|
||||
|
||||
def _normalize_items_or_raise(raw_items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
for raw in raw_items or []:
|
||||
item = normalize_item_payload(raw)
|
||||
if item:
|
||||
out.append(item)
|
||||
if not out:
|
||||
raise ValueError("请至少添加一条任务名称非空的条目")
|
||||
return out
|
||||
|
||||
|
||||
def _assert_routes_exist(
|
||||
db: Session, tenant_no: str, items: List[Dict[str, Any]]
|
||||
) -> Dict[str, str]:
|
||||
wanted = [i["route_id"] for i in items if i.get("route_id")]
|
||||
name_map = _load_route_name_map(db, tenant_no, wanted)
|
||||
missing = collect_missing_route_ids(items, set(name_map.keys()))
|
||||
if missing:
|
||||
raise ValueError("存在无效或已删除的工艺路线")
|
||||
return name_map
|
||||
|
||||
|
||||
def _replace_items(
|
||||
db: Session,
|
||||
*,
|
||||
set_id: str,
|
||||
tenant_no: str,
|
||||
items: List[Dict[str, Any]],
|
||||
route_names: Dict[str, str],
|
||||
user_id: Optional[str],
|
||||
create_dept_id: str,
|
||||
) -> None:
|
||||
now = datetime.now()
|
||||
existing = (
|
||||
db.query(BizProductionTaskSetItem)
|
||||
.filter(
|
||||
BizProductionTaskSetItem.set_id == set_id,
|
||||
BizProductionTaskSetItem.tenant_no == tenant_no,
|
||||
BizProductionTaskSetItem.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for row in existing:
|
||||
row.del_flag = 1
|
||||
row.update_by = user_id
|
||||
row.update_time = now
|
||||
db.add(row)
|
||||
for idx, item in enumerate(items, start=1):
|
||||
rid = item.get("route_id")
|
||||
db.add(
|
||||
BizProductionTaskSetItem(
|
||||
id=generate_uuid(),
|
||||
set_id=set_id,
|
||||
task_name=item["task_name"],
|
||||
route_id=rid,
|
||||
route_name=route_names.get(rid or "") if rid else None,
|
||||
description=item.get("description"),
|
||||
remark=item.get("remark"),
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _item_count_map(db: Session, tenant_no: str, set_ids: List[str]) -> Dict[str, int]:
|
||||
if not set_ids:
|
||||
return {}
|
||||
rows = (
|
||||
db.query(BizProductionTaskSetItem.set_id, func.count(BizProductionTaskSetItem.id))
|
||||
.filter(
|
||||
BizProductionTaskSetItem.tenant_no == tenant_no,
|
||||
BizProductionTaskSetItem.del_flag == 0,
|
||||
BizProductionTaskSetItem.set_id.in_(set_ids),
|
||||
)
|
||||
.group_by(BizProductionTaskSetItem.set_id)
|
||||
.all()
|
||||
)
|
||||
return {sid: int(cnt) for sid, cnt in rows}
|
||||
|
||||
|
||||
def list_sets(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
q = db.query(BizProductionTaskSet).filter(
|
||||
BizProductionTaskSet.tenant_no == tenant_no,
|
||||
BizProductionTaskSet.del_flag == 0,
|
||||
)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
q = q.filter(BizProductionTaskSet.set_name.like(f"%{kw}%"))
|
||||
st = (status or "").strip()
|
||||
if st in ("0", "1"):
|
||||
q = q.filter(BizProductionTaskSet.status == st)
|
||||
total = q.count()
|
||||
rows = (
|
||||
q.order_by(BizProductionTaskSet.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
counts = _item_count_map(db, tenant_no, [r.id for r in rows])
|
||||
return [_set_to_dict(r, item_count=counts.get(r.id, 0)) for r in rows], int(total)
|
||||
|
||||
|
||||
def list_active_sets(db: Session, *, tenant_no: str) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(BizProductionTaskSet)
|
||||
.filter(
|
||||
BizProductionTaskSet.tenant_no == tenant_no,
|
||||
BizProductionTaskSet.del_flag == 0,
|
||||
BizProductionTaskSet.status == "1",
|
||||
)
|
||||
.order_by(BizProductionTaskSet.create_time.desc())
|
||||
.all()
|
||||
)
|
||||
counts = _item_count_map(db, tenant_no, [r.id for r in rows])
|
||||
return [_set_to_dict(r, item_count=counts.get(r.id, 0)) for r in rows]
|
||||
|
||||
|
||||
def get_set(db: Session, set_id: str, tenant_no: str) -> Optional[Dict[str, Any]]:
|
||||
row = (
|
||||
db.query(BizProductionTaskSet)
|
||||
.filter(
|
||||
BizProductionTaskSet.id == set_id,
|
||||
BizProductionTaskSet.tenant_no == tenant_no,
|
||||
BizProductionTaskSet.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
items = (
|
||||
db.query(BizProductionTaskSetItem)
|
||||
.filter(
|
||||
BizProductionTaskSetItem.set_id == set_id,
|
||||
BizProductionTaskSetItem.tenant_no == tenant_no,
|
||||
BizProductionTaskSetItem.del_flag == 0,
|
||||
)
|
||||
.order_by(
|
||||
BizProductionTaskSetItem.order_num.asc(),
|
||||
BizProductionTaskSetItem.create_time.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return _set_to_dict(row, item_count=len(items), items=[_item_to_dict(i) for i in items])
|
||||
|
||||
|
||||
def is_super_admin_user(user: Optional[Dict[str, Any]]) -> bool:
|
||||
return bool(user) and (user.get("role") or "") == "super_admin"
|
||||
|
||||
|
||||
def assert_set_writable(
|
||||
db: Session,
|
||||
set_id: str,
|
||||
tenant_no: str,
|
||||
user: Optional[Dict[str, Any]] = None,
|
||||
) -> BizProductionTaskSet:
|
||||
"""内置模版仅超级管理员可写。"""
|
||||
row = (
|
||||
db.query(BizProductionTaskSet)
|
||||
.filter(
|
||||
BizProductionTaskSet.id == set_id,
|
||||
BizProductionTaskSet.tenant_no == tenant_no,
|
||||
BizProductionTaskSet.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not row:
|
||||
raise ValueError("集合不存在")
|
||||
if (getattr(row, "is_builtin", None) or "0") == "1" and not is_super_admin_user(user):
|
||||
raise ValueError("内置模版仅超级管理员可修改")
|
||||
return row
|
||||
|
||||
|
||||
def create_set(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
create_dept_id: str = "",
|
||||
set_name: str,
|
||||
status: str = "1",
|
||||
remark: Optional[str] = None,
|
||||
items: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
name = (set_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("集合名称不能为空")
|
||||
if set_name_exists(db, name, tenant_no):
|
||||
raise ValueError("集合名称已存在")
|
||||
normalized = _normalize_items_or_raise(items or [])
|
||||
route_names = _assert_routes_exist(db, tenant_no, normalized)
|
||||
now = datetime.now()
|
||||
obj = BizProductionTaskSet(
|
||||
id=generate_uuid(),
|
||||
set_name=name,
|
||||
status=_require_status(status),
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark=(remark or "").strip() or None,
|
||||
is_builtin="0",
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.flush()
|
||||
_replace_items(
|
||||
db,
|
||||
set_id=obj.id,
|
||||
tenant_no=tenant_no,
|
||||
items=normalized,
|
||||
route_names=route_names,
|
||||
user_id=user_id,
|
||||
create_dept_id=create_dept_id,
|
||||
)
|
||||
db.flush()
|
||||
detail = get_set(db, obj.id, tenant_no)
|
||||
if not detail:
|
||||
raise ValueError("集合创建失败")
|
||||
return detail
|
||||
|
||||
|
||||
def update_set(
|
||||
db: Session,
|
||||
set_id: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
set_name: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
remark: Optional[str] = None,
|
||||
items: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
obj = (
|
||||
db.query(BizProductionTaskSet)
|
||||
.filter(
|
||||
BizProductionTaskSet.id == set_id,
|
||||
BizProductionTaskSet.tenant_no == tenant_no,
|
||||
BizProductionTaskSet.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise ValueError("集合不存在")
|
||||
if set_name is not None:
|
||||
name = set_name.strip()
|
||||
if not name:
|
||||
raise ValueError("集合名称不能为空")
|
||||
if set_name_exists(db, name, tenant_no, exclude_id=set_id):
|
||||
raise ValueError("集合名称已存在")
|
||||
obj.set_name = name
|
||||
if status is not None:
|
||||
obj.status = _require_status(status)
|
||||
if remark is not None:
|
||||
obj.remark = remark.strip() or None
|
||||
if items is not None:
|
||||
normalized = _normalize_items_or_raise(items)
|
||||
route_names = _assert_routes_exist(db, tenant_no, normalized)
|
||||
_replace_items(
|
||||
db,
|
||||
set_id=obj.id,
|
||||
tenant_no=tenant_no,
|
||||
items=normalized,
|
||||
route_names=route_names,
|
||||
user_id=user_id,
|
||||
create_dept_id=obj.create_dept_id or "",
|
||||
)
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.flush()
|
||||
detail = get_set(db, obj.id, tenant_no)
|
||||
if not detail:
|
||||
raise ValueError("集合不存在")
|
||||
return detail
|
||||
|
||||
|
||||
def delete_set(db: Session, set_id: str, *, tenant_no: str, user_id: Optional[str] = None) -> None:
|
||||
obj = (
|
||||
db.query(BizProductionTaskSet)
|
||||
.filter(
|
||||
BizProductionTaskSet.id == set_id,
|
||||
BizProductionTaskSet.tenant_no == tenant_no,
|
||||
BizProductionTaskSet.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise ValueError("集合不存在")
|
||||
now = datetime.now()
|
||||
obj.del_flag = 1
|
||||
obj.update_by = user_id
|
||||
obj.update_time = now
|
||||
db.add(obj)
|
||||
items = (
|
||||
db.query(BizProductionTaskSetItem)
|
||||
.filter(
|
||||
BizProductionTaskSetItem.set_id == set_id,
|
||||
BizProductionTaskSetItem.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for row in items:
|
||||
row.del_flag = 1
|
||||
row.update_by = user_id
|
||||
row.update_time = now
|
||||
db.add(row)
|
||||
db.flush()
|
||||
|
||||
|
||||
def create_set_from_work_order(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
create_dept_id: str = "",
|
||||
work_order_id: str,
|
||||
set_name: str,
|
||||
remark: Optional[str] = None,
|
||||
items: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
wo_id = (work_order_id or "").strip()
|
||||
wo = (
|
||||
db.query(BizProductionWorkOrder)
|
||||
.filter(
|
||||
BizProductionWorkOrder.id == wo_id,
|
||||
BizProductionWorkOrder.tenant_no == tenant_no,
|
||||
BizProductionWorkOrder.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not wo:
|
||||
raise ValueError("工单不存在")
|
||||
if items is not None:
|
||||
raw = items
|
||||
else:
|
||||
tasks = (
|
||||
db.query(BizProductionTask)
|
||||
.filter(
|
||||
BizProductionTask.work_order_id == wo_id,
|
||||
BizProductionTask.tenant_no == tenant_no,
|
||||
BizProductionTask.del_flag == 0,
|
||||
)
|
||||
.order_by(BizProductionTask.create_time.asc(), BizProductionTask.id.asc())
|
||||
.all()
|
||||
)
|
||||
raw = [
|
||||
{
|
||||
"task_name": t.task_name,
|
||||
"route_id": t.route_id,
|
||||
"description": t.description,
|
||||
"remark": t.remark,
|
||||
}
|
||||
for t in tasks
|
||||
]
|
||||
kept, skipped = skip_empty_named_tasks(raw)
|
||||
if not kept:
|
||||
raise ValueError("无可保存的生产任务(任务名称均为空)")
|
||||
lab_storage_code = resolve_work_order_lab_storage_code(
|
||||
db, tenant_no=tenant_no, wo=wo
|
||||
)
|
||||
route_ids = [i["route_id"] for i in kept if i.get("route_id")]
|
||||
name_map = _load_route_name_map(db, tenant_no, route_ids)
|
||||
payload_items = []
|
||||
for i in kept:
|
||||
rid = i.get("route_id")
|
||||
if rid and rid not in name_map:
|
||||
rid = None
|
||||
payload_items.append(
|
||||
{
|
||||
"taskName": revert_task_name_placeholders(
|
||||
i["task_name"] or "",
|
||||
lab_storage_code=lab_storage_code,
|
||||
),
|
||||
"routeId": rid,
|
||||
"description": i.get("description"),
|
||||
"remark": i.get("remark"),
|
||||
}
|
||||
)
|
||||
detail = create_set(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
create_dept_id=create_dept_id,
|
||||
set_name=set_name,
|
||||
status="1",
|
||||
remark=remark,
|
||||
items=payload_items,
|
||||
)
|
||||
return {**detail, "skippedEmptyNameCount": skipped}
|
||||
|
||||
|
||||
def create_tasks_from_set(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
create_dept_id: str = "",
|
||||
work_order_id: str,
|
||||
set_id: str,
|
||||
items: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
from app.crud import production_task as production_task_svc
|
||||
|
||||
wo_id = (work_order_id or "").strip()
|
||||
wo = (
|
||||
db.query(BizProductionWorkOrder)
|
||||
.filter(
|
||||
BizProductionWorkOrder.id == wo_id,
|
||||
BizProductionWorkOrder.tenant_no == tenant_no,
|
||||
BizProductionWorkOrder.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not wo:
|
||||
raise ValueError("工单不存在")
|
||||
status = getattr(wo, "work_order_status", None) or ""
|
||||
if status not in ("dpz", "jxz"):
|
||||
raise ValueError("仅待配置或进行中的工单可派发任务")
|
||||
header = (
|
||||
db.query(BizProductionTaskSet)
|
||||
.filter(
|
||||
BizProductionTaskSet.id == (set_id or "").strip(),
|
||||
BizProductionTaskSet.tenant_no == tenant_no,
|
||||
BizProductionTaskSet.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not header:
|
||||
raise ValueError("任务组合模版不存在")
|
||||
if (header.status or "1") != "1":
|
||||
raise ValueError("任务组合模版已停用")
|
||||
if items is not None:
|
||||
payloads = _normalize_items_or_raise(items)
|
||||
else:
|
||||
rows = (
|
||||
db.query(BizProductionTaskSetItem)
|
||||
.filter(
|
||||
BizProductionTaskSetItem.set_id == header.id,
|
||||
BizProductionTaskSetItem.tenant_no == tenant_no,
|
||||
BizProductionTaskSetItem.del_flag == 0,
|
||||
)
|
||||
.order_by(
|
||||
BizProductionTaskSetItem.order_num.asc(),
|
||||
BizProductionTaskSetItem.create_time.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if not rows:
|
||||
raise ValueError("任务组合模版没有可派发的任务")
|
||||
payloads = [
|
||||
{
|
||||
"route_id": row.route_id,
|
||||
"task_name": row.task_name,
|
||||
"description": row.description,
|
||||
"remark": row.remark,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
route_ids = [p["route_id"] for p in payloads if p.get("route_id")]
|
||||
name_map = _load_route_name_map(db, tenant_no, route_ids)
|
||||
missing = collect_missing_route_ids(payloads, set(name_map.keys()))
|
||||
if missing:
|
||||
raise ValueError("工艺路线已删除,无法派发:" + "、".join(missing))
|
||||
with_steps = _load_route_ids_with_steps(db, route_ids)
|
||||
empty_steps = collect_route_ids_without_steps(payloads, with_steps)
|
||||
if empty_steps:
|
||||
labels = [name_map.get(rid) or rid for rid in empty_steps]
|
||||
raise ValueError("工艺路线无工序步骤,无法派发:" + "、".join(labels))
|
||||
lab_storage_code = resolve_work_order_lab_storage_code(
|
||||
db, tenant_no=tenant_no, wo=wo
|
||||
)
|
||||
created = []
|
||||
for row in payloads:
|
||||
resolved_name = apply_task_name_placeholders(
|
||||
row["task_name"] or "",
|
||||
lab_storage_code=lab_storage_code,
|
||||
)
|
||||
task = production_task_svc.create_production_task(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
create_dept_id=create_dept_id,
|
||||
data={
|
||||
"work_order_id": wo_id,
|
||||
"task_name": resolved_name,
|
||||
"route_id": row.get("route_id"),
|
||||
"description": row.get("description"),
|
||||
"remark": row.get("remark"),
|
||||
},
|
||||
)
|
||||
created.append(production_task_svc._task_to_dict(task))
|
||||
return {"createdCount": len(created), "list": created}
|
||||
|
||||
|
||||
def try_auto_dispatch_builtin_tasks_after_reception(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
create_dept_id: str = "",
|
||||
work_order: BizProductionWorkOrder,
|
||||
lab_storage_code: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""样本接收建工单后按入库编码尝试自动派发;失败不抛错。"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
wo = work_order
|
||||
if not wo or not getattr(wo, "id", None):
|
||||
return None
|
||||
existing = (
|
||||
db.query(BizProductionTask.id)
|
||||
.filter(
|
||||
BizProductionTask.work_order_id == wo.id,
|
||||
BizProductionTask.tenant_no == tenant_no,
|
||||
BizProductionTask.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
logger.info(
|
||||
"skip auto-dispatch: work_order %s already has tasks",
|
||||
wo.id,
|
||||
)
|
||||
return None
|
||||
code = (lab_storage_code or "").strip()
|
||||
if not code:
|
||||
code = resolve_work_order_lab_storage_code(db, tenant_no=tenant_no, wo=wo)
|
||||
from app.utils.lab_storage_code_task_set import (
|
||||
match_builtin_task_set_name_by_lab_storage_code,
|
||||
)
|
||||
|
||||
set_name = match_builtin_task_set_name_by_lab_storage_code(code)
|
||||
if not set_name:
|
||||
logger.info(
|
||||
"skip auto-dispatch: lab_storage_code=%r no builtin set rule",
|
||||
code,
|
||||
)
|
||||
return None
|
||||
header = (
|
||||
db.query(BizProductionTaskSet)
|
||||
.filter(
|
||||
BizProductionTaskSet.tenant_no == tenant_no,
|
||||
BizProductionTaskSet.set_name == set_name,
|
||||
BizProductionTaskSet.is_builtin == "1",
|
||||
BizProductionTaskSet.status == "1",
|
||||
BizProductionTaskSet.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not header:
|
||||
logger.warning(
|
||||
"skip auto-dispatch: builtin set %r not found/enabled for tenant %s",
|
||||
set_name,
|
||||
tenant_no,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
return create_tasks_from_set(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
work_order_id=wo.id,
|
||||
set_id=header.id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"auto-dispatch failed: work_order=%s set=%s lab_storage_code=%r",
|
||||
wo.id,
|
||||
set_name,
|
||||
code,
|
||||
)
|
||||
return None
|
||||
@@ -8,7 +8,8 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db_collation import eq_unicode
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizProductionTaskStep, BizProductionTaskStepAssignee, SysRole, SysUser, SysUserRole
|
||||
from app.models.models import BizProductionTaskStep, BizProductionTaskStepAssignee, SysUser
|
||||
from app.services.web_auth_roles import list_user_ids_with_effective_role_name
|
||||
|
||||
PRODUCTION_STEP_ASSIGNEE_ROLE = "实验专员"
|
||||
|
||||
@@ -35,30 +36,17 @@ def _active_users_in_tenant(
|
||||
def _active_users_with_role_in_tenant(
|
||||
db: Session, user_ids: List[str], tenant_no: str, role_name: str
|
||||
) -> Set[str]:
|
||||
"""校验候选用户是否拥有指定角色(直接绑定 ∪ 职位继承)。"""
|
||||
ids = [x.strip() for x in user_ids if (x or "").strip()]
|
||||
rn = (role_name or "").strip()
|
||||
if not ids or not rn:
|
||||
return set()
|
||||
rows = (
|
||||
db.query(SysUser.id)
|
||||
.join(SysUserRole, SysUserRole.user_id == SysUser.id)
|
||||
.join(SysRole, SysRole.id == SysUserRole.role_id)
|
||||
.filter(
|
||||
SysUser.id.in_(ids),
|
||||
SysUser.tenant_no == tenant_no,
|
||||
SysUser.del_flag == 0,
|
||||
SysUser.status == "1",
|
||||
SysUserRole.del_flag == 0,
|
||||
SysUserRole.tenant_no == tenant_no,
|
||||
SysRole.del_flag == 0,
|
||||
SysRole.status == "1",
|
||||
SysRole.tenant_no == tenant_no,
|
||||
SysRole.role_name == rn,
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
return list_user_ids_with_effective_role_name(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
role_name=rn,
|
||||
candidate_user_ids=ids,
|
||||
)
|
||||
return {r[0] for r in rows}
|
||||
|
||||
|
||||
def validate_assignee_user_ids(
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""生产任务步骤废弃物产出 — 只读汇总列表。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
||||
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.models import (
|
||||
BizOutputType,
|
||||
BizProductionTask,
|
||||
BizProductionTaskStep,
|
||||
BizProductionTaskStepOutput,
|
||||
BizProductionWorkOrder,
|
||||
)
|
||||
|
||||
WASTE_OUTPUT_TYPE_CODE = "FQW"
|
||||
WASTE_OUTPUT_TYPE_NAME = "废弃物"
|
||||
|
||||
|
||||
def pick_waste_output_type_ids(rows: Iterable[Dict[str, Any]]) -> Set[str]:
|
||||
"""从产出类型行中选出废弃物类型 id:优先 type_code=FQW,否则兜底 type_name=废弃物。"""
|
||||
rows = list(rows or [])
|
||||
by_code = {
|
||||
str(r.get("id") or "").strip()
|
||||
for r in rows
|
||||
if str(r.get("type_code") or "").strip() == WASTE_OUTPUT_TYPE_CODE
|
||||
and str(r.get("id") or "").strip()
|
||||
}
|
||||
if by_code:
|
||||
return by_code
|
||||
return {
|
||||
str(r.get("id") or "").strip()
|
||||
for r in rows
|
||||
if str(r.get("type_name") or "").strip() == WASTE_OUTPUT_TYPE_NAME
|
||||
and str(r.get("id") or "").strip()
|
||||
}
|
||||
|
||||
|
||||
def match_waste_output_keyword(
|
||||
keyword: Optional[str], output_name: Optional[str], output_barcode: Optional[str]
|
||||
) -> bool:
|
||||
kw = (keyword or "").strip()
|
||||
if not kw:
|
||||
return True
|
||||
name = output_name or ""
|
||||
barcode = output_barcode or ""
|
||||
return kw in name or kw in barcode
|
||||
|
||||
|
||||
def resolve_waste_output_type_ids(db: Session, tenant_no: str) -> Set[str]:
|
||||
rows = (
|
||||
db.query(BizOutputType.id, BizOutputType.type_code, BizOutputType.type_name)
|
||||
.filter(BizOutputType.tenant_no == tenant_no, BizOutputType.del_flag == 0)
|
||||
.all()
|
||||
)
|
||||
return pick_waste_output_type_ids(
|
||||
[{"id": r.id, "type_code": r.type_code, "type_name": r.type_name} for r in rows]
|
||||
)
|
||||
|
||||
|
||||
def _fmt_dt(t) -> str:
|
||||
if t is None:
|
||||
return ""
|
||||
if hasattr(t, "strftime"):
|
||||
return t.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return str(t)
|
||||
|
||||
|
||||
def _normalize_date_bound(value: str, *, end_of_day: bool = False) -> str:
|
||||
v = (value or "").strip()
|
||||
if not v:
|
||||
return v
|
||||
if len(v) <= 10:
|
||||
return f"{v} 23:59:59" if end_of_day else f"{v} 00:00:00"
|
||||
return v
|
||||
|
||||
|
||||
def list_production_waste_outputs(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
create_time_start: Optional[str] = None,
|
||||
create_time_end: Optional[str] = None,
|
||||
user_info=None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
type_ids = resolve_waste_output_type_ids(db, tenant_no)
|
||||
if not type_ids:
|
||||
return [], 0
|
||||
|
||||
q = (
|
||||
db.query(
|
||||
BizProductionTaskStepOutput,
|
||||
BizProductionTaskStep,
|
||||
BizProductionTask,
|
||||
BizProductionWorkOrder,
|
||||
)
|
||||
.join(
|
||||
BizProductionTaskStep,
|
||||
and_(
|
||||
BizProductionTaskStep.id == BizProductionTaskStepOutput.step_id,
|
||||
BizProductionTaskStep.del_flag == 0,
|
||||
),
|
||||
)
|
||||
.join(
|
||||
BizProductionTask,
|
||||
and_(
|
||||
BizProductionTask.id == BizProductionTaskStep.task_id,
|
||||
BizProductionTask.del_flag == 0,
|
||||
BizProductionTask.tenant_no == tenant_no,
|
||||
),
|
||||
)
|
||||
.outerjoin(
|
||||
BizProductionWorkOrder,
|
||||
and_(
|
||||
BizProductionWorkOrder.id == BizProductionTask.work_order_id,
|
||||
BizProductionWorkOrder.del_flag == 0,
|
||||
),
|
||||
)
|
||||
.filter(
|
||||
BizProductionTaskStepOutput.tenant_no == tenant_no,
|
||||
BizProductionTaskStepOutput.del_flag == 0,
|
||||
BizProductionTaskStepOutput.output_type_id.in_(list(type_ids)),
|
||||
)
|
||||
)
|
||||
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q = q.filter(
|
||||
or_(
|
||||
BizProductionTaskStepOutput.output_name.like(like),
|
||||
BizProductionTaskStepOutput.output_barcode.like(like),
|
||||
)
|
||||
)
|
||||
if create_time_start and create_time_start.strip():
|
||||
q = q.filter(
|
||||
BizProductionTaskStepOutput.create_time
|
||||
>= _normalize_date_bound(create_time_start.strip())
|
||||
)
|
||||
if create_time_end and create_time_end.strip():
|
||||
q = q.filter(
|
||||
BizProductionTaskStepOutput.create_time
|
||||
<= _normalize_date_bound(create_time_end.strip(), end_of_day=True)
|
||||
)
|
||||
|
||||
if user_info is not None:
|
||||
from app.core.data_scope import apply_data_scope
|
||||
|
||||
q = apply_data_scope(q, BizProductionTask, user_info, db, tenant_no)
|
||||
|
||||
q = q.order_by(BizProductionTaskStepOutput.create_time.desc())
|
||||
total = q.count()
|
||||
page = max(1, int(page or 1))
|
||||
page_size = max(1, min(int(page_size or 10), 100))
|
||||
rows = q.offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
for out, step, task, wo in rows:
|
||||
items.append(
|
||||
{
|
||||
"id": out.id,
|
||||
"outputName": out.output_name or "",
|
||||
"outputBarcode": out.output_barcode or "",
|
||||
"remark": out.remark or "",
|
||||
"createTime": _fmt_dt(out.create_time),
|
||||
"stepId": out.step_id or "",
|
||||
"processName": (step.process_name if step else None) or "",
|
||||
"taskId": (task.id if task else None) or "",
|
||||
"taskNo": (task.task_no if task else None) or "",
|
||||
"workOrderId": (wo.id if wo else None) or (task.work_order_id if task else "") or "",
|
||||
"workOrderNo": (wo.work_order_no if wo else None) or "",
|
||||
"sampleCode": (wo.sample_code if wo else None) or "",
|
||||
}
|
||||
)
|
||||
return items, total
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if page_size <= 0:
|
||||
return 0
|
||||
return (total + page_size - 1) // page_size
|
||||
@@ -208,6 +208,11 @@ def _wo_to_dict(wo: BizProductionWorkOrder) -> Dict[str, Any]:
|
||||
}.get(getattr(wo, "biz_type", None) or "", ""),
|
||||
"workOrderStatus": wo.work_order_status,
|
||||
"workOrderStatusLabel": _WORK_ORDER_STATUS_LABELS.get(wo.work_order_status, wo.work_order_status),
|
||||
"qcConclusion": "",
|
||||
"qcConclusionLabel": "",
|
||||
"qcWorkOrderNo": "",
|
||||
"qcWorkOrderStatus": "",
|
||||
"qcWorkOrderStatusLabel": "",
|
||||
"receptionTime": "",
|
||||
"handlerName": "",
|
||||
"sampleTypeName": "",
|
||||
@@ -223,6 +228,69 @@ def _enrich_wo_items(db: Session, items: List[Dict[str, Any]]) -> List[Dict[str,
|
||||
return qc_wo_svc._enrich_work_order_list_items(db, items)
|
||||
|
||||
|
||||
def _enrich_inbound_qc_conclusions(
|
||||
db: Session, tenant_no: str, items: List[Dict[str, Any]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""入库(细胞存储/科研捐赠)生产工单列表补充门禁质检摘要。"""
|
||||
if not items or not (tenant_no or "").strip():
|
||||
return items
|
||||
from collections import defaultdict
|
||||
|
||||
from app.services.protocol_sample_list_summary import (
|
||||
empty_gate_qc_summary_fields,
|
||||
get_list_gate_qc_summary_map_by_reception_ids,
|
||||
get_list_qc_conclusion_map_by_sample_codes,
|
||||
)
|
||||
|
||||
def _apply_gate_qc_fields(item: Dict[str, Any], fields: Dict[str, str]) -> None:
|
||||
merged = empty_gate_qc_summary_fields()
|
||||
merged.update(fields or {})
|
||||
item["qcWorkOrderNo"] = merged.get("qcWorkOrderNo") or ""
|
||||
item["qcWorkOrderStatus"] = merged.get("qcWorkOrderStatus") or ""
|
||||
item["qcWorkOrderStatusLabel"] = merged.get("qcWorkOrderStatusLabel") or ""
|
||||
item["qcConclusion"] = merged.get("qcConclusion") or ""
|
||||
item["qcConclusionLabel"] = merged.get("qcConclusionLabel") or ""
|
||||
|
||||
inbound_items: List[Dict[str, Any]] = []
|
||||
reception_ids: List[str] = []
|
||||
codes_by_source: Dict[str, List[str]] = defaultdict(list)
|
||||
|
||||
for item in items:
|
||||
wo_type = (item.get("workOrderType") or "inbound").strip() or "inbound"
|
||||
source = (item.get("receptionSource") or "").strip()
|
||||
code = (item.get("sampleCode") or "").strip()
|
||||
if wo_type != "inbound" or source not in ("cell_storage", "research_donation"):
|
||||
_apply_gate_qc_fields(item, {})
|
||||
continue
|
||||
inbound_items.append(item)
|
||||
rid = (item.get("receptionId") or "").strip()
|
||||
if rid:
|
||||
reception_ids.append(rid)
|
||||
elif code:
|
||||
codes_by_source[source].append(code)
|
||||
|
||||
summary_by_reception = get_list_gate_qc_summary_map_by_reception_ids(
|
||||
db, tenant_no, reception_ids
|
||||
)
|
||||
|
||||
conclusion_maps: Dict[str, Dict[str, Dict[str, str]]] = {}
|
||||
for source, codes in codes_by_source.items():
|
||||
conclusion_maps[source] = get_list_qc_conclusion_map_by_sample_codes(
|
||||
db, tenant_no, codes, reception_source=source
|
||||
)
|
||||
|
||||
for item in inbound_items:
|
||||
source = (item.get("receptionSource") or "").strip()
|
||||
code = (item.get("sampleCode") or "").strip()
|
||||
rid = (item.get("receptionId") or "").strip()
|
||||
if rid and rid in summary_by_reception:
|
||||
_apply_gate_qc_fields(item, summary_by_reception[rid])
|
||||
continue
|
||||
fields = conclusion_maps.get(source, {}).get(code) or {}
|
||||
_apply_gate_qc_fields(item, fields)
|
||||
return items
|
||||
|
||||
|
||||
_BIZ_TYPE_LABELS = {
|
||||
"pilot_service": "中试服务",
|
||||
"clinical_application": "临床应用",
|
||||
@@ -279,6 +347,8 @@ def _enrich_outbound_items(
|
||||
"projectName": product.project_name or "",
|
||||
"cellTypeName": product.cell_type_name or "",
|
||||
"specText": product.spec_text or "",
|
||||
"packageId": getattr(product, "package_id", None) or "",
|
||||
"packageText": getattr(product, "package_text", None) or "",
|
||||
"agreementPrice": _dec_price(product.agreement_price),
|
||||
"actualPrice": _dec_price(product.actual_price),
|
||||
"quantity": product.quantity,
|
||||
@@ -367,6 +437,8 @@ def _enrich_outbound_items(
|
||||
it["totalCourseCount"] = course.get("totalCourseCount")
|
||||
it["cellType"] = sched.cell_type or ""
|
||||
it["specText"] = sched.spec_text or ""
|
||||
it["packageId"] = getattr(sched, "package_id", None) or ""
|
||||
it["packageText"] = getattr(sched, "package_text", None) or ""
|
||||
it["infusionTime"] = _fmt_dt(sched.infusion_time) if sched.infusion_time else ""
|
||||
it["infusionLocation"] = sched.infusion_location or ""
|
||||
it["products"] = products_map.get(it["infusionScheduleId"], [])
|
||||
@@ -515,6 +587,8 @@ def _attach_outbound_detail(
|
||||
out["infusionSchedule"] = {
|
||||
"cellType": sched.cell_type if sched else "",
|
||||
"specText": sched.spec_text if sched else "",
|
||||
"packageId": getattr(sched, "package_id", None) or "" if sched else "",
|
||||
"packageText": getattr(sched, "package_text", None) or "" if sched else "",
|
||||
"infusionTime": _fmt_dt(sched.infusion_time) if sched and sched.infusion_time else "",
|
||||
"infusionLocation": sched.infusion_location if sched else "",
|
||||
"infusionMethod": sched.infusion_method if sched else "",
|
||||
@@ -525,11 +599,13 @@ def _attach_outbound_detail(
|
||||
def _wo_to_response_dict(db: Session, wo: BizProductionWorkOrder) -> Dict[str, Any]:
|
||||
"""工单响应(含接收记录关联的样本类型、接收人、接收时间及 reception 详情)。"""
|
||||
out = _enrich_wo_items(db, [_wo_to_dict(wo)])[0]
|
||||
tenant_no = (wo.tenant_no or "").strip()
|
||||
if tenant_no:
|
||||
out = _enrich_inbound_qc_conclusions(db, tenant_no, [out])[0]
|
||||
if wo.work_order_type == "outbound" and wo.infusion_schedule_id:
|
||||
_attach_outbound_detail(db, wo, out)
|
||||
else:
|
||||
_attach_reception_detail(db, wo, out, wo.tenant_no or "")
|
||||
tenant_no = (wo.tenant_no or "").strip()
|
||||
if tenant_no:
|
||||
out = enrich_work_order_fee_summaries(db, [out], tenant_no)[0]
|
||||
return out
|
||||
@@ -677,27 +753,30 @@ def list_production_work_orders(
|
||||
kw = (query.keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
keyword_conditions = [
|
||||
BizProductionWorkOrder.work_order_no.like(like),
|
||||
BizProductionWorkOrder.sample_code.like(like),
|
||||
]
|
||||
from app.crud import sample_reception as reception_svc
|
||||
if wo_type == "temporary":
|
||||
q0 = q0.filter(BizProductionWorkOrder.remark.like(like))
|
||||
else:
|
||||
keyword_conditions = [
|
||||
BizProductionWorkOrder.work_order_no.like(like),
|
||||
BizProductionWorkOrder.sample_code.like(like),
|
||||
]
|
||||
from app.crud import sample_reception as reception_svc
|
||||
|
||||
rec_ids = reception_svc.reception_ids_matching_lab_storage_keyword(
|
||||
db, tenant_no, kw
|
||||
)
|
||||
if rec_ids:
|
||||
keyword_conditions.append(BizProductionWorkOrder.reception_id.in_(rec_ids))
|
||||
lab_codes = reception_svc.sample_codes_matching_lab_storage_keyword(
|
||||
db, tenant_no, kw
|
||||
)
|
||||
if lab_codes:
|
||||
keyword_conditions.append(BizProductionWorkOrder.sample_code.in_(lab_codes))
|
||||
if wo_type == "outbound":
|
||||
outbound_ids = _outbound_work_order_ids_by_keyword(db, tenant_no, kw)
|
||||
if outbound_ids:
|
||||
keyword_conditions.append(BizProductionWorkOrder.id.in_(outbound_ids))
|
||||
q0 = q0.filter(or_(*keyword_conditions))
|
||||
rec_ids = reception_svc.reception_ids_matching_lab_storage_keyword(
|
||||
db, tenant_no, kw
|
||||
)
|
||||
if rec_ids:
|
||||
keyword_conditions.append(BizProductionWorkOrder.reception_id.in_(rec_ids))
|
||||
lab_codes = reception_svc.sample_codes_matching_lab_storage_keyword(
|
||||
db, tenant_no, kw
|
||||
)
|
||||
if lab_codes:
|
||||
keyword_conditions.append(BizProductionWorkOrder.sample_code.in_(lab_codes))
|
||||
if wo_type == "outbound":
|
||||
outbound_ids = _outbound_work_order_ids_by_keyword(db, tenant_no, kw)
|
||||
if outbound_ids:
|
||||
keyword_conditions.append(BizProductionWorkOrder.id.in_(outbound_ids))
|
||||
q0 = q0.filter(or_(*keyword_conditions))
|
||||
elif query.work_order_no and query.work_order_no.strip():
|
||||
like = f"%{query.work_order_no.strip()}%"
|
||||
q0 = q0.filter(BizProductionWorkOrder.work_order_no.like(like))
|
||||
@@ -778,6 +857,7 @@ def list_production_work_orders(
|
||||
.all()
|
||||
)
|
||||
items = _enrich_wo_items(db, [_wo_to_dict(x) for x in rows])
|
||||
items = _enrich_inbound_qc_conclusions(db, tenant_no, items)
|
||||
items = _enrich_outbound_items(db, tenant_no, items)
|
||||
items = enrich_work_order_fee_summaries(db, items, tenant_no)
|
||||
return {"items": items, "total": int(total)}
|
||||
@@ -873,12 +953,98 @@ def complete_production_work_order(
|
||||
raise ValueError("工单不存在")
|
||||
if wo.work_order_status == "ywc":
|
||||
raise ValueError("工单已完成")
|
||||
if not data:
|
||||
raise ValueError("须选择生产结论(成功或失败)")
|
||||
concl = (getattr(data, "conclusion", None) or "").strip()
|
||||
if concl not in ("cg", "sb"):
|
||||
raise ValueError("须选择生产结论(成功或失败)")
|
||||
remark_val = None
|
||||
if getattr(data, "remark", None) is not None:
|
||||
remark_val = (data.remark or "").strip() or None
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
incomplete_tasks = (
|
||||
db.query(BizProductionTask)
|
||||
.filter(
|
||||
BizProductionTask.work_order_id == wo.id,
|
||||
BizProductionTask.tenant_no == tenant_no,
|
||||
BizProductionTask.del_flag == 0,
|
||||
BizProductionTask.task_status != "ywc",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for task in incomplete_tasks:
|
||||
production_task_svc._force_complete_task_steps(
|
||||
db, task.id, tenant_no, user_id, now
|
||||
)
|
||||
task.task_status = "ywc"
|
||||
task.conclusion = concl
|
||||
if remark_val is not None:
|
||||
task.remark = remark_val
|
||||
if not task.completed_time:
|
||||
task.completed_time = now
|
||||
task.update_by = user_id
|
||||
task.update_time = now
|
||||
db.add(task)
|
||||
production_task_svc._consume_task_input_cell_products(
|
||||
db, task.id, user_id, now
|
||||
)
|
||||
|
||||
wo.work_order_status = "ywc"
|
||||
wo.update_by = user_id
|
||||
wo.update_time = datetime.now()
|
||||
if data and data.remark is not None:
|
||||
wo.remark = data.remark
|
||||
wo.update_time = now
|
||||
if remark_val is not None:
|
||||
wo.remark = remark_val
|
||||
db.add(wo)
|
||||
|
||||
from app.crud.pilot_outbound_status_sync import sync_schedule_from_production_work_order
|
||||
|
||||
sync_schedule_from_production_work_order(db, wo, user_id=user_id)
|
||||
db.commit()
|
||||
db.refresh(wo)
|
||||
return wo
|
||||
|
||||
|
||||
def void_production_work_order(
|
||||
db: Session,
|
||||
row_id: str,
|
||||
tenant_no: str,
|
||||
user_id: str,
|
||||
) -> BizProductionWorkOrder:
|
||||
wo = get_production_work_order(db, row_id, tenant_no)
|
||||
if not wo:
|
||||
raise ValueError("工单不存在")
|
||||
if wo.work_order_status == "ywc":
|
||||
raise ValueError("已完成的工单不能作废")
|
||||
if wo.work_order_status == "yzf":
|
||||
raise ValueError("工单已作废")
|
||||
if wo.work_order_status not in ("dpz", "jxz"):
|
||||
raise ValueError("仅待配置或进行中的工单可作废")
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
incomplete_tasks = (
|
||||
db.query(BizProductionTask)
|
||||
.filter(
|
||||
BizProductionTask.work_order_id == wo.id,
|
||||
BizProductionTask.tenant_no == tenant_no,
|
||||
BizProductionTask.del_flag == 0,
|
||||
BizProductionTask.task_status != "ywc",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for task in incomplete_tasks:
|
||||
task.task_status = "yzf"
|
||||
task.update_by = user_id
|
||||
task.update_time = now
|
||||
db.add(task)
|
||||
|
||||
wo.work_order_status = "yzf"
|
||||
wo.update_by = user_id
|
||||
wo.update_time = now
|
||||
db.add(wo)
|
||||
|
||||
from app.crud.pilot_outbound_status_sync import sync_schedule_from_production_work_order
|
||||
|
||||
sync_schedule_from_production_work_order(db, wo, user_id=user_id)
|
||||
@@ -892,6 +1058,7 @@ _TASK_STATUS_LABELS = {
|
||||
"dzx": "待执行",
|
||||
"jxz": "执行中",
|
||||
"ywc": "已完成",
|
||||
"yzf": "已作废",
|
||||
}
|
||||
|
||||
|
||||
@@ -1223,6 +1390,8 @@ def create_temp_production_work_order(
|
||||
id=generate_uuid(),
|
||||
work_order_no=_generate_temp_work_order_no(db, tenant_no),
|
||||
work_order_type="temporary",
|
||||
# 临时工单不走样本接收;显式置空,避免列默认 cell_storage 误导完成工单门禁
|
||||
reception_source=None,
|
||||
work_order_status="dpz",
|
||||
tenant_no=tenant_no,
|
||||
remark=remark,
|
||||
@@ -1257,7 +1426,7 @@ def list_temp_production_work_orders(
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q = q.filter(BizProductionWorkOrder.work_order_no.like(like))
|
||||
q = q.filter(BizProductionWorkOrder.remark.like(like))
|
||||
st_raw = (work_order_status or "").strip()
|
||||
if st_raw:
|
||||
statuses = [s.strip() for s in st_raw.split(",") if s.strip()]
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizCollectionProtocol, BizProtocolSample
|
||||
from app.models.models import BizCollectionProtocol, BizPresignOrder, BizProtocolSample
|
||||
from app.models.research_donation import (
|
||||
BizProtocolDonationConvert,
|
||||
BizResearchDonation,
|
||||
@@ -193,10 +193,11 @@ def assert_biz_no_available(
|
||||
biz_no: str,
|
||||
exclude_protocol_id: Optional[str] = None,
|
||||
exclude_donation_id: Optional[str] = None,
|
||||
exclude_presign_order_id: Optional[str] = None,
|
||||
for_donation: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
校验业务编号在采集协议与科研捐赠间可用。
|
||||
校验业务编号在采集协议、科研捐赠与预签约订单间可用。
|
||||
若当前单存在转移配对,则豁免配对单据上的编号占用。
|
||||
"""
|
||||
pn = (biz_no or "").strip()
|
||||
@@ -259,6 +260,22 @@ def assert_biz_no_available(
|
||||
raise ValueError("捐赠协议号已存在")
|
||||
raise ValueError("协议编号已被捐赠协议单使用")
|
||||
|
||||
presign_rows = (
|
||||
db.query(BizPresignOrder)
|
||||
.filter(
|
||||
and_(
|
||||
BizPresignOrder.protocol_no == pn,
|
||||
BizPresignOrder.tenant_no == tenant_no,
|
||||
BizPresignOrder.del_flag == 0,
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for row in presign_rows:
|
||||
if exclude_presign_order_id and row.id == exclude_presign_order_id:
|
||||
continue
|
||||
raise ValueError("协议编号已存在")
|
||||
|
||||
|
||||
def upsert_convert_link_for_to_storage(
|
||||
db: Session,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""采集协议单 — 协议级退费登记 CRUD(不改转账笔 pay_status)"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid, parse_datetime
|
||||
from app.crud.collection_protocol import urls_to_attachment_list
|
||||
from app.models.models import BizProtocolRefundRecord
|
||||
from app.services.protocol_refund_record import validate_refund_amount
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now()
|
||||
|
||||
|
||||
def _record_to_dict(
|
||||
db: Session, r: BizProtocolRefundRecord, tenant_no: str
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"collectionProtocolId": r.collection_protocol_id,
|
||||
"refundAmount": float(r.refund_amount) if r.refund_amount is not None else None,
|
||||
"refundTime": r.refund_time.strftime("%Y-%m-%d %H:%M:%S") if r.refund_time else "",
|
||||
"refundRemark": r.refund_remark or "",
|
||||
"refundAttachments": r.refund_attachments or "",
|
||||
"refundAttachmentFiles": urls_to_attachment_list(
|
||||
r.refund_attachments, db, tenant_no
|
||||
),
|
||||
"workflowInstanceId": r.workflow_instance_id or "",
|
||||
"remark": r.remark or "",
|
||||
"createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
|
||||
"updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_refund_records(
|
||||
db: Session, *, collection_protocol_id: str, tenant_no: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(BizProtocolRefundRecord)
|
||||
.filter(
|
||||
BizProtocolRefundRecord.collection_protocol_id == collection_protocol_id,
|
||||
BizProtocolRefundRecord.tenant_no == tenant_no,
|
||||
BizProtocolRefundRecord.del_flag == 0,
|
||||
)
|
||||
.order_by(BizProtocolRefundRecord.create_time.desc())
|
||||
.all()
|
||||
)
|
||||
return [_record_to_dict(db, r, tenant_no) for r in rows]
|
||||
|
||||
|
||||
def create_refund_record(
|
||||
db: Session,
|
||||
*,
|
||||
collection_protocol_id: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
refund_amount: Any,
|
||||
refund_time: Any = None,
|
||||
refund_remark: Optional[str] = None,
|
||||
refund_attachments: Optional[str] = None,
|
||||
workflow_instance_id: Optional[str] = None,
|
||||
remark: Optional[str] = None,
|
||||
) -> BizProtocolRefundRecord:
|
||||
amt = validate_refund_amount(refund_amount)
|
||||
now = _now()
|
||||
rt = None
|
||||
if refund_time not in (None, ""):
|
||||
rt = (
|
||||
refund_time
|
||||
if isinstance(refund_time, datetime)
|
||||
else parse_datetime(str(refund_time))
|
||||
)
|
||||
obj = BizProtocolRefundRecord(
|
||||
id=generate_uuid(),
|
||||
collection_protocol_id=collection_protocol_id,
|
||||
refund_amount=amt,
|
||||
refund_time=rt or now,
|
||||
refund_remark=(refund_remark or "").strip() or None,
|
||||
refund_attachments=(refund_attachments or "").strip() or None,
|
||||
workflow_instance_id=(workflow_instance_id or "").strip() or None,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_refund_record(
|
||||
db: Session,
|
||||
*,
|
||||
record_id: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
refund_amount: Any = None,
|
||||
refund_time: Any = None,
|
||||
refund_remark: Optional[str] = None,
|
||||
refund_attachments: Optional[str] = None,
|
||||
workflow_instance_id: Optional[str] = None,
|
||||
remark: Optional[str] = None,
|
||||
fields_set: Optional[set] = None,
|
||||
) -> BizProtocolRefundRecord:
|
||||
obj = (
|
||||
db.query(BizProtocolRefundRecord)
|
||||
.filter(
|
||||
BizProtocolRefundRecord.id == record_id,
|
||||
BizProtocolRefundRecord.tenant_no == tenant_no,
|
||||
BizProtocolRefundRecord.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise ValueError("退费登记不存在")
|
||||
fs = fields_set or {
|
||||
"refund_amount",
|
||||
"refund_time",
|
||||
"refund_remark",
|
||||
"refund_attachments",
|
||||
"workflow_instance_id",
|
||||
"remark",
|
||||
}
|
||||
if "refund_amount" in fs and refund_amount is not None:
|
||||
obj.refund_amount = validate_refund_amount(refund_amount)
|
||||
if "refund_time" in fs:
|
||||
if refund_time in (None, ""):
|
||||
obj.refund_time = None
|
||||
elif isinstance(refund_time, datetime):
|
||||
obj.refund_time = refund_time
|
||||
else:
|
||||
obj.refund_time = parse_datetime(str(refund_time))
|
||||
if "refund_remark" in fs:
|
||||
obj.refund_remark = (refund_remark or "").strip() or None
|
||||
if "refund_attachments" in fs:
|
||||
obj.refund_attachments = (refund_attachments or "").strip() or None
|
||||
if "workflow_instance_id" in fs:
|
||||
obj.workflow_instance_id = (workflow_instance_id or "").strip() or None
|
||||
if "remark" in fs:
|
||||
obj.remark = (remark or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = _now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_refund_record(
|
||||
db: Session,
|
||||
*,
|
||||
record_id: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
) -> None:
|
||||
obj = (
|
||||
db.query(BizProtocolRefundRecord)
|
||||
.filter(
|
||||
BizProtocolRefundRecord.id == record_id,
|
||||
BizProtocolRefundRecord.tenant_no == tenant_no,
|
||||
BizProtocolRefundRecord.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise ValueError("退费登记不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_by = user_id
|
||||
obj.update_time = _now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
@@ -126,9 +126,113 @@ def _apply_sample_status_filter(q, sample_status: Optional[str]):
|
||||
BizProtocolSample.sample_status == "",
|
||||
)
|
||||
)
|
||||
if st == "collected":
|
||||
return q.filter(BizProtocolSample.sample_status.in_(["ycj", "jcz", "yrk"]))
|
||||
if st == "voided":
|
||||
return q.filter(BizProtocolSample.sample_status == "yzf")
|
||||
return q.filter(BizProtocolSample.sample_status == st)
|
||||
|
||||
|
||||
def _apply_reception_flag_filter(q, flag: Optional[str], *, sample_code_col, tenant_no: str, source: str):
|
||||
from sqlalchemy import exists
|
||||
|
||||
from app.models.models import BizSampleReception
|
||||
|
||||
f = (flag or "").strip()
|
||||
if not f:
|
||||
return q
|
||||
base = and_(
|
||||
BizSampleReception.tenant_no == tenant_no,
|
||||
BizSampleReception.del_flag == 0,
|
||||
BizSampleReception.reception_source == source,
|
||||
BizSampleReception.sample_code == sample_code_col,
|
||||
)
|
||||
if f == "js":
|
||||
return q.filter(exists().where(and_(base, BizSampleReception.accept_conclusion == "js")))
|
||||
if f == "bj":
|
||||
return q.filter(exists().where(and_(base, BizSampleReception.accept_conclusion == "bj")))
|
||||
if f == "none":
|
||||
return q.filter(
|
||||
~exists().where(and_(base, BizSampleReception.accept_conclusion.in_(["js", "bj"])))
|
||||
)
|
||||
return q
|
||||
|
||||
|
||||
def _apply_qc_flag_filter(q, flag: Optional[str], *, sample_code_col, tenant_no: str, source: str):
|
||||
from sqlalchemy import exists
|
||||
|
||||
from app.models.models import BizQcWorkOrder
|
||||
|
||||
f = (flag or "").strip()
|
||||
if not f:
|
||||
return q
|
||||
base = and_(
|
||||
BizQcWorkOrder.tenant_no == tenant_no,
|
||||
BizQcWorkOrder.del_flag == 0,
|
||||
BizQcWorkOrder.reception_source == source,
|
||||
BizQcWorkOrder.trigger_source == "reception",
|
||||
BizQcWorkOrder.sample_code == sample_code_col,
|
||||
)
|
||||
if f == "complete":
|
||||
return q.filter(exists().where(and_(base, BizQcWorkOrder.conclusion.in_(["tg", "btg"]))))
|
||||
if f == "voided":
|
||||
return q.filter(exists().where(and_(base, BizQcWorkOrder.work_order_status == "yzf")))
|
||||
if f == "incomplete":
|
||||
return q.filter(
|
||||
~exists().where(
|
||||
and_(
|
||||
base,
|
||||
or_(
|
||||
BizQcWorkOrder.conclusion.in_(["tg", "btg"]),
|
||||
BizQcWorkOrder.work_order_status == "yzf",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
return q
|
||||
|
||||
|
||||
def _apply_storage_flag_filter(q, flag: Optional[str], *, sample_code_col, tenant_no: str, source: str):
|
||||
from sqlalchemy import exists
|
||||
|
||||
from app.models.models import BizStorageReleaseReview
|
||||
|
||||
f = (flag or "").strip()
|
||||
if not f:
|
||||
return q
|
||||
base = and_(
|
||||
BizStorageReleaseReview.tenant_no == tenant_no,
|
||||
BizStorageReleaseReview.del_flag == 0,
|
||||
BizStorageReleaseReview.reception_source == source,
|
||||
BizStorageReleaseReview.sample_code == sample_code_col,
|
||||
)
|
||||
if f == "yrk":
|
||||
return q.filter(exists().where(and_(base, BizStorageReleaseReview.storage_status == "yrk")))
|
||||
if f == "rejected":
|
||||
return q.filter(
|
||||
exists().where(
|
||||
and_(
|
||||
base,
|
||||
BizStorageReleaseReview.storage_status != "yrk",
|
||||
BizStorageReleaseReview.review_conclusion == "btg",
|
||||
)
|
||||
)
|
||||
)
|
||||
if f == "none":
|
||||
return q.filter(
|
||||
~exists().where(
|
||||
and_(
|
||||
base,
|
||||
or_(
|
||||
BizStorageReleaseReview.storage_status == "yrk",
|
||||
BizStorageReleaseReview.review_conclusion == "btg",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
return q
|
||||
|
||||
|
||||
def list_protocol_sample_collections(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -137,13 +241,64 @@ def list_protocol_sample_collections(
|
||||
tenant_no: str,
|
||||
keyword: Optional[str] = None,
|
||||
sample_status: Optional[str] = None,
|
||||
collection_flag: Optional[str] = None,
|
||||
reception_flag: Optional[str] = None,
|
||||
qc_flag: Optional[str] = None,
|
||||
storage_flag: Optional[str] = None,
|
||||
storage_cert_handled: Optional[str] = None,
|
||||
insurance_handled: Optional[str] = None,
|
||||
user_info=None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
if not tenant_no:
|
||||
return [], 0
|
||||
|
||||
q = _base_query(db, tenant_no)
|
||||
q = _apply_sample_status_filter(q, sample_status)
|
||||
q = _apply_sample_status_filter(q, collection_flag or sample_status)
|
||||
q = _apply_reception_flag_filter(
|
||||
q, reception_flag, sample_code_col=BizProtocolSample.sample_code, tenant_no=tenant_no, source="cell_storage"
|
||||
)
|
||||
q = _apply_qc_flag_filter(
|
||||
q, qc_flag, sample_code_col=BizProtocolSample.sample_code, tenant_no=tenant_no, source="cell_storage"
|
||||
)
|
||||
q = _apply_storage_flag_filter(
|
||||
q, storage_flag, sample_code_col=BizProtocolSample.sample_code, tenant_no=tenant_no, source="cell_storage"
|
||||
)
|
||||
from app.models.models import BizProtocolFullmoonTodo, BizProtocolStorageCert
|
||||
|
||||
sch = (storage_cert_handled or "").strip()
|
||||
if sch in ("1", "0", "true", "false"):
|
||||
want = sch in ("1", "true")
|
||||
handled_pred = or_(
|
||||
BizProtocolStorageCert.handle_time.isnot(None),
|
||||
BizProtocolStorageCert.make_status == "1",
|
||||
and_(BizProtocolStorageCert.make_date.isnot(None), BizProtocolStorageCert.make_date != ""),
|
||||
)
|
||||
q = q.outerjoin(
|
||||
BizProtocolStorageCert,
|
||||
and_(
|
||||
BizProtocolStorageCert.collection_protocol_id == BizCollectionProtocol.id,
|
||||
BizProtocolStorageCert.tenant_no == tenant_no,
|
||||
BizProtocolStorageCert.del_flag == 0,
|
||||
),
|
||||
)
|
||||
q = q.filter(handled_pred) if want else q.filter(~handled_pred)
|
||||
ih = (insurance_handled or "").strip()
|
||||
if ih in ("1", "0", "true", "false"):
|
||||
want = ih in ("1", "true")
|
||||
handled_pred = or_(
|
||||
BizProtocolFullmoonTodo.handle_time.isnot(None),
|
||||
BizProtocolFullmoonTodo.todo_status == "1",
|
||||
and_(BizProtocolFullmoonTodo.handle_date.isnot(None), BizProtocolFullmoonTodo.handle_date != ""),
|
||||
)
|
||||
q = q.outerjoin(
|
||||
BizProtocolFullmoonTodo,
|
||||
and_(
|
||||
BizProtocolFullmoonTodo.collection_protocol_id == BizCollectionProtocol.id,
|
||||
BizProtocolFullmoonTodo.tenant_no == tenant_no,
|
||||
BizProtocolFullmoonTodo.del_flag == 0,
|
||||
),
|
||||
)
|
||||
q = q.filter(handled_pred) if want else q.filter(~handled_pred)
|
||||
|
||||
if keyword and keyword.strip():
|
||||
kw = f"%{keyword.strip()}%"
|
||||
@@ -244,15 +399,58 @@ def list_protocol_sample_collections(
|
||||
maternal_blood_collect_time=_fmt_dt(ci.maternal_blood_collect_time) if ci else "",
|
||||
)
|
||||
item.update(sample_special_to_dict(psample))
|
||||
# 客户服务宽表:当事人 / 协议附件 / 备注 / 市场专员 id
|
||||
item["motherPhone"] = (party.mother_phone or "") if party else ""
|
||||
item["motherIdCard"] = (party.mother_id_card or "") if party else ""
|
||||
item["fatherName"] = (party.father_name or "") if party else ""
|
||||
item["fatherPhone"] = (party.father_phone or "") if party else ""
|
||||
item["fatherIdCard"] = (party.father_id_card or "") if party else ""
|
||||
item["newbornIdCard"] = (party.newborn_id_card or "") if party else ""
|
||||
item["newbornSex"] = (party.newborn_sex or "") if party else ""
|
||||
item["newbornDeliveryTime"] = _fmt_dt(party.newborn_delivery_time) if party else ""
|
||||
item["protocolRemark"] = (proto.remark or "") if proto else ""
|
||||
item["protocolAttachments"] = (proto.protocol_attachments or "") if proto else ""
|
||||
item["consentFormUrls"] = (proto.consent_form_urls or "") if proto else ""
|
||||
item["healthSurveyFormUrls"] = (proto.health_survey_form_urls or "") if proto else ""
|
||||
item["salesUserId"] = (ci.sales_user_id or "") if ci else ""
|
||||
items.append(item)
|
||||
if items and tenant_no:
|
||||
from app.crud import sample_reception as reception_svc
|
||||
from app.crud.storage_release_review import _get_user_name
|
||||
from app.models.models import BizProtocolSamplePayment
|
||||
|
||||
codes = [i["sampleCode"] for i in items if i.get("sampleCode")]
|
||||
code_map = reception_svc.get_lab_storage_code_map_by_sample_codes(
|
||||
db, tenant_no, codes, reception_source="cell_storage"
|
||||
)
|
||||
pay_mode_map: Dict[str, str] = {}
|
||||
if codes:
|
||||
for sp in (
|
||||
db.query(BizProtocolSamplePayment)
|
||||
.filter(
|
||||
BizProtocolSamplePayment.tenant_no == tenant_no,
|
||||
BizProtocolSamplePayment.del_flag == 0,
|
||||
BizProtocolSamplePayment.sample_code.in_(codes),
|
||||
)
|
||||
.all()
|
||||
):
|
||||
sc = (sp.sample_code or "").strip()
|
||||
if sc and sc not in pay_mode_map:
|
||||
pay_mode_map[sc] = (sp.pay_mode or "").strip()
|
||||
sales_ids = list({(i.get("salesUserId") or "").strip() for i in items if i.get("salesUserId")})
|
||||
sales_name_map = {uid: _get_user_name(db, uid) for uid in sales_ids if uid}
|
||||
pay_mode_label = {"dj": "趸交", "fq": "分期"}
|
||||
for item in items:
|
||||
sc = (item.get("sampleCode") or "").strip()
|
||||
item["labStorageCode"] = code_map.get(sc, "") if sc else ""
|
||||
mode = pay_mode_map.get(sc, "")
|
||||
item["payMode"] = mode
|
||||
item["payModeLabel"] = pay_mode_label.get(mode, mode or "")
|
||||
sid = (item.get("salesUserId") or "").strip()
|
||||
item["marketSpecialistName"] = sales_name_map.get(sid, "") if sid else ""
|
||||
from app.services.sample_info_list_enrich import enrich_sample_info_items
|
||||
|
||||
enrich_sample_info_items(
|
||||
db, tenant_no, items, reception_source="cell_storage", include_handle_flags=True
|
||||
)
|
||||
return items, total
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,81 @@
|
||||
"""入库质检列表:按实验室入库编码排序的辅助逻辑。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import and_, func, literal, select
|
||||
from sqlalchemy.sql import ColumnElement
|
||||
|
||||
from app.core.db_collation import eq_unicode, unicode_ci
|
||||
from app.models.models import BizQcWorkOrder, BizSampleReception
|
||||
|
||||
_INBOUND_LAB_SORT_SOURCES = frozenset({"cell_storage", "research_donation"})
|
||||
|
||||
|
||||
def uses_inbound_lab_storage_list_sort(reception_source: Optional[str]) -> bool:
|
||||
return (reception_source or "").strip() in _INBOUND_LAB_SORT_SOURCES
|
||||
|
||||
|
||||
def compare_lab_storage_then_create_time_desc(
|
||||
code_a: Optional[str],
|
||||
time_a: Optional[str],
|
||||
code_b: Optional[str],
|
||||
time_b: Optional[str],
|
||||
) -> int:
|
||||
"""列表序比较:编码 DESC,同编码时间 DESC。返回负值表示 a 应排在 b 前。"""
|
||||
ca = (code_a or "").strip()
|
||||
cb = (code_b or "").strip()
|
||||
if ca != cb:
|
||||
# DESC: 较大字符串在前
|
||||
return -1 if ca > cb else 1
|
||||
ta = (time_a or "").strip()
|
||||
tb = (time_b or "").strip()
|
||||
if ta != tb:
|
||||
return -1 if ta > tb else 1
|
||||
return 0
|
||||
|
||||
|
||||
def lab_storage_code_expr_for_work_order() -> ColumnElement:
|
||||
"""相关子查询:优先 reception_id,否则同租户 sample_code 最新一条接收记录的编码。
|
||||
|
||||
子查询结果与空串字面量统一 utf8mb4_unicode_ci,避免 ORDER BY COALESCE
|
||||
触发 Illegal mix of collations(unicode_ci vs general_ci)。
|
||||
"""
|
||||
by_id = (
|
||||
select(unicode_ci(BizSampleReception.lab_storage_code))
|
||||
.where(
|
||||
and_(
|
||||
eq_unicode(BizSampleReception.id, BizQcWorkOrder.reception_id),
|
||||
BizSampleReception.del_flag == 0,
|
||||
)
|
||||
)
|
||||
.correlate(BizQcWorkOrder)
|
||||
.scalar_subquery()
|
||||
)
|
||||
by_sample = (
|
||||
select(unicode_ci(BizSampleReception.lab_storage_code))
|
||||
.where(
|
||||
and_(
|
||||
eq_unicode(BizSampleReception.sample_code, BizQcWorkOrder.sample_code),
|
||||
eq_unicode(BizSampleReception.tenant_no, BizQcWorkOrder.tenant_no),
|
||||
BizSampleReception.del_flag == 0,
|
||||
BizQcWorkOrder.sample_code.isnot(None),
|
||||
unicode_ci(BizQcWorkOrder.sample_code) != "",
|
||||
)
|
||||
)
|
||||
.order_by(BizSampleReception.create_time.desc())
|
||||
.limit(1)
|
||||
.correlate(BizQcWorkOrder)
|
||||
.scalar_subquery()
|
||||
)
|
||||
empty = unicode_ci(literal(""))
|
||||
return func.coalesce(by_id, by_sample, empty)
|
||||
|
||||
|
||||
def inbound_lab_storage_order_by_clauses():
|
||||
"""供 Query.order_by(*clauses) 使用。"""
|
||||
code = lab_storage_code_expr_for_work_order()
|
||||
return (
|
||||
code.desc(),
|
||||
BizQcWorkOrder.create_time.desc(),
|
||||
)
|
||||
@@ -189,6 +189,80 @@ def _assignee_names(db: Session, user_ids: List[str]) -> str:
|
||||
return "、".join(name_map.get(uid, uid) for uid in user_ids)
|
||||
|
||||
|
||||
def _prefetch_batch_side_data(
|
||||
db: Session,
|
||||
batches: List[BizQcWorkOrderInspectionBatch],
|
||||
tenant_no: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""一次预取多批次的负责人 / 实验室项 / 第三方项 / 用户名。"""
|
||||
batch_ids = [b.id for b in batches if b and b.id]
|
||||
assignees_by_batch: Dict[str, List[str]] = {bid: [] for bid in batch_ids}
|
||||
lab_by_batch: Dict[str, List[str]] = {bid: [] for bid in batch_ids}
|
||||
third_by_batch: Dict[str, List[str]] = {bid: [] for bid in batch_ids}
|
||||
name_map: Dict[str, str] = {}
|
||||
if not batch_ids:
|
||||
return {
|
||||
"assignees_by_batch": assignees_by_batch,
|
||||
"lab_by_batch": lab_by_batch,
|
||||
"third_by_batch": third_by_batch,
|
||||
"name_map": name_map,
|
||||
}
|
||||
|
||||
for batch_id, user_id in (
|
||||
db.query(BizQcBatchAssignee.batch_id, BizQcBatchAssignee.user_id)
|
||||
.filter(
|
||||
BizQcBatchAssignee.batch_id.in_(batch_ids),
|
||||
BizQcBatchAssignee.tenant_no == tenant_no,
|
||||
BizQcBatchAssignee.del_flag == 0,
|
||||
)
|
||||
.order_by(BizQcBatchAssignee.order_num.asc())
|
||||
.all()
|
||||
):
|
||||
assignees_by_batch.setdefault(batch_id, []).append(user_id)
|
||||
|
||||
for batch_id, lab_id in (
|
||||
db.query(BizQcWorkOrderLabItem.batch_id, BizQcWorkOrderLabItem.lab_test_item_id)
|
||||
.filter(
|
||||
BizQcWorkOrderLabItem.batch_id.in_(batch_ids),
|
||||
BizQcWorkOrderLabItem.del_flag == 0,
|
||||
)
|
||||
.order_by(BizQcWorkOrderLabItem.order_num.asc())
|
||||
.all()
|
||||
):
|
||||
if lab_id:
|
||||
lab_by_batch.setdefault(batch_id, []).append(lab_id)
|
||||
|
||||
for batch_id, third_id in (
|
||||
db.query(BizQcWorkOrderThirdItem.batch_id, BizQcWorkOrderThirdItem.third_party_item_id)
|
||||
.filter(
|
||||
BizQcWorkOrderThirdItem.batch_id.in_(batch_ids),
|
||||
BizQcWorkOrderThirdItem.del_flag == 0,
|
||||
)
|
||||
.order_by(BizQcWorkOrderThirdItem.order_num.asc())
|
||||
.all()
|
||||
):
|
||||
if third_id:
|
||||
third_by_batch.setdefault(batch_id, []).append(third_id)
|
||||
|
||||
all_user_ids = _unique_ids(
|
||||
[uid for ids in assignees_by_batch.values() for uid in ids]
|
||||
)
|
||||
if all_user_ids:
|
||||
for row in (
|
||||
db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
|
||||
.filter(SysUser.id.in_(all_user_ids), SysUser.del_flag == 0)
|
||||
.all()
|
||||
):
|
||||
name_map[row.id] = row.nick_name or row.user_no or row.id
|
||||
|
||||
return {
|
||||
"assignees_by_batch": assignees_by_batch,
|
||||
"lab_by_batch": lab_by_batch,
|
||||
"third_by_batch": third_by_batch,
|
||||
"name_map": name_map,
|
||||
}
|
||||
|
||||
|
||||
def _apply_assignees_to_batch_tasks(
|
||||
db: Session,
|
||||
batch_id: str,
|
||||
@@ -590,9 +664,18 @@ def _batch_to_dict(
|
||||
*,
|
||||
tenant_no: str,
|
||||
tasks: Optional[List[Dict[str, Any]]] = None,
|
||||
side_data: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
assignee_ids = _batch_assignee_user_ids(db, batch.id, tenant_no)
|
||||
lab_ids, third_ids = _batch_item_ids(db, batch.id)
|
||||
if side_data is not None:
|
||||
assignee_ids = list(side_data["assignees_by_batch"].get(batch.id, []))
|
||||
lab_ids = list(side_data["lab_by_batch"].get(batch.id, []))
|
||||
third_ids = list(side_data["third_by_batch"].get(batch.id, []))
|
||||
name_map = side_data.get("name_map") or {}
|
||||
assignee_names = "、".join(name_map.get(uid, uid) for uid in assignee_ids)
|
||||
else:
|
||||
assignee_ids = _batch_assignee_user_ids(db, batch.id, tenant_no)
|
||||
lab_ids, third_ids = _batch_item_ids(db, batch.id)
|
||||
assignee_names = _assignee_names(db, assignee_ids)
|
||||
task_list = tasks or []
|
||||
completed = sum(1 for t in task_list if (t.get("taskStatus") or "") == "ywc")
|
||||
return {
|
||||
@@ -602,7 +685,7 @@ def _batch_to_dict(
|
||||
"batchName": batch.batch_name,
|
||||
"orderNum": batch.order_num or 0,
|
||||
"assigneeUserIds": assignee_ids,
|
||||
"assigneeUserNames": _assignee_names(db, assignee_ids),
|
||||
"assigneeUserNames": assignee_names,
|
||||
"hasLab": bool(lab_ids),
|
||||
"hasThirdParty": bool(third_ids),
|
||||
"labItemIds": lab_ids,
|
||||
@@ -619,6 +702,8 @@ def _build_inspection_batch_list(
|
||||
batches: List[BizQcWorkOrderInspectionBatch],
|
||||
all_tasks: List[Dict[str, Any]],
|
||||
tenant_no: str,
|
||||
*,
|
||||
side_data: Optional[Dict[str, Any]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
tasks_by_batch: Dict[str, List[Dict[str, Any]]] = {}
|
||||
orphan: List[Dict[str, Any]] = []
|
||||
@@ -629,8 +714,17 @@ def _build_inspection_batch_list(
|
||||
tasks_by_batch.setdefault(bid, []).append(t)
|
||||
else:
|
||||
orphan.append(t)
|
||||
resolved_side = side_data
|
||||
if resolved_side is None and batches:
|
||||
resolved_side = _prefetch_batch_side_data(db, batches, tenant_no)
|
||||
out = [
|
||||
_batch_to_dict(db, b, tenant_no=tenant_no, tasks=tasks_by_batch.get(b.id, []))
|
||||
_batch_to_dict(
|
||||
db,
|
||||
b,
|
||||
tenant_no=tenant_no,
|
||||
tasks=tasks_by_batch.get(b.id, []),
|
||||
side_data=resolved_side,
|
||||
)
|
||||
for b in batches
|
||||
]
|
||||
if orphan and out:
|
||||
@@ -700,6 +794,7 @@ def list_inspection_batches_by_work_order_ids(
|
||||
.all()
|
||||
)
|
||||
tasks_by_wo = task_svc.list_tasks_by_work_order_ids(db, ids, tenant_no)
|
||||
side_data = _prefetch_batch_side_data(db, batches, tenant_no)
|
||||
batches_by_wo: Dict[str, List[BizQcWorkOrderInspectionBatch]] = {}
|
||||
for batch in batches:
|
||||
batches_by_wo.setdefault(batch.work_order_id, []).append(batch)
|
||||
@@ -711,6 +806,7 @@ def list_inspection_batches_by_work_order_ids(
|
||||
batches_by_wo.get(wo_id, []),
|
||||
tasks_by_wo.get(wo_id, []),
|
||||
tenant_no,
|
||||
side_data=side_data,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
"""质检检测项目模版 CRUD"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import (
|
||||
BizQcInspectionItemTemplate,
|
||||
BizQcLabTestItem,
|
||||
BizQcThirdPartyItem,
|
||||
)
|
||||
|
||||
|
||||
def parse_id_csv(raw: Optional[str]) -> List[str]:
|
||||
if not raw:
|
||||
return []
|
||||
out: List[str] = []
|
||||
seen: Set[str] = set()
|
||||
for part in str(raw).split(","):
|
||||
sid = part.strip()
|
||||
if not sid or sid in seen:
|
||||
continue
|
||||
seen.add(sid)
|
||||
out.append(sid)
|
||||
return out
|
||||
|
||||
|
||||
def join_id_list(ids: List[str]) -> str:
|
||||
cleaned = parse_id_csv(",".join(ids or []))
|
||||
return ",".join(cleaned)
|
||||
|
||||
|
||||
def filter_active_ids(ids: List[str], active_set: Set[str]) -> Tuple[List[str], int]:
|
||||
"""返回 (保留且去重保序的 id 列表, 跳过次数含重复无效)。"""
|
||||
kept: List[str] = []
|
||||
seen: Set[str] = set()
|
||||
skipped = 0
|
||||
for raw in ids or []:
|
||||
sid = (raw or "").strip()
|
||||
if not sid:
|
||||
continue
|
||||
if sid not in active_set:
|
||||
skipped += 1
|
||||
continue
|
||||
if sid in seen:
|
||||
continue
|
||||
seen.add(sid)
|
||||
kept.append(sid)
|
||||
return kept, skipped
|
||||
|
||||
|
||||
def require_nonempty_items(lab_ids: List[str], third_ids: List[str]) -> None:
|
||||
if not lab_ids and not third_ids:
|
||||
raise ValueError("请至少选择一个检测项目")
|
||||
|
||||
|
||||
def _row_to_dict(r: BizQcInspectionItemTemplate) -> Dict[str, Any]:
|
||||
lab_ids = parse_id_csv(r.lab_item_ids)
|
||||
third_ids = parse_id_csv(r.third_party_item_ids)
|
||||
return {
|
||||
"id": r.id,
|
||||
"templateName": r.template_name,
|
||||
"labItemIds": lab_ids,
|
||||
"thirdPartyItemIds": third_ids,
|
||||
"labItemCount": len(lab_ids),
|
||||
"thirdPartyItemCount": len(third_ids),
|
||||
"status": r.status or "1",
|
||||
"orderNum": r.order_num if r.order_num is not None else 0,
|
||||
"remark": r.remark or "",
|
||||
"createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
|
||||
"updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def template_name_exists(
|
||||
db: Session, name: str, tenant_no: str, exclude_id: Optional[str] = None
|
||||
) -> bool:
|
||||
n = (name or "").strip()
|
||||
if not n:
|
||||
return False
|
||||
q = db.query(BizQcInspectionItemTemplate).filter(
|
||||
BizQcInspectionItemTemplate.template_name == n,
|
||||
BizQcInspectionItemTemplate.tenant_no == tenant_no,
|
||||
BizQcInspectionItemTemplate.del_flag == 0,
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.filter(BizQcInspectionItemTemplate.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def _assert_item_ids_exist(
|
||||
db: Session, tenant_no: str, lab_ids: List[str], third_ids: List[str]
|
||||
) -> None:
|
||||
if lab_ids:
|
||||
found = {
|
||||
r.id
|
||||
for r in db.query(BizQcLabTestItem.id)
|
||||
.filter(
|
||||
BizQcLabTestItem.tenant_no == tenant_no,
|
||||
BizQcLabTestItem.del_flag == 0,
|
||||
BizQcLabTestItem.id.in_(lab_ids),
|
||||
)
|
||||
.all()
|
||||
}
|
||||
if found != set(lab_ids):
|
||||
raise ValueError("存在无效的检测项目")
|
||||
if third_ids:
|
||||
found = {
|
||||
r.id
|
||||
for r in db.query(BizQcThirdPartyItem.id)
|
||||
.filter(
|
||||
BizQcThirdPartyItem.tenant_no == tenant_no,
|
||||
BizQcThirdPartyItem.del_flag == 0,
|
||||
BizQcThirdPartyItem.id.in_(third_ids),
|
||||
)
|
||||
.all()
|
||||
}
|
||||
if found != set(third_ids):
|
||||
raise ValueError("存在无效的检测项目")
|
||||
|
||||
|
||||
def list_templates(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
user_info=None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizQcInspectionItemTemplate).filter(
|
||||
BizQcInspectionItemTemplate.del_flag == 0
|
||||
)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizQcInspectionItemTemplate.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
q0 = q0.filter(BizQcInspectionItemTemplate.template_name.like(f"%{kw}%"))
|
||||
st = (status or "").strip()
|
||||
if st in ("0", "1"):
|
||||
q0 = q0.filter(BizQcInspectionItemTemplate.status == st)
|
||||
if user_info:
|
||||
from app.core.data_scope import apply_data_scope
|
||||
|
||||
q0 = apply_data_scope(q0, BizQcInspectionItemTemplate, user_info, db, tenant_no)
|
||||
return q0
|
||||
|
||||
total = (
|
||||
_filtered()
|
||||
.order_by(None)
|
||||
.with_entities(func.count(BizQcInspectionItemTemplate.id))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizQcInspectionItemTemplate.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def list_active_templates(
|
||||
db: Session, tenant_no: str, *, user_info=None
|
||||
) -> List[Dict[str, Any]]:
|
||||
q = db.query(BizQcInspectionItemTemplate).filter(
|
||||
BizQcInspectionItemTemplate.tenant_no == tenant_no,
|
||||
BizQcInspectionItemTemplate.del_flag == 0,
|
||||
BizQcInspectionItemTemplate.status == "1",
|
||||
)
|
||||
if user_info:
|
||||
from app.core.data_scope import apply_data_scope
|
||||
|
||||
q = apply_data_scope(q, BizQcInspectionItemTemplate, user_info, db, tenant_no)
|
||||
rows = q.order_by(BizQcInspectionItemTemplate.create_time.desc()).all()
|
||||
return [_row_to_dict(x) for x in rows]
|
||||
|
||||
|
||||
def get_template(
|
||||
db: Session, row_id: str, tenant_no: Optional[str] = None
|
||||
) -> Optional[BizQcInspectionItemTemplate]:
|
||||
q = db.query(BizQcInspectionItemTemplate).filter(
|
||||
BizQcInspectionItemTemplate.id == row_id,
|
||||
BizQcInspectionItemTemplate.del_flag == 0,
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(BizQcInspectionItemTemplate.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def create_template(
|
||||
db: Session,
|
||||
*,
|
||||
template_name: str,
|
||||
lab_item_ids: Optional[List[str]] = None,
|
||||
third_party_item_ids: Optional[List[str]] = None,
|
||||
status: str = "1",
|
||||
tenant_no: str,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
create_dept_id: str = "",
|
||||
) -> BizQcInspectionItemTemplate:
|
||||
name = (template_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("模版名称不能为空")
|
||||
if template_name_exists(db, name, tenant_no):
|
||||
raise ValueError("模版名称已存在")
|
||||
lab_ids = parse_id_csv(",".join(lab_item_ids or []))
|
||||
third_ids = parse_id_csv(",".join(third_party_item_ids or []))
|
||||
require_nonempty_items(lab_ids, third_ids)
|
||||
_assert_item_ids_exist(db, tenant_no, lab_ids, third_ids)
|
||||
st = (status or "1").strip()
|
||||
if st not in ("0", "1"):
|
||||
raise ValueError("状态无效")
|
||||
obj = BizQcInspectionItemTemplate(
|
||||
id=generate_uuid(),
|
||||
template_name=name,
|
||||
lab_item_ids=join_id_list(lab_ids),
|
||||
third_party_item_ids=join_id_list(third_ids),
|
||||
status=st,
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_template(
|
||||
db: Session,
|
||||
row_id: str,
|
||||
*,
|
||||
template_name: Optional[str] = None,
|
||||
lab_item_ids: Optional[List[str]] = None,
|
||||
third_party_item_ids: Optional[List[str]] = None,
|
||||
status: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> BizQcInspectionItemTemplate:
|
||||
obj = get_template(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("模版不存在")
|
||||
if template_name is not None:
|
||||
name = (template_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("模版名称不能为空")
|
||||
if name != obj.template_name and template_name_exists(
|
||||
db, name, obj.tenant_no, exclude_id=row_id
|
||||
):
|
||||
raise ValueError("模版名称已存在")
|
||||
obj.template_name = name
|
||||
lab_ids = (
|
||||
parse_id_csv(",".join(lab_item_ids))
|
||||
if lab_item_ids is not None
|
||||
else parse_id_csv(obj.lab_item_ids)
|
||||
)
|
||||
third_ids = (
|
||||
parse_id_csv(",".join(third_party_item_ids))
|
||||
if third_party_item_ids is not None
|
||||
else parse_id_csv(obj.third_party_item_ids)
|
||||
)
|
||||
if lab_item_ids is not None or third_party_item_ids is not None:
|
||||
require_nonempty_items(lab_ids, third_ids)
|
||||
_assert_item_ids_exist(db, obj.tenant_no, lab_ids, third_ids)
|
||||
obj.lab_item_ids = join_id_list(lab_ids)
|
||||
obj.third_party_item_ids = join_id_list(third_ids)
|
||||
if status is not None:
|
||||
st = status.strip()
|
||||
if st not in ("0", "1"):
|
||||
raise ValueError("状态无效")
|
||||
obj.status = st
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = (remark or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_template(
|
||||
db: Session, row_id: str, tenant_no: Optional[str] = None
|
||||
) -> None:
|
||||
obj = get_template(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("模版不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
@@ -145,6 +145,23 @@ def get_material(
|
||||
return q.first()
|
||||
|
||||
|
||||
def get_material_by_code(
|
||||
db: Session, material_code: str, *, tenant_no: str
|
||||
) -> Optional[BizQcMaterial]:
|
||||
code = (material_code or "").strip()
|
||||
if not code:
|
||||
return None
|
||||
return (
|
||||
db.query(BizQcMaterial)
|
||||
.filter(
|
||||
BizQcMaterial.tenant_no == tenant_no,
|
||||
BizQcMaterial.material_code == code,
|
||||
BizQcMaterial.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def create_material(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -160,6 +177,7 @@ def create_material(
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
create_dept_id: str = "",
|
||||
commit: bool = True,
|
||||
) -> BizQcMaterial:
|
||||
code = (material_code or "").strip()
|
||||
if not code:
|
||||
@@ -190,8 +208,11 @@ def create_material(
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
else:
|
||||
db.flush()
|
||||
return obj
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""质检物料入库批次:按剩余数量过滤的语义辅助。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def normalize_batch_stock_status(stock_status: Optional[str]) -> Optional[str]:
|
||||
"""返回 'available' | 'exhausted';全部 / 空 / 非法 → None(不加剩余数量条件)。"""
|
||||
s = (stock_status or "").strip().lower()
|
||||
if s in ("available", "exhausted"):
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def batch_matches_stock_status(remaining_qty: Any, stock_status: Optional[str]) -> bool:
|
||||
"""纯函数判定(供单测);list_batches 用 SQL 等价条件。"""
|
||||
mode = normalize_batch_stock_status(stock_status)
|
||||
if mode is None:
|
||||
return True
|
||||
qty = Decimal(str(remaining_qty if remaining_qty is not None else 0))
|
||||
if mode == "available":
|
||||
return qty > 0
|
||||
return qty == 0
|
||||
@@ -0,0 +1,379 @@
|
||||
"""质检物料月初入库模板 CRUD / preview / confirm。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud import qc_material as mat_svc
|
||||
from app.crud import qc_material_stock_batch as batch_svc
|
||||
from app.crud.qc_material import _is_enabled_status
|
||||
from app.crud.qc_material_stock_batch import _fmt_date, _parse_date, _require_str
|
||||
from app.crud.qc_material_stock_in_no import allocate_stock_in_nos
|
||||
from app.models.qc_material import BizQcMaterialMonthTemplate
|
||||
|
||||
|
||||
def _optional_str(v: Any) -> str:
|
||||
return str(v or "").strip()
|
||||
|
||||
|
||||
def _optional_decimal(v: Any, *, default: Decimal = Decimal("0")) -> Decimal:
|
||||
if v is None or str(v).strip() == "":
|
||||
return default
|
||||
try:
|
||||
d = Decimal(str(v))
|
||||
except Exception as e:
|
||||
raise ValueError("数值格式无效") from e
|
||||
if d < 0:
|
||||
raise ValueError("数值不能为负数")
|
||||
return d
|
||||
|
||||
|
||||
def _optional_expire_date(v: Any) -> Optional[date]:
|
||||
s = str(v or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
return _parse_date(v, label="过期日期")
|
||||
|
||||
|
||||
def _require_template_fields(payload: dict) -> dict:
|
||||
"""规范化模板写入字段;模板行字段均可空,确认入库时再校验必填。"""
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("模板数据无效")
|
||||
|
||||
material_id = (payload.get("material_id") or payload.get("materialId") or "").strip() or None
|
||||
material_code = _optional_str(payload.get("material_code") or payload.get("materialCode"))
|
||||
material_name = _optional_str(payload.get("material_name") or payload.get("materialName"))
|
||||
catalog_no = _optional_str(payload.get("catalog_no") or payload.get("catalogNo"))
|
||||
batch_no = _optional_str(payload.get("batch_no") or payload.get("batchNo"))
|
||||
manufacturer = _optional_str(payload.get("manufacturer"))
|
||||
unit_price = _optional_decimal(payload.get("unit_price") or payload.get("unitPrice"))
|
||||
amount = _optional_decimal(payload.get("amount"))
|
||||
in_qty_raw = payload.get("in_qty") if payload.get("in_qty") is not None else payload.get("inQty")
|
||||
in_qty = _optional_decimal(in_qty_raw)
|
||||
expire_date = _optional_expire_date(payload.get("expire_date") or payload.get("expireDate"))
|
||||
|
||||
spec_model = _optional_str(payload.get("spec_model") or payload.get("specModel")) or None
|
||||
unit = _optional_str(payload.get("unit")) or None
|
||||
remark = _optional_str(payload.get("remark")) or None
|
||||
order_num = payload.get("order_num")
|
||||
if order_num is None:
|
||||
order_num = payload.get("orderNum")
|
||||
if order_num is None:
|
||||
order_num = 0
|
||||
try:
|
||||
order_num = int(order_num)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ValueError("排序号格式无效") from e
|
||||
|
||||
return {
|
||||
"material_id": material_id,
|
||||
"material_code": material_code,
|
||||
"material_name": material_name,
|
||||
"spec_model": spec_model,
|
||||
"unit": unit,
|
||||
"catalog_no": catalog_no,
|
||||
"batch_no": batch_no,
|
||||
"manufacturer": manufacturer,
|
||||
"unit_price": unit_price,
|
||||
"amount": amount,
|
||||
"in_qty": in_qty,
|
||||
"expire_date": expire_date,
|
||||
"order_num": order_num,
|
||||
"remark": remark,
|
||||
}
|
||||
|
||||
|
||||
def _row_to_dict(row: BizQcMaterialMonthTemplate) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"materialId": row.material_id or "",
|
||||
"materialCode": row.material_code or "",
|
||||
"materialName": row.material_name or "",
|
||||
"specModel": row.spec_model or "",
|
||||
"unit": row.unit or "",
|
||||
"catalogNo": row.catalog_no or "",
|
||||
"batchNo": row.batch_no or "",
|
||||
"manufacturer": row.manufacturer or "",
|
||||
"unitPrice": float(row.unit_price) if row.unit_price is not None else 0,
|
||||
"amount": float(row.amount) if row.amount is not None else 0,
|
||||
"inQty": float(row.in_qty) if row.in_qty is not None else 0,
|
||||
"expireDate": _fmt_date(row.expire_date),
|
||||
"orderNum": row.order_num if row.order_num is not None else 0,
|
||||
"remark": row.remark or "",
|
||||
"createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
|
||||
"updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_templates(db: Session, *, tenant_no: str) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(BizQcMaterialMonthTemplate)
|
||||
.filter(
|
||||
BizQcMaterialMonthTemplate.tenant_no == tenant_no,
|
||||
BizQcMaterialMonthTemplate.del_flag == 0,
|
||||
)
|
||||
.order_by(
|
||||
BizQcMaterialMonthTemplate.order_num.asc(),
|
||||
BizQcMaterialMonthTemplate.create_time.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def create_template(
|
||||
db: Session,
|
||||
*,
|
||||
data: dict,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
create_dept_id: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
fields = _require_template_fields(data)
|
||||
if fields["material_id"]:
|
||||
mat = mat_svc.get_material(db, fields["material_id"], tenant_no=tenant_no)
|
||||
if not mat:
|
||||
raise ValueError("关联物资不存在")
|
||||
now = datetime.now()
|
||||
row = BizQcMaterialMonthTemplate(
|
||||
id=generate_uuid(),
|
||||
material_id=fields["material_id"],
|
||||
material_code=fields["material_code"],
|
||||
material_name=fields["material_name"],
|
||||
spec_model=fields["spec_model"],
|
||||
unit=fields["unit"],
|
||||
catalog_no=fields["catalog_no"],
|
||||
batch_no=fields["batch_no"],
|
||||
manufacturer=fields["manufacturer"],
|
||||
unit_price=fields["unit_price"],
|
||||
amount=fields["amount"],
|
||||
in_qty=fields["in_qty"],
|
||||
expire_date=fields["expire_date"],
|
||||
tenant_no=tenant_no,
|
||||
order_num=fields["order_num"],
|
||||
remark=fields["remark"],
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def update_template(
|
||||
db: Session,
|
||||
row_id: str,
|
||||
*,
|
||||
data: dict,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
row = (
|
||||
db.query(BizQcMaterialMonthTemplate)
|
||||
.filter(
|
||||
BizQcMaterialMonthTemplate.id == row_id,
|
||||
BizQcMaterialMonthTemplate.tenant_no == tenant_no,
|
||||
BizQcMaterialMonthTemplate.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not row:
|
||||
raise ValueError("模板行不存在")
|
||||
fields = _require_template_fields(data)
|
||||
if fields["material_id"]:
|
||||
mat = mat_svc.get_material(db, fields["material_id"], tenant_no=tenant_no)
|
||||
if not mat:
|
||||
raise ValueError("关联物资不存在")
|
||||
row.material_id = fields["material_id"]
|
||||
row.material_code = fields["material_code"]
|
||||
row.material_name = fields["material_name"]
|
||||
row.spec_model = fields["spec_model"]
|
||||
row.unit = fields["unit"]
|
||||
row.catalog_no = fields["catalog_no"]
|
||||
row.batch_no = fields["batch_no"]
|
||||
row.manufacturer = fields["manufacturer"]
|
||||
row.unit_price = fields["unit_price"]
|
||||
row.amount = fields["amount"]
|
||||
row.in_qty = fields["in_qty"]
|
||||
row.expire_date = fields["expire_date"]
|
||||
row.order_num = fields["order_num"]
|
||||
row.remark = fields["remark"]
|
||||
row.update_by = user_id
|
||||
row.update_time = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def soft_delete_template(db: Session, row_id: str, *, tenant_no: str) -> None:
|
||||
row = (
|
||||
db.query(BizQcMaterialMonthTemplate)
|
||||
.filter(
|
||||
BizQcMaterialMonthTemplate.id == row_id,
|
||||
BizQcMaterialMonthTemplate.tenant_no == tenant_no,
|
||||
BizQcMaterialMonthTemplate.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not row:
|
||||
raise ValueError("模板行不存在")
|
||||
row.del_flag = 1
|
||||
row.update_time = datetime.now()
|
||||
db.commit()
|
||||
|
||||
|
||||
def preview_templates(
|
||||
db: Session, *, tenant_no: str, stock_in_date: date
|
||||
) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(BizQcMaterialMonthTemplate)
|
||||
.filter(
|
||||
BizQcMaterialMonthTemplate.tenant_no == tenant_no,
|
||||
BizQcMaterialMonthTemplate.del_flag == 0,
|
||||
)
|
||||
.order_by(
|
||||
BizQcMaterialMonthTemplate.order_num.asc(),
|
||||
BizQcMaterialMonthTemplate.create_time.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if not rows:
|
||||
raise ValueError("请先维护月初模板")
|
||||
nos = allocate_stock_in_nos(
|
||||
db, tenant_no=tenant_no, stock_in_date=stock_in_date, count=len(rows)
|
||||
)
|
||||
out: List[Dict[str, Any]] = []
|
||||
for i, row in enumerate(rows):
|
||||
out.append(
|
||||
{
|
||||
"templateId": row.id,
|
||||
"materialId": row.material_id or "",
|
||||
"materialCode": row.material_code or "",
|
||||
"materialName": row.material_name or "",
|
||||
"specModel": row.spec_model or "",
|
||||
"unit": row.unit or "",
|
||||
"stockInNo": nos[i],
|
||||
"stockInDate": _fmt_date(stock_in_date),
|
||||
"expireDate": _fmt_date(row.expire_date),
|
||||
"catalogNo": row.catalog_no or "",
|
||||
"batchNo": row.batch_no or "",
|
||||
"manufacturer": row.manufacturer or "",
|
||||
"unitPrice": float(row.unit_price) if row.unit_price is not None else 0,
|
||||
"amount": float(row.amount) if row.amount is not None else 0,
|
||||
"qty": float(row.in_qty) if row.in_qty is not None else 0,
|
||||
"remark": row.remark or "",
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def ensure_material_id(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
material_id: Optional[str],
|
||||
material_code: str,
|
||||
material_name: str,
|
||||
spec_model: Optional[str],
|
||||
unit: Optional[str],
|
||||
) -> str:
|
||||
mid = (material_id or "").strip()
|
||||
if mid:
|
||||
mat = mat_svc.get_material(db, mid, tenant_no=tenant_no)
|
||||
if not mat:
|
||||
raise ValueError("关联物资不存在")
|
||||
if not _is_enabled_status(mat.status):
|
||||
raise ValueError(f"物资「{mat.material_name or mid}」已停用")
|
||||
return mat.id
|
||||
|
||||
code = _require_str(material_code, "物料编码")
|
||||
name = _require_str(material_name, "物料名称")
|
||||
existing = mat_svc.get_material_by_code(db, code, tenant_no=tenant_no)
|
||||
if existing:
|
||||
if not _is_enabled_status(existing.status):
|
||||
raise ValueError(f"物资「{existing.material_name or code}」已停用")
|
||||
return existing.id
|
||||
|
||||
created = mat_svc.create_material(
|
||||
db,
|
||||
material_code=code,
|
||||
material_name=name,
|
||||
spec_model=spec_model,
|
||||
unit=unit,
|
||||
stock_qty=Decimal("0"),
|
||||
status="1",
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
commit=False,
|
||||
)
|
||||
return created.id
|
||||
|
||||
|
||||
def confirm_month_import(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
stock_in_date: date,
|
||||
items: list,
|
||||
remark: Optional[str] = None,
|
||||
operate_time: Optional[datetime] = None,
|
||||
) -> int:
|
||||
if not items:
|
||||
raise ValueError("请至少添加一条入库明细")
|
||||
|
||||
stock_in_items: List[Dict[str, Any]] = []
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
raise ValueError("入库明细格式无效")
|
||||
mid = ensure_material_id(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
material_id=it.get("material_id") or it.get("materialId"),
|
||||
material_code=it.get("material_code") or it.get("materialCode") or "",
|
||||
material_name=it.get("material_name") or it.get("materialName") or "",
|
||||
spec_model=it.get("spec_model") or it.get("specModel"),
|
||||
unit=it.get("unit"),
|
||||
)
|
||||
stock_in_items.append(
|
||||
{
|
||||
"material_id": mid,
|
||||
"materialId": mid,
|
||||
"stock_in_no": it.get("stock_in_no") or it.get("stockInNo"),
|
||||
"stockInNo": it.get("stock_in_no") or it.get("stockInNo"),
|
||||
"stock_in_date": _fmt_date(stock_in_date),
|
||||
"stockInDate": _fmt_date(stock_in_date),
|
||||
"expire_date": it.get("expire_date") or it.get("expireDate"),
|
||||
"expireDate": it.get("expire_date") or it.get("expireDate"),
|
||||
"catalog_no": it.get("catalog_no") or it.get("catalogNo"),
|
||||
"catalogNo": it.get("catalog_no") or it.get("catalogNo"),
|
||||
"batch_no": it.get("batch_no") or it.get("batchNo"),
|
||||
"batchNo": it.get("batch_no") or it.get("batchNo"),
|
||||
"manufacturer": it.get("manufacturer"),
|
||||
"unit_price": it.get("unit_price") if it.get("unit_price") is not None else it.get("unitPrice"),
|
||||
"unitPrice": it.get("unit_price") if it.get("unit_price") is not None else it.get("unitPrice"),
|
||||
"amount": it.get("amount"),
|
||||
"qty": it.get("qty"),
|
||||
"remark": it.get("remark"),
|
||||
}
|
||||
)
|
||||
|
||||
return batch_svc.batch_stock_in(
|
||||
db,
|
||||
items=stock_in_items,
|
||||
remark=remark,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
operate_time=operate_time,
|
||||
)
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.qc_material import _dec, _fmt_dt, _is_enabled_status, _norm_status
|
||||
from app.crud.qc_material_batch_stock_status import normalize_batch_stock_status
|
||||
from app.models.qc_material import BizQcMaterial, BizQcMaterialStockBatch, BizQcMaterialUsage
|
||||
|
||||
|
||||
@@ -150,6 +151,7 @@ def list_batches(
|
||||
keyword: Optional[str] = None,
|
||||
material_id: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
stock_status: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
user_info=None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
@@ -184,6 +186,11 @@ def list_batches(
|
||||
st = (status or "").strip()
|
||||
if st in ("0", "1"):
|
||||
q0 = q0.filter(BizQcMaterial.status == st)
|
||||
mode = normalize_batch_stock_status(stock_status)
|
||||
if mode == "available":
|
||||
q0 = q0.filter(BizQcMaterialStockBatch.remaining_qty > 0)
|
||||
elif mode == "exhausted":
|
||||
q0 = q0.filter(BizQcMaterialStockBatch.remaining_qty == 0)
|
||||
if user_info:
|
||||
from app.core.data_scope import apply_data_scope
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""质检物料入库编号生成:RK + YYYYMMDD + 3位序号。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date
|
||||
from typing import Iterable, List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.qc_material import BizQcMaterialStockBatch
|
||||
|
||||
_SUFFIX_RE = re.compile(r"^(\d{3})$")
|
||||
|
||||
|
||||
def format_stock_in_no(d: date, seq: int) -> str:
|
||||
if seq < 1 or seq > 999:
|
||||
raise ValueError(f"入库编号序号超出范围: {seq}")
|
||||
return f"RK{d.strftime('%Y%m%d')}{seq:03d}"
|
||||
|
||||
|
||||
def prefix_for_date(d: date) -> str:
|
||||
return f"RK{d.strftime('%Y%m%d')}"
|
||||
|
||||
|
||||
def max_seq_from_stock_in_nos(prefix: str, nos: Iterable[str]) -> int:
|
||||
max_seq = 0
|
||||
for raw in nos:
|
||||
s = (raw or "").strip()
|
||||
if not s.startswith(prefix):
|
||||
continue
|
||||
suffix = s[len(prefix) :]
|
||||
m = _SUFFIX_RE.match(suffix)
|
||||
if not m:
|
||||
continue
|
||||
max_seq = max(max_seq, int(m.group(1)))
|
||||
return max_seq
|
||||
|
||||
|
||||
def next_stock_in_nos(
|
||||
d: date,
|
||||
*,
|
||||
count: int,
|
||||
existing_nos: Iterable[str],
|
||||
) -> List[str]:
|
||||
if count <= 0:
|
||||
return []
|
||||
prefix = prefix_for_date(d)
|
||||
start = max_seq_from_stock_in_nos(prefix, existing_nos) + 1
|
||||
if start + count - 1 > 999:
|
||||
raise ValueError("当日入库编号序号已用尽(上限 999)")
|
||||
return [format_stock_in_no(d, start + i) for i in range(count)]
|
||||
|
||||
|
||||
def list_existing_stock_in_nos_for_prefix(
|
||||
db: Session, *, tenant_no: str, prefix: str
|
||||
) -> List[str]:
|
||||
like = f"{prefix}%"
|
||||
rows = (
|
||||
db.query(BizQcMaterialStockBatch.stock_in_no)
|
||||
.filter(
|
||||
BizQcMaterialStockBatch.tenant_no == tenant_no,
|
||||
BizQcMaterialStockBatch.del_flag == 0,
|
||||
BizQcMaterialStockBatch.stock_in_no.like(like),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return [r[0] for r in rows if r[0]]
|
||||
|
||||
|
||||
def allocate_stock_in_nos(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
stock_in_date: date,
|
||||
count: int,
|
||||
) -> List[str]:
|
||||
prefix = prefix_for_date(stock_in_date)
|
||||
existing = list_existing_stock_in_nos_for_prefix(db, tenant_no=tenant_no, prefix=prefix)
|
||||
return next_stock_in_nos(stock_in_date, count=count, existing_nos=existing)
|
||||
Loaded 100 of 8568 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user