Compare commits
731
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8020e667f1 | ||
|
|
50d56ce0bb | ||
|
|
4efd423d2d | ||
|
|
5e563a6669 | ||
|
|
d66e55af65 | ||
|
|
045ee8d0e3 | ||
|
|
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/
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# 生产 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
|
||||
# 国内机访问 dl-cdn.alpinelinux.org 偶发 Permission denied,改用阿里云镜像
|
||||
- sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
|
||||
- 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
|
||||
@@ -70,6 +70,7 @@ Full specs: `openspec/changes/zkxw-engineering-standards/specs/*/spec.md`. Below
|
||||
- **DB changes**: SHALL maintain SQL in `docs/sql/`. Schema changes SHALL align with `database-conventions` public field rules.
|
||||
- **Commit messages**: SHALL be complete sentences describing "what changed and why". Follow Conventional Commits (`feat|fix|docs|…`).
|
||||
- **Field consistency (CRUD ↔ Schema ↔ Frontend)**: When implementing CRUD for any entity, the `_row_to_dict` function is the **single source of truth** for all readable fields. The CreateBody/UpdateBody Pydantic schemas and the frontend `formData` initialization SHALL include every writable field present in `_row_to_dict` (excluding computed/read-only fields like `id`, `createTime`, `updateTime`, `stockQty`). Run `python scripts/check_field_consistency.py` after implementation to verify. See `docs/dev-field-consistency.md` for details.
|
||||
- **Child-row save(禁止整表删除再新增)**: 保存子表时 SHALL 按行处理:已有行就地更新,只插入新增行,只软删除用户去掉的行。客户端可以提交稳定 `id`,字段变化时该行必须保留原 `id`。请求未带 `id` 时,先用稳定业务键(样本编码、物料编码、条码、组分 id 等)匹配一条尚未占用的有效行,匹配不到再插入新 UUID。属于其他父记录或其他租户的 `id` SHALL 拒绝。SHALL NOT 在保存时把子表全部软删除再整批插入新行——其他表会拿这些 id 做外键(`production_step_output_id`、`protocol_sample_id`、`donation_sample_id`),整批换 id 会把关联打断。删除父记录时可以级联软删除其子行。复制或克隆父记录时 SHALL 为子行分配新 id。实现入口:`backend/app/crud/child_row_sync.py` 的 `sync_child_rows`。
|
||||
|
||||
### Permission Conventions
|
||||
|
||||
@@ -220,3 +221,5 @@ 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.
|
||||
- **Child-row save**: SHALL NOT soft-delete every child row and insert new rows on save. Update in place, insert new rows, soft-delete only removed rows. See Coding Conventions.
|
||||
- **进程重启**:修改 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.
+46
-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,44 @@ 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_6yH4t1SHqpEkaiwNWKueY0kpwE
|
||||
# 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
|
||||
# WX_MP_TPL_SC_FIELD_SAMPLE_TYPE=thing17
|
||||
@@ -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",
|
||||
]
|
||||
+128
-47
@@ -1,4 +1,5 @@
|
||||
"""运营端管理接口(租户、角色下拉等)"""
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, List, Optional
|
||||
@@ -26,8 +27,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 +81,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 +89,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 +237,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 +270,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 +289,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 +334,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 +359,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 +399,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 +425,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 +457,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 +471,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 +1068,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))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 物品单位管理
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1541,7 +1617,10 @@ async def upload_attachment(
|
||||
return error_response(400, "请选择文件")
|
||||
try:
|
||||
file_content = await file.read()
|
||||
full_url = storage.upload_file(file_content, file.filename, folder or "")
|
||||
# MinIO/本地写入是同步的。多张大图并行时若占住事件循环,其它上传会一直等不到响应,前端表现为超时后文件消失。
|
||||
full_url = await asyncio.to_thread(
|
||||
storage.upload_file, file_content, file.filename, folder or ""
|
||||
)
|
||||
base_url = settings.MINIO_BASE_URL.rstrip("/") if settings.MINIO_BASE_URL else ""
|
||||
if base_url and full_url.startswith(base_url + "/"):
|
||||
file_url = full_url[len(base_url) + 1:]
|
||||
@@ -1595,8 +1674,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).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
@@ -171,6 +171,7 @@ class StockCheckCreateBody(BaseModel):
|
||||
|
||||
class StockCheckUnusedLineItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
usage_id: Optional[str] = Field(None, alias="usageId")
|
||||
batch_id: Optional[str] = Field(None, alias="batchId")
|
||||
material_code: Optional[str] = Field(None, alias="materialCode")
|
||||
|
||||
+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,448 @@
|
||||
"""预签约订单 / 意向金转账 / 转正式 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
|
||||
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=["细胞存储-预签约订单"])
|
||||
|
||||
|
||||
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_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.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_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.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))
|
||||
+390
-15
@@ -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. 耗材包(主 + 子项)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1164,6 +1277,7 @@ async def delete_consumable_pack(
|
||||
|
||||
class ProcessReagentInput(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
reagent_code: Optional[str] = Field(None, alias="reagentCode")
|
||||
reagent_name: Optional[str] = Field(None, alias="reagentName")
|
||||
spec: Optional[str] = None
|
||||
@@ -1175,6 +1289,7 @@ class ProcessReagentInput(BaseModel):
|
||||
|
||||
class ProcessConsumableInput(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
consumable_code: Optional[str] = Field(None, alias="consumableCode")
|
||||
consumable_name: Optional[str] = Field(None, alias="consumableName")
|
||||
spec_model: Optional[str] = Field(None, alias="specModel")
|
||||
@@ -1186,6 +1301,7 @@ class ProcessConsumableInput(BaseModel):
|
||||
|
||||
class ProcessOutputInput(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
output_type_id: Optional[str] = Field(None, alias="outputTypeId")
|
||||
output_name: Optional[str] = Field(None, alias="outputName")
|
||||
output_barcode: Optional[str] = Field(None, alias="outputBarcode")
|
||||
@@ -1194,6 +1310,7 @@ class ProcessOutputInput(BaseModel):
|
||||
|
||||
class ProcessDataCollectionInput(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
collection_name: str = Field(..., alias="collectionName")
|
||||
unit: Optional[str] = None
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
@@ -1209,6 +1326,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 +1344,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 +1417,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 +1459,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 +1558,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 +1583,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 +1594,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 +1614,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 +1636,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 +1656,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 +1668,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 +1682,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 +1702,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 +1718,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 +1734,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 +1750,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 +1758,6 @@ async def update_step_data_collections(
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. 入库生产工单
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1810,6 +1944,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 +2069,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 +2245,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 +2301,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,
|
||||
@@ -2381,6 +2738,7 @@ async def replace_task_step_reagents(
|
||||
for it in body:
|
||||
d = it.model_dump(by_alias=False)
|
||||
items_data.append({
|
||||
"id": d.get("id"),
|
||||
"reagent_code": d.get("material_code"),
|
||||
"reagent_name": d.get("material_name"),
|
||||
"spec": d.get("spec"),
|
||||
@@ -2414,6 +2772,7 @@ async def replace_task_step_consumables(
|
||||
for it in body:
|
||||
d = it.model_dump(by_alias=False)
|
||||
items_data.append({
|
||||
"id": d.get("id"),
|
||||
"consumable_code": d.get("material_code"),
|
||||
"consumable_name": d.get("material_name"),
|
||||
"spec_model": d.get("spec_model"),
|
||||
@@ -2447,6 +2806,7 @@ async def replace_task_step_configured_reagents(
|
||||
for it in body:
|
||||
d = it.model_dump(by_alias=False)
|
||||
items_data.append({
|
||||
"id": d.get("id"),
|
||||
"configured_reagent_code": d.get("material_code"),
|
||||
"configured_reagent_name": d.get("material_name"),
|
||||
"spec": d.get("spec"),
|
||||
@@ -2498,8 +2858,10 @@ async def replace_task_step_outputs(
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items_data = [it.model_dump(by_alias=False) for it in body]
|
||||
production_task_svc.replace_step_outputs(db, step_id, tenant_no, user.get("user_id"), items_data)
|
||||
return success_response(None, message="更新成功")
|
||||
saved = production_task_svc.replace_step_outputs(
|
||||
db, step_id, tenant_no, user.get("user_id"), items_data
|
||||
)
|
||||
return success_response(saved, message="更新成功")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
@@ -2693,6 +3055,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,
|
||||
|
||||
@@ -739,6 +739,7 @@ class StockCheckCreateBody(BaseModel):
|
||||
|
||||
class StockCheckUnusedLineItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
usage_id: Optional[str] = Field(None, alias="usageId")
|
||||
batch_id: Optional[str] = Field(None, alias="batchId")
|
||||
material_code: Optional[str] = Field(None, alias="materialCode")
|
||||
@@ -926,6 +927,7 @@ async def cancel_production_material_stock_check(
|
||||
|
||||
class FormulaLineBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
component_material_id: str = Field(..., alias="componentMaterialId")
|
||||
component_material_type: str = Field(..., alias="componentMaterialType")
|
||||
standard_qty: float = Field(..., gt=0, alias="standardQty")
|
||||
|
||||
@@ -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,646 @@
|
||||
"""按样本缴费方案 / 转账笔财务确认 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
|
||||
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=["细胞存储-按样本缴费"])
|
||||
|
||||
|
||||
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_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.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_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.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))
|
||||
|
||||
|
||||
# ---------- 物料变动记录 ----------
|
||||
|
||||
|
||||
@@ -2056,6 +2452,7 @@ class StockCheckCreateBody(BaseModel):
|
||||
|
||||
class StockCheckUnusedLineItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
usage_id: Optional[str] = Field(None, alias="usageId")
|
||||
batch_id: Optional[str] = Field(None, alias="batchId")
|
||||
material_code: Optional[str] = Field(None, alias="materialCode")
|
||||
|
||||
@@ -11,6 +11,12 @@ 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=["科研捐赠管理"])
|
||||
|
||||
@@ -119,6 +125,17 @@ class ConvertFromStorageBody(BaseModel):
|
||||
protocol_id: str = Field(..., alias="protocolId")
|
||||
|
||||
|
||||
def assert_donation_sensitive_fields(
|
||||
db: Session,
|
||||
user: dict,
|
||||
body: BaseModel,
|
||||
*,
|
||||
creating: bool,
|
||||
) -> None:
|
||||
"""保留钩子;不再校验菜单权限(与采集协议敏感字段策略对齐)。"""
|
||||
_ = (db, user, body, creating)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 列表
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -229,6 +246,7 @@ async def create_research_donation(
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
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 +260,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="创建成功",
|
||||
@@ -262,10 +291,15 @@ async def update_research_donation(
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
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 +307,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 +442,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_admin_or_super_admin, require_any_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_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_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,55 @@ 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"
|
||||
)
|
||||
# 协议新增样本采集通知(工单类模板)
|
||||
# 工单标题 thing2 / 发起人 thing8 / 发起时间 time3 / 任务类型 phrase10 / 工单类型 thing17(样本类型名称)
|
||||
WX_MP_TEMPLATE_SAMPLE_COLLECT: str = (
|
||||
"iOnQ9k2mavAOwFbp_6yH4t1SHqpEkaiwNWKueY0kpwE"
|
||||
)
|
||||
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"
|
||||
WX_MP_TPL_SC_FIELD_SAMPLE_TYPE: str = "thing17"
|
||||
|
||||
# 与 docs/sql/zkxw.sql 基线一致:登录按租户 + sys_user.user_no
|
||||
DEFAULT_TENANT_NO: str = "zkxw"
|
||||
# 已废弃:无角色用户固定为 guest,不可通过环境变量提升为 super_admin
|
||||
@@ -126,6 +207,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 +218,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,
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
"""按行同步子表。
|
||||
|
||||
带 ID 的行原地修改,没有 ID 时按业务键对上尚未占用的原行,其余才新增。
|
||||
本次提交里没有的活动行才软删除。不要整表软删后再插入,否则外键会指向已删除的行。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
|
||||
|
||||
def _item_id(item: Dict[str, Any]) -> str:
|
||||
return str(item.get("id") or "").strip()
|
||||
|
||||
|
||||
def _sort_key(row: Any):
|
||||
order_num = getattr(row, "order_num", None)
|
||||
create_time = getattr(row, "create_time", None)
|
||||
return (
|
||||
order_num if order_num is not None else 0,
|
||||
create_time or datetime.min,
|
||||
str(getattr(row, "id", "") or ""),
|
||||
)
|
||||
|
||||
|
||||
def sync_child_rows(
|
||||
db: Session,
|
||||
model,
|
||||
*,
|
||||
parent_attr: str,
|
||||
parent_id: str,
|
||||
items: List[Dict[str, Any]],
|
||||
build_row: Callable[[str], Any],
|
||||
apply_fields: Callable[[Any, Dict[str, Any], int, datetime], None],
|
||||
tenant_no: Optional[str] = None,
|
||||
tenant_attr: str = "tenant_no",
|
||||
user_id: Optional[str] = None,
|
||||
now: Optional[datetime] = None,
|
||||
natural_key: Optional[Callable[[Dict[str, Any]], str]] = None,
|
||||
row_natural_key: Optional[Callable[[Any], str]] = None,
|
||||
duplicate_message: str = "编号重复",
|
||||
foreign_message: str = "记录不属于当前数据",
|
||||
extra_filters: Optional[Dict[str, Any]] = None,
|
||||
reuse_deleted: bool = False,
|
||||
) -> List[Any]:
|
||||
now = now or datetime.now()
|
||||
payload = list(items or [])
|
||||
seen_ids = set()
|
||||
for item in payload:
|
||||
row_id = _item_id(item)
|
||||
if not row_id:
|
||||
continue
|
||||
if row_id in seen_ids:
|
||||
raise ValueError(duplicate_message)
|
||||
seen_ids.add(row_id)
|
||||
|
||||
def _scoped(query):
|
||||
query = query.filter(getattr(model, parent_attr) == parent_id)
|
||||
if tenant_no is not None and hasattr(model, tenant_attr):
|
||||
query = query.filter(getattr(model, tenant_attr) == tenant_no)
|
||||
for key, value in (extra_filters or {}).items():
|
||||
query = query.filter(getattr(model, key) == value)
|
||||
return query
|
||||
|
||||
active_rows = _scoped(db.query(model).filter(model.del_flag == 0)).all()
|
||||
active_by_id = {row.id: row for row in active_rows}
|
||||
|
||||
for row_id in seen_ids:
|
||||
if row_id in active_by_id:
|
||||
continue
|
||||
other = db.query(model).filter(model.id == row_id).first()
|
||||
if other is None:
|
||||
continue
|
||||
other_parent = getattr(other, parent_attr)
|
||||
other_tenant = getattr(other, tenant_attr, None) if tenant_no is not None else tenant_no
|
||||
if other_parent != parent_id or (
|
||||
tenant_no is not None and (other_tenant or "") != tenant_no
|
||||
):
|
||||
raise ValueError(foreign_message)
|
||||
|
||||
queues: Dict[str, List[Any]] = {}
|
||||
deleted_queues: Dict[str, List[Any]] = {}
|
||||
if natural_key is not None and row_natural_key is not None:
|
||||
for row in sorted(active_rows, key=_sort_key):
|
||||
key = (row_natural_key(row) or "").strip()
|
||||
if not key:
|
||||
continue
|
||||
queues.setdefault(key, []).append(row)
|
||||
if reuse_deleted:
|
||||
deleted_rows = _scoped(db.query(model).filter(model.del_flag == 1)).all()
|
||||
for row in sorted(deleted_rows, key=_sort_key):
|
||||
key = (row_natural_key(row) or "").strip()
|
||||
if not key:
|
||||
continue
|
||||
deleted_queues.setdefault(key, []).append(row)
|
||||
|
||||
claimed = set()
|
||||
assigned: List[Optional[Any]] = [None] * len(payload)
|
||||
|
||||
def _take(row: Any) -> Any:
|
||||
if row.id in claimed:
|
||||
raise ValueError(duplicate_message)
|
||||
claimed.add(row.id)
|
||||
return row
|
||||
|
||||
for idx, item in enumerate(payload):
|
||||
row_id = _item_id(item)
|
||||
if not row_id:
|
||||
continue
|
||||
if row_id in active_by_id:
|
||||
assigned[idx] = _take(active_by_id[row_id])
|
||||
continue
|
||||
row = db.query(model).filter(model.id == row_id).first()
|
||||
if row is None:
|
||||
row = build_row(row_id)
|
||||
db.add(row)
|
||||
assigned[idx] = _take(row)
|
||||
|
||||
for bucket in queues.values():
|
||||
bucket[:] = [row for row in bucket if row.id not in claimed]
|
||||
|
||||
for idx, item in enumerate(payload):
|
||||
if assigned[idx] is not None:
|
||||
continue
|
||||
row = None
|
||||
if natural_key is not None:
|
||||
key = (natural_key(item) or "").strip()
|
||||
bucket = queues.get(key) if key else None
|
||||
while bucket:
|
||||
candidate = bucket.pop(0)
|
||||
if candidate.id not in claimed:
|
||||
row = candidate
|
||||
break
|
||||
if row is None and reuse_deleted and natural_key is not None:
|
||||
key = (natural_key(item) or "").strip()
|
||||
bucket = deleted_queues.get(key) if key else None
|
||||
while bucket:
|
||||
candidate = bucket.pop(0)
|
||||
if candidate.id not in claimed:
|
||||
row = candidate
|
||||
break
|
||||
if row is None:
|
||||
row = build_row(generate_uuid())
|
||||
db.add(row)
|
||||
assigned[idx] = _take(row)
|
||||
|
||||
result: List[Any] = []
|
||||
for idx, item in enumerate(payload):
|
||||
row = assigned[idx]
|
||||
setattr(row, parent_attr, parent_id)
|
||||
if tenant_no is not None and hasattr(row, tenant_attr):
|
||||
setattr(row, tenant_attr, tenant_no)
|
||||
apply_fields(row, item, idx, now)
|
||||
row.del_flag = 0
|
||||
if hasattr(row, "update_by"):
|
||||
row.update_by = user_id
|
||||
if hasattr(row, "update_time"):
|
||||
row.update_time = now
|
||||
db.add(row)
|
||||
result.append(row)
|
||||
|
||||
if reuse_deleted and row_natural_key is not None:
|
||||
keys_to_clear = set()
|
||||
for row in result:
|
||||
keys_to_clear.add((row_natural_key(row) or "").strip())
|
||||
for row in active_rows:
|
||||
if row.id not in claimed:
|
||||
keys_to_clear.add((row_natural_key(row) or "").strip())
|
||||
removed_tombstone = False
|
||||
for key in keys_to_clear:
|
||||
if not key:
|
||||
continue
|
||||
for tomb in deleted_queues.get(key, []):
|
||||
if tomb.id in claimed:
|
||||
continue
|
||||
db.delete(tomb)
|
||||
removed_tombstone = True
|
||||
if removed_tombstone:
|
||||
db.flush()
|
||||
|
||||
for row in active_rows:
|
||||
if row.id in claimed:
|
||||
continue
|
||||
row.del_flag = 1
|
||||
if hasattr(row, "update_by"):
|
||||
row.update_by = user_id
|
||||
if hasattr(row, "update_time"):
|
||||
row.update_time = now
|
||||
db.add(row)
|
||||
return result
|
||||
|
||||
|
||||
def sync_link_rows(
|
||||
db: Session,
|
||||
model,
|
||||
*,
|
||||
parent_attr: str,
|
||||
parent_id: str,
|
||||
link_attr: str,
|
||||
link_ids: List[str],
|
||||
tenant_no: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
duplicate_message: str = "关联重复",
|
||||
foreign_message: str = "关联不属于当前数据",
|
||||
) -> List[Any]:
|
||||
"""按关联 id 更新关系行。重新加回已删除的关联时恢复原行,避免唯一索引冲突。"""
|
||||
now = datetime.now()
|
||||
ordered: List[Dict[str, Any]] = []
|
||||
seen = set()
|
||||
for raw in link_ids or []:
|
||||
link_id = str(raw or "").strip()
|
||||
if not link_id or link_id in seen:
|
||||
continue
|
||||
seen.add(link_id)
|
||||
ordered.append({link_attr: link_id})
|
||||
|
||||
def build_row(row_id: str):
|
||||
data = {
|
||||
"id": row_id,
|
||||
parent_attr: parent_id,
|
||||
link_attr: "",
|
||||
"create_by": user_id,
|
||||
"create_dept_id": "",
|
||||
"create_time": now,
|
||||
}
|
||||
if tenant_no is not None and hasattr(model, "tenant_no"):
|
||||
data["tenant_no"] = tenant_no
|
||||
return model(**data)
|
||||
|
||||
def apply_row(row: Any, item: Dict[str, Any], idx: int, _now: datetime) -> None:
|
||||
setattr(row, link_attr, item[link_attr])
|
||||
if tenant_no is not None and hasattr(row, "tenant_no"):
|
||||
row.tenant_no = tenant_no
|
||||
if hasattr(row, "order_num"):
|
||||
row.order_num = idx
|
||||
|
||||
return sync_child_rows(
|
||||
db,
|
||||
model,
|
||||
parent_attr=parent_attr,
|
||||
parent_id=parent_id,
|
||||
tenant_no=tenant_no,
|
||||
items=ordered,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: item[link_attr],
|
||||
row_natural_key=lambda row: str(getattr(row, link_attr) or "").strip(),
|
||||
duplicate_message=duplicate_message,
|
||||
foreign_message=foreign_message,
|
||||
reuse_deleted=True,
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
+1331
-251
File diff suppressed because it is too large.
Load diff
@@ -24,7 +24,7 @@ def _resolve_issued_user_display_name(
|
||||
uid = (user_id or "").strip()
|
||||
if not uid:
|
||||
return None
|
||||
u = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
|
||||
u = db.query(SysUser).filter(SysUser.id == uid).first()
|
||||
if not u:
|
||||
return None
|
||||
return (u.nick_name or u.user_no or "").strip() or None
|
||||
@@ -41,7 +41,7 @@ def enrich_issued_user_names(db: Session, rows: List[Dict[str, Any]]) -> None:
|
||||
return
|
||||
users = (
|
||||
db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
|
||||
.filter(SysUser.id.in_(missing_uids), SysUser.del_flag == 0)
|
||||
.filter(SysUser.id.in_(missing_uids))
|
||||
.all()
|
||||
)
|
||||
name_map = {(u.id or ""): ((u.nick_name or u.user_no or "").strip()) for u in users}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.models.models import BizConsumablePack, BizConsumablePackItem
|
||||
|
||||
|
||||
@@ -230,37 +231,45 @@ def update_consumable_pack(
|
||||
db.add(obj)
|
||||
|
||||
if items is not None:
|
||||
existing = (
|
||||
db.query(BizConsumablePackItem)
|
||||
.filter(and_(BizConsumablePackItem.pack_id == pack_id, BizConsumablePackItem.del_flag == 0))
|
||||
.all()
|
||||
)
|
||||
now = datetime.now()
|
||||
for ei in existing:
|
||||
ei.del_flag = 1
|
||||
ei.update_time = now
|
||||
db.add(ei)
|
||||
|
||||
for it in items:
|
||||
item_obj = BizConsumablePackItem(
|
||||
id=generate_uuid(),
|
||||
def build_item(row_id):
|
||||
return BizConsumablePackItem(
|
||||
id=row_id,
|
||||
pack_id=pack_id,
|
||||
consumable_code=(it.get("consumable_code") or "").strip(),
|
||||
consumable_name=(it.get("consumable_name") or "").strip(),
|
||||
spec_model=(it.get("spec_model") or "").strip() or None,
|
||||
standard_usage=(it.get("standard_usage") or "").strip() or None,
|
||||
unit=(it.get("unit") or "").strip() or None,
|
||||
remark=(it.get("remark") or "").strip() or None,
|
||||
consumable_code="",
|
||||
consumable_name="",
|
||||
tenant_no=obj.tenant_no,
|
||||
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(item_obj)
|
||||
|
||||
def apply_item(row, it, _idx, _now):
|
||||
row.consumable_code = (it.get("consumable_code") or "").strip()
|
||||
row.consumable_name = (it.get("consumable_name") or "").strip()
|
||||
row.spec_model = (it.get("spec_model") or "").strip() or None
|
||||
row.standard_usage = (it.get("standard_usage") or "").strip() or None
|
||||
row.unit = (it.get("unit") or "").strip() or None
|
||||
row.remark = (it.get("remark") or "").strip() or None
|
||||
row.order_num = 0
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizConsumablePackItem,
|
||||
parent_attr="pack_id",
|
||||
parent_id=pack_id,
|
||||
tenant_no=obj.tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_item,
|
||||
apply_fields=apply_item,
|
||||
natural_key=lambda it: (it.get("consumable_code") or "").strip(),
|
||||
row_natural_key=lambda row: (row.consumable_code or "").strip(),
|
||||
duplicate_message="耗材包明细重复",
|
||||
foreign_message="耗材包明细不属于当前耗材包",
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.crud.material_stock_batch import _dec, _fmt_date, _fmt_dt, _parse_date, _require_str
|
||||
from app.models.models import SysUser
|
||||
from app.models.material import (
|
||||
@@ -442,6 +443,44 @@ def _resolve_unused_item_fields(
|
||||
return out
|
||||
|
||||
|
||||
def _unused_line_key(usage_id: Optional[str], batch_id: Optional[str]) -> str:
|
||||
usage = (usage_id or "").strip()
|
||||
if usage:
|
||||
return f"usage:{usage}"
|
||||
batch = (batch_id or "").strip()
|
||||
if batch:
|
||||
return f"batch:{batch}"
|
||||
return ""
|
||||
|
||||
|
||||
def _prepare_unused_item(db: Session, it: Dict[str, Any], tenant_no: str) -> Dict[str, Any]:
|
||||
it = _resolve_unused_item_fields(db, it, tenant_no)
|
||||
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
|
||||
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
|
||||
if qty is None or Decimal(str(qty)) <= 0:
|
||||
raise ValueError("未用完数量必须大于0")
|
||||
unit_price = it.get("unit_price") if it.get("unit_price") is not None else it.get("unitPrice")
|
||||
return {
|
||||
"id": str(it.get("id") or "").strip(),
|
||||
"usage_id": (it.get("usage_id") or it.get("usageId") or "").strip() or None,
|
||||
"batch_id": (it.get("batch_id") or it.get("batchId") or "").strip() or None,
|
||||
"material_code": (it.get("material_code") or it.get("materialCode") or "").strip() or None,
|
||||
"material_name": name,
|
||||
"spec_model": (it.get("spec_model") or it.get("specModel") or "").strip() or None,
|
||||
"unit": (it.get("unit") or "").strip() or None,
|
||||
"stock_in_no": (it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
|
||||
"stock_in_date": _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate")),
|
||||
"expire_date": _parse_optional_date(it.get("expire_date") or it.get("expireDate")),
|
||||
"catalog_no": (it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
|
||||
"batch_no": (it.get("batch_no") or it.get("batchNo") or "").strip() or None,
|
||||
"manufacturer": (it.get("manufacturer") or "").strip() or None,
|
||||
"unit_price": Decimal(str(unit_price)) if unit_price is not None else None,
|
||||
"holder": (it.get("holder") or "").strip() or None,
|
||||
"unused_qty": Decimal(str(qty)),
|
||||
"remark": (it.get("remark") or "").strip() or None,
|
||||
}
|
||||
|
||||
|
||||
def save_unused_lines(
|
||||
db: Session,
|
||||
check_id: str,
|
||||
@@ -458,72 +497,61 @@ def save_unused_lines(
|
||||
raise ValueError("仅进行中的盘点单可登记未用完物料")
|
||||
|
||||
now = datetime.now()
|
||||
old_rows = (
|
||||
db.query(BizMaterialStockCheckUnusedLine)
|
||||
.filter(
|
||||
BizMaterialStockCheckUnusedLine.check_id == check_id,
|
||||
BizMaterialStockCheckUnusedLine.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for r in old_rows:
|
||||
r.del_flag = 1
|
||||
r.update_by = user_id
|
||||
r.update_time = now
|
||||
db.add(r)
|
||||
prepared = [_prepare_unused_item(db, it, tenant_no) for it in (items or [])]
|
||||
|
||||
count = 0
|
||||
for it in items or []:
|
||||
it = _resolve_unused_item_fields(db, it, tenant_no)
|
||||
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
|
||||
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
|
||||
if qty is None or Decimal(str(qty)) <= 0:
|
||||
raise ValueError("未用完数量必须大于0")
|
||||
qty_dec = Decimal(str(qty))
|
||||
stock_in_date = _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate"))
|
||||
expire_date = _parse_optional_date(it.get("expire_date") or it.get("expireDate"))
|
||||
usage_id = (it.get("usage_id") or it.get("usageId") or "").strip() or None
|
||||
batch_id = (it.get("batch_id") or it.get("batchId") or "").strip() or None
|
||||
|
||||
row = BizMaterialStockCheckUnusedLine(
|
||||
id=generate_uuid(),
|
||||
def build_row(row_id: str):
|
||||
return BizMaterialStockCheckUnusedLine(
|
||||
id=row_id,
|
||||
check_id=check_id,
|
||||
usage_id=usage_id,
|
||||
batch_id=batch_id,
|
||||
material_code=(it.get("material_code") or it.get("materialCode") or "").strip() or None,
|
||||
material_name=name,
|
||||
spec_model=(it.get("spec_model") or it.get("specModel") or "").strip() or None,
|
||||
unit=(it.get("unit") or "").strip() or None,
|
||||
stock_in_no=(it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
|
||||
stock_in_date=stock_in_date,
|
||||
expire_date=expire_date,
|
||||
catalog_no=(it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
|
||||
batch_no=(it.get("batch_no") or it.get("batchNo") or "").strip() or None,
|
||||
manufacturer=(it.get("manufacturer") or "").strip() or None,
|
||||
unit_price=(
|
||||
Decimal(str(it.get("unit_price") or it.get("unitPrice")))
|
||||
if (it.get("unit_price") is not None or it.get("unitPrice") is not None)
|
||||
else None
|
||||
),
|
||||
holder=(it.get("holder") or "").strip() or None,
|
||||
unused_qty=qty_dec,
|
||||
material_name="",
|
||||
unused_qty=0,
|
||||
tenant_no=tenant_no,
|
||||
remark=(it.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(row)
|
||||
count += 1
|
||||
|
||||
def apply_row(row, it, idx, _now):
|
||||
row.usage_id = it["usage_id"]
|
||||
row.batch_id = it["batch_id"]
|
||||
row.material_code = it["material_code"]
|
||||
row.material_name = it["material_name"]
|
||||
row.spec_model = it["spec_model"]
|
||||
row.unit = it["unit"]
|
||||
row.stock_in_no = it["stock_in_no"]
|
||||
row.stock_in_date = it["stock_in_date"]
|
||||
row.expire_date = it["expire_date"]
|
||||
row.catalog_no = it["catalog_no"]
|
||||
row.batch_no = it["batch_no"]
|
||||
row.manufacturer = it["manufacturer"]
|
||||
row.unit_price = it["unit_price"]
|
||||
row.holder = it["holder"]
|
||||
row.unused_qty = it["unused_qty"]
|
||||
row.remark = it["remark"]
|
||||
row.order_num = idx
|
||||
|
||||
saved = sync_child_rows(
|
||||
db,
|
||||
BizMaterialStockCheckUnusedLine,
|
||||
parent_attr="check_id",
|
||||
parent_id=check_id,
|
||||
tenant_no=tenant_no,
|
||||
items=prepared,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda it: _unused_line_key(it.get("usage_id"), it.get("batch_id")),
|
||||
row_natural_key=lambda row: _unused_line_key(row.usage_id, row.batch_id),
|
||||
duplicate_message="未用完明细重复",
|
||||
foreign_message="未用完明细不属于当前盘点单",
|
||||
)
|
||||
|
||||
check.update_by = user_id
|
||||
check.update_time = now
|
||||
db.add(check)
|
||||
db.commit()
|
||||
return count
|
||||
return len(saved)
|
||||
|
||||
|
||||
def complete_check(
|
||||
|
||||
+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
|
||||
@@ -9,6 +9,7 @@ from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.crud import cell_product as cell_product_svc
|
||||
from app.models.models import (
|
||||
BizCellProduct,
|
||||
@@ -396,13 +397,6 @@ def save_outbound_material(
|
||||
db, schedule_id, tenant_no, incoming_qc_ids, user_id, now
|
||||
)
|
||||
|
||||
db.query(BizOutboundReleasePaper).filter(
|
||||
BizOutboundReleasePaper.infusion_schedule_id == schedule_id,
|
||||
BizOutboundReleasePaper.tenant_no == tenant_no,
|
||||
BizOutboundReleasePaper.del_flag == 0,
|
||||
).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False)
|
||||
db.flush()
|
||||
|
||||
for idx, item in enumerate(cells or []):
|
||||
cp_id = (item.get("cellProductId") or item.get("cell_product_id") or "").strip()
|
||||
if not cp_id:
|
||||
@@ -532,7 +526,8 @@ def save_outbound_material(
|
||||
)
|
||||
)
|
||||
|
||||
for idx, item in enumerate(paper_materials or []):
|
||||
paper_payload = []
|
||||
for item in paper_materials or []:
|
||||
paper_name = (item.get("paperName") or item.get("paper_name") or "").strip()
|
||||
attachment_url = (item.get("attachmentUrl") or item.get("attachment_url") or "").strip()
|
||||
copies_raw = item.get("copies")
|
||||
@@ -546,26 +541,53 @@ def save_outbound_material(
|
||||
raise HTTPException(status_code=400, detail="纸质材料份数格式无效")
|
||||
if copies <= 0:
|
||||
raise HTTPException(status_code=400, detail="纸质材料份数必须大于0")
|
||||
|
||||
db.add(
|
||||
BizOutboundReleasePaper(
|
||||
id=generate_uuid(),
|
||||
material_id=header.id,
|
||||
infusion_schedule_id=schedule_id,
|
||||
paper_name=paper_name,
|
||||
copies=copies,
|
||||
attachment_url=attachment_url,
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx + 1,
|
||||
remark=(item.get("remark") or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
paper_payload.append(
|
||||
{
|
||||
"paper_name": paper_name,
|
||||
"copies": copies,
|
||||
"attachment_url": attachment_url,
|
||||
"remark": (item.get("remark") or "").strip() or None,
|
||||
}
|
||||
)
|
||||
|
||||
def build_paper(row_id):
|
||||
return BizOutboundReleasePaper(
|
||||
id=row_id,
|
||||
material_id=header.id,
|
||||
infusion_schedule_id=schedule_id,
|
||||
paper_name="",
|
||||
copies=1,
|
||||
attachment_url="",
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
def apply_paper(row, item, idx, _now):
|
||||
row.material_id = header.id
|
||||
row.paper_name = item["paper_name"]
|
||||
row.copies = item["copies"]
|
||||
row.attachment_url = item["attachment_url"]
|
||||
row.order_num = idx + 1
|
||||
row.remark = item["remark"]
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizOutboundReleasePaper,
|
||||
parent_attr="infusion_schedule_id",
|
||||
parent_id=schedule_id,
|
||||
tenant_no=tenant_no,
|
||||
items=paper_payload,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_paper,
|
||||
apply_fields=apply_paper,
|
||||
natural_key=lambda item: item["attachment_url"],
|
||||
row_natural_key=lambda row: row.attachment_url or "",
|
||||
duplicate_message="纸质材料重复",
|
||||
foreign_message="纸质材料不属于当前输注计划",
|
||||
)
|
||||
|
||||
db.flush()
|
||||
return get_outbound_material(db, schedule_id, tenant_no)
|
||||
@@ -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,
|
||||
|
||||
@@ -8,6 +8,9 @@ from typing import Any, Dict, List, Optional
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
|
||||
from app.models.models import (
|
||||
BizCheckupInstitution,
|
||||
BizPilotApplication,
|
||||
@@ -429,6 +432,52 @@ def list_reserve_items(
|
||||
return [_row_to_dict_reserve_item(r) for r in rows]
|
||||
|
||||
|
||||
def _checkup_project_name(item: Dict[str, Any]) -> str:
|
||||
return (item.get("projectName") or item.get("project_name") or "").strip()
|
||||
|
||||
|
||||
def _checkup_project_fee(item: Dict[str, Any]):
|
||||
if item.get("projectFee") is not None:
|
||||
return item.get("projectFee")
|
||||
return item.get("project_fee")
|
||||
|
||||
|
||||
def _sync_checkup_items(db, model, checkup_id, items_json, tenant_no, user_id, now):
|
||||
def build_row(row_id):
|
||||
return model(
|
||||
id=row_id,
|
||||
pilot_checkup_id=checkup_id,
|
||||
project_name="",
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
def apply_row(row, item, idx, _now):
|
||||
row.project_name = _checkup_project_name(item)
|
||||
row.project_fee = _checkup_project_fee(item)
|
||||
row.order_num = idx + 1
|
||||
row.remark = item.get("remark") or ""
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
model,
|
||||
parent_attr="pilot_checkup_id",
|
||||
parent_id=checkup_id,
|
||||
tenant_no=tenant_no,
|
||||
items=list(items_json or []),
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=_checkup_project_name,
|
||||
row_natural_key=lambda row: (row.project_name or "").strip(),
|
||||
duplicate_message="体检项目重复",
|
||||
foreign_message="体检项目不属于当前体检单",
|
||||
)
|
||||
|
||||
|
||||
def save_reserve_items(
|
||||
db: Session,
|
||||
checkup_id: str,
|
||||
@@ -437,35 +486,15 @@ def save_reserve_items(
|
||||
user_id: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
now = datetime.now()
|
||||
|
||||
# Soft-delete existing items
|
||||
db.query(BizPilotCheckupReserveItem).filter(
|
||||
BizPilotCheckupReserveItem.pilot_checkup_id == checkup_id,
|
||||
BizPilotCheckupReserveItem.tenant_no == tenant_no,
|
||||
BizPilotCheckupReserveItem.del_flag == 0,
|
||||
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
|
||||
|
||||
# Insert new items
|
||||
for idx, item in enumerate(items_json):
|
||||
row = BizPilotCheckupReserveItem(
|
||||
id=uuid.uuid4().hex,
|
||||
pilot_checkup_id=checkup_id,
|
||||
project_name=item.get("projectName") or item.get("project_name") or "",
|
||||
project_fee=item.get("projectFee") if item.get("projectFee") is not None else (
|
||||
item.get("project_fee") if item.get("project_fee") is not None else None
|
||||
),
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx + 1,
|
||||
remark=item.get("remark") or "",
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(row)
|
||||
|
||||
_sync_checkup_items(
|
||||
db,
|
||||
BizPilotCheckupReserveItem,
|
||||
checkup_id,
|
||||
items_json,
|
||||
tenant_no,
|
||||
user_id,
|
||||
now,
|
||||
)
|
||||
db.commit()
|
||||
return list_reserve_items(db, checkup_id, tenant_no)
|
||||
|
||||
@@ -520,35 +549,15 @@ def save_actual_items(
|
||||
user_id: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
now = datetime.now()
|
||||
|
||||
# Soft-delete existing items
|
||||
db.query(BizPilotCheckupActualItem).filter(
|
||||
BizPilotCheckupActualItem.pilot_checkup_id == checkup_id,
|
||||
BizPilotCheckupActualItem.tenant_no == tenant_no,
|
||||
BizPilotCheckupActualItem.del_flag == 0,
|
||||
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
|
||||
|
||||
# Insert new items
|
||||
for idx, item in enumerate(items_json):
|
||||
row = BizPilotCheckupActualItem(
|
||||
id=uuid.uuid4().hex,
|
||||
pilot_checkup_id=checkup_id,
|
||||
project_name=item.get("projectName") or item.get("project_name") or "",
|
||||
project_fee=item.get("projectFee") if item.get("projectFee") is not None else (
|
||||
item.get("project_fee") if item.get("project_fee") is not None else None
|
||||
),
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx + 1,
|
||||
remark=item.get("remark") or "",
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(row)
|
||||
|
||||
_sync_checkup_items(
|
||||
db,
|
||||
BizPilotCheckupActualItem,
|
||||
checkup_id,
|
||||
items_json,
|
||||
tenant_no,
|
||||
user_id,
|
||||
now,
|
||||
)
|
||||
db.commit()
|
||||
return list_actual_items(db, checkup_id, tenant_no)
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ from typing import Any, Dict, List, Optional
|
||||
from sqlalchemy import and_, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
|
||||
from app.models.models import (
|
||||
BizChannelAccount,
|
||||
BizChannelAgreementProductPrice,
|
||||
@@ -539,7 +541,7 @@ def save_delivery_materials(
|
||||
tenant_no: str,
|
||||
user_id: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Bulk save: soft-delete existing + insert new."""
|
||||
"""按物料名称更新已有行,只插入新物料,只软删去掉的物料。"""
|
||||
now = datetime.now()
|
||||
|
||||
# Validate delivery exists
|
||||
@@ -555,36 +557,40 @@ def save_delivery_materials(
|
||||
if not delivery:
|
||||
raise ValueError("交付信息不存在")
|
||||
|
||||
# Soft-delete existing materials
|
||||
db.query(BizPilotDeliveryMaterial).filter(
|
||||
BizPilotDeliveryMaterial.delivery_id == delivery_id,
|
||||
BizPilotDeliveryMaterial.tenant_no == tenant_no,
|
||||
BizPilotDeliveryMaterial.del_flag == 0,
|
||||
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
|
||||
db.flush()
|
||||
|
||||
# Insert new
|
||||
created = []
|
||||
for idx, item in enumerate(items_json):
|
||||
material = BizPilotDeliveryMaterial(
|
||||
id=str(uuid.uuid4()),
|
||||
def build_row(row_id):
|
||||
return BizPilotDeliveryMaterial(
|
||||
id=row_id,
|
||||
delivery_id=delivery_id,
|
||||
material_name=item.get("material_name") or "",
|
||||
qty=item.get("qty"),
|
||||
unit_id=item.get("unit_id") or "",
|
||||
material_name="",
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx + 1,
|
||||
remark=item.get("remark") or "",
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(material)
|
||||
created.append(material)
|
||||
|
||||
def apply_row(row, item, idx, _now):
|
||||
row.material_name = item.get("material_name") or ""
|
||||
row.qty = item.get("qty")
|
||||
row.unit_id = item.get("unit_id") or ""
|
||||
row.order_num = idx + 1
|
||||
row.remark = item.get("remark") or ""
|
||||
|
||||
created = sync_child_rows(
|
||||
db,
|
||||
BizPilotDeliveryMaterial,
|
||||
parent_attr="delivery_id",
|
||||
parent_id=delivery_id,
|
||||
tenant_no=tenant_no,
|
||||
items=list(items_json or []),
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("material_name") or "").strip(),
|
||||
row_natural_key=lambda row: (row.material_name or "").strip(),
|
||||
duplicate_message="交付物料重复",
|
||||
foreign_message="交付物料不属于当前交付单",
|
||||
)
|
||||
db.commit()
|
||||
for m in created:
|
||||
db.refresh(m)
|
||||
|
||||
@@ -129,7 +129,7 @@ def get_confirm_by_schedule(
|
||||
if uid:
|
||||
user = (
|
||||
db.query(SysUser)
|
||||
.filter(SysUser.id == uid, SysUser.del_flag == 0)
|
||||
.filter(SysUser.id == uid)
|
||||
.first()
|
||||
)
|
||||
data["confirmUserName"] = (user.nick_name or user.user_no or "") if user else ""
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -10,6 +9,7 @@ from sqlalchemy import and_, desc, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.models.models import (
|
||||
BizChannelAgreementProductPrice,
|
||||
BizChannelServiceOrder,
|
||||
@@ -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",
|
||||
}
|
||||
@@ -1134,44 +1142,68 @@ def update_infusion_schedule_mgmt(
|
||||
row.update_by = user_id
|
||||
row.update_time = now
|
||||
|
||||
# Update products if provided
|
||||
products_data = data.get("products")
|
||||
if products_data is not None:
|
||||
# Soft-delete existing products
|
||||
db.query(BizPilotInfusionScheduleProduct).filter(
|
||||
BizPilotInfusionScheduleProduct.infusion_schedule_id == schedule_id,
|
||||
BizPilotInfusionScheduleProduct.tenant_no == tenant_no,
|
||||
BizPilotInfusionScheduleProduct.del_flag == 0,
|
||||
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
|
||||
def _price_id(item):
|
||||
return (item.get("priceId") or item.get("price_id") or "").strip()
|
||||
|
||||
for idx, item in enumerate(products_data):
|
||||
p_row = BizPilotInfusionScheduleProduct(
|
||||
id=str(uuid.uuid4()),
|
||||
def _product_key(item):
|
||||
price_id = _price_id(item)
|
||||
if price_id:
|
||||
return "price:" + price_id
|
||||
project_code = (item.get("projectCode") or item.get("project_code") or "").strip()
|
||||
spec_text = (item.get("specText") or item.get("spec_text") or "").strip()
|
||||
return f"proj:{project_code}|{spec_text}"
|
||||
|
||||
def build_product(row_id):
|
||||
return BizPilotInfusionScheduleProduct(
|
||||
id=row_id,
|
||||
infusion_schedule_id=schedule_id,
|
||||
price_id=item.get("priceId") or item.get("price_id") or "",
|
||||
project_code=item.get("projectCode") or item.get("project_code") or "",
|
||||
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 "",
|
||||
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
|
||||
),
|
||||
actual_price=item.get("actualPrice") if item.get("actualPrice") is not None else (
|
||||
item.get("actual_price") if item.get("actual_price") is not None else None
|
||||
),
|
||||
quantity=item.get("quantity") if item.get("quantity") is not None else 1,
|
||||
product_status=status,
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx + 1,
|
||||
remark=item.get("remark") or "",
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(p_row)
|
||||
|
||||
def apply_product(row, item, idx, _now):
|
||||
row.price_id = _price_id(item)
|
||||
row.project_code = item.get("projectCode") or item.get("project_code") or ""
|
||||
row.project_name = item.get("projectName") or item.get("project_name") or ""
|
||||
row.cell_type_name = item.get("cellTypeName") or item.get("cell_type_name") or ""
|
||||
row.spec_text = item.get("specText") or item.get("spec_text") or ""
|
||||
row.package_id = item.get("packageId") or item.get("package_id") or ""
|
||||
row.package_text = item.get("packageText") or item.get("package_text") or ""
|
||||
row.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
|
||||
)
|
||||
row.actual_price = item.get("actualPrice") if item.get("actualPrice") is not None else (
|
||||
item.get("actual_price") if item.get("actual_price") is not None else None
|
||||
)
|
||||
row.quantity = item.get("quantity") if item.get("quantity") is not None else 1
|
||||
row.product_status = status
|
||||
row.order_num = idx + 1
|
||||
row.remark = item.get("remark") or ""
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizPilotInfusionScheduleProduct,
|
||||
parent_attr="infusion_schedule_id",
|
||||
parent_id=schedule_id,
|
||||
tenant_no=tenant_no,
|
||||
items=list(products_data or []),
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_product,
|
||||
apply_fields=apply_product,
|
||||
natural_key=_product_key,
|
||||
row_natural_key=lambda row: (
|
||||
"price:" + (row.price_id or "").strip()
|
||||
if (row.price_id or "").strip()
|
||||
else f"proj:{(row.project_code or '').strip()}|{(row.spec_text or '').strip()}"
|
||||
),
|
||||
duplicate_message="输注产品重复",
|
||||
foreign_message="输注产品不属于当前计划",
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
|
||||
@@ -10,10 +10,12 @@ from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.crud.collection_protocol import urls_to_attachment_list
|
||||
from app.models.models import (
|
||||
BizCellProductType,
|
||||
BizCellProductSpec,
|
||||
BizCellProductPackage,
|
||||
BizCellProductPrice,
|
||||
BizCellProductProjectSystem,
|
||||
BizCellProductProjectSystemItem,
|
||||
@@ -281,6 +283,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 细胞产品价格
|
||||
# ────────────────────────────────────────────
|
||||
@@ -2151,7 +2288,9 @@ def _insert_package_items(
|
||||
tenant_no,
|
||||
user_id=None,
|
||||
):
|
||||
for idx, it in enumerate(items):
|
||||
now = datetime.now()
|
||||
prepared = []
|
||||
for idx, it in enumerate(items or []):
|
||||
pid = it.get("checkup_institution_project_id") or it.get("checkupInstitutionProjectId")
|
||||
proj = _get_checkup_project_for_package(db, pid, institution_id, tenant_no)
|
||||
name = (it.get("project_name") or it.get("projectName") or "").strip()
|
||||
@@ -2160,22 +2299,51 @@ def _insert_package_items(
|
||||
fee = it.get("project_fee")
|
||||
if fee is None:
|
||||
fee = it.get("projectFee")
|
||||
obj = BizCheckupInstitutionPackageItem(
|
||||
id=generate_uuid(),
|
||||
package_id=package_id,
|
||||
checkup_institution_project_id=pid,
|
||||
project_name=name,
|
||||
project_fee=fee,
|
||||
tenant_no=tenant_no,
|
||||
order_num=it.get("order_num") if it.get("order_num") is not None else it.get("orderNum") or (idx + 1),
|
||||
remark=it.get("remark") or "",
|
||||
create_by=user_id,
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
prepared.append(
|
||||
{
|
||||
"project_id": pid,
|
||||
"project_name": name,
|
||||
"project_fee": fee,
|
||||
"order_num": it.get("order_num") if it.get("order_num") is not None else it.get("orderNum") or (idx + 1),
|
||||
"remark": it.get("remark") or "",
|
||||
}
|
||||
)
|
||||
db.add(obj)
|
||||
|
||||
def build_row(row_id):
|
||||
return BizCheckupInstitutionPackageItem(
|
||||
id=row_id,
|
||||
package_id=package_id,
|
||||
checkup_institution_project_id="",
|
||||
project_name="",
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
def apply_row(row, item, _idx, _now):
|
||||
row.checkup_institution_project_id = item["project_id"]
|
||||
row.project_name = item["project_name"]
|
||||
row.project_fee = item["project_fee"]
|
||||
row.order_num = item["order_num"]
|
||||
row.remark = item["remark"]
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizCheckupInstitutionPackageItem,
|
||||
parent_attr="package_id",
|
||||
parent_id=package_id,
|
||||
tenant_no=tenant_no,
|
||||
items=prepared,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: str(item.get("project_id") or "").strip(),
|
||||
row_natural_key=lambda row: (row.checkup_institution_project_id or "").strip(),
|
||||
duplicate_message="套餐项目重复",
|
||||
foreign_message="套餐项目不属于当前套餐",
|
||||
reuse_deleted=True,
|
||||
)
|
||||
|
||||
|
||||
def list_checkup_institution_packages(db, checkup_institution_id, tenant_no=None, user_info=None):
|
||||
@@ -2319,15 +2487,6 @@ def update_checkup_institution_package(
|
||||
tenant_no,
|
||||
existing_item_project_ids=existing_pids,
|
||||
)
|
||||
# 全量替换:物理删除旧明细,避免 uk(package_id, project_id) 与软删行冲突
|
||||
q_items = db.query(BizCheckupInstitutionPackageItem).filter(
|
||||
BizCheckupInstitutionPackageItem.package_id == package_id
|
||||
)
|
||||
if tenant_no:
|
||||
q_items = q_items.filter(BizCheckupInstitutionPackageItem.tenant_no == tenant_no)
|
||||
for row in q_items.all():
|
||||
db.delete(row)
|
||||
db.flush()
|
||||
_insert_package_items(
|
||||
db,
|
||||
package_id=package_id,
|
||||
@@ -3076,50 +3235,60 @@ def _save_gmcp_product_rows(
|
||||
user_id,
|
||||
now=None,
|
||||
):
|
||||
"""仅保存购买订单产品明细,不调整份数库存"""
|
||||
"""仅保存购买订单产品明细,不调整份数库存。已有项目按编码、规格、细胞类型更新。"""
|
||||
now = now or datetime.now()
|
||||
for p in products:
|
||||
project_code = p.get("projectCode") or p.get("project_code") or ""
|
||||
project_name = p.get("projectName") or p.get("project_name") or ""
|
||||
cell_type = p.get("cellType") or p.get("cell_type") or ""
|
||||
spec_text = p.get("specText") or p.get("spec_text") or ""
|
||||
agreement_unit_price = float(p.get("agreementUnitPrice") or p.get("agreement_unit_price") or 0)
|
||||
buy_qty = float(p.get("buyQty") or p.get("buy_qty") or 0)
|
||||
buy_amount = float(p.get("buyAmount") or p.get("buy_amount") or agreement_unit_price * buy_qty)
|
||||
tenant = tenant_no or "zkxw"
|
||||
|
||||
prod_row = BizChannelServiceOrderProduct(
|
||||
id=generate_uuid(),
|
||||
def _product_key(item):
|
||||
project_code = (item.get("projectCode") or item.get("project_code") or "").strip()
|
||||
spec_text = (item.get("specText") or item.get("spec_text") or "").strip()
|
||||
cell_type = (item.get("cellType") or item.get("cell_type") or "").strip()
|
||||
return f"{project_code}|{spec_text}|{cell_type}"
|
||||
|
||||
def build_row(row_id):
|
||||
return BizChannelServiceOrderProduct(
|
||||
id=row_id,
|
||||
service_order_id=order_id,
|
||||
project_code=project_code,
|
||||
project_name=project_name,
|
||||
cell_type=cell_type,
|
||||
spec_text=spec_text,
|
||||
agreement_unit_price=agreement_unit_price,
|
||||
buy_qty=buy_qty,
|
||||
buy_amount=buy_amount,
|
||||
tenant_no=tenant_no or "zkxw",
|
||||
order_num=0,
|
||||
remark="",
|
||||
tenant_no=tenant,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(prod_row)
|
||||
|
||||
def apply_row(row, p, _idx, _now):
|
||||
agreement_unit_price = float(p.get("agreementUnitPrice") or p.get("agreement_unit_price") or 0)
|
||||
buy_qty = float(p.get("buyQty") or p.get("buy_qty") or 0)
|
||||
row.project_code = p.get("projectCode") or p.get("project_code") or ""
|
||||
row.project_name = p.get("projectName") or p.get("project_name") or ""
|
||||
row.cell_type = p.get("cellType") or p.get("cell_type") or ""
|
||||
row.spec_text = p.get("specText") or p.get("spec_text") or ""
|
||||
row.agreement_unit_price = agreement_unit_price
|
||||
row.buy_qty = buy_qty
|
||||
row.buy_amount = float(p.get("buyAmount") or p.get("buy_amount") or agreement_unit_price * buy_qty)
|
||||
row.order_num = 0
|
||||
row.remark = ""
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizChannelServiceOrderProduct,
|
||||
parent_attr="service_order_id",
|
||||
parent_id=order_id,
|
||||
tenant_no=tenant,
|
||||
items=list(products or []),
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=_product_key,
|
||||
row_natural_key=lambda row: f"{(row.project_code or '').strip()}|{(row.spec_text or '').strip()}|{(row.cell_type or '').strip()}",
|
||||
duplicate_message="订单产品重复",
|
||||
foreign_message="订单产品不属于当前订单",
|
||||
)
|
||||
db.flush()
|
||||
|
||||
|
||||
def _replace_gmcp_product_rows(db, order_id, products, tenant_no, user_id):
|
||||
now = datetime.now()
|
||||
db.query(BizChannelServiceOrderProduct).filter(
|
||||
and_(
|
||||
BizChannelServiceOrderProduct.service_order_id == order_id,
|
||||
BizChannelServiceOrderProduct.del_flag == 0,
|
||||
)
|
||||
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
|
||||
_save_gmcp_product_rows(db, order_id, products, tenant_no, user_id, now=now)
|
||||
_save_gmcp_product_rows(db, order_id, products, tenant_no, user_id)
|
||||
|
||||
|
||||
def _apply_gmcp_order_quotas(db, order, tenant_no, user_id):
|
||||
|
||||
+109
-4
@@ -9,10 +9,53 @@ 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.crud.child_row_sync import sync_link_rows
|
||||
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 +64,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 +102,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 +201,67 @@ 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:
|
||||
"""按角色更新本职位的角色关联。"""
|
||||
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("存在无效角色或不属于当前租户")
|
||||
|
||||
sync_link_rows(
|
||||
db,
|
||||
SysPostRole,
|
||||
parent_attr="post_id",
|
||||
parent_id=post_id,
|
||||
link_attr="role_id",
|
||||
link_ids=ordered,
|
||||
tenant_no=tenant_no,
|
||||
user_id=updater_id,
|
||||
duplicate_message="角色重复",
|
||||
foreign_message="角色关联不属于当前职位",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+138
-12
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.models.models import (
|
||||
BizProcess,
|
||||
BizProcessCategory,
|
||||
@@ -82,6 +83,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 "",
|
||||
@@ -299,6 +301,132 @@ def _create_sub_data_collection(db: Session, process_id: str, tenant_no: str, us
|
||||
db.add(obj)
|
||||
|
||||
|
||||
def _new_process_child(model, row_id: str, process_id: str, tenant_no: str, user_id: Optional[str], now: datetime):
|
||||
return model(
|
||||
id=row_id,
|
||||
process_id=process_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
|
||||
def _sync_process_reagents(db, process_id, tenant_no, user_id, now, items):
|
||||
def apply_row(row, data, idx, _now):
|
||||
item = _sub_item_with_order(data, idx)
|
||||
row.reagent_code = (item.get("reagent_code") or "").strip()
|
||||
row.reagent_name = (item.get("reagent_name") or "").strip()
|
||||
row.spec = (item.get("spec") or "").strip() or None
|
||||
row.standard_usage = (item.get("standard_usage") or "").strip() or None
|
||||
row.unit = (item.get("unit") or "").strip() or None
|
||||
row.is_key_material = item.get("is_key_material", 0)
|
||||
row.remark = (item.get("remark") or "").strip() or None
|
||||
row.order_num = item.get("order_num", 0) if item.get("order_num") is not None else 0
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProcessReagent,
|
||||
parent_attr="process_id",
|
||||
parent_id=process_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=lambda row_id: _new_process_child(BizProcessReagent, row_id, process_id, tenant_no, user_id, now),
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("reagent_code") or "").strip(),
|
||||
row_natural_key=lambda row: (row.reagent_code or "").strip(),
|
||||
duplicate_message="试剂编号重复",
|
||||
foreign_message="试剂不属于当前工艺",
|
||||
)
|
||||
|
||||
|
||||
def _sync_process_consumables(db, process_id, tenant_no, user_id, now, items):
|
||||
def apply_row(row, data, idx, _now):
|
||||
item = _sub_item_with_order(data, idx)
|
||||
row.consumable_code = (item.get("consumable_code") or "").strip()
|
||||
row.consumable_name = (item.get("consumable_name") or "").strip()
|
||||
row.spec_model = (item.get("spec_model") or "").strip() or None
|
||||
row.standard_usage = (item.get("standard_usage") or "").strip() or None
|
||||
row.unit = (item.get("unit") or "").strip() or None
|
||||
row.is_required = item.get("is_required", 0)
|
||||
row.remark = (item.get("remark") or "").strip() or None
|
||||
row.order_num = item.get("order_num", 0) if item.get("order_num") is not None else 0
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProcessConsumable,
|
||||
parent_attr="process_id",
|
||||
parent_id=process_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=lambda row_id: _new_process_child(BizProcessConsumable, row_id, process_id, tenant_no, user_id, now),
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("consumable_code") or "").strip(),
|
||||
row_natural_key=lambda row: (row.consumable_code or "").strip(),
|
||||
duplicate_message="耗材编号重复",
|
||||
foreign_message="耗材不属于当前工艺",
|
||||
)
|
||||
|
||||
|
||||
def _sync_process_outputs(db, process_id, tenant_no, user_id, now, items):
|
||||
def apply_row(row, data, idx, _now):
|
||||
item = _sub_item_with_order(data, idx)
|
||||
row.output_type_id = (item.get("output_type_id") or "").strip() or None
|
||||
row.output_name = (item.get("output_name") or "").strip() or None
|
||||
row.output_barcode = (item.get("output_barcode") or "").strip() or None
|
||||
row.remark = (item.get("remark") or "").strip() or None
|
||||
row.order_num = item.get("order_num", 0) if item.get("order_num") is not None else 0
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProcessOutput,
|
||||
parent_attr="process_id",
|
||||
parent_id=process_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=lambda row_id: _new_process_child(BizProcessOutput, row_id, process_id, tenant_no, user_id, now),
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("output_barcode") or item.get("output_name") or "").strip(),
|
||||
row_natural_key=lambda row: (row.output_barcode or row.output_name or "").strip(),
|
||||
duplicate_message="产出物编号重复",
|
||||
foreign_message="产出物不属于当前工艺",
|
||||
)
|
||||
|
||||
|
||||
def _sync_process_data_collections(db, process_id, tenant_no, user_id, now, items):
|
||||
def apply_row(row, data, idx, _now):
|
||||
row.collection_name = (data.get("collection_name") or "").strip()
|
||||
row.unit = (data.get("unit") or "").strip() or None
|
||||
order_num = data.get("order_num")
|
||||
row.order_num = idx if order_num is None else order_num
|
||||
row.remark = (data.get("remark") or "").strip() or None
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProcessDataCollection,
|
||||
parent_attr="process_id",
|
||||
parent_id=process_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=lambda row_id: _new_process_child(
|
||||
BizProcessDataCollection, row_id, process_id, tenant_no, user_id, now
|
||||
),
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("collection_name") or "").strip(),
|
||||
row_natural_key=lambda row: (row.collection_name or "").strip(),
|
||||
duplicate_message="数据采集编号重复",
|
||||
foreign_message="数据采集不属于当前工艺",
|
||||
)
|
||||
|
||||
|
||||
def create_process(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -309,6 +437,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 +463,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 +518,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 +548,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:
|
||||
@@ -428,22 +561,15 @@ def update_process(
|
||||
sub_updated = reagents is not None or consumables is not None or outputs is not None or data_collections is not None
|
||||
if sub_updated:
|
||||
now = datetime.now()
|
||||
tenant_no = obj.tenant_no
|
||||
if reagents is not None:
|
||||
_soft_delete_sub_records(db, BizProcessReagent, process_id, now)
|
||||
for idx, r in enumerate(reagents):
|
||||
_create_sub_reagent(db, process_id, obj.tenant_no, user_id, now, _sub_item_with_order(r, idx))
|
||||
_sync_process_reagents(db, process_id, tenant_no, user_id, now, reagents)
|
||||
if consumables is not None:
|
||||
_soft_delete_sub_records(db, BizProcessConsumable, process_id, now)
|
||||
for idx, c in enumerate(consumables):
|
||||
_create_sub_consumable(db, process_id, obj.tenant_no, user_id, now, _sub_item_with_order(c, idx))
|
||||
_sync_process_consumables(db, process_id, tenant_no, user_id, now, consumables)
|
||||
if outputs is not None:
|
||||
_soft_delete_sub_records(db, BizProcessOutput, process_id, now)
|
||||
for idx, o in enumerate(outputs):
|
||||
_create_sub_output(db, process_id, obj.tenant_no, user_id, now, _sub_item_with_order(o, idx))
|
||||
_sync_process_outputs(db, process_id, tenant_no, user_id, now, outputs)
|
||||
if data_collections is not None:
|
||||
_soft_delete_sub_records(db, BizProcessDataCollection, process_id, now)
|
||||
for d in data_collections:
|
||||
_create_sub_data_collection(db, process_id, obj.tenant_no, user_id, now, d)
|
||||
_sync_process_data_collections(db, process_id, tenant_no, user_id, now, data_collections)
|
||||
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
|
||||
@@ -9,8 +9,10 @@ from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
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 +39,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 +62,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 +150,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 +174,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 +262,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 +284,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 +421,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 +478,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 +543,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,
|
||||
@@ -603,38 +653,147 @@ def soft_delete_route(db: Session, route_id: str) -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 子表完整替换更新
|
||||
# 子表按行更新:保留原行 ID,去掉的才软删除
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _new_route_step_child(model, row_id, step_id, tenant_no, user_id, now):
|
||||
return model(
|
||||
id=row_id,
|
||||
step_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
|
||||
def update_step_reagents(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
|
||||
now = datetime.now()
|
||||
_soft_delete_sub_records(db, BizProcessRouteStepReagent, step_id, now)
|
||||
for it in items:
|
||||
_create_step_reagent(db, step_id, tenant_no, user_id, now, it)
|
||||
|
||||
def apply_row(row, data, idx, _now):
|
||||
row.reagent_code = (data.get("reagent_code") or "").strip() or None
|
||||
row.reagent_name = (data.get("reagent_name") or "").strip() or None
|
||||
row.spec = (data.get("spec") or "").strip() or None
|
||||
row.standard_usage = (data.get("standard_usage") or "").strip() or None
|
||||
row.unit = (data.get("unit") or "").strip() or None
|
||||
row.is_key_material = data.get("is_key_material", "0")
|
||||
row.remark = (data.get("remark") or "").strip() or None
|
||||
row.order_num = idx
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProcessRouteStepReagent,
|
||||
parent_attr="step_id",
|
||||
parent_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=lambda row_id: _new_route_step_child(
|
||||
BizProcessRouteStepReagent, row_id, step_id, tenant_no, user_id, now
|
||||
),
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("reagent_code") or "").strip(),
|
||||
row_natural_key=lambda row: (row.reagent_code or "").strip(),
|
||||
duplicate_message="试剂编号重复",
|
||||
foreign_message="试剂不属于当前步骤",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def update_step_consumables(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
|
||||
now = datetime.now()
|
||||
_soft_delete_sub_records(db, BizProcessRouteStepConsumable, step_id, now)
|
||||
for it in items:
|
||||
_create_step_consumable(db, step_id, tenant_no, user_id, now, it)
|
||||
|
||||
def apply_row(row, data, idx, _now):
|
||||
row.consumable_code = (data.get("consumable_code") or "").strip() or None
|
||||
row.consumable_name = (data.get("consumable_name") or "").strip() or None
|
||||
row.spec_model = (data.get("spec_model") or "").strip() or None
|
||||
row.standard_usage = (data.get("standard_usage") or "").strip() or None
|
||||
row.unit = (data.get("unit") or "").strip() or None
|
||||
row.is_required = data.get("is_required", "1")
|
||||
row.remark = (data.get("remark") or "").strip() or None
|
||||
row.order_num = idx
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProcessRouteStepConsumable,
|
||||
parent_attr="step_id",
|
||||
parent_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=lambda row_id: _new_route_step_child(
|
||||
BizProcessRouteStepConsumable, row_id, step_id, tenant_no, user_id, now
|
||||
),
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("consumable_code") or "").strip(),
|
||||
row_natural_key=lambda row: (row.consumable_code or "").strip(),
|
||||
duplicate_message="耗材编号重复",
|
||||
foreign_message="耗材不属于当前步骤",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def update_step_outputs(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
|
||||
now = datetime.now()
|
||||
_soft_delete_sub_records(db, BizProcessRouteStepOutput, step_id, now)
|
||||
for it in items:
|
||||
_create_step_output(db, step_id, tenant_no, user_id, now, it)
|
||||
|
||||
def apply_row(row, data, idx, _now):
|
||||
row.output_type_id = (data.get("output_type_id") or "").strip() or None
|
||||
row.output_name = (data.get("output_name") or "").strip() or None
|
||||
row.output_barcode = (data.get("output_barcode") or "").strip() or None
|
||||
row.remark = (data.get("remark") or "").strip() or None
|
||||
row.order_num = idx
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProcessRouteStepOutput,
|
||||
parent_attr="step_id",
|
||||
parent_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=lambda row_id: _new_route_step_child(
|
||||
BizProcessRouteStepOutput, row_id, step_id, tenant_no, user_id, now
|
||||
),
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("output_barcode") or item.get("output_name") or "").strip(),
|
||||
row_natural_key=lambda row: (row.output_barcode or row.output_name or "").strip(),
|
||||
duplicate_message="产出物编号重复",
|
||||
foreign_message="产出物不属于当前步骤",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def update_step_data_collections(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
|
||||
now = datetime.now()
|
||||
_soft_delete_sub_records(db, BizProcessRouteStepDataCollection, step_id, now)
|
||||
for it in items:
|
||||
_create_step_data_collection(db, step_id, tenant_no, user_id, now, it)
|
||||
|
||||
def apply_row(row, data, idx, _now):
|
||||
row.collection_name = (data.get("collection_name") or "").strip() or None
|
||||
row.unit = (data.get("unit") or "").strip() or None
|
||||
order_num = data.get("order_num")
|
||||
row.order_num = idx if order_num is None else (order_num or 0)
|
||||
row.remark = (data.get("remark") or "").strip() or None
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProcessRouteStepDataCollection,
|
||||
parent_attr="step_id",
|
||||
parent_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=lambda row_id: _new_route_step_child(
|
||||
BizProcessRouteStepDataCollection, row_id, step_id, tenant_no, user_id, now
|
||||
),
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("collection_name") or "").strip(),
|
||||
row_natural_key=lambda row: (row.collection_name or "").strip(),
|
||||
duplicate_message="数据采集编号重复",
|
||||
foreign_message="数据采集不属于当前步骤",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.crud.production_material import _dec, _fmt_dt
|
||||
from app.models.production_material import BizProductionMaterial, BizProductionMaterialFormula
|
||||
|
||||
@@ -84,7 +84,7 @@ def replace_formula(
|
||||
raise ValueError("请至少添加一条配方明细")
|
||||
|
||||
seen_components: set = set()
|
||||
new_rows: List[BizProductionMaterialFormula] = []
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
|
||||
for idx, it in enumerate(lines):
|
||||
comp_id = (
|
||||
@@ -125,45 +125,58 @@ def replace_formula(
|
||||
it.get("usageUnit") or it.get("usage_unit") or comp.unit or ""
|
||||
).strip()[:32]
|
||||
|
||||
new_rows.append(
|
||||
BizProductionMaterialFormula(
|
||||
id=generate_uuid(),
|
||||
configured_material_id=configured_material_id,
|
||||
component_material_id=comp_id,
|
||||
component_material_type=comp_type,
|
||||
component_material_code=comp.material_code,
|
||||
component_material_name=comp.material_name,
|
||||
standard_qty=standard_qty,
|
||||
usage_unit=usage_unit or None,
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx,
|
||||
remark=(it.get("remark") or "").strip()[:512] or None,
|
||||
create_by=user_id,
|
||||
create_dept_id=dept_id or configured.create_dept_id or "",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
normalized.append(
|
||||
{
|
||||
"id": (it.get("id") or "").strip(),
|
||||
"component_material_id": comp_id,
|
||||
"component_material_type": comp_type,
|
||||
"component_material_code": comp.material_code,
|
||||
"component_material_name": comp.material_name,
|
||||
"standard_qty": standard_qty,
|
||||
"usage_unit": usage_unit or None,
|
||||
"order_num": idx,
|
||||
"remark": (it.get("remark") or "").strip()[:512] or None,
|
||||
}
|
||||
)
|
||||
|
||||
existing = (
|
||||
db.query(BizProductionMaterialFormula)
|
||||
.filter(
|
||||
BizProductionMaterialFormula.configured_material_id == configured_material_id,
|
||||
BizProductionMaterialFormula.tenant_no == tenant_no,
|
||||
BizProductionMaterialFormula.del_flag == 0,
|
||||
dept = dept_id or configured.create_dept_id or ""
|
||||
|
||||
def build_row(row_id: str) -> BizProductionMaterialFormula:
|
||||
return BizProductionMaterialFormula(
|
||||
id=row_id,
|
||||
configured_material_id=configured_material_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id=dept,
|
||||
create_time=now,
|
||||
)
|
||||
.all()
|
||||
|
||||
def apply_row(row: BizProductionMaterialFormula, it: Dict[str, Any], idx: int, _now: datetime) -> None:
|
||||
row.component_material_id = it["component_material_id"]
|
||||
row.component_material_type = it["component_material_type"]
|
||||
row.component_material_code = it["component_material_code"]
|
||||
row.component_material_name = it["component_material_name"]
|
||||
row.standard_qty = it["standard_qty"]
|
||||
row.usage_unit = it["usage_unit"]
|
||||
row.order_num = idx
|
||||
row.remark = it["remark"]
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProductionMaterialFormula,
|
||||
parent_attr="configured_material_id",
|
||||
parent_id=configured_material_id,
|
||||
tenant_no=tenant_no,
|
||||
items=normalized,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda it: (it.get("component_material_id") or "").strip(),
|
||||
row_natural_key=lambda row: (row.component_material_id or "").strip(),
|
||||
duplicate_message="配方明细编号重复",
|
||||
foreign_message="配方明细不属于当前配置试剂",
|
||||
)
|
||||
for row in existing:
|
||||
row.del_flag = 1
|
||||
row.update_by = user_id
|
||||
row.update_time = now
|
||||
db.add(row)
|
||||
|
||||
for row in new_rows:
|
||||
db.add(row)
|
||||
|
||||
db.commit()
|
||||
return get_formula(db, configured_material_id, tenant_no)
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid, parse_datetime
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.crud.production_material import _dec, _fmt_dt, _is_enabled_status
|
||||
from app.crud.production_material_stock_batch import (
|
||||
_create_batch_and_usage,
|
||||
@@ -1007,44 +1008,65 @@ def update_prep(
|
||||
lines_raw = data.get("lines")
|
||||
if lines_raw is not None:
|
||||
parsed_lines = _parse_prep_lines(db, lines_raw, tenant_no=tenant_no)
|
||||
existing = (
|
||||
db.query(BizProductionMaterialPrepLine)
|
||||
.filter(
|
||||
BizProductionMaterialPrepLine.prep_id == prep_id,
|
||||
BizProductionMaterialPrepLine.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for ln in existing:
|
||||
ln.del_flag = 1
|
||||
ln.update_by = user_id
|
||||
ln.update_time = now
|
||||
db.add(ln)
|
||||
line_items = []
|
||||
for pl in parsed_lines:
|
||||
comp = pl["component"]
|
||||
db.add(
|
||||
BizProductionMaterialPrepLine(
|
||||
id=generate_uuid(),
|
||||
prep_id=prep_id,
|
||||
component_material_id=comp.id,
|
||||
component_material_type=pl["component_type"],
|
||||
component_material_code=comp.material_code,
|
||||
component_material_name=comp.material_name,
|
||||
requisition_usage_id=pl["requisition"].id,
|
||||
actual_qty=pl["actual_qty"],
|
||||
usage_unit=pl["usage_unit"] or None,
|
||||
tenant_no=tenant_no,
|
||||
order_num=pl["idx"],
|
||||
remark=pl["remark"],
|
||||
create_by=user_id,
|
||||
create_dept_id=dept_id or prep.create_dept_id or "",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
line_items.append(
|
||||
{
|
||||
"id": (pl.get("id") or "").strip(),
|
||||
"component_material_id": comp.id,
|
||||
"component_material_type": pl["component_type"],
|
||||
"component_material_code": comp.material_code,
|
||||
"component_material_name": comp.material_name,
|
||||
"requisition_usage_id": pl["requisition"].id,
|
||||
"actual_qty": pl["actual_qty"],
|
||||
"usage_unit": pl["usage_unit"] or None,
|
||||
"order_num": pl["idx"],
|
||||
"remark": pl["remark"],
|
||||
}
|
||||
)
|
||||
|
||||
def build_line(row_id):
|
||||
return BizProductionMaterialPrepLine(
|
||||
id=row_id,
|
||||
prep_id=prep_id,
|
||||
component_material_id="",
|
||||
component_material_type="",
|
||||
actual_qty=0,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id=dept_id or prep.create_dept_id or "",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
def apply_line(row, item, _idx, _now):
|
||||
row.component_material_id = item["component_material_id"]
|
||||
row.component_material_type = item["component_material_type"]
|
||||
row.component_material_code = item["component_material_code"]
|
||||
row.component_material_name = item["component_material_name"]
|
||||
row.requisition_usage_id = item["requisition_usage_id"]
|
||||
row.actual_qty = item["actual_qty"]
|
||||
row.usage_unit = item["usage_unit"]
|
||||
row.order_num = item["order_num"]
|
||||
row.remark = item["remark"]
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProductionMaterialPrepLine,
|
||||
parent_attr="prep_id",
|
||||
parent_id=prep_id,
|
||||
tenant_no=tenant_no,
|
||||
items=line_items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_line,
|
||||
apply_fields=apply_line,
|
||||
natural_key=lambda item: f"{item['component_material_id']}:{item['requisition_usage_id'] or ''}",
|
||||
row_natural_key=lambda row: f"{row.component_material_id}:{row.requisition_usage_id or ''}",
|
||||
duplicate_message="配制明细重复",
|
||||
foreign_message="配制明细不属于当前配制单",
|
||||
)
|
||||
|
||||
prep.update_by = user_id
|
||||
prep.update_time = now
|
||||
db.add(prep)
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.crud.production_material_stock_batch import _dec, _fmt_date, _fmt_dt, _parse_date, _require_str
|
||||
from app.models.models import SysUser
|
||||
from app.models.production_material import (
|
||||
@@ -449,6 +450,47 @@ def _resolve_unused_item_fields(
|
||||
return out
|
||||
|
||||
|
||||
def _unused_line_key(usage_id: Optional[str], batch_id: Optional[str]) -> str:
|
||||
usage = (usage_id or "").strip()
|
||||
if usage:
|
||||
return f"usage:{usage}"
|
||||
batch = (batch_id or "").strip()
|
||||
if batch:
|
||||
return f"batch:{batch}"
|
||||
return ""
|
||||
|
||||
|
||||
def _prepare_unused_item(db: Session, it: Dict[str, Any], tenant_no: str) -> Dict[str, Any]:
|
||||
it = _resolve_unused_item_fields(db, it, tenant_no)
|
||||
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
|
||||
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
|
||||
if qty is None or Decimal(str(qty)) <= 0:
|
||||
raise ValueError("未用完数量必须大于0")
|
||||
unit_price = it.get("unit_price") if it.get("unit_price") is not None else it.get("unitPrice")
|
||||
material_type = it.get("material_type") if "material_type" in it else it.get("materialType")
|
||||
return {
|
||||
"id": str(it.get("id") or "").strip(),
|
||||
"usage_id": (it.get("usage_id") or it.get("usageId") or "").strip() or None,
|
||||
"batch_id": (it.get("batch_id") or it.get("batchId") or "").strip() or None,
|
||||
"material_type": (material_type or "").strip() or None if material_type is not None else None,
|
||||
"has_material_type": "material_type" in it or "materialType" in it,
|
||||
"material_code": (it.get("material_code") or it.get("materialCode") or "").strip() or None,
|
||||
"material_name": name,
|
||||
"spec_model": (it.get("spec_model") or it.get("specModel") or "").strip() or None,
|
||||
"unit": (it.get("unit") or "").strip() or None,
|
||||
"stock_in_no": (it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
|
||||
"stock_in_date": _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate")),
|
||||
"expire_date": _parse_optional_date(it.get("expire_date") or it.get("expireDate")),
|
||||
"catalog_no": (it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
|
||||
"batch_no": (it.get("batch_no") or it.get("batchNo") or "").strip() or None,
|
||||
"manufacturer": (it.get("manufacturer") or "").strip() or None,
|
||||
"unit_price": Decimal(str(unit_price)) if unit_price is not None else None,
|
||||
"holder": (it.get("holder") or "").strip() or None,
|
||||
"unused_qty": Decimal(str(qty)),
|
||||
"remark": (it.get("remark") or "").strip() or None,
|
||||
}
|
||||
|
||||
|
||||
def save_unused_lines(
|
||||
db: Session,
|
||||
check_id: str,
|
||||
@@ -465,73 +507,63 @@ def save_unused_lines(
|
||||
raise ValueError("仅进行中的盘点单可登记未用完物料")
|
||||
|
||||
now = datetime.now()
|
||||
old_rows = (
|
||||
db.query(BizProductionMaterialStockCheckUnusedLine)
|
||||
.filter(
|
||||
BizProductionMaterialStockCheckUnusedLine.check_id == check_id,
|
||||
BizProductionMaterialStockCheckUnusedLine.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for r in old_rows:
|
||||
r.del_flag = 1
|
||||
r.update_by = user_id
|
||||
r.update_time = now
|
||||
db.add(r)
|
||||
prepared = [_prepare_unused_item(db, it, tenant_no) for it in (items or [])]
|
||||
|
||||
count = 0
|
||||
for it in items or []:
|
||||
it = _resolve_unused_item_fields(db, it, tenant_no)
|
||||
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
|
||||
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
|
||||
if qty is None or Decimal(str(qty)) <= 0:
|
||||
raise ValueError("未用完数量必须大于0")
|
||||
qty_dec = Decimal(str(qty))
|
||||
stock_in_date = _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate"))
|
||||
expire_date = _parse_optional_date(it.get("expire_date") or it.get("expireDate"))
|
||||
usage_id = (it.get("usage_id") or it.get("usageId") or "").strip() or None
|
||||
batch_id = (it.get("batch_id") or it.get("batchId") or "").strip() or None
|
||||
|
||||
row = BizProductionMaterialStockCheckUnusedLine(
|
||||
id=generate_uuid(),
|
||||
def build_row(row_id: str):
|
||||
return BizProductionMaterialStockCheckUnusedLine(
|
||||
id=row_id,
|
||||
check_id=check_id,
|
||||
usage_id=usage_id,
|
||||
batch_id=batch_id,
|
||||
material_type=(it.get("material_type") or it.get("materialType") or "").strip() or None,
|
||||
material_code=(it.get("material_code") or it.get("materialCode") or "").strip() or None,
|
||||
material_name=name,
|
||||
spec_model=(it.get("spec_model") or it.get("specModel") or "").strip() or None,
|
||||
unit=(it.get("unit") or "").strip() or None,
|
||||
stock_in_no=(it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
|
||||
stock_in_date=stock_in_date,
|
||||
expire_date=expire_date,
|
||||
catalog_no=(it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
|
||||
batch_no=(it.get("batch_no") or it.get("batchNo") or "").strip() or None,
|
||||
manufacturer=(it.get("manufacturer") or "").strip() or None,
|
||||
unit_price=(
|
||||
Decimal(str(it.get("unit_price") or it.get("unitPrice")))
|
||||
if (it.get("unit_price") is not None or it.get("unitPrice") is not None)
|
||||
else None
|
||||
),
|
||||
holder=(it.get("holder") or "").strip() or None,
|
||||
unused_qty=qty_dec,
|
||||
material_name="",
|
||||
unused_qty=0,
|
||||
tenant_no=tenant_no,
|
||||
remark=(it.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(row)
|
||||
count += 1
|
||||
|
||||
def apply_row(row, it, idx, _now):
|
||||
row.usage_id = it["usage_id"]
|
||||
row.batch_id = it["batch_id"]
|
||||
if it["has_material_type"]:
|
||||
row.material_type = it["material_type"]
|
||||
row.material_code = it["material_code"]
|
||||
row.material_name = it["material_name"]
|
||||
row.spec_model = it["spec_model"]
|
||||
row.unit = it["unit"]
|
||||
row.stock_in_no = it["stock_in_no"]
|
||||
row.stock_in_date = it["stock_in_date"]
|
||||
row.expire_date = it["expire_date"]
|
||||
row.catalog_no = it["catalog_no"]
|
||||
row.batch_no = it["batch_no"]
|
||||
row.manufacturer = it["manufacturer"]
|
||||
row.unit_price = it["unit_price"]
|
||||
row.holder = it["holder"]
|
||||
row.unused_qty = it["unused_qty"]
|
||||
row.remark = it["remark"]
|
||||
row.order_num = idx
|
||||
|
||||
saved = sync_child_rows(
|
||||
db,
|
||||
BizProductionMaterialStockCheckUnusedLine,
|
||||
parent_attr="check_id",
|
||||
parent_id=check_id,
|
||||
tenant_no=tenant_no,
|
||||
items=prepared,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda it: _unused_line_key(it.get("usage_id"), it.get("batch_id")),
|
||||
row_natural_key=lambda row: _unused_line_key(row.usage_id, row.batch_id),
|
||||
duplicate_message="未用完明细重复",
|
||||
foreign_message="未用完明细不属于当前盘点单",
|
||||
)
|
||||
|
||||
check.update_by = user_id
|
||||
check.update_time = now
|
||||
db.add(check)
|
||||
db.commit()
|
||||
return count
|
||||
return len(saved)
|
||||
|
||||
|
||||
def complete_check(
|
||||
|
||||
@@ -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)))
|
||||
+406
-139
@@ -9,7 +9,9 @@ from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import format_date, generate_uuid, parse_datetime
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.crud.equipment_snapshot import parse_equipment_ids, resolve_equipment_list_snapshot
|
||||
from app.crud.user_display import user_display_name_map
|
||||
from app.models.models import (
|
||||
BizCellProduct,
|
||||
BizCollectionProtocol,
|
||||
@@ -309,6 +311,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 +691,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 "",
|
||||
@@ -725,12 +729,7 @@ def _enrich_assignee_names(db: Session, items: List[Dict[str, Any]]) -> None:
|
||||
user_ids = list({(x.get("currentAssigneeUserId") or "").strip() for x in items if (x.get("currentAssigneeUserId") or "").strip()})
|
||||
if not user_ids:
|
||||
return
|
||||
rows = (
|
||||
db.query(SysUser.id, SysUser.nick_name)
|
||||
.filter(SysUser.id.in_(user_ids), SysUser.del_flag == 0)
|
||||
.all()
|
||||
)
|
||||
name_map = {r.id: (r.nick_name or "").strip() for r in rows}
|
||||
name_map = user_display_name_map(db, user_ids)
|
||||
for item in items:
|
||||
uid = (item.get("currentAssigneeUserId") or "").strip()
|
||||
if uid:
|
||||
@@ -1017,6 +1016,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 +1180,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 +1971,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 +2033,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 +2094,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 +2175,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 +2227,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"):
|
||||
@@ -2260,10 +2303,17 @@ def replace_step_reagents(
|
||||
task_no = task_row.task_no or ""
|
||||
|
||||
try:
|
||||
_soft_delete_sub(db, BizProductionTaskStepReagent, "step_id", step_id, now)
|
||||
def build_row(row_id: str) -> BizProductionTaskStepReagent:
|
||||
return BizProductionTaskStepReagent(
|
||||
id=row_id,
|
||||
step_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
usage_items: List[Dict[str, Any]] = []
|
||||
for idx, it in enumerate(items):
|
||||
def apply_row(row: BizProductionTaskStepReagent, it: Dict[str, Any], idx: int, _now: datetime) -> None:
|
||||
req_id = (
|
||||
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
|
||||
)
|
||||
@@ -2275,20 +2325,39 @@ def replace_step_reagents(
|
||||
it.get("actual_usage"),
|
||||
req_id,
|
||||
)
|
||||
db.add(BizProductionTaskStepReagent(
|
||||
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
|
||||
reagent_code=(it.get("reagent_code") or "").strip() or None,
|
||||
reagent_name=(it.get("reagent_name") or "").strip() or None,
|
||||
spec=(it.get("spec") or "").strip() or None,
|
||||
standard_usage=(it.get("standard_usage") or "").strip() or None,
|
||||
actual_usage=(it.get("actual_usage") or "").strip() or None,
|
||||
unit=(it.get("unit") or "").strip() or None,
|
||||
requisition_usage_id=req_id or None,
|
||||
is_key_material=it.get("is_key_material", "0"),
|
||||
remark=(it.get("remark") or "").strip() or None,
|
||||
order_num=idx, create_by=user_id, create_dept_id="",
|
||||
create_time=now, update_by=user_id, update_time=now, del_flag=0,
|
||||
))
|
||||
row.reagent_code = (it.get("reagent_code") or "").strip() or None
|
||||
row.reagent_name = (it.get("reagent_name") or "").strip() or None
|
||||
row.spec = (it.get("spec") or "").strip() or None
|
||||
row.standard_usage = (it.get("standard_usage") or "").strip() or None
|
||||
row.actual_usage = (it.get("actual_usage") or "").strip() or None
|
||||
row.unit = (it.get("unit") or "").strip() or None
|
||||
row.requisition_usage_id = req_id or None
|
||||
row.is_key_material = it.get("is_key_material", "0")
|
||||
row.remark = (it.get("remark") or "").strip() or None
|
||||
row.order_num = idx
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProductionTaskStepReagent,
|
||||
parent_attr="step_id",
|
||||
parent_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda it: (it.get("reagent_code") or "").strip(),
|
||||
row_natural_key=lambda row: (row.reagent_code or "").strip(),
|
||||
duplicate_message="试剂编号重复",
|
||||
foreign_message="试剂不属于当前步骤",
|
||||
)
|
||||
|
||||
usage_items: List[Dict[str, Any]] = []
|
||||
for it in items:
|
||||
req_id = (
|
||||
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
|
||||
)
|
||||
if req_id:
|
||||
usage_items.append({
|
||||
"requisitionUsageId": req_id,
|
||||
@@ -2330,10 +2399,17 @@ def replace_step_consumables(
|
||||
task_no = task_row.task_no or ""
|
||||
|
||||
try:
|
||||
_soft_delete_sub(db, BizProductionTaskStepConsumable, "step_id", step_id, now)
|
||||
def build_row(row_id: str) -> BizProductionTaskStepConsumable:
|
||||
return BizProductionTaskStepConsumable(
|
||||
id=row_id,
|
||||
step_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
usage_items: List[Dict[str, Any]] = []
|
||||
for idx, it in enumerate(items):
|
||||
def apply_row(row: BizProductionTaskStepConsumable, it: Dict[str, Any], idx: int, _now: datetime) -> None:
|
||||
req_id = (
|
||||
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
|
||||
)
|
||||
@@ -2345,20 +2421,39 @@ def replace_step_consumables(
|
||||
it.get("actual_usage"),
|
||||
req_id,
|
||||
)
|
||||
db.add(BizProductionTaskStepConsumable(
|
||||
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
|
||||
consumable_code=(it.get("consumable_code") or "").strip() or None,
|
||||
consumable_name=(it.get("consumable_name") or "").strip() or None,
|
||||
spec_model=(it.get("spec_model") or "").strip() or None,
|
||||
standard_usage=(it.get("standard_usage") or "").strip() or None,
|
||||
actual_usage=(it.get("actual_usage") or "").strip() or None,
|
||||
unit=(it.get("unit") or "").strip() or None,
|
||||
requisition_usage_id=req_id or None,
|
||||
is_required=it.get("is_required", "1"),
|
||||
remark=(it.get("remark") or "").strip() or None,
|
||||
order_num=idx, create_by=user_id, create_dept_id="",
|
||||
create_time=now, update_by=user_id, update_time=now, del_flag=0,
|
||||
))
|
||||
row.consumable_code = (it.get("consumable_code") or "").strip() or None
|
||||
row.consumable_name = (it.get("consumable_name") or "").strip() or None
|
||||
row.spec_model = (it.get("spec_model") or "").strip() or None
|
||||
row.standard_usage = (it.get("standard_usage") or "").strip() or None
|
||||
row.actual_usage = (it.get("actual_usage") or "").strip() or None
|
||||
row.unit = (it.get("unit") or "").strip() or None
|
||||
row.requisition_usage_id = req_id or None
|
||||
row.is_required = it.get("is_required", "1")
|
||||
row.remark = (it.get("remark") or "").strip() or None
|
||||
row.order_num = idx
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProductionTaskStepConsumable,
|
||||
parent_attr="step_id",
|
||||
parent_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda it: (it.get("consumable_code") or "").strip(),
|
||||
row_natural_key=lambda row: (row.consumable_code or "").strip(),
|
||||
duplicate_message="耗材编号重复",
|
||||
foreign_message="耗材不属于当前步骤",
|
||||
)
|
||||
|
||||
usage_items: List[Dict[str, Any]] = []
|
||||
for it in items:
|
||||
req_id = (
|
||||
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
|
||||
)
|
||||
if req_id:
|
||||
usage_items.append({
|
||||
"requisitionUsageId": req_id,
|
||||
@@ -2399,10 +2494,22 @@ def replace_step_configured_reagents(
|
||||
task_no = task_row.task_no or ""
|
||||
|
||||
try:
|
||||
_soft_delete_sub(db, BizProductionTaskStepConfiguredReagent, "step_id", step_id, now)
|
||||
def build_row(row_id: str) -> BizProductionTaskStepConfiguredReagent:
|
||||
return BizProductionTaskStepConfiguredReagent(
|
||||
id=row_id,
|
||||
step_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
usage_items: List[Dict[str, Any]] = []
|
||||
for idx, it in enumerate(items):
|
||||
def apply_row(
|
||||
row: BizProductionTaskStepConfiguredReagent,
|
||||
it: Dict[str, Any],
|
||||
idx: int,
|
||||
_now: datetime,
|
||||
) -> None:
|
||||
req_id = (
|
||||
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
|
||||
)
|
||||
@@ -2414,19 +2521,38 @@ def replace_step_configured_reagents(
|
||||
it.get("actual_usage"),
|
||||
req_id,
|
||||
)
|
||||
db.add(BizProductionTaskStepConfiguredReagent(
|
||||
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
|
||||
configured_reagent_code=(it.get("configured_reagent_code") or "").strip() or None,
|
||||
configured_reagent_name=(it.get("configured_reagent_name") or "").strip() or None,
|
||||
spec=(it.get("spec") or "").strip() or None,
|
||||
standard_usage=(it.get("standard_usage") or "").strip() or None,
|
||||
actual_usage=(it.get("actual_usage") or "").strip() or None,
|
||||
unit=(it.get("unit") or "").strip() or None,
|
||||
requisition_usage_id=req_id or None,
|
||||
remark=(it.get("remark") or "").strip() or None,
|
||||
order_num=idx, create_by=user_id, create_dept_id="",
|
||||
create_time=now, update_by=user_id, update_time=now, del_flag=0,
|
||||
))
|
||||
row.configured_reagent_code = (it.get("configured_reagent_code") or "").strip() or None
|
||||
row.configured_reagent_name = (it.get("configured_reagent_name") or "").strip() or None
|
||||
row.spec = (it.get("spec") or "").strip() or None
|
||||
row.standard_usage = (it.get("standard_usage") or "").strip() or None
|
||||
row.actual_usage = (it.get("actual_usage") or "").strip() or None
|
||||
row.unit = (it.get("unit") or "").strip() or None
|
||||
row.requisition_usage_id = req_id or None
|
||||
row.remark = (it.get("remark") or "").strip() or None
|
||||
row.order_num = idx
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProductionTaskStepConfiguredReagent,
|
||||
parent_attr="step_id",
|
||||
parent_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda it: (it.get("configured_reagent_code") or "").strip(),
|
||||
row_natural_key=lambda row: (row.configured_reagent_code or "").strip(),
|
||||
duplicate_message="配置试剂编号重复",
|
||||
foreign_message="配置试剂不属于当前步骤",
|
||||
)
|
||||
|
||||
usage_items: List[Dict[str, Any]] = []
|
||||
for it in items:
|
||||
req_id = (
|
||||
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
|
||||
)
|
||||
if req_id:
|
||||
usage_items.append({
|
||||
"requisitionUsageId": req_id,
|
||||
@@ -2446,23 +2572,62 @@ def replace_step_configured_reagents(
|
||||
raise
|
||||
|
||||
|
||||
def _apply_step_output_fields(
|
||||
row: BizProductionTaskStepOutput,
|
||||
item: Dict[str, Any],
|
||||
*,
|
||||
order_num: int,
|
||||
user_id: Optional[str],
|
||||
now: datetime,
|
||||
) -> None:
|
||||
row.output_type_id = (item.get("output_type_id") or "").strip() or None
|
||||
row.output_name = (item.get("output_name") or "").strip() or None
|
||||
row.output_barcode = (item.get("output_barcode") or "").strip() or None
|
||||
row.remark = (item.get("remark") or "").strip() or None
|
||||
row.order_num = order_num
|
||||
row.update_by = user_id
|
||||
row.update_time = now
|
||||
row.del_flag = 0
|
||||
|
||||
|
||||
def replace_step_outputs(
|
||||
db: Session, step_id: str, tenant_no: str,
|
||||
user_id: Optional[str], items: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""按产出物 ID 更新原行。带 ID 的行原地修改,没有 ID 的才新增,提交列表里没有的才软删除。
|
||||
|
||||
质检工单用 production_step_output_id 指向这里。整表软删再插入会换掉 ID,关联就断了。
|
||||
"""
|
||||
now = datetime.now()
|
||||
_soft_delete_sub(db, BizProductionTaskStepOutput, "step_id", step_id, now)
|
||||
for idx, it in enumerate(items):
|
||||
db.add(BizProductionTaskStepOutput(
|
||||
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
|
||||
output_type_id=(it.get("output_type_id") or "").strip() or None,
|
||||
output_name=(it.get("output_name") or "").strip() or None,
|
||||
output_barcode=(it.get("output_barcode") or "").strip() or None,
|
||||
remark=(it.get("remark") or "").strip() or None,
|
||||
order_num=idx, create_by=user_id, create_dept_id="",
|
||||
create_time=now, update_by=user_id, update_time=now, del_flag=0,
|
||||
))
|
||||
|
||||
def build_row(row_id: str) -> BizProductionTaskStepOutput:
|
||||
return BizProductionTaskStepOutput(
|
||||
id=row_id,
|
||||
step_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
result_rows = sync_child_rows(
|
||||
db,
|
||||
BizProductionTaskStepOutput,
|
||||
parent_attr="step_id",
|
||||
parent_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=lambda row, item, idx, now_: _apply_step_output_fields(
|
||||
row, item, order_num=idx, user_id=user_id, now=now_
|
||||
),
|
||||
duplicate_message="产出物编号重复",
|
||||
foreign_message="产出物不属于当前步骤",
|
||||
)
|
||||
db.commit()
|
||||
return [_step_output_to_dict(row) for row in result_rows]
|
||||
|
||||
|
||||
def replace_step_data_collections(
|
||||
@@ -2470,17 +2635,45 @@ def replace_step_data_collections(
|
||||
user_id: Optional[str], items: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
now = datetime.now()
|
||||
_soft_delete_sub(db, BizProductionTaskStepDataCollection, "step_id", step_id, now)
|
||||
for idx, it in enumerate(items):
|
||||
db.add(BizProductionTaskStepDataCollection(
|
||||
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
|
||||
collection_name=(it.get("collection_name") or "").strip() or None,
|
||||
unit=(it.get("unit") or "").strip() or None,
|
||||
collection_value=(it.get("collection_value") or "").strip() or None,
|
||||
remark=(it.get("remark") or "").strip() or None,
|
||||
order_num=idx, create_by=user_id, create_dept_id="",
|
||||
create_time=now, update_by=user_id, update_time=now, del_flag=0,
|
||||
))
|
||||
|
||||
def build_row(row_id: str) -> BizProductionTaskStepDataCollection:
|
||||
return BizProductionTaskStepDataCollection(
|
||||
id=row_id,
|
||||
step_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
def apply_row(
|
||||
row: BizProductionTaskStepDataCollection,
|
||||
it: Dict[str, Any],
|
||||
idx: int,
|
||||
_now: datetime,
|
||||
) -> None:
|
||||
row.collection_name = (it.get("collection_name") or "").strip() or None
|
||||
row.unit = (it.get("unit") or "").strip() or None
|
||||
row.collection_value = (it.get("collection_value") or "").strip() or None
|
||||
row.remark = (it.get("remark") or "").strip() or None
|
||||
row.order_num = idx
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProductionTaskStepDataCollection,
|
||||
parent_attr="step_id",
|
||||
parent_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda it: (it.get("collection_name") or "").strip(),
|
||||
row_natural_key=lambda row: (row.collection_name or "").strip(),
|
||||
duplicate_message="数据采集编号重复",
|
||||
foreign_message="数据采集不属于当前步骤",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -2558,58 +2751,129 @@ def spawn_temporary_work_order_from_task(
|
||||
}
|
||||
|
||||
|
||||
def _task_input_identity(input_type: Optional[str], input_id: Optional[str], input_code: Optional[str]) -> str:
|
||||
kind = (input_type or "").strip()
|
||||
ident = (input_id or "").strip() or (input_code or "").strip()
|
||||
if not kind or not ident:
|
||||
return ""
|
||||
return f"{kind}:{ident}"
|
||||
|
||||
|
||||
def _task_input_item_identity(it: Dict[str, Any]) -> str:
|
||||
return _task_input_identity(
|
||||
it.get("input_type") or it.get("inputType"),
|
||||
it.get("input_id") or it.get("inputId"),
|
||||
it.get("input_code") or it.get("inputCode"),
|
||||
)
|
||||
|
||||
|
||||
def _set_cell_product_status(
|
||||
db: Session,
|
||||
product_id: Optional[str],
|
||||
*,
|
||||
user_id: Optional[str],
|
||||
now: datetime,
|
||||
to_status: str,
|
||||
from_statuses: tuple,
|
||||
) -> None:
|
||||
pid = (product_id or "").strip()
|
||||
if not pid:
|
||||
return
|
||||
product = db.query(BizCellProduct).filter(
|
||||
BizCellProduct.id == pid, BizCellProduct.del_flag == 0
|
||||
).first()
|
||||
if product and product.product_status in from_statuses:
|
||||
product.product_status = to_status
|
||||
product.update_by = user_id
|
||||
product.update_time = now
|
||||
db.add(product)
|
||||
|
||||
|
||||
def set_task_inputs(
|
||||
db: Session, task_id: str, tenant_no: str,
|
||||
user_id: Optional[str], items: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
now = datetime.now()
|
||||
|
||||
# --- 1. 加载旧 inputs 并按 cell_product 恢复状态 ---
|
||||
old_rows = db.query(BizProductionTaskInput).filter(
|
||||
and_(
|
||||
BizProductionTaskInput.task_id == task_id,
|
||||
BizProductionTaskInput.tenant_no == tenant_no,
|
||||
BizProductionTaskInput.del_flag == 0,
|
||||
)
|
||||
).all()
|
||||
old_identity = {
|
||||
row.id: (row.input_type or "", row.input_id or "")
|
||||
for row in old_rows
|
||||
}
|
||||
|
||||
def build_row(row_id: str) -> BizProductionTaskInput:
|
||||
return BizProductionTaskInput(
|
||||
id=row_id,
|
||||
task_id=task_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
def apply_row(row: BizProductionTaskInput, it: Dict[str, Any], idx: int, _now: datetime) -> None:
|
||||
row.input_type = (it.get("input_type") or it.get("inputType") or "").strip() or None
|
||||
row.input_id = (it.get("input_id") or it.get("inputId") or "").strip() or None
|
||||
row.input_code = (it.get("input_code") or it.get("inputCode") or "").strip() or None
|
||||
row.input_name = (it.get("input_name") or it.get("inputName") or "").strip() or None
|
||||
row.remark = (it.get("remark") or "").strip() or None
|
||||
row.order_num = idx
|
||||
|
||||
saved = sync_child_rows(
|
||||
db,
|
||||
BizProductionTaskInput,
|
||||
parent_attr="task_id",
|
||||
parent_id=task_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=_task_input_item_identity,
|
||||
row_natural_key=lambda row: _task_input_identity(row.input_type, row.input_id, row.input_code),
|
||||
duplicate_message="原材料编号重复",
|
||||
foreign_message="原材料不属于当前任务",
|
||||
)
|
||||
saved_ids = {row.id for row in saved}
|
||||
for old_row in old_rows:
|
||||
# 如果是细胞产品,恢复状态
|
||||
if old_row.input_type == "cell_product" and old_row.input_id:
|
||||
product = db.query(BizCellProduct).filter(
|
||||
BizCellProduct.id == old_row.input_id, BizCellProduct.del_flag == 0
|
||||
).first()
|
||||
if product and product.product_status == "in_production":
|
||||
product.product_status = "frozen"
|
||||
product.update_by = user_id
|
||||
product.update_time = now
|
||||
db.add(product)
|
||||
|
||||
# --- 2. 全量软删除旧行 ---
|
||||
_soft_delete_sub(db, BizProductionTaskInput, "task_id", task_id, now)
|
||||
|
||||
# --- 3. 按传入顺序批量新建 ---
|
||||
for idx, it in enumerate(items):
|
||||
inp_type = (it.get("input_type") or it.get("inputType") or "").strip()
|
||||
inp_id = (it.get("input_id") or it.get("inputId") or "").strip()
|
||||
# 如果是细胞产品,设置状态为 in_production
|
||||
if inp_type == "cell_product" and inp_id:
|
||||
product = db.query(BizCellProduct).filter(
|
||||
BizCellProduct.id == inp_id, BizCellProduct.del_flag == 0
|
||||
).first()
|
||||
if product and product.product_status in ("frozen", "thawed"):
|
||||
product.product_status = "in_production"
|
||||
product.update_by = user_id
|
||||
product.update_time = now
|
||||
db.add(product)
|
||||
db.add(BizProductionTaskInput(
|
||||
id=generate_uuid(), task_id=task_id, tenant_no=tenant_no,
|
||||
input_type=inp_type or None,
|
||||
input_id=inp_id or None,
|
||||
input_code=(it.get("input_code") or it.get("inputCode") or "").strip() or None,
|
||||
input_name=(it.get("input_name") or it.get("inputName") or "").strip() or None,
|
||||
remark=(it.get("remark") or "").strip() or None,
|
||||
order_num=idx, create_by=user_id, create_dept_id="",
|
||||
create_time=now, update_by=user_id, update_time=now, del_flag=0,
|
||||
))
|
||||
if old_row.id in saved_ids:
|
||||
continue
|
||||
if old_row.input_type == "cell_product":
|
||||
_set_cell_product_status(
|
||||
db,
|
||||
old_row.input_id,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
to_status="frozen",
|
||||
from_statuses=("in_production",),
|
||||
)
|
||||
for row in saved:
|
||||
previous = old_identity.get(row.id)
|
||||
current = (row.input_type or "", row.input_id or "")
|
||||
if previous != current and previous and previous[0] == "cell_product":
|
||||
_set_cell_product_status(
|
||||
db,
|
||||
previous[1],
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
to_status="frozen",
|
||||
from_statuses=("in_production",),
|
||||
)
|
||||
if row.input_type == "cell_product":
|
||||
_set_cell_product_status(
|
||||
db,
|
||||
row.input_id,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
to_status="in_production",
|
||||
from_statuses=("frozen", "thawed"),
|
||||
)
|
||||
|
||||
db.flush()
|
||||
db.commit()
|
||||
@@ -2765,7 +3029,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 +3177,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,822 @@
|
||||
"""生产任务集合 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.crud.child_row_sync import sync_child_rows
|
||||
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()
|
||||
|
||||
def _set_item_key(item):
|
||||
return f"{(item.get('route_id') or '').strip()}|{(item.get('task_name') or '').strip()}"
|
||||
|
||||
def build_item(row_id):
|
||||
return BizProductionTaskSetItem(
|
||||
id=row_id,
|
||||
set_id=set_id,
|
||||
task_name="",
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
def apply_item(row, item, idx, _now):
|
||||
rid = item.get("route_id")
|
||||
row.task_name = item["task_name"]
|
||||
row.route_id = rid
|
||||
row.route_name = route_names.get(rid or "") if rid else None
|
||||
row.description = item.get("description")
|
||||
row.remark = item.get("remark")
|
||||
row.order_num = idx + 1
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProductionTaskSetItem,
|
||||
parent_attr="set_id",
|
||||
parent_id=set_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_item,
|
||||
apply_fields=apply_item,
|
||||
natural_key=_set_item_key,
|
||||
row_natural_key=lambda row: f"{(row.route_id or '').strip()}|{(row.task_name or '').strip()}",
|
||||
duplicate_message="任务集合条目重复",
|
||||
foreign_message="任务集合条目不属于当前集合",
|
||||
)
|
||||
|
||||
|
||||
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(
|
||||
@@ -194,7 +182,7 @@ def enrich_step_completed_by_names(db: Session, steps: List[Dict]) -> None:
|
||||
return
|
||||
users = {
|
||||
u.id: u
|
||||
for u in db.query(SysUser).filter(SysUser.id.in_(uids), SysUser.del_flag == 0).all()
|
||||
for u in db.query(SysUser).filter(SysUser.id.in_(uids)).all()
|
||||
}
|
||||
for step in steps:
|
||||
uid = (step.get("completedBy") or "").strip()
|
||||
|
||||
@@ -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
|
||||
Loaded 100 of 8558 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user