更新登录页及优化相关功能
This commit is contained in:
@@ -57,3 +57,38 @@
|
||||
2026-09-28 15:44:48.546 | f3d841a80e0c49b6a2ef12e0fb03c0e9 | d09057ad24a24e7ea4a22f44065f962d | 40ab25364cde46b29603eec07bb0410f | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-09-28 18:15:15.372 | 55fe7aed5a5049eabdae0edd5e13b9c6 | e70f5cad542640e5b84a4a8f7bcbf349 | c96b83685c474b3fa2eb47238c10b3c3 | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-09-28 18:24:37.733 | | | | 19536-893d88 | WARNING | config.get_scheduler:on_lock_lost:273 - ⚠️ Worker 19536-893d88 失去 Application 锁
|
||||
2026-09-29 08:31:19.884 | ecd7f13ca30e45b294640064c6541d62 | c138e385f39748c2a69c9647d5f62168 | 730afa863d39409385556bd9e7a9aa74 | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-09-29 08:31:19.884 | 8a5b859f4b5b42f587f3c68f80df2a29 | ffec21d61d124182814ca9e284d064c5 | e7b7bc0477ed4d06a3f722a03213f7df | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-09-29 08:31:19.887 | 42584df6b4524e5ab17b92ab7bbad363 | 9e7dd342207143cfbf58edb837db9bc8 | fe765689650d4074b3214fb8385a891c | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-09-29 08:31:19.887 | 6ace3b292707421699b77bbab6cc2c17 | ef13031420d84be8bd1b6bb949cc2e73 | ee66dbbf2cb34f0e881d9b8ff1720ca4 | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-09-29 08:31:19.888 | ab831cab113b475fab50d19f98ae5ad5 | ec6c14b01c0f462f8f68487ffdc2e7a8 | 2c6e0349e8544bb99b1407297b2934ff | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-09-29 08:31:19.888 | 9a3993f714a0480599a69853c7a160b7 | 7639accb89c04e3eab0cecb39b4222d6 | 5f1da52c460d45db94c6f753e760d1ff | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-09-29 08:31:19.889 | 8bfdefdcc33d4a02a58ff162d637d78e | 695c2b2b62644fcb83ca330ed6c35377 | a9890dfac0be43f1af1a706257ad90f7 | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-09-29 08:31:19.889 | 8074c1fbe9324750a34f1b41701ae10a | c8db0c5645af428aab6f891676743e90 | 1522f21948074184944eda7bb2c7a457 | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-09-29 08:31:19.890 | 311c84b119c74c34926e8f105cb6b89d | 80faa8776a174e848a7ea949d49650a9 | 4e0791453ffd4109a7b057a6a1f9c89d | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-09-29 10:36:18.935 | f336ad9851934b909fb22f7dfc4b3b65 | 8a0adcc4279e4a90ac233295a77dc47b | fdbf1ccbb5db4fc18600351d0e947184 | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-09-29 14:34:17.408 | b5bd453c58164df3888a79f9347b08f2 | f2f3b6f4a9c94a038970ebe3c4cb4178 | 1d6fa79fe14a428189f4f10cbe02ffbb | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-09-29 14:34:17.411 | ac47fd90ab8643a0b769eaee235c45cb | bab76393434043f4835d06527b31c85f | d154b8c62bf045e484bf4d0dd149e37a | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-09-29 14:34:17.412 | 9a9f12f1f0a34a8fbe9cad915594afc6 | a054fea4972c4c2c8e7d96a4383e6974 | a7724448dd3b40a7aa90356608b39c37 | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-09-29 14:34:17.413 | dc6317ef7e6e4196823e65609cd37af4 | cac0c26cbd5c47f194d66af70744a317 | 27c38b6b3345428981d4226abb767e68 | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-09-29 14:34:17.414 | 43fd8348a96a43c486c5bbf15388af25 | 146ae1ecb2bc45dd827136680e972f48 | 5a59b2426cd74877aa7e1795317b3ede | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-09-29 14:34:17.414 | c2aab8152a00445881b6fcab4936339c | 59a37c27d2b14dc9a3df640123dd48cb | 56b5f26cc25d475faf7dc7eb085afd7d | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-09-29 14:34:17.414 | e2e16657d6bc4986ac054e12547cde7e | 80b7928ebcd141c89b9be78a5f451f31 | 7ddf26d600534e36936101698cb41375 | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-09-29 14:34:17.414 | 5a0a281ce5ca4dc086a5e97e34f54064 | 6a2e8aad8ab648cd8c1f1145061b8617 | 169b721c5b184e058ef422f7a4dfa2d5 | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-09-29 14:34:17.414 | a6848b04d69744c6903ff8f5488aff2a | 455b6c7725124b528d632116e50d8692 | 1d4829970328409a9c4241da7ef0e3df | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-09-29 14:44:44.532 | | | | 19536-893d88 | ERROR | sqlalchemy.pool.base:_close_connection:376 - Exception closing connection <AdaptedConnection <asyncmy.connection.Connection object at 0x12526bf40>>
|
||||
Traceback (most recent call last):
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/pool/base.py", line 374, in _close_connection
|
||||
self._dialect.do_close(connection)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/default.py", line 721, in do_close
|
||||
dbapi_connection.close()
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/dialects/mysql/asyncmy.py", line 114, in close
|
||||
self.await_(self._connection.ensure_closed())
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 132, in await_only
|
||||
return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 196, in greenlet_spawn
|
||||
value = await result
|
||||
File "asyncmy/connection.pyx", line 367, in ensure_closed
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/asyncio/streams.py", line 343, in wait_closed
|
||||
await self._protocol._get_close_waiter(self)
|
||||
BrokenPipeError: [Errno 32] Broken pipe
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
2026-09-29 15:19:51.438 | 989860659b114ab38204ade1141ea3c1 | 6064d458f0404b84a1d7848686d38762 | 36ef966879ce4a618a18c71affcb9e5d | 55906-91d48f | WARNING | module_admin.service.login_service:get_current_user:219 - 用户token已失效,请重新登录
|
||||
2026-09-29 17:02:23.083 | 63ef7d446d464450b9b1d1965ff974da | e8f78596035e433686c099886c38dd09 | 05a6cd1aec2f42b79ce34b5c1afeee25 | 55906-91d48f | WARNING | common.annotation.rate_limit_annotation:_log_rate_limit_hit:652 - 接口限流命中: namespace=captcha:image preset=ANON_AUTH_CAPTCHA algorithm=sliding_window fail_strategy=local_fallback method=GET path=/captchaImage scope=ip scope_value=ip:127.0.0.1 current=36 limit=36 retry_after=32s
|
||||
2026-09-29 17:02:23.881 | dfad946f3e3b4ad39a2824ff3843d26e | 6ac7acb690a94b28adc82485c2ffc24d | 13afc3a1f3c742eba1fa824b5c3b1c3d | 55906-91d48f | WARNING | common.annotation.rate_limit_annotation:_log_rate_limit_hit:652 - 接口限流命中: namespace=captcha:image preset=ANON_AUTH_CAPTCHA algorithm=sliding_window fail_strategy=local_fallback method=GET path=/captchaImage scope=ip scope_value=ip:127.0.0.1 current=36 limit=36 retry_after=31s
|
||||
2026-09-29 17:02:45.046 | 5be0428cea524497b4a6174bb9f1a6ed | d7189467f9a544bca5adc90241a1c82d | e25f7456285147a990a16c907e9930c4 | 55906-91d48f | WARNING | common.annotation.rate_limit_annotation:_log_rate_limit_hit:652 - 接口限流命中: namespace=captcha:image preset=ANON_AUTH_CAPTCHA algorithm=sliding_window fail_strategy=local_fallback method=GET path=/captchaImage scope=ip scope_value=ip:127.0.0.1 current=36 limit=36 retry_after=10s
|
||||
2026-09-29 17:02:49.279 | ca8fee87f80a4984ba64adea0235615a | 995b7c31d34c4af3b91e7e38691c55ed | 9840642de3244d5a83607d5fa4931f54 | 55906-91d48f | WARNING | common.annotation.rate_limit_annotation:_log_rate_limit_hit:652 - 接口限流命中: namespace=captcha:image preset=ANON_AUTH_CAPTCHA algorithm=sliding_window fail_strategy=local_fallback method=GET path=/captchaImage scope=ip scope_value=ip:127.0.0.1 current=36 limit=36 retry_after=6s
|
||||
2026-09-29 18:46:30.336 | | | | 55906-91d48f | WARNING | config.get_scheduler:on_lock_lost:273 - ⚠️ Worker 55906-91d48f 失去 Application 锁
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,287 @@
|
||||
2026-10-05 10:32:04.685 | 5ce457e932e34f669b2c7fda06bab450 | 5e433faf6cb54daa8fefbd591de4a42a | 30356484b94b4f1d8bd4b734ee876c59 | 21381-1d9e7d | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-10-05 13:18:49.387 | | | | 95189-da1f7e | WARNING | config.get_scheduler:on_lock_lost:273 - ⚠️ Worker 95189-da1f7e 失去 Application 锁
|
||||
2026-10-05 13:23:35.421 | | | | 34715-45b610 | ERROR | exceptions.handle:exception_handler:71 - (asyncmy.errors.OperationalError) (1054, "Unknown column 'biz_usage_record.tunnel_id' in 'field list'")
|
||||
[SQL: SELECT count(%s) AS count_1
|
||||
FROM (SELECT biz_usage_record.usage_id AS usage_id, biz_usage_record.test_batch_id AS test_batch_id, biz_usage_record.usage_no AS usage_no, biz_usage_record.pallet_id AS pallet_id, biz_usage_record.active_filling_id AS active_filling_id, biz_usage_record.site_id AS site_id, biz_usage_record.tunnel_id AS tunnel_id, biz_usage_record.trace_no AS trace_no, biz_usage_record.batch_no_snapshot AS batch_no_snapshot, biz_usage_record.line_id AS line_id, biz_usage_record.used_packages AS used_packages, biz_usage_record.remaining_packages_after AS remaining_packages_after, biz_usage_record.idempotency_key AS idempotency_key, biz_usage_record.request_hash AS request_hash, biz_usage_record.operator_id AS operator_id, biz_usage_record.occurred_at AS occurred_at
|
||||
FROM biz_usage_record
|
||||
WHERE biz_usage_record.test_batch_id IS NULL ORDER BY biz_usage_record.usage_id) AS anon_1]
|
||||
[parameters: ('*',)]
|
||||
(Background on this error at: https://sqlalche.me/e/20/e3q8)
|
||||
Traceback (most recent call last):
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1967, in _exec_single_context
|
||||
self.dialect.do_execute(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/default.py", line 952, in do_execute
|
||||
cursor.execute(statement, parameters)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 230, in execute
|
||||
self._adapt_connection._handle_exception(error)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/dialects/mysql/asyncmy.py", line 91, in _handle_exception
|
||||
raise error
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 228, in execute
|
||||
return self.await_(self._execute_async(operation, parameters))
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 132, in await_only
|
||||
return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 196, in greenlet_spawn
|
||||
value = await result
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 251, in _execute_async
|
||||
result = await self._cursor.execute(operation, parameters)
|
||||
File "asyncmy/cursors.pyx", line 179, in execute
|
||||
File "asyncmy/cursors.pyx", line 364, in _query
|
||||
File "asyncmy/connection.pyx", line 500, in query
|
||||
File "asyncmy/connection.pyx", line 688, in _read_query_result
|
||||
File "asyncmy/connection.pyx", line 1075, in read
|
||||
File "asyncmy/connection.pyx", line 650, in read_packet
|
||||
File "asyncmy/protocol.pyx", line 190, in asyncmy.protocol.MysqlPacket.raise_for_error
|
||||
File "asyncmy/protocol.pyx", line 194, in asyncmy.protocol.MysqlPacket.raise_for_error
|
||||
File "asyncmy/errors.pyx", line 128, in asyncmy.errors.raise_mysql_exception
|
||||
File "asyncmy/errors.pyx", line 137, in asyncmy.errors.raise_mysql_exception
|
||||
asyncmy.errors.OperationalError: (1054, "Unknown column 'biz_usage_record.tunnel_id' in 'field list'")
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
|
||||
Traceback (most recent call last):
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
||||
await self.app(scope, receive, _send)
|
||||
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/middlewares/transport_crypto_middleware.py", line 53, in __call__
|
||||
await self.app(scope, receive, send)
|
||||
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/middlewares/trace_middleware/middle.py", line 44, in __call__
|
||||
await self.app(scope, handle_outgoing_receive, handle_outgoing_request)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/base.py", line 191, in __call__
|
||||
with recv_stream, send_stream, collapse_excgroups():
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/contextlib.py", line 153, in __exit__
|
||||
self.gen.throw(typ, value, traceback)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/_utils.py", line 85, in collapse_excgroups
|
||||
raise exc
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/base.py", line 193, in __call__
|
||||
response = await self.dispatch_func(request, call_next)
|
||||
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/middlewares/api_response_header_middleware.py", line 15, in dispatch
|
||||
response = await call_next(request)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/base.py", line 168, in call_next
|
||||
raise app_exc from app_exc.__cause__ or app_exc.__context__
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/base.py", line 144, in coro
|
||||
await self.app(scope, receive_or_disconnect, send_no_error)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/gzip.py", line 29, in __call__
|
||||
await responder(scope, receive, send)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/gzip.py", line 130, in __call__
|
||||
await super().__call__(scope, receive, send)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/gzip.py", line 46, in __call__
|
||||
await self.app(scope, receive, self.send_with_compression)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/cors.py", line 85, in __call__
|
||||
await self.app(scope, receive, send)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/base.py", line 191, in __call__
|
||||
with recv_stream, send_stream, collapse_excgroups():
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/contextlib.py", line 153, in __exit__
|
||||
self.gen.throw(typ, value, traceback)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/_utils.py", line 85, in collapse_excgroups
|
||||
raise exc
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/base.py", line 193, in __call__
|
||||
response = await self.dispatch_func(request, call_next)
|
||||
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/middlewares/context_middleware.py", line 18, in dispatch
|
||||
return await call_next(request)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/base.py", line 168, in call_next
|
||||
raise app_exc from app_exc.__cause__ or app_exc.__context__
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/base.py", line 144, in coro
|
||||
await self.app(scope, receive_or_disconnect, send_no_error)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/exceptions.py", line 63, in __call__
|
||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
raise exc
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/fastapi/middleware/asyncexitstack.py", line 18, in __call__
|
||||
await self.app(scope, receive, send)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/routing.py", line 716, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/routing.py", line 736, in app
|
||||
await route.handle(scope, receive, send)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/routing.py", line 290, in handle
|
||||
await self.app(scope, receive, send)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/fastapi/routing.py", line 115, in app
|
||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
raise exc
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/fastapi/routing.py", line 101, in app
|
||||
response = await f(request)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/fastapi/routing.py", line 355, in app
|
||||
raw_response = await run_endpoint_function(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/fastapi/routing.py", line 243, in run_endpoint_function
|
||||
return await dependant.call(**values)
|
||||
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/module_business/controller/usage_controller.py", line 52, in get_business_usage_list
|
||||
result = await UsageService.get_usage_list_services(
|
||||
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/module_business/service/usage_service.py", line 259, in get_usage_list_services
|
||||
result = await OperationDao.get_usage_list(
|
||||
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/module_business/dao/operation_dao.py", line 835, in get_usage_list
|
||||
return await cls._paginate(db, statement, query_object, is_page)
|
||||
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/module_business/dao/operation_dao.py", line 142, in _paginate
|
||||
return await PageUtil.paginate(
|
||||
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/utils/page_util.py", line 83, in paginate
|
||||
total = (await db.execute(select(func.count('*')).select_from(query.subquery()))).scalar()
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/ext/asyncio/session.py", line 449, in execute
|
||||
result = await greenlet_spawn(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 203, in greenlet_spawn
|
||||
result = context.switch(value)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/orm/session.py", line 2351, in execute
|
||||
return self._execute_internal(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/orm/session.py", line 2249, in _execute_internal
|
||||
result: Result[Any] = compile_state_cls.orm_execute_statement(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/orm/context.py", line 306, in orm_execute_statement
|
||||
result = conn.execute(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1419, in execute
|
||||
return meth(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/sql/elements.py", line 527, in _execute_on_connection
|
||||
return connection._execute_clauseelement(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1641, in _execute_clauseelement
|
||||
ret = self._execute_context(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1846, in _execute_context
|
||||
return self._exec_single_context(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1986, in _exec_single_context
|
||||
self._handle_dbapi_exception(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 2363, in _handle_dbapi_exception
|
||||
raise sqlalchemy_exception.with_traceback(exc_info[2]) from e
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1967, in _exec_single_context
|
||||
self.dialect.do_execute(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/default.py", line 952, in do_execute
|
||||
cursor.execute(statement, parameters)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 230, in execute
|
||||
self._adapt_connection._handle_exception(error)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/dialects/mysql/asyncmy.py", line 91, in _handle_exception
|
||||
raise error
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 228, in execute
|
||||
return self.await_(self._execute_async(operation, parameters))
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 132, in await_only
|
||||
return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 196, in greenlet_spawn
|
||||
value = await result
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 251, in _execute_async
|
||||
result = await self._cursor.execute(operation, parameters)
|
||||
File "asyncmy/cursors.pyx", line 179, in execute
|
||||
File "asyncmy/cursors.pyx", line 364, in _query
|
||||
File "asyncmy/connection.pyx", line 500, in query
|
||||
File "asyncmy/connection.pyx", line 688, in _read_query_result
|
||||
File "asyncmy/connection.pyx", line 1075, in read
|
||||
File "asyncmy/connection.pyx", line 650, in read_packet
|
||||
File "asyncmy/protocol.pyx", line 190, in asyncmy.protocol.MysqlPacket.raise_for_error
|
||||
File "asyncmy/protocol.pyx", line 194, in asyncmy.protocol.MysqlPacket.raise_for_error
|
||||
File "asyncmy/errors.pyx", line 128, in asyncmy.errors.raise_mysql_exception
|
||||
File "asyncmy/errors.pyx", line 137, in asyncmy.errors.raise_mysql_exception
|
||||
sqlalchemy.exc.OperationalError: (asyncmy.errors.OperationalError) (1054, "Unknown column 'biz_usage_record.tunnel_id' in 'field list'")
|
||||
[SQL: SELECT count(%s) AS count_1
|
||||
FROM (SELECT biz_usage_record.usage_id AS usage_id, biz_usage_record.test_batch_id AS test_batch_id, biz_usage_record.usage_no AS usage_no, biz_usage_record.pallet_id AS pallet_id, biz_usage_record.active_filling_id AS active_filling_id, biz_usage_record.site_id AS site_id, biz_usage_record.tunnel_id AS tunnel_id, biz_usage_record.trace_no AS trace_no, biz_usage_record.batch_no_snapshot AS batch_no_snapshot, biz_usage_record.line_id AS line_id, biz_usage_record.used_packages AS used_packages, biz_usage_record.remaining_packages_after AS remaining_packages_after, biz_usage_record.idempotency_key AS idempotency_key, biz_usage_record.request_hash AS request_hash, biz_usage_record.operator_id AS operator_id, biz_usage_record.occurred_at AS occurred_at
|
||||
FROM biz_usage_record
|
||||
WHERE biz_usage_record.test_batch_id IS NULL ORDER BY biz_usage_record.usage_id) AS anon_1]
|
||||
[parameters: ('*',)]
|
||||
(Background on this error at: https://sqlalche.me/e/20/e3q8)
|
||||
2026-10-05 14:58:00.827 | 30919192f3594497a59fc913008fda10 | 53240ccf884a443ba4ada988273cbd6e | dcdb5888a906403b894cda1fb4d83974 | 34715-45b610 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
|
||||
2026-10-05 15:23:25.800 | | | | 34715-45b610 | WARNING | config.get_scheduler:on_lock_lost:273 - ⚠️ Worker 34715-45b610 失去 Application 锁
|
||||
2026-10-05 17:57:42.275 | | | | 38941-a4ab07 | WARNING | config.get_scheduler:on_lock_lost:273 - ⚠️ Worker 38941-a4ab07 失去 Application 锁
|
||||
2026-10-05 17:58:13.287 | eba30441d8d247f1a71c18ca28b545e3 | a66289a299b54b39949859fc20d7c49e | cdc11db34b914473b5bfe9f4cc8bf96a | 46633-a22437 | WARNING | module_admin.service.login_service:authenticate_user:133 - 密码错误
|
||||
2026-10-05 17:58:13.287 | eba30441d8d247f1a71c18ca28b545e3 | a66289a299b54b39949859fc20d7c49e | cdc11db34b914473b5bfe9f4cc8bf96a | 46633-a22437 | WARNING | common.annotation.log_annotation:wrapper:197 - 密码错误
|
||||
2026-10-05 18:01:12.069 | 15caaca0a4cc4c6382be942118562672 | af3f9aab3ded4ab0b4ffd746dff5a6c2 | 47b5ecf753d44f11b5812048deee1540 | 46633-a22437 | ERROR | common.annotation.log_annotation:wrapper:205 - (asyncmy.errors.OperationalError) (1054, "Unknown column 'tunnel_id' in 'field list'")
|
||||
[SQL: INSERT INTO biz_pallet_inventory (pallet_id, test_batch_id, active_filling_id, warehouse_id, site_id, tunnel_id, trace_no, current_line_id, flow_state, remaining_packages, last_event_id, version, updated_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)]
|
||||
[parameters: (5, None, None, None, None, None, None, None, 'UNSTOCKED', 0, None, 0, datetime.datetime(2026, 10, 5, 18, 1, 12, 36126))]
|
||||
(Background on this error at: https://sqlalche.me/e/20/e3q8)
|
||||
Traceback (most recent call last):
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1967, in _exec_single_context
|
||||
self.dialect.do_execute(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/default.py", line 952, in do_execute
|
||||
cursor.execute(statement, parameters)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 230, in execute
|
||||
self._adapt_connection._handle_exception(error)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/dialects/mysql/asyncmy.py", line 91, in _handle_exception
|
||||
raise error
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 228, in execute
|
||||
return self.await_(self._execute_async(operation, parameters))
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 132, in await_only
|
||||
return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 196, in greenlet_spawn
|
||||
value = await result
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 251, in _execute_async
|
||||
result = await self._cursor.execute(operation, parameters)
|
||||
File "asyncmy/cursors.pyx", line 179, in execute
|
||||
File "asyncmy/cursors.pyx", line 364, in _query
|
||||
File "asyncmy/connection.pyx", line 500, in query
|
||||
File "asyncmy/connection.pyx", line 688, in _read_query_result
|
||||
File "asyncmy/connection.pyx", line 1075, in read
|
||||
File "asyncmy/connection.pyx", line 650, in read_packet
|
||||
File "asyncmy/protocol.pyx", line 190, in asyncmy.protocol.MysqlPacket.raise_for_error
|
||||
File "asyncmy/protocol.pyx", line 194, in asyncmy.protocol.MysqlPacket.raise_for_error
|
||||
File "asyncmy/errors.pyx", line 128, in asyncmy.errors.raise_mysql_exception
|
||||
File "asyncmy/errors.pyx", line 137, in asyncmy.errors.raise_mysql_exception
|
||||
asyncmy.errors.OperationalError: (1054, "Unknown column 'tunnel_id' in 'field list'")
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
|
||||
Traceback (most recent call last):
|
||||
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/common/annotation/log_annotation.py", line 195, in wrapper
|
||||
result = await func(*args, **kwargs)
|
||||
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/module_business/controller/pallet_controller.py", line 98, in add_master_pallet
|
||||
result = await PalletService.add_pallet(query_db, add_pallet, current_user, scope)
|
||||
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/module_business/service/pallet_service.py", line 147, in add_pallet
|
||||
await MasterDao.add_pallet_inventory(db, inventory)
|
||||
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/module_business/dao/master_dao.py", line 568, in add_pallet_inventory
|
||||
await db.flush()
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/ext/asyncio/session.py", line 787, in flush
|
||||
await greenlet_spawn(self.sync_session.flush, objects=objects)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 203, in greenlet_spawn
|
||||
result = context.switch(value)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/orm/session.py", line 4331, in flush
|
||||
self._flush(objects)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/orm/session.py", line 4466, in _flush
|
||||
with util.safe_reraise():
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/langhelpers.py", line 224, in __exit__
|
||||
raise exc_value.with_traceback(exc_tb)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/orm/session.py", line 4427, in _flush
|
||||
flush_context.execute()
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/orm/unitofwork.py", line 466, in execute
|
||||
rec.execute(self)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/orm/unitofwork.py", line 642, in execute
|
||||
util.preloaded.orm_persistence.save_obj(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/orm/persistence.py", line 93, in save_obj
|
||||
_emit_insert_statements(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/orm/persistence.py", line 1048, in _emit_insert_statements
|
||||
result = connection.execute(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1419, in execute
|
||||
return meth(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/sql/elements.py", line 527, in _execute_on_connection
|
||||
return connection._execute_clauseelement(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1641, in _execute_clauseelement
|
||||
ret = self._execute_context(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1846, in _execute_context
|
||||
return self._exec_single_context(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1986, in _exec_single_context
|
||||
self._handle_dbapi_exception(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 2363, in _handle_dbapi_exception
|
||||
raise sqlalchemy_exception.with_traceback(exc_info[2]) from e
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1967, in _exec_single_context
|
||||
self.dialect.do_execute(
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/default.py", line 952, in do_execute
|
||||
cursor.execute(statement, parameters)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 230, in execute
|
||||
self._adapt_connection._handle_exception(error)
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/dialects/mysql/asyncmy.py", line 91, in _handle_exception
|
||||
raise error
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 228, in execute
|
||||
return self.await_(self._execute_async(operation, parameters))
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 132, in await_only
|
||||
return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 196, in greenlet_spawn
|
||||
value = await result
|
||||
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 251, in _execute_async
|
||||
result = await self._cursor.execute(operation, parameters)
|
||||
File "asyncmy/cursors.pyx", line 179, in execute
|
||||
File "asyncmy/cursors.pyx", line 364, in _query
|
||||
File "asyncmy/connection.pyx", line 500, in query
|
||||
File "asyncmy/connection.pyx", line 688, in _read_query_result
|
||||
File "asyncmy/connection.pyx", line 1075, in read
|
||||
File "asyncmy/connection.pyx", line 650, in read_packet
|
||||
File "asyncmy/protocol.pyx", line 190, in asyncmy.protocol.MysqlPacket.raise_for_error
|
||||
File "asyncmy/protocol.pyx", line 194, in asyncmy.protocol.MysqlPacket.raise_for_error
|
||||
File "asyncmy/errors.pyx", line 128, in asyncmy.errors.raise_mysql_exception
|
||||
File "asyncmy/errors.pyx", line 137, in asyncmy.errors.raise_mysql_exception
|
||||
sqlalchemy.exc.OperationalError: (asyncmy.errors.OperationalError) (1054, "Unknown column 'tunnel_id' in 'field list'")
|
||||
[SQL: INSERT INTO biz_pallet_inventory (pallet_id, test_batch_id, active_filling_id, warehouse_id, site_id, tunnel_id, trace_no, current_line_id, flow_state, remaining_packages, last_event_id, version, updated_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)]
|
||||
[parameters: (5, None, None, None, None, None, None, None, 'UNSTOCKED', 0, None, 0, datetime.datetime(2026, 10, 5, 18, 1, 12, 36126))]
|
||||
(Background on this error at: https://sqlalche.me/e/20/e3q8)
|
||||
2026-10-05 18:02:21.594 | f09ec57e4f274520839c9684c6dbc940 | 793ffb29205b4a5888c569a22c678400 | 60b65cd4baeb4392a338a65fc6c32904 | 46633-a22437 | ERROR | common.annotation.log_annotation:wrapper:202 - 目标客户不存在
|
||||
2026-10-05 18:37:04.581 | | | | 52026-91894b | WARNING | config.get_scheduler:on_lock_lost:273 - ⚠️ Worker 52026-91894b 失去 Application 锁
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -72,7 +72,7 @@ async def get_business_filling_strain_options(
|
||||
|
||||
|
||||
@filling_controller.get(
|
||||
'/scan/{qr_token}',
|
||||
'/scan/{qr_token:path}',
|
||||
summary='扫描待装填托盘接口',
|
||||
description='按托盘当前仓库数据范围判断空托盘是否可装填',
|
||||
response_model=DataResponseModel[PalletScanResponseModel],
|
||||
|
||||
@@ -75,7 +75,7 @@ async def get_business_inbound_warehouse_options(
|
||||
|
||||
|
||||
@inbound_controller.get(
|
||||
'/scan/{qr_token}',
|
||||
'/scan/{qr_token:path}',
|
||||
summary='扫描首次入库托盘接口',
|
||||
description='查询托盘是否可加入所选仓库的首次入库单',
|
||||
response_model=DataResponseModel[PalletScanResponseModel],
|
||||
|
||||
@@ -90,7 +90,7 @@ async def get_shipment_report_list(
|
||||
|
||||
@report_controller.get(
|
||||
'/consumption/list',
|
||||
summary='获取产线消耗报表分页列表接口',
|
||||
summary='获取隧道消耗报表分页列表接口(兼容产线)',
|
||||
response_model=PageResponseModel[ConsumptionReportModel],
|
||||
dependencies=[UserInterfaceAuthDependency('report:consumption:list')],
|
||||
)
|
||||
|
||||
@@ -76,7 +76,7 @@ async def get_business_return_warehouse_options(
|
||||
|
||||
|
||||
@return_controller.get(
|
||||
'/scan/{qr_token}',
|
||||
'/scan/{qr_token:path}',
|
||||
summary='扫描退回托盘接口',
|
||||
description='按申请现场或确认仓库数据范围重新查询托盘退回状态',
|
||||
response_model=DataResponseModel[PalletScanResponseModel],
|
||||
|
||||
@@ -23,6 +23,8 @@ from module_business.entity.vo.operation_vo import (
|
||||
ShipmentScanQueryModel,
|
||||
ShipmentUpdateModel,
|
||||
)
|
||||
from module_business.entity.vo.shipment_label_vo import ShipmentLabelModel
|
||||
from module_business.service.shipment_label_service import ShipmentLabelService
|
||||
from module_business.service.shipment_service import ShipmentService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
@@ -92,7 +94,7 @@ async def get_business_shipment_site_options(
|
||||
|
||||
|
||||
@shipment_controller.get(
|
||||
'/scan/{qr_token}',
|
||||
'/scan/{qr_token:path}',
|
||||
summary='扫描整托发货托盘接口',
|
||||
description='查询托盘是否可加入指定仓库至现场的整托发货单',
|
||||
response_model=DataResponseModel[PalletScanResponseModel],
|
||||
@@ -126,6 +128,24 @@ async def scan_business_shipment_pallet(
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@shipment_controller.get(
|
||||
'/{shipment_id}/labels',
|
||||
summary='获取整托发货标签接口',
|
||||
description='在发货查询权限与仓库、现场、数据批次范围内读取原托盘标签',
|
||||
response_model=DataResponseModel[list[ShipmentLabelModel]],
|
||||
dependencies=[UserInterfaceAuthDependency('business:shipment:query')],
|
||||
)
|
||||
async def get_business_shipment_labels(
|
||||
request: Request,
|
||||
shipment_id: Annotated[int, Path(gt=0, description='发货单ID')],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await ShipmentLabelService.get_labels_services(query_db, shipment_id, current_user, test_scope)
|
||||
return ResponseUtil.success(data=result, headers={'Cache-Control': 'no-store'})
|
||||
|
||||
|
||||
@shipment_controller.get(
|
||||
'/{shipment_id}',
|
||||
summary='获取整托发货单详情接口',
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Path, Query, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.annotation.log_annotation import Log
|
||||
from common.aspect.db_seesion import DBSessionDependency
|
||||
from common.aspect.interface_auth import UserInterfaceAuthDependency
|
||||
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
|
||||
from common.enums import BusinessType
|
||||
from common.router import APIRouterPro
|
||||
from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.operation_vo import ShipmentModel
|
||||
from module_business.entity.vo.shipment_receipt_vo import (
|
||||
ShipmentReceiptCommandModel,
|
||||
ShipmentReceiptItemModel,
|
||||
ShipmentReceiptModel,
|
||||
ShipmentReceiptQueryModel,
|
||||
)
|
||||
from module_business.service.shipment_receipt_service import ShipmentReceiptService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
shipment_receipt_controller = APIRouterPro(
|
||||
prefix='/business/shipment-receipt',
|
||||
tags=['业务管理-发货收货核对'],
|
||||
dependencies=[PreAuthDependency()],
|
||||
)
|
||||
|
||||
|
||||
@shipment_receipt_controller.get(
|
||||
'/list',
|
||||
response_model=PageResponseModel[ShipmentModel],
|
||||
summary='查询目标现场收货单',
|
||||
dependencies=[UserInterfaceAuthDependency('business:shipment:receive')],
|
||||
)
|
||||
async def list_shipment_receipts(
|
||||
request: Request,
|
||||
query: Annotated[ShipmentReceiptQueryModel, Query()],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await ShipmentReceiptService.list_services(query_db, query, current_user, test_scope)
|
||||
return ResponseUtil.success(model_content=result)
|
||||
|
||||
|
||||
@shipment_receipt_controller.get(
|
||||
'/{shipment_id}',
|
||||
response_model=DataResponseModel[ShipmentReceiptModel],
|
||||
summary='查询原发货核对明细',
|
||||
dependencies=[UserInterfaceAuthDependency('business:shipment:receive')],
|
||||
)
|
||||
async def get_shipment_receipt(
|
||||
request: Request,
|
||||
shipment_id: Annotated[int, Path(gt=0)],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await ShipmentReceiptService.detail_services(query_db, shipment_id, current_user, test_scope)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@shipment_receipt_controller.get(
|
||||
'/{shipment_id}/scan/{identifier:path}',
|
||||
response_model=DataResponseModel[ShipmentReceiptItemModel],
|
||||
summary='扫码或录入原托盘号核对',
|
||||
dependencies=[UserInterfaceAuthDependency('business:shipment:receive')],
|
||||
)
|
||||
@Log(
|
||||
title='收货扫码',
|
||||
business_type=BusinessType.OTHER,
|
||||
request_log_mode='none',
|
||||
response_log_mode='summary',
|
||||
oper_url_mode='route',
|
||||
)
|
||||
async def scan_shipment_receipt(
|
||||
request: Request,
|
||||
shipment_id: Annotated[int, Path(gt=0)],
|
||||
identifier: Annotated[str, Path(min_length=1, max_length=128, pattern=r'\S')],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await ShipmentReceiptService.scan_services(query_db, shipment_id, identifier, current_user, test_scope)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@shipment_receipt_controller.put(
|
||||
'/{shipment_id}/confirm',
|
||||
response_model=ResponseBaseModel,
|
||||
summary='接收方确认核对收货',
|
||||
dependencies=[UserInterfaceAuthDependency('business:shipment:receive')],
|
||||
)
|
||||
@Log(title='发货收货核对', business_type=BusinessType.UPDATE)
|
||||
async def confirm_shipment_receipt(
|
||||
request: Request,
|
||||
shipment_id: Annotated[int, Path(gt=0)],
|
||||
command: ShipmentReceiptCommandModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
) -> Response:
|
||||
result = await ShipmentReceiptService.confirm_services(query_db, shipment_id, command, current_user, test_scope)
|
||||
return ResponseUtil.success(msg=result.message)
|
||||
@@ -0,0 +1,126 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Path, Query, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.annotation.log_annotation import Log
|
||||
from common.aspect.db_seesion import DBSessionDependency
|
||||
from common.aspect.interface_auth import UserInterfaceAuthDependency
|
||||
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
|
||||
from common.enums import BusinessType
|
||||
from common.router import APIRouterPro
|
||||
from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.master_vo import DeleteTunnelModel, TunnelModel, TunnelPageQueryModel
|
||||
from module_business.service.master_service import MasterService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
tunnel_controller = APIRouterPro(
|
||||
prefix='/master/tunnel',
|
||||
tags=['基础资料-隧道管理'],
|
||||
dependencies=[PreAuthDependency()],
|
||||
)
|
||||
|
||||
|
||||
@tunnel_controller.get(
|
||||
'/list',
|
||||
summary='获取隧道分页列表接口',
|
||||
description='用于获取客户现场隧道分页列表',
|
||||
response_model=PageResponseModel[TunnelModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:tunnel:list')],
|
||||
)
|
||||
async def get_master_tunnel_list(
|
||||
request: Request,
|
||||
tunnel_page_query: Annotated[TunnelPageQueryModel, Query()],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await MasterService.list_tunnel(query_db, tunnel_page_query, scope, is_page=True)
|
||||
return ResponseUtil.success(model_content=result)
|
||||
|
||||
|
||||
@tunnel_controller.get(
|
||||
'/{tunnel_id}',
|
||||
summary='获取隧道详情接口',
|
||||
description='用于获取指定隧道的详细信息',
|
||||
response_model=DataResponseModel[TunnelModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:tunnel:query')],
|
||||
)
|
||||
async def query_detail_master_tunnel(
|
||||
request: Request,
|
||||
tunnel_id: Annotated[int, Path(gt=0, description='隧道ID')],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await MasterService.detail_tunnel(query_db, tunnel_id, scope)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@tunnel_controller.post(
|
||||
'',
|
||||
summary='新增隧道接口',
|
||||
description='用于新增客户现场隧道',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('master:tunnel:add')],
|
||||
)
|
||||
@Log(title='隧道管理', business_type=BusinessType.INSERT)
|
||||
async def add_master_tunnel(
|
||||
request: Request,
|
||||
add_tunnel: TunnelModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
) -> Response:
|
||||
current_time = datetime.now()
|
||||
add_tunnel.create_by = current_user.user.user_name
|
||||
add_tunnel.create_time = current_time
|
||||
add_tunnel.update_by = current_user.user.user_name
|
||||
add_tunnel.update_time = current_time
|
||||
result = await MasterService.add_tunnel(query_db, add_tunnel, current_user, scope)
|
||||
return ResponseUtil.success(msg=result.message)
|
||||
|
||||
|
||||
@tunnel_controller.put(
|
||||
'',
|
||||
summary='编辑隧道接口',
|
||||
description='用于编辑客户现场隧道',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('master:tunnel:edit')],
|
||||
)
|
||||
@Log(title='隧道管理', business_type=BusinessType.UPDATE)
|
||||
async def edit_master_tunnel(
|
||||
request: Request,
|
||||
edit_tunnel: TunnelModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
) -> Response:
|
||||
edit_tunnel.update_by = current_user.user.user_name
|
||||
edit_tunnel.update_time = datetime.now()
|
||||
result = await MasterService.edit_tunnel(query_db, edit_tunnel, current_user, scope)
|
||||
return ResponseUtil.success(msg=result.message)
|
||||
|
||||
|
||||
@tunnel_controller.delete(
|
||||
'/{tunnel_ids}',
|
||||
summary='兼容旧接口,固定拒绝,请使用测试数据批次清理',
|
||||
description='兼容旧接口,固定拒绝单条物理删除,请使用测试数据批次清理',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('master:tunnel:remove')],
|
||||
)
|
||||
@Log(title='隧道管理', business_type=BusinessType.DELETE)
|
||||
async def delete_master_tunnel(
|
||||
request: Request,
|
||||
tunnel_ids: Annotated[
|
||||
str,
|
||||
Path(pattern=r'^[1-9]\d*(,[1-9]\d*)*$', description='需要删除的隧道ID'),
|
||||
],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> Response:
|
||||
delete_tunnel = DeleteTunnelModel(tunnelIds=[int(tunnel_id) for tunnel_id in tunnel_ids.split(',')])
|
||||
result = await MasterService.remove_tunnel(query_db, delete_tunnel)
|
||||
return ResponseUtil.success(msg=result.message)
|
||||
@@ -18,6 +18,8 @@ from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
PalletScanResponseModel,
|
||||
TunnelEntryCreateModel,
|
||||
TunnelEntrySuccessResponseModel,
|
||||
UsageCreateModel,
|
||||
UsageModel,
|
||||
UsagePageQueryModel,
|
||||
@@ -76,7 +78,44 @@ async def get_business_usage_line_options(
|
||||
|
||||
|
||||
@usage_controller.get(
|
||||
'/scan/{qr_token}',
|
||||
'/tunnel-options',
|
||||
summary='获取客户现场启用隧道选项接口',
|
||||
description='仅返回当前用户有权访问的指定客户现场启用隧道',
|
||||
response_model=DataResponseModel[list[BusinessOptionModel]],
|
||||
dependencies=[UserInterfaceAuthDependency('business:usage:query')],
|
||||
)
|
||||
async def get_business_usage_tunnel_options(
|
||||
request: Request,
|
||||
site_id: Annotated[int, Query(alias='siteId', gt=0, description='客户现场ID')],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await UsageService.get_tunnel_options_services(query_db, site_id, current_user, test_scope)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@usage_controller.post(
|
||||
'/enter-tunnel',
|
||||
summary='扫码入隧接口',
|
||||
description='将单个已到客户现场的托盘绑定到指定隧道,并生成或复用隧道内部溯源号',
|
||||
response_model=DataResponseModel[TunnelEntrySuccessResponseModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:usage:add')],
|
||||
)
|
||||
@Log(title='扫码入隧', business_type=BusinessType.INSERT)
|
||||
async def enter_business_usage_tunnel(
|
||||
request: Request,
|
||||
entry_command: TunnelEntryCreateModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
) -> Response:
|
||||
result = await UsageService.enter_tunnel_services(query_db, entry_command, current_user, test_scope)
|
||||
return ResponseUtil.success(msg=result.message, data=result.result)
|
||||
|
||||
|
||||
@usage_controller.get(
|
||||
'/scan/{qr_token:path}',
|
||||
summary='扫描客户现场待使用托盘接口',
|
||||
description='按托盘当前客户现场数据范围判断是否允许记录使用',
|
||||
response_model=DataResponseModel[PalletScanResponseModel],
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""只读先进先出建议;不强行阻断会议要求的多批次混用。"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Query, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.aspect.db_seesion import DBSessionDependency
|
||||
from common.aspect.interface_auth import UserInterfaceAuthDependency
|
||||
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
|
||||
from common.router import APIRouterPro
|
||||
from common.vo import DataResponseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.usage_fifo_vo import UsageFifoRecommendationModel
|
||||
from module_business.service.usage_fifo_service import UsageFifoService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
usage_fifo_controller = APIRouterPro(
|
||||
prefix='/business/usage-fifo',
|
||||
tags=['业务管理-客户现场使用'],
|
||||
dependencies=[PreAuthDependency(), UserInterfaceAuthDependency('business:usage:query')],
|
||||
)
|
||||
|
||||
|
||||
@usage_fifo_controller.get('/recommendation', response_model=DataResponseModel[UsageFifoRecommendationModel])
|
||||
async def get_usage_fifo_recommendation(
|
||||
request: Request,
|
||||
qr_token: Annotated[str, Query(alias='qrToken', min_length=1, max_length=128, pattern=r'\S')],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
tunnel_id: Annotated[int | None, Query(alias='tunnelId', gt=0)] = None,
|
||||
) -> Response:
|
||||
result = await UsageFifoService.recommendation(
|
||||
query_db, qr_token, current_user, test_scope, tunnel_id=tunnel_id,
|
||||
)
|
||||
return ResponseUtil.success(data=result)
|
||||
@@ -1,6 +1,8 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.exc import MultipleResultsFound
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from exceptions.exception import ServiceException
|
||||
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
|
||||
from module_business.entity.do.master_do import BizPallet
|
||||
from module_business.entity.do.operation_do import BizPalletEvent, BizPalletInventory
|
||||
@@ -57,13 +59,18 @@ class InventoryDao:
|
||||
qr_token: str,
|
||||
scope: TestBatchScope,
|
||||
) -> BizPallet | None:
|
||||
"""兼容印刷托盘号;调用方仍须复核仓库/现场权限,写入使用规范二维码。"""
|
||||
identifier = qr_token.strip()
|
||||
result = await db.execute(
|
||||
select(BizPallet).where(
|
||||
BizPallet.qr_token == qr_token,
|
||||
or_(BizPallet.qr_token == identifier, BizPallet.pallet_code == identifier),
|
||||
batch_scope_condition(BizPallet.test_batch_id, scope),
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
try:
|
||||
return result.scalar_one_or_none()
|
||||
except MultipleResultsFound:
|
||||
raise ServiceException(message='托盘号与二维码标识存在歧义,请联系管理员核对后重试') from None
|
||||
|
||||
@classmethod
|
||||
async def get_pallets_by_ids(
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import ColumnElement, Select, select, update
|
||||
from sqlalchemy import ColumnElement, Select, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
|
||||
@@ -15,6 +15,7 @@ from module_business.entity.do.master_do import (
|
||||
BizPallet,
|
||||
BizProductionLine,
|
||||
BizStrain,
|
||||
BizTunnel,
|
||||
BizUserSite,
|
||||
BizUserWarehouse,
|
||||
BizWarehouse,
|
||||
@@ -31,6 +32,8 @@ from module_business.entity.vo.master_vo import (
|
||||
SitePageQueryModel,
|
||||
StrainModel,
|
||||
StrainPageQueryModel,
|
||||
TunnelModel,
|
||||
TunnelPageQueryModel,
|
||||
WarehouseModel,
|
||||
WarehousePageQueryModel,
|
||||
)
|
||||
@@ -38,7 +41,7 @@ from utils.page_util import PageUtil
|
||||
|
||||
|
||||
class MasterDao:
|
||||
"""菌种、客户、现场、产线和仓库主数据数据库操作层。"""
|
||||
"""菌种、客户、现场、产线、隧道和仓库主数据数据库操作层。"""
|
||||
|
||||
@staticmethod
|
||||
async def _paginate(
|
||||
@@ -339,6 +342,67 @@ class MasterDao:
|
||||
async def update_line(cls, db: AsyncSession, line: LineModel, scope: TestBatchScope) -> None:
|
||||
await cls._update(db, BizProductionLine, BizProductionLine.line_id, 'line_id', line, scope)
|
||||
|
||||
@classmethod
|
||||
async def list_tunnel(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
query_object: TunnelPageQueryModel,
|
||||
scope: TestBatchScope,
|
||||
is_page: bool = True,
|
||||
) -> PageModel | list[dict[str, Any]]:
|
||||
conditions = [batch_scope_condition(BizTunnel.test_batch_id, scope)]
|
||||
if query_object.site_id:
|
||||
conditions.append(BizTunnel.site_id == query_object.site_id)
|
||||
if query_object.tunnel_code:
|
||||
conditions.append(BizTunnel.tunnel_code.like(f'%{query_object.tunnel_code}%'))
|
||||
if query_object.tunnel_name:
|
||||
conditions.append(BizTunnel.tunnel_name.like(f'%{query_object.tunnel_name}%'))
|
||||
if query_object.status:
|
||||
conditions.append(BizTunnel.status == query_object.status)
|
||||
statement = select(BizTunnel).where(*conditions).order_by(BizTunnel.tunnel_id)
|
||||
return await cls._paginate(db, statement, query_object, is_page)
|
||||
|
||||
@classmethod
|
||||
async def get_tunnel(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
tunnel_id: int,
|
||||
scope: TestBatchScope,
|
||||
*,
|
||||
for_update: bool = False,
|
||||
) -> BizTunnel | None:
|
||||
return await cls._get(
|
||||
db,
|
||||
BizTunnel,
|
||||
BizTunnel.tunnel_id,
|
||||
tunnel_id,
|
||||
batch_scope_condition(BizTunnel.test_batch_id, scope),
|
||||
for_update=for_update,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def find_tunnel_by_code(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
tunnel_code: str,
|
||||
site_id: int,
|
||||
) -> BizTunnel | None:
|
||||
return await cls._find(
|
||||
db,
|
||||
BizTunnel,
|
||||
BizTunnel.tunnel_code,
|
||||
tunnel_code,
|
||||
BizTunnel.site_id == site_id,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def add_tunnel(cls, db: AsyncSession, tunnel: TunnelModel, test_batch_id: int | None) -> BizTunnel:
|
||||
return await cls._add(db, BizTunnel, 'tunnel_id', tunnel, test_batch_id)
|
||||
|
||||
@classmethod
|
||||
async def update_tunnel(cls, db: AsyncSession, tunnel: TunnelModel, scope: TestBatchScope) -> None:
|
||||
await cls._update(db, BizTunnel, BizTunnel.tunnel_id, 'tunnel_id', tunnel, scope)
|
||||
|
||||
@classmethod
|
||||
async def list_warehouse(
|
||||
cls,
|
||||
@@ -475,6 +539,25 @@ class MasterDao:
|
||||
result = await db.execute(statement)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
async def count_tunnel_trace_members(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
tunnel_id: int,
|
||||
trace_no: str,
|
||||
scope: TestBatchScope,
|
||||
) -> int:
|
||||
result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(BizPalletInventory)
|
||||
.where(
|
||||
BizPalletInventory.tunnel_id == tunnel_id,
|
||||
BizPalletInventory.trace_no == trace_no,
|
||||
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
|
||||
)
|
||||
)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
@classmethod
|
||||
async def add_pallet_inventory(
|
||||
cls,
|
||||
|
||||
@@ -810,6 +810,10 @@ class OperationDao:
|
||||
conditions.append(BizUsageRecord.usage_no.like(f'%{query_object.usage_no}%'))
|
||||
if query_object.site_id:
|
||||
conditions.append(BizUsageRecord.site_id == query_object.site_id)
|
||||
if query_object.tunnel_id:
|
||||
conditions.append(BizUsageRecord.tunnel_id == query_object.tunnel_id)
|
||||
if query_object.trace_no:
|
||||
conditions.append(BizUsageRecord.trace_no.like(f'%{query_object.trace_no}%'))
|
||||
if query_object.line_id:
|
||||
conditions.append(BizUsageRecord.line_id == query_object.line_id)
|
||||
if query_object.pallet_code:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime, time, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import ColumnElement, Select, and_, false, or_, select
|
||||
from sqlalchemy import ColumnElement, Select, and_, case, false, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
@@ -15,6 +15,7 @@ from module_business.entity.do.master_do import (
|
||||
BizCustomerSite,
|
||||
BizPallet,
|
||||
BizProductionLine,
|
||||
BizTunnel,
|
||||
BizWarehouse,
|
||||
)
|
||||
from module_business.entity.do.operation_do import (
|
||||
@@ -40,6 +41,7 @@ from module_business.entity.vo.report_vo import (
|
||||
ReturnReportQueryModel,
|
||||
ShipmentReportModel,
|
||||
ShipmentReportQueryModel,
|
||||
TunnelReportQueryModel,
|
||||
)
|
||||
from utils.page_util import PageUtil
|
||||
|
||||
@@ -84,6 +86,31 @@ class ReportDao:
|
||||
"""保留空的可选 join,同时拒绝已存在的跨批次关联记录。"""
|
||||
return or_(id_column.is_(None), batch_scope_condition(scope_column, scope))
|
||||
|
||||
@staticmethod
|
||||
def _tunnel_conditions(
|
||||
query: TunnelReportQueryModel,
|
||||
trace_column: ColumnElement,
|
||||
*tunnels: tuple[ColumnElement, Any],
|
||||
) -> list[ColumnElement[bool]]:
|
||||
"""历史事件可匹配变更前/后隧道;同一组编号、名称须命中同一个隧道。"""
|
||||
conditions = []
|
||||
if query.trace_no:
|
||||
conditions.append(trace_column == query.trace_no)
|
||||
matches = []
|
||||
for id_column, tunnel in tunnels:
|
||||
filters = []
|
||||
if query.tunnel_id:
|
||||
filters.append(id_column == query.tunnel_id)
|
||||
if query.tunnel_code:
|
||||
filters.append(tunnel.tunnel_code.like(f'%{query.tunnel_code}%'))
|
||||
if query.tunnel_name:
|
||||
filters.append(tunnel.tunnel_name.like(f'%{query.tunnel_name}%'))
|
||||
if filters:
|
||||
matches.append(and_(*filters))
|
||||
if matches:
|
||||
conditions.append(or_(*matches))
|
||||
return conditions
|
||||
|
||||
@classmethod
|
||||
def _or_scope_conditions(
|
||||
cls,
|
||||
@@ -158,6 +185,7 @@ class ReportDao:
|
||||
current_warehouse = aliased(BizWarehouse, name='current_warehouse')
|
||||
current_site = aliased(BizCustomerSite, name='current_site')
|
||||
current_line = aliased(BizProductionLine, name='current_line')
|
||||
current_tunnel = aliased(BizTunnel, name='current_tunnel')
|
||||
active_filling = aliased(BizFillingRecord, name='active_filling')
|
||||
|
||||
conditions = [
|
||||
@@ -167,7 +195,11 @@ class ReportDao:
|
||||
cls._joined_scope_condition(current_warehouse.warehouse_id, current_warehouse.test_batch_id, scope),
|
||||
cls._joined_scope_condition(current_site.site_id, current_site.test_batch_id, scope),
|
||||
cls._joined_scope_condition(current_line.line_id, current_line.test_batch_id, scope),
|
||||
cls._joined_scope_condition(current_tunnel.tunnel_id, current_tunnel.test_batch_id, scope),
|
||||
]
|
||||
conditions.extend(cls._tunnel_conditions(
|
||||
query_object, BizPalletInventory.trace_no, (BizPalletInventory.tunnel_id, current_tunnel),
|
||||
))
|
||||
conditions.extend(
|
||||
cls._or_scope_conditions(
|
||||
(BizPalletInventory.warehouse_id, warehouse_ids),
|
||||
@@ -214,6 +246,12 @@ class ReportDao:
|
||||
active_filling.initial_packages.label('initial_packages'),
|
||||
BizPalletInventory.remaining_packages.label('remaining_packages'),
|
||||
BizPalletInventory.updated_at.label('updated_at'),
|
||||
BizPalletInventory.tunnel_id.label('tunnel_id'),
|
||||
current_tunnel.tunnel_code.label('tunnel_code'),
|
||||
current_tunnel.tunnel_name.label('tunnel_name'),
|
||||
BizPalletInventory.trace_no.label('trace_no'),
|
||||
active_filling.production_date.label('production_date'),
|
||||
active_filling.expiry_date.label('expiry_date'),
|
||||
)
|
||||
.select_from(BizPalletInventory)
|
||||
.join(BizPallet, BizPallet.pallet_id == BizPalletInventory.pallet_id)
|
||||
@@ -221,6 +259,7 @@ class ReportDao:
|
||||
.outerjoin(current_warehouse, current_warehouse.warehouse_id == BizPalletInventory.warehouse_id)
|
||||
.outerjoin(current_site, current_site.site_id == BizPalletInventory.site_id)
|
||||
.outerjoin(current_line, current_line.line_id == BizPalletInventory.current_line_id)
|
||||
.outerjoin(current_tunnel, current_tunnel.tunnel_id == BizPalletInventory.tunnel_id)
|
||||
.where(*conditions)
|
||||
.order_by(BizPalletInventory.pallet_id)
|
||||
)
|
||||
@@ -374,14 +413,21 @@ class ReportDao:
|
||||
site_ids: list[int] | None = None,
|
||||
) -> PageModel[ConsumptionReportModel]:
|
||||
operator_user = aliased(SysUser, name='operator_user')
|
||||
usage_tunnel = aliased(BizTunnel, name='usage_tunnel')
|
||||
# 新记录优先使用业务快照;历史空值只回查该次使用引用的装填记录。
|
||||
batch_no = func.coalesce(BizUsageRecord.batch_no_snapshot, BizFillingRecord.batch_no)
|
||||
conditions = [
|
||||
batch_scope_condition(BizUsageRecord.test_batch_id, scope),
|
||||
batch_scope_condition(BizPallet.test_batch_id, scope),
|
||||
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
|
||||
batch_scope_condition(BizCustomerSite.test_batch_id, scope),
|
||||
batch_scope_condition(BizProductionLine.test_batch_id, scope),
|
||||
cls._joined_scope_condition(BizProductionLine.line_id, BizProductionLine.test_batch_id, scope),
|
||||
cls._joined_scope_condition(usage_tunnel.tunnel_id, usage_tunnel.test_batch_id, scope),
|
||||
cls._joined_scope_condition(BizCustomer.customer_id, BizCustomer.test_batch_id, scope),
|
||||
]
|
||||
conditions.extend(cls._tunnel_conditions(
|
||||
query_object, BizUsageRecord.trace_no, (BizUsageRecord.tunnel_id, usage_tunnel),
|
||||
))
|
||||
site_scope = cls._scope_condition(BizUsageRecord.site_id, site_ids)
|
||||
if site_scope is not None:
|
||||
conditions.append(site_scope)
|
||||
@@ -396,7 +442,7 @@ class ReportDao:
|
||||
if query_object.strain_id:
|
||||
conditions.append(BizFillingRecord.strain_id == query_object.strain_id)
|
||||
if query_object.batch_no:
|
||||
conditions.append(BizFillingRecord.batch_no.like(f'%{query_object.batch_no}%'))
|
||||
conditions.append(batch_no.like(f'%{query_object.batch_no}%'))
|
||||
conditions.extend(
|
||||
cls._time_conditions(
|
||||
BizUsageRecord.occurred_at,
|
||||
@@ -421,17 +467,25 @@ class ReportDao:
|
||||
BizFillingRecord.strain_id.label('strain_id'),
|
||||
BizFillingRecord.strain_code_snapshot.label('strain_code'),
|
||||
BizFillingRecord.strain_name_snapshot.label('strain_name'),
|
||||
BizFillingRecord.batch_no.label('batch_no'),
|
||||
batch_no.label('batch_no'),
|
||||
BizUsageRecord.used_packages.label('used_packages'),
|
||||
BizUsageRecord.remaining_packages_after.label('remaining_packages_after'),
|
||||
BizUsageRecord.operator_id.label('operator_id'),
|
||||
operator_user.user_name.label('operator_name'),
|
||||
BizUsageRecord.tunnel_id.label('tunnel_id'),
|
||||
usage_tunnel.tunnel_code.label('tunnel_code'),
|
||||
usage_tunnel.tunnel_name.label('tunnel_name'),
|
||||
BizUsageRecord.trace_no.label('trace_no'),
|
||||
BizUsageRecord.batch_no_snapshot.label('batch_no_snapshot'),
|
||||
BizFillingRecord.production_date.label('production_date'),
|
||||
BizFillingRecord.expiry_date.label('expiry_date'),
|
||||
)
|
||||
.select_from(BizUsageRecord)
|
||||
.join(BizPallet, BizPallet.pallet_id == BizUsageRecord.pallet_id)
|
||||
.join(BizFillingRecord, BizFillingRecord.filling_id == BizUsageRecord.active_filling_id)
|
||||
.join(BizCustomerSite, BizCustomerSite.site_id == BizUsageRecord.site_id)
|
||||
.join(BizProductionLine, BizProductionLine.line_id == BizUsageRecord.line_id)
|
||||
.outerjoin(BizProductionLine, BizProductionLine.line_id == BizUsageRecord.line_id)
|
||||
.outerjoin(usage_tunnel, usage_tunnel.tunnel_id == BizUsageRecord.tunnel_id)
|
||||
.outerjoin(BizCustomer, BizCustomer.customer_id == BizCustomerSite.customer_id)
|
||||
.outerjoin(operator_user, operator_user.user_id == BizUsageRecord.operator_id)
|
||||
.where(*conditions)
|
||||
@@ -450,13 +504,23 @@ class ReportDao:
|
||||
) -> PageModel[ReturnReportModel]:
|
||||
source_site = aliased(BizCustomerSite, name='source_site')
|
||||
target_warehouse = aliased(BizWarehouse, name='target_warehouse')
|
||||
return_tunnel = aliased(BizTunnel, name='return_tunnel')
|
||||
return_filling = aliased(BizFillingRecord, name='return_filling')
|
||||
batch_no = func.coalesce(BizReturnItem.batch_no_snapshot, return_filling.batch_no)
|
||||
conditions = [
|
||||
batch_scope_condition(BizReturnOrder.test_batch_id, scope),
|
||||
batch_scope_condition(BizReturnItem.test_batch_id, scope),
|
||||
batch_scope_condition(BizPallet.test_batch_id, scope),
|
||||
cls._joined_scope_condition(source_site.site_id, source_site.test_batch_id, scope),
|
||||
cls._joined_scope_condition(target_warehouse.warehouse_id, target_warehouse.test_batch_id, scope),
|
||||
cls._joined_scope_condition(return_tunnel.tunnel_id, return_tunnel.test_batch_id, scope),
|
||||
cls._joined_scope_condition(return_filling.filling_id, return_filling.test_batch_id, scope),
|
||||
]
|
||||
conditions.extend(cls._tunnel_conditions(
|
||||
query_object, BizReturnItem.trace_no_snapshot, (BizReturnItem.tunnel_id_snapshot, return_tunnel),
|
||||
))
|
||||
if query_object.batch_no:
|
||||
conditions.append(batch_no.like(f'%{query_object.batch_no}%'))
|
||||
conditions.extend(
|
||||
cls._or_scope_conditions(
|
||||
(BizReturnOrder.source_site_id, site_ids),
|
||||
@@ -496,12 +560,22 @@ class ReportDao:
|
||||
BizPallet.pallet_code.label('pallet_code'),
|
||||
BizReturnItem.active_filling_id_snapshot.label('active_filling_id_snapshot'),
|
||||
BizReturnItem.remaining_packages_snapshot.label('remaining_packages_snapshot'),
|
||||
BizReturnItem.tunnel_id_snapshot.label('tunnel_id'),
|
||||
return_tunnel.tunnel_code.label('tunnel_code'),
|
||||
return_tunnel.tunnel_name.label('tunnel_name'),
|
||||
BizReturnItem.trace_no_snapshot.label('trace_no'),
|
||||
BizReturnItem.batch_no_snapshot.label('batch_no_snapshot'),
|
||||
batch_no.label('batch_no'),
|
||||
return_filling.production_date.label('production_date'),
|
||||
return_filling.expiry_date.label('expiry_date'),
|
||||
)
|
||||
.select_from(BizReturnOrder)
|
||||
.join(BizReturnItem, BizReturnItem.return_id == BizReturnOrder.return_id)
|
||||
.join(BizPallet, BizPallet.pallet_id == BizReturnItem.pallet_id)
|
||||
.outerjoin(source_site, source_site.site_id == BizReturnOrder.source_site_id)
|
||||
.outerjoin(target_warehouse, target_warehouse.warehouse_id == BizReturnOrder.target_warehouse_id)
|
||||
.outerjoin(return_tunnel, return_tunnel.tunnel_id == BizReturnItem.tunnel_id_snapshot)
|
||||
.outerjoin(return_filling, return_filling.filling_id == BizReturnItem.active_filling_id_snapshot)
|
||||
.where(*conditions)
|
||||
.order_by(BizReturnOrder.return_id, BizReturnItem.return_item_id)
|
||||
)
|
||||
@@ -522,6 +596,41 @@ class ReportDao:
|
||||
after_site = aliased(BizCustomerSite, name='after_site')
|
||||
before_line = aliased(BizProductionLine, name='before_line')
|
||||
after_line = aliased(BizProductionLine, name='after_line')
|
||||
before_tunnel = aliased(BizTunnel, name='before_tunnel')
|
||||
after_tunnel = aliased(BizTunnel, name='after_tunnel')
|
||||
trace_return_item = aliased(BizReturnItem, name='trace_return_item')
|
||||
trace_usage = aliased(BizUsageRecord, name='trace_usage')
|
||||
trace_shipment_item = aliased(BizShipmentItem, name='trace_shipment_item')
|
||||
trace_filling = aliased(BizFillingRecord, name='trace_filling')
|
||||
prior_filling_event = aliased(BizPalletEvent, name='prior_filling_event')
|
||||
# 入隧事件来源是隧道而非装填记录,按托盘事件序号回查当时已发生的最后一次装填。
|
||||
# 其他事件只使用来源单据的装填引用;引用缺失时保留空日期,不借用后续重装填。
|
||||
prior_filling_id = (
|
||||
select(prior_filling_event.source_id)
|
||||
.where(
|
||||
prior_filling_event.pallet_id == BizPalletEvent.pallet_id,
|
||||
prior_filling_event.event_seq < BizPalletEvent.event_seq,
|
||||
prior_filling_event.event_type == PalletEventType.FILLING_CONFIRMED.value,
|
||||
prior_filling_event.source_type == 'FILLING',
|
||||
batch_scope_condition(prior_filling_event.test_batch_id, scope),
|
||||
)
|
||||
.order_by(prior_filling_event.event_seq.desc())
|
||||
.limit(1)
|
||||
.correlate(BizPalletEvent)
|
||||
.scalar_subquery()
|
||||
)
|
||||
trace_filling_id = case(
|
||||
(BizPalletEvent.source_type == 'FILLING', BizPalletEvent.source_id),
|
||||
(BizPalletEvent.source_type == 'USAGE', trace_usage.active_filling_id),
|
||||
(BizPalletEvent.source_type == 'RETURN', trace_return_item.active_filling_id_snapshot),
|
||||
(BizPalletEvent.source_type == 'SHIPMENT', trace_shipment_item.active_filling_id),
|
||||
(BizPalletEvent.event_type == PalletEventType.TUNNEL_ENTERED.value, prior_filling_id),
|
||||
else_=None,
|
||||
)
|
||||
# 部分退回事件未保存隧道;只从同单同托盘的冻结退回项补读,不读取当前库存。
|
||||
before_tunnel_id = func.coalesce(BizPalletEvent.before_tunnel_id, trace_return_item.tunnel_id_snapshot)
|
||||
trace_no = func.coalesce(BizPalletEvent.trace_no, trace_return_item.trace_no_snapshot)
|
||||
batch_no = func.coalesce(BizPalletEvent.batch_no_snapshot, trace_return_item.batch_no_snapshot)
|
||||
operator_user = aliased(SysUser, name='operator_user')
|
||||
conditions = [
|
||||
batch_scope_condition(BizPalletEvent.test_batch_id, scope),
|
||||
@@ -532,7 +641,20 @@ class ReportDao:
|
||||
cls._joined_scope_condition(after_site.site_id, after_site.test_batch_id, scope),
|
||||
cls._joined_scope_condition(before_line.line_id, before_line.test_batch_id, scope),
|
||||
cls._joined_scope_condition(after_line.line_id, after_line.test_batch_id, scope),
|
||||
cls._joined_scope_condition(before_tunnel.tunnel_id, before_tunnel.test_batch_id, scope),
|
||||
cls._joined_scope_condition(after_tunnel.tunnel_id, after_tunnel.test_batch_id, scope),
|
||||
cls._joined_scope_condition(trace_return_item.return_item_id, trace_return_item.test_batch_id, scope),
|
||||
cls._joined_scope_condition(trace_usage.usage_id, trace_usage.test_batch_id, scope),
|
||||
cls._joined_scope_condition(trace_shipment_item.shipment_item_id, trace_shipment_item.test_batch_id, scope),
|
||||
cls._joined_scope_condition(trace_filling.filling_id, trace_filling.test_batch_id, scope),
|
||||
]
|
||||
conditions.extend(cls._tunnel_conditions(
|
||||
query_object, trace_no,
|
||||
(before_tunnel_id, before_tunnel),
|
||||
(BizPalletEvent.after_tunnel_id, after_tunnel),
|
||||
))
|
||||
if query_object.batch_no:
|
||||
conditions.append(batch_no.like(f'%{query_object.batch_no}%'))
|
||||
conditions.extend(cls._pallet_trace_scope_conditions(warehouse_ids, site_ids))
|
||||
if query_object.pallet_code:
|
||||
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
|
||||
@@ -574,6 +696,19 @@ class ReportDao:
|
||||
BizPalletEvent.operator_id.label('operator_id'),
|
||||
operator_user.user_name.label('operator_name'),
|
||||
BizPalletEvent.occurred_at.label('occurred_at'),
|
||||
func.coalesce(BizPalletEvent.after_tunnel_id, before_tunnel_id).label('tunnel_id'),
|
||||
func.coalesce(after_tunnel.tunnel_code, before_tunnel.tunnel_code).label('tunnel_code'),
|
||||
func.coalesce(after_tunnel.tunnel_name, before_tunnel.tunnel_name).label('tunnel_name'),
|
||||
trace_no.label('trace_no'),
|
||||
batch_no.label('batch_no_snapshot'),
|
||||
before_tunnel_id.label('before_tunnel_id'),
|
||||
before_tunnel.tunnel_code.label('before_tunnel_code'),
|
||||
before_tunnel.tunnel_name.label('before_tunnel_name'),
|
||||
BizPalletEvent.after_tunnel_id.label('after_tunnel_id'),
|
||||
after_tunnel.tunnel_code.label('after_tunnel_code'),
|
||||
after_tunnel.tunnel_name.label('after_tunnel_name'),
|
||||
trace_filling.production_date.label('production_date'),
|
||||
trace_filling.expiry_date.label('expiry_date'),
|
||||
)
|
||||
.select_from(BizPalletEvent)
|
||||
.join(BizPallet, BizPallet.pallet_id == BizPalletEvent.pallet_id)
|
||||
@@ -583,6 +718,27 @@ class ReportDao:
|
||||
.outerjoin(after_site, after_site.site_id == BizPalletEvent.after_site_id)
|
||||
.outerjoin(before_line, before_line.line_id == BizPalletEvent.before_line_id)
|
||||
.outerjoin(after_line, after_line.line_id == BizPalletEvent.after_line_id)
|
||||
.outerjoin(trace_return_item, and_(
|
||||
BizPalletEvent.source_type == 'RETURN',
|
||||
trace_return_item.return_id == BizPalletEvent.source_id,
|
||||
trace_return_item.pallet_id == BizPalletEvent.pallet_id,
|
||||
))
|
||||
.outerjoin(trace_usage, and_(
|
||||
BizPalletEvent.source_type == 'USAGE',
|
||||
trace_usage.usage_id == BizPalletEvent.source_id,
|
||||
trace_usage.pallet_id == BizPalletEvent.pallet_id,
|
||||
))
|
||||
.outerjoin(trace_shipment_item, and_(
|
||||
BizPalletEvent.source_type == 'SHIPMENT',
|
||||
trace_shipment_item.shipment_id == BizPalletEvent.source_id,
|
||||
trace_shipment_item.pallet_id == BizPalletEvent.pallet_id,
|
||||
))
|
||||
.outerjoin(trace_filling, and_(
|
||||
trace_filling.filling_id == trace_filling_id,
|
||||
trace_filling.pallet_id == BizPalletEvent.pallet_id,
|
||||
))
|
||||
.outerjoin(before_tunnel, before_tunnel.tunnel_id == before_tunnel_id)
|
||||
.outerjoin(after_tunnel, after_tunnel.tunnel_id == BizPalletEvent.after_tunnel_id)
|
||||
.outerjoin(operator_user, operator_user.user_id == BizPalletEvent.operator_id)
|
||||
.where(*conditions)
|
||||
.order_by(BizPalletEvent.occurred_at, BizPalletEvent.event_id)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from module_business.domain.enums import DocumentStatus, PalletFlowState
|
||||
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
|
||||
from module_business.entity.do.master_do import BizPallet
|
||||
from module_business.entity.do.operation_do import (
|
||||
BizFillingRecord,
|
||||
BizPalletInventory,
|
||||
BizShipmentItem,
|
||||
BizShipmentOrder,
|
||||
)
|
||||
|
||||
|
||||
class ShipmentLabelDao:
|
||||
"""只读标签资料;外连接保留失效明细,由服务拒绝不完整的整单打印。"""
|
||||
|
||||
@staticmethod
|
||||
async def get_label_sources(
|
||||
db: AsyncSession,
|
||||
shipment: BizShipmentOrder,
|
||||
scope: TestBatchScope,
|
||||
) -> list[tuple[BizShipmentItem, BizPallet | None, BizFillingRecord | None, int | None]]:
|
||||
draft = shipment.status == DocumentStatus.DRAFT.value
|
||||
quantity = BizPalletInventory.remaining_packages if draft else BizShipmentItem.package_qty
|
||||
statement = (
|
||||
select(BizShipmentItem, BizPallet, BizFillingRecord, quantity)
|
||||
.select_from(BizShipmentItem)
|
||||
.outerjoin(BizPallet, and_(
|
||||
BizShipmentItem.pallet_id == BizPallet.pallet_id,
|
||||
batch_scope_condition(BizPallet.test_batch_id, scope),
|
||||
))
|
||||
)
|
||||
if draft:
|
||||
statement = statement.outerjoin(BizPalletInventory, and_(
|
||||
BizShipmentItem.pallet_id == BizPalletInventory.pallet_id,
|
||||
BizPalletInventory.warehouse_id == shipment.source_warehouse_id,
|
||||
BizPalletInventory.flow_state == PalletFlowState.FILLED_IN_WAREHOUSE.value,
|
||||
BizPalletInventory.site_id.is_(None),
|
||||
BizPalletInventory.current_line_id.is_(None),
|
||||
BizPalletInventory.active_filling_id.is_not(None),
|
||||
BizPalletInventory.remaining_packages > 0,
|
||||
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
|
||||
))
|
||||
# 已确认单只关联确认时的装填记录,即使周期已关闭、托盘已重新装填也可补打。
|
||||
filling_id = BizPalletInventory.active_filling_id if draft else BizShipmentItem.active_filling_id
|
||||
filling_conditions = [
|
||||
filling_id == BizFillingRecord.filling_id,
|
||||
BizShipmentItem.pallet_id == BizFillingRecord.pallet_id,
|
||||
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
|
||||
]
|
||||
if draft:
|
||||
filling_conditions.append(BizFillingRecord.active_slot == 'ACTIVE')
|
||||
statement = (
|
||||
statement.outerjoin(BizFillingRecord, and_(*filling_conditions))
|
||||
.where(
|
||||
BizShipmentItem.shipment_id == shipment.shipment_id,
|
||||
batch_scope_condition(BizShipmentItem.test_batch_id, scope),
|
||||
)
|
||||
.order_by(BizShipmentItem.shipment_item_id)
|
||||
)
|
||||
return list((await db.execute(statement)).all())
|
||||
@@ -0,0 +1,36 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.vo import PageModel
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.do.operation_do import BizShipmentOrder
|
||||
from module_business.entity.vo.shipment_receipt_vo import ShipmentReceiptQueryModel
|
||||
from utils.page_util import PageUtil
|
||||
|
||||
|
||||
class ShipmentReceiptDao:
|
||||
@staticmethod
|
||||
async def list_orders(
|
||||
db: AsyncSession, query: ShipmentReceiptQueryModel, scope: TestBatchScope, site_ids: list[int] | None
|
||||
) -> PageModel:
|
||||
# 接收方只需要目标现场范围,不扩大原发货操作的双范围权限。
|
||||
conditions = OperationDao._shipment_scope_conditions(None, site_ids, scope)
|
||||
conditions.append(BizShipmentOrder.status == 'CONFIRMED')
|
||||
if query.shipment_no:
|
||||
conditions.append(BizShipmentOrder.shipment_no.like(f'%{query.shipment_no}%'))
|
||||
if query.target_site_id:
|
||||
conditions.append(BizShipmentOrder.target_site_id == query.target_site_id)
|
||||
if query.source_warehouse_id:
|
||||
conditions.append(BizShipmentOrder.source_warehouse_id == query.source_warehouse_id)
|
||||
if query.receipt_state:
|
||||
conditions.append(BizShipmentOrder.receipt_state == query.receipt_state)
|
||||
conditions.extend(
|
||||
OperationDao._time_conditions(BizShipmentOrder.confirmed_at, query.begin_time, query.end_time)
|
||||
)
|
||||
statement = (
|
||||
select(BizShipmentOrder)
|
||||
.where(*conditions)
|
||||
.order_by(BizShipmentOrder.confirmed_at, BizShipmentOrder.shipment_id)
|
||||
)
|
||||
return await PageUtil.paginate(db, statement, query.page_num, query.page_size, is_page=True)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""按真实收货时间推荐可用物料,不推断历史到货时间。"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
|
||||
from module_business.entity.do.master_do import BizPallet
|
||||
from module_business.entity.do.operation_do import BizFillingRecord, BizPalletInventory
|
||||
|
||||
|
||||
class UsageFifoDao:
|
||||
@staticmethod
|
||||
async def recommendation(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
site_id: int,
|
||||
strain_id: int,
|
||||
tunnel_id: int | None,
|
||||
trace_no: str | None,
|
||||
scope: TestBatchScope,
|
||||
for_update: bool = False,
|
||||
) -> tuple[dict[str, Any] | None, int]:
|
||||
inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet
|
||||
unbound = and_(inventory.tunnel_id.is_(None), inventory.trace_no.is_(None))
|
||||
conditions = [
|
||||
inventory.site_id == site_id,
|
||||
inventory.warehouse_id.is_(None),
|
||||
inventory.flow_state.in_(['AT_SITE_UNUSED', 'AT_SITE_PARTIAL']),
|
||||
inventory.remaining_packages > 0,
|
||||
inventory.remaining_packages <= pallet.capacity_packages,
|
||||
filling.strain_id == strain_id,
|
||||
filling.active_slot == 'ACTIVE',
|
||||
filling.closed_at.is_(None),
|
||||
pallet.asset_status == '0',
|
||||
batch_scope_condition(inventory.test_batch_id, scope),
|
||||
batch_scope_condition(filling.test_batch_id, scope),
|
||||
batch_scope_condition(pallet.test_batch_id, scope),
|
||||
(
|
||||
unbound
|
||||
if tunnel_id is None
|
||||
else or_(unbound, and_(inventory.tunnel_id == tunnel_id, inventory.trace_no == trace_no))
|
||||
),
|
||||
]
|
||||
base = (
|
||||
select(
|
||||
pallet.pallet_id,
|
||||
pallet.pallet_code,
|
||||
filling.batch_no,
|
||||
inventory.remaining_packages,
|
||||
inventory.site_arrived_at,
|
||||
)
|
||||
.select_from(inventory)
|
||||
.join(pallet, pallet.pallet_id == inventory.pallet_id)
|
||||
.join(
|
||||
filling,
|
||||
and_(filling.filling_id == inventory.active_filling_id, filling.pallet_id == inventory.pallet_id),
|
||||
)
|
||||
.where(*conditions)
|
||||
)
|
||||
statement = (
|
||||
base.where(inventory.site_arrived_at.is_not(None))
|
||||
.order_by(
|
||||
inventory.site_arrived_at,
|
||||
inventory.pallet_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if for_update:
|
||||
# 写入决策必须用当前读,不能复用权限预检期间建立的 MVCC 旧快照。
|
||||
# 不使用 skip_locked,避免把正在操作的早到物料悄悄跳过。
|
||||
# 此时已持有所选库存及现场/隧道锁,不能等待其他候选库存造成反向锁依赖。
|
||||
# NOWAIT 不会跳过早到托盘:冲突令整个命令回滚,再按原幂等键有限重试。
|
||||
statement = statement.with_for_update(nowait=True)
|
||||
known = await db.execute(statement)
|
||||
first = known.mappings().first()
|
||||
if for_update:
|
||||
return (dict(first) if first is not None else None, 0)
|
||||
unknown = await db.execute(
|
||||
select(func.count()).select_from(
|
||||
base.where(inventory.site_arrived_at.is_(None)).subquery(),
|
||||
)
|
||||
)
|
||||
return (dict(first) if first is not None else None, unknown.scalar_one())
|
||||
@@ -5,6 +5,7 @@ class PalletFlowState(str, Enum):
|
||||
UNSTOCKED = 'UNSTOCKED'
|
||||
EMPTY_IN_WAREHOUSE = 'EMPTY_IN_WAREHOUSE'
|
||||
FILLED_IN_WAREHOUSE = 'FILLED_IN_WAREHOUSE'
|
||||
IN_TRANSIT = 'IN_TRANSIT'
|
||||
AT_SITE_UNUSED = 'AT_SITE_UNUSED'
|
||||
AT_SITE_PARTIAL = 'AT_SITE_PARTIAL'
|
||||
AT_SITE_EMPTY = 'AT_SITE_EMPTY'
|
||||
@@ -25,6 +26,8 @@ class PalletEventType(str, Enum):
|
||||
INBOUND_CONFIRMED = 'INBOUND_CONFIRMED'
|
||||
FILLING_CONFIRMED = 'FILLING_CONFIRMED'
|
||||
SHIPMENT_CONFIRMED = 'SHIPMENT_CONFIRMED'
|
||||
SHIPMENT_RECEIVED = 'SHIPMENT_RECEIVED'
|
||||
TUNNEL_ENTERED = 'TUNNEL_ENTERED'
|
||||
USAGE_RECORDED = 'USAGE_RECORDED'
|
||||
RETURN_REQUESTED = 'RETURN_REQUESTED'
|
||||
RETURN_CONFIRMED = 'RETURN_CONFIRMED'
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"""只比较真实到货时间,不将未知时间或托盘编号作为先后依据。"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
def requires_fifo_skip_reason(
|
||||
pallet_id: int, arrived_at: datetime | None, candidate: dict[str, Any] | None
|
||||
) -> bool:
|
||||
return bool(
|
||||
arrived_at is not None
|
||||
and candidate is not None
|
||||
and candidate['pallet_id'] != pallet_id
|
||||
and candidate['site_arrived_at'] < arrived_at
|
||||
)
|
||||
@@ -13,7 +13,8 @@ class PalletStateMachine:
|
||||
PalletFlowState.EMPTY_IN_WAREHOUSE,
|
||||
PalletFlowState.FILLED_IN_WAREHOUSE,
|
||||
),
|
||||
(PalletFlowState.FILLED_IN_WAREHOUSE, PalletFlowState.AT_SITE_UNUSED),
|
||||
(PalletFlowState.FILLED_IN_WAREHOUSE, PalletFlowState.IN_TRANSIT),
|
||||
(PalletFlowState.IN_TRANSIT, PalletFlowState.AT_SITE_UNUSED),
|
||||
(PalletFlowState.AT_SITE_UNUSED, PalletFlowState.AT_SITE_PARTIAL),
|
||||
(PalletFlowState.AT_SITE_UNUSED, PalletFlowState.AT_SITE_EMPTY),
|
||||
(PalletFlowState.AT_SITE_UNUSED, PalletFlowState.RETURNING),
|
||||
|
||||
@@ -15,6 +15,7 @@ from module_business.entity.do.master_do import (
|
||||
BizPallet,
|
||||
BizProductionLine,
|
||||
BizStrain,
|
||||
BizTunnel,
|
||||
BizWarehouse,
|
||||
)
|
||||
from module_business.entity.do.operation_do import (
|
||||
@@ -42,6 +43,7 @@ BATCH_MODELS = {
|
||||
BizProductionLine.line_id,
|
||||
BizProductionLine.line_code,
|
||||
),
|
||||
'biz_tunnel': (BizTunnel, BizTunnel.tunnel_id, BizTunnel.tunnel_code),
|
||||
'biz_warehouse': (BizWarehouse, BizWarehouse.warehouse_id, BizWarehouse.warehouse_code),
|
||||
'biz_pallet': (BizPallet, BizPallet.pallet_id, BizPallet.pallet_code),
|
||||
'biz_inbound_order': (BizInboundOrder, BizInboundOrder.inbound_id, BizInboundOrder.inbound_no),
|
||||
@@ -85,6 +87,7 @@ BATCH_LABELS = {
|
||||
'biz_customer': '客户',
|
||||
'biz_customer_site': '客户现场',
|
||||
'biz_production_line': '生产线',
|
||||
'biz_tunnel': '隧道',
|
||||
'biz_warehouse': '仓库',
|
||||
'biz_pallet': '托盘',
|
||||
'biz_inbound_order': '入库单',
|
||||
@@ -111,6 +114,7 @@ _DELETE_TIE_BREAK_ORDER = (
|
||||
'biz_pallet_event',
|
||||
'biz_filling_record',
|
||||
'biz_production_line',
|
||||
'biz_tunnel',
|
||||
'biz_customer_site',
|
||||
'biz_customer',
|
||||
'biz_warehouse',
|
||||
@@ -123,6 +127,7 @@ ROOT_TABLES = {
|
||||
'customer': 'biz_customer',
|
||||
'site': 'biz_customer_site',
|
||||
'line': 'biz_production_line',
|
||||
'tunnel': 'biz_tunnel',
|
||||
'warehouse': 'biz_warehouse',
|
||||
'pallet': 'biz_pallet',
|
||||
'inbound': 'biz_inbound_order',
|
||||
@@ -136,6 +141,7 @@ _EVENT_SOURCE_TABLES = {
|
||||
'INBOUND': 'biz_inbound_order',
|
||||
'FILLING': 'biz_filling_record',
|
||||
'SHIPMENT': 'biz_shipment_order',
|
||||
'TUNNEL': 'biz_tunnel',
|
||||
'USAGE': 'biz_usage_record',
|
||||
'RETURN': 'biz_return_order',
|
||||
}
|
||||
|
||||
@@ -193,6 +193,47 @@ class BizProductionLine(Base):
|
||||
)
|
||||
|
||||
|
||||
class BizTunnel(Base):
|
||||
"""客户现场隧道主数据表。"""
|
||||
|
||||
__tablename__ = 'biz_tunnel'
|
||||
__table_args__ = (
|
||||
UniqueConstraint('site_id', 'tunnel_code', name='uq_biz_tunnel_site_code'),
|
||||
CheckConstraint("status IN ('0', '1')", name='ck_biz_tunnel_status'),
|
||||
{'comment': '客户现场隧道主数据表'},
|
||||
)
|
||||
|
||||
tunnel_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='隧道ID')
|
||||
test_batch_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_test_batch.batch_id'),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment='测试数据批次ID;NULL表示正式数据',
|
||||
)
|
||||
site_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_customer_site.site_id'),
|
||||
nullable=False,
|
||||
comment='现场ID',
|
||||
)
|
||||
tunnel_code = Column(String(64), nullable=False, comment='隧道编号')
|
||||
tunnel_name = Column(String(100), nullable=False, comment='隧道名称')
|
||||
current_trace_no = Column(String(64), nullable=True, comment='当前隧道物料溯源号')
|
||||
current_trace_started_at = Column(DateTime, nullable=True, comment='当前溯源批次开始时间')
|
||||
status = Column(CHAR(1), nullable=False, server_default='0', comment='状态(0正常 1停用)')
|
||||
create_by = Column(String(64), nullable=True, server_default='', comment='创建者')
|
||||
create_time = Column(DateTime, nullable=True, default=datetime.now, comment='创建时间')
|
||||
update_by = Column(String(64), nullable=True, server_default='', comment='更新者')
|
||||
update_time = Column(DateTime, nullable=True, default=datetime.now, comment='更新时间')
|
||||
remark = Column(
|
||||
String(500),
|
||||
nullable=True,
|
||||
server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type),
|
||||
comment='备注',
|
||||
)
|
||||
|
||||
|
||||
class BizPallet(Base):
|
||||
"""托盘资产主数据表。"""
|
||||
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, CheckConstraint, Column, DateTime, ForeignKey, Integer, String, UniqueConstraint
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
CheckConstraint,
|
||||
Column,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
)
|
||||
|
||||
from config.database import Base
|
||||
from config.env import DataBaseConfig
|
||||
@@ -99,6 +110,11 @@ class BizFillingRecord(Base):
|
||||
),
|
||||
UniqueConstraint('pallet_id', 'active_slot', name='uq_biz_filling_record_pallet_active_slot'),
|
||||
CheckConstraint('initial_packages > 0', name='ck_biz_filling_record_initial_packages'),
|
||||
CheckConstraint(
|
||||
'(production_date IS NULL AND expiry_date IS NULL) OR '
|
||||
'(production_date IS NOT NULL AND expiry_date IS NOT NULL AND expiry_date >= production_date)',
|
||||
name='ck_biz_filling_record_material_dates',
|
||||
),
|
||||
CheckConstraint(
|
||||
"active_slot IS NULL OR active_slot = 'ACTIVE'",
|
||||
name='ck_biz_filling_record_active_slot',
|
||||
@@ -126,6 +142,8 @@ class BizFillingRecord(Base):
|
||||
strain_code_snapshot = Column(String(64), nullable=False, comment='菌种编码快照')
|
||||
strain_name_snapshot = Column(String(100), nullable=False, comment='菌种名称快照')
|
||||
batch_no = Column(String(64), nullable=False, comment='生产批次')
|
||||
production_date = Column(Date, nullable=True, comment='来源生产日期;历史未登记为空')
|
||||
expiry_date = Column(Date, nullable=True, comment='有效期至;历史未登记为空')
|
||||
package_spec = Column(String(64), nullable=False, comment='包装规格')
|
||||
initial_packages = Column(Integer, nullable=False, comment='初始包数')
|
||||
active_slot = Column(String(16), nullable=True, comment='活动装填槽')
|
||||
@@ -158,6 +176,8 @@ class BizShipmentOrder(Base):
|
||||
name='uq_biz_shipment_order_route_confirm_idempotency',
|
||||
),
|
||||
CheckConstraint("status IN ('DRAFT', 'CONFIRMED')", name='ck_biz_shipment_order_status'),
|
||||
CheckConstraint("receipt_state IN ('PENDING', 'RECEIVED', 'LEGACY')", name='ck_biz_shipment_order_receipt_state'),
|
||||
UniqueConstraint('target_site_id', 'receipt_idempotency_key', name='uq_biz_shipment_receipt_site_key'),
|
||||
{'comment': '整托发货单'},
|
||||
)
|
||||
|
||||
@@ -187,6 +207,11 @@ class BizShipmentOrder(Base):
|
||||
confirm_request_hash = Column(String(64), nullable=True, comment='确认请求摘要')
|
||||
confirmed_by = Column(BigInteger, ForeignKey('sys_user.user_id'), nullable=True, comment='确认人ID')
|
||||
confirmed_at = Column(DateTime, nullable=True, comment='确认时间')
|
||||
receipt_state = Column(String(16), nullable=False, server_default='PENDING', comment='收货状态;LEGACY表示历史未登记')
|
||||
received_by = Column(BigInteger, ForeignKey('sys_user.user_id'), nullable=True, comment='收货核对人ID')
|
||||
received_at = Column(DateTime, nullable=True, comment='实际收货核对时间')
|
||||
receipt_idempotency_key = Column(String(64), nullable=True, comment='收货确认幂等键')
|
||||
receipt_request_hash = Column(String(64), nullable=True, comment='收货核对请求摘要')
|
||||
create_by = Column(String(64), nullable=True, server_default='', comment='创建者')
|
||||
create_time = Column(DateTime, nullable=True, default=datetime.now, comment='创建时间')
|
||||
update_by = Column(String(64), nullable=True, server_default='', comment='更新者')
|
||||
@@ -275,13 +300,23 @@ class BizUsageRecord(Base):
|
||||
comment='活动装填记录ID',
|
||||
)
|
||||
site_id = Column(BigInteger, ForeignKey('biz_customer_site.site_id'), nullable=False, comment='现场ID')
|
||||
line_id = Column(BigInteger, ForeignKey('biz_production_line.line_id'), nullable=False, comment='产线ID')
|
||||
tunnel_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_tunnel.tunnel_id'),
|
||||
nullable=True,
|
||||
comment='隧道ID;历史记录可能为空',
|
||||
)
|
||||
trace_no = Column(String(64), nullable=True, comment='隧道物料内部溯源号;历史记录可能为空')
|
||||
batch_no_snapshot = Column(String(64), nullable=True, comment='生产批次快照;历史记录可能为空')
|
||||
line_id = Column(BigInteger, ForeignKey('biz_production_line.line_id'), nullable=True, comment='产线ID(可选)')
|
||||
used_packages = Column(Integer, nullable=False, comment='本次使用包数')
|
||||
remaining_packages_after = Column(Integer, nullable=False, comment='使用后剩余包数')
|
||||
idempotency_key = Column(String(64), nullable=False, comment='幂等键')
|
||||
request_hash = Column(String(64), nullable=False, comment='请求摘要')
|
||||
operator_id = Column(BigInteger, ForeignKey('sys_user.user_id'), nullable=False, comment='操作人ID')
|
||||
occurred_at = Column(DateTime, nullable=False, comment='发生时间')
|
||||
fifo_skip_reason = Column(String(500), nullable=True, comment='跳过先到托盘原因')
|
||||
fifo_recommendation = Column(JSON, nullable=True, comment='跳过时先到托盘身份及到货时间快照')
|
||||
|
||||
|
||||
class BizReturnOrder(Base):
|
||||
@@ -387,6 +422,14 @@ class BizReturnItem(Base):
|
||||
nullable=True,
|
||||
comment='申请时活动装填记录快照',
|
||||
)
|
||||
tunnel_id_snapshot = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_tunnel.tunnel_id'),
|
||||
nullable=True,
|
||||
comment='申请时隧道ID快照',
|
||||
)
|
||||
trace_no_snapshot = Column(String(64), nullable=True, comment='申请时物料溯源号快照')
|
||||
batch_no_snapshot = Column(String(64), nullable=True, comment='申请时生产批次快照')
|
||||
remaining_packages_snapshot = Column(Integer, nullable=False, comment='申请时剩余包数快照')
|
||||
create_by = Column(String(64), nullable=True, server_default='', comment='创建者')
|
||||
create_time = Column(DateTime, nullable=True, default=datetime.now, comment='创建时间')
|
||||
@@ -399,7 +442,7 @@ class BizPalletInventory(Base):
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"flow_state IN ('UNSTOCKED', 'EMPTY_IN_WAREHOUSE', 'FILLED_IN_WAREHOUSE', "
|
||||
"'AT_SITE_UNUSED', 'AT_SITE_PARTIAL', 'AT_SITE_EMPTY', 'RETURNING')",
|
||||
"'IN_TRANSIT', 'AT_SITE_UNUSED', 'AT_SITE_PARTIAL', 'AT_SITE_EMPTY', 'RETURNING')",
|
||||
name='ck_biz_pallet_inventory_flow_state',
|
||||
),
|
||||
CheckConstraint('remaining_packages >= 0', name='ck_biz_pallet_inventory_remaining_packages'),
|
||||
@@ -444,6 +487,13 @@ class BizPalletInventory(Base):
|
||||
nullable=True,
|
||||
comment='当前现场ID',
|
||||
)
|
||||
tunnel_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_tunnel.tunnel_id'),
|
||||
nullable=True,
|
||||
comment='当前隧道ID',
|
||||
)
|
||||
trace_no = Column(String(64), nullable=True, comment='当前隧道物料溯源号')
|
||||
current_line_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_production_line.line_id'),
|
||||
@@ -452,6 +502,7 @@ class BizPalletInventory(Base):
|
||||
)
|
||||
flow_state = Column(String(32), nullable=False, server_default='UNSTOCKED', comment='托盘流转状态')
|
||||
remaining_packages = Column(Integer, nullable=False, server_default='0', comment='当前剩余包数')
|
||||
site_arrived_at = Column(DateTime, nullable=True, comment='本轮实际收货核对时间;历史未登记为空')
|
||||
last_event_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_pallet_event.event_id'),
|
||||
@@ -471,7 +522,7 @@ class BizPalletEvent(Base):
|
||||
UniqueConstraint('pallet_id', 'idempotency_key', name='uq_biz_pallet_event_pallet_idempotency'),
|
||||
CheckConstraint(
|
||||
"event_type IN ('INBOUND_CONFIRMED', 'FILLING_CONFIRMED', 'SHIPMENT_CONFIRMED', "
|
||||
"'USAGE_RECORDED', 'RETURN_REQUESTED', 'RETURN_CONFIRMED')",
|
||||
"'SHIPMENT_RECEIVED', 'TUNNEL_ENTERED', 'USAGE_RECORDED', 'RETURN_REQUESTED', 'RETURN_CONFIRMED')",
|
||||
name='ck_biz_pallet_event_event_type',
|
||||
),
|
||||
{'comment': '托盘只追加流转事件'},
|
||||
@@ -504,6 +555,12 @@ class BizPalletEvent(Base):
|
||||
nullable=True,
|
||||
comment='变更前现场ID',
|
||||
)
|
||||
before_tunnel_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_tunnel.tunnel_id'),
|
||||
nullable=True,
|
||||
comment='变更前隧道ID',
|
||||
)
|
||||
before_line_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_production_line.line_id'),
|
||||
@@ -522,6 +579,12 @@ class BizPalletEvent(Base):
|
||||
nullable=True,
|
||||
comment='变更后现场ID',
|
||||
)
|
||||
after_tunnel_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_tunnel.tunnel_id'),
|
||||
nullable=True,
|
||||
comment='变更后隧道ID',
|
||||
)
|
||||
after_line_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_production_line.line_id'),
|
||||
@@ -530,6 +593,10 @@ class BizPalletEvent(Base):
|
||||
)
|
||||
before_remaining_packages = Column(Integer, nullable=False, comment='变更前剩余包数')
|
||||
after_remaining_packages = Column(Integer, nullable=False, comment='变更后剩余包数')
|
||||
trace_no = Column(String(64), nullable=True, comment='事件关联物料溯源号')
|
||||
batch_no_snapshot = Column(String(64), nullable=True, comment='事件生产批次快照')
|
||||
fifo_skip_reason = Column(String(500), nullable=True, comment='跳过先到托盘原因')
|
||||
fifo_recommendation = Column(JSON, nullable=True, comment='跳过时先到托盘身份及到货时间快照')
|
||||
operator_id = Column(BigInteger, ForeignKey('sys_user.user_id'), nullable=False, comment='操作人ID')
|
||||
occurred_at = Column(DateTime, nullable=False, comment='发生时间')
|
||||
idempotency_key = Column(String(64), nullable=False, comment='幂等键')
|
||||
|
||||
@@ -240,6 +240,64 @@ class DeleteLineModel(BaseModel):
|
||||
_validate_unique_ids = field_validator('line_ids')(_ensure_unique_ids)
|
||||
|
||||
|
||||
class TunnelModel(BaseModel):
|
||||
"""隧道主数据写入及展示模型。"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
alias_generator=to_camel,
|
||||
from_attributes=True,
|
||||
populate_by_name=True,
|
||||
extra='forbid',
|
||||
)
|
||||
|
||||
tunnel_id: int | None = Field(default=None, gt=0, description='隧道ID')
|
||||
site_id: int = Field(gt=0, description='现场ID')
|
||||
tunnel_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='隧道编号')
|
||||
tunnel_name: str = Field(min_length=1, max_length=100, pattern=r'\S', description='隧道名称')
|
||||
status: Literal['0', '1'] = Field(default='0', description='状态(0正常 1停用)')
|
||||
current_trace_no: str | None = Field(default=None, max_length=64, description='当前物料溯源号')
|
||||
current_trace_started_at: datetime | None = Field(default=None, description='当前溯源批次开始时间')
|
||||
create_by: str | None = Field(default=None, max_length=64, description='创建者')
|
||||
create_time: datetime | None = Field(default=None, description='创建时间')
|
||||
update_by: str | None = Field(default=None, max_length=64, description='更新者')
|
||||
update_time: datetime | None = Field(default=None, description='更新时间')
|
||||
remark: str | None = Field(default=None, max_length=500, description='备注')
|
||||
|
||||
|
||||
class TunnelPageQueryModel(BaseModel):
|
||||
"""隧道分页查询模型。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
page_num: int = Field(default=1, gt=0, description='当前页码')
|
||||
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
|
||||
site_id: int | None = Field(default=None, gt=0, description='现场ID')
|
||||
tunnel_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='隧道编号')
|
||||
tunnel_name: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
pattern=r'\S',
|
||||
description='隧道名称',
|
||||
)
|
||||
status: Literal['0', '1'] | None = Field(default=None, description='状态')
|
||||
|
||||
|
||||
class DeleteTunnelModel(BaseModel):
|
||||
"""删除隧道模型。"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
alias_generator=to_camel,
|
||||
from_attributes=True,
|
||||
populate_by_name=True,
|
||||
extra='forbid',
|
||||
)
|
||||
|
||||
tunnel_ids: list[PositiveId] = Field(min_length=1, description='需要删除的隧道ID')
|
||||
|
||||
_validate_unique_ids = field_validator('tunnel_ids')(_ensure_unique_ids)
|
||||
|
||||
|
||||
class WarehouseModel(BaseModel):
|
||||
"""仓库主数据写入及展示模型。"""
|
||||
|
||||
@@ -329,7 +387,7 @@ class PalletCreateModel(BaseModel):
|
||||
)
|
||||
|
||||
pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
|
||||
capacity_packages: int = Field(strict=True, gt=0, description='容量包数')
|
||||
capacity_packages: Literal[100] = Field(description='容量包数(固定 100 包)')
|
||||
remark: str | None = Field(default=None, max_length=500, description='备注')
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
@@ -16,6 +16,10 @@ def _ensure_unique_ids(value: list[int]) -> list[int]:
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_fifo_reason(value: str | None) -> str | None:
|
||||
return (value.strip() or None) if isinstance(value, str) else value
|
||||
|
||||
|
||||
def _parse_page_query_date(value: str) -> datetime:
|
||||
try:
|
||||
parsed = datetime.strptime(value, '%Y-%m-%d')
|
||||
@@ -98,10 +102,18 @@ class FillingCreateModel(BaseModel):
|
||||
warehouse_id: int = Field(gt=0, description='仓库ID')
|
||||
strain_id: int = Field(gt=0, description='菌种ID')
|
||||
batch_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='生产批次')
|
||||
production_date: date = Field(description='发货来源生产日期')
|
||||
expiry_date: date = Field(description='有效期至')
|
||||
package_spec: str = Field(min_length=1, max_length=64, pattern=r'\S', description='包装规格')
|
||||
initial_packages: int = Field(strict=True, gt=0, description='初始包数')
|
||||
initial_packages: Literal[100] = Field(description='初始包数(固定 100 包)')
|
||||
idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键')
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_material_dates(self) -> 'FillingCreateModel':
|
||||
if self.expiry_date < self.production_date:
|
||||
raise ValueError('有效期不能早于生产日期')
|
||||
return self
|
||||
|
||||
|
||||
class ShipmentCreateModel(BaseModel):
|
||||
"""整托发货草稿新增模型。"""
|
||||
@@ -149,9 +161,41 @@ class UsageCreateModel(BaseModel):
|
||||
)
|
||||
|
||||
qr_token: str = Field(min_length=1, max_length=128, pattern=r'\S', description='二维码固定标识')
|
||||
line_id: int = Field(gt=0, description='产线ID')
|
||||
tunnel_id: int = Field(gt=0, description='隧道ID')
|
||||
line_id: int | None = Field(default=None, gt=0, description='产线ID(可选)')
|
||||
used_packages: int = Field(strict=True, gt=0, description='本次使用包数')
|
||||
idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键')
|
||||
fifo_skip_reason: str | None = Field(default=None, max_length=500, description='跳过先到托盘原因')
|
||||
|
||||
_normalize_reason = field_validator('fifo_skip_reason', mode='before')(_normalize_fifo_reason)
|
||||
|
||||
|
||||
class TunnelEntryCreateModel(BaseModel):
|
||||
"""托盘扫码入隧命令模型。"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
alias_generator=to_camel,
|
||||
from_attributes=True,
|
||||
populate_by_name=True,
|
||||
extra='forbid',
|
||||
)
|
||||
|
||||
qr_token: str = Field(min_length=1, max_length=128, pattern=r'\S', description='二维码固定标识')
|
||||
tunnel_id: int = Field(gt=0, description='隧道ID')
|
||||
idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键')
|
||||
fifo_skip_reason: str | None = Field(default=None, max_length=500, description='跳过先到托盘原因')
|
||||
|
||||
_normalize_reason = field_validator('fifo_skip_reason', mode='before')(_normalize_fifo_reason)
|
||||
|
||||
|
||||
class TunnelEntrySuccessResponseModel(BaseModel):
|
||||
"""托盘扫码入隧成功响应模型。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
pallet_id: int = Field(gt=0, description='托盘ID')
|
||||
tunnel_id: int = Field(gt=0, description='隧道ID')
|
||||
trace_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='物料溯源号')
|
||||
|
||||
|
||||
class UsageSuccessResponseModel(BaseModel):
|
||||
@@ -244,6 +288,8 @@ class FillingModel(BaseModel):
|
||||
strain_code_snapshot: str = Field(min_length=1, max_length=64, pattern=r'\S', description='菌种编码快照')
|
||||
strain_name_snapshot: str = Field(min_length=1, max_length=100, pattern=r'\S', description='菌种名称快照')
|
||||
batch_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='生产批次')
|
||||
production_date: date | None = Field(default=None, description='生产日期;历史未登记时为空')
|
||||
expiry_date: date | None = Field(default=None, description='有效期至;历史未登记时为空')
|
||||
package_spec: str = Field(min_length=1, max_length=64, pattern=r'\S', description='包装规格')
|
||||
initial_packages: int = Field(strict=True, gt=0, description='初始包数')
|
||||
active_slot: Literal['ACTIVE'] | None = Field(default=None, description='活动装填槽')
|
||||
@@ -293,6 +339,9 @@ class ShipmentModel(BaseModel):
|
||||
confirm_request_hash: str | None = Field(default=None, max_length=64, description='确认请求摘要')
|
||||
confirmed_by: int | None = Field(default=None, gt=0, description='确认人ID')
|
||||
confirmed_at: datetime | None = Field(default=None, description='确认时间')
|
||||
receipt_state: Literal['PENDING', 'RECEIVED', 'LEGACY'] | None = Field(default=None, description='收货核对状态')
|
||||
received_by: int | None = Field(default=None, gt=0, description='收货核对人')
|
||||
received_at: datetime | None = Field(default=None, description='实际收货核对时间')
|
||||
create_by: str | None = Field(default=None, max_length=64, description='创建者')
|
||||
create_time: datetime | None = Field(default=None, description='创建时间')
|
||||
update_by: str | None = Field(default=None, max_length=64, description='更新者')
|
||||
@@ -311,13 +360,17 @@ class UsageModel(BaseModel):
|
||||
pallet_id: int = Field(gt=0, description='托盘ID')
|
||||
active_filling_id: int = Field(gt=0, description='活动装填记录ID')
|
||||
site_id: int = Field(gt=0, description='现场ID')
|
||||
line_id: int = Field(gt=0, description='产线ID')
|
||||
tunnel_id: int | None = Field(default=None, gt=0, description='隧道ID')
|
||||
trace_no: str | None = Field(default=None, max_length=64, description='物料溯源号')
|
||||
batch_no_snapshot: str | None = Field(default=None, max_length=64, description='生产批次快照')
|
||||
line_id: int | None = Field(default=None, gt=0, description='产线ID(可选)')
|
||||
used_packages: int = Field(strict=True, gt=0, description='本次使用包数')
|
||||
remaining_packages_after: int = Field(strict=True, ge=0, description='使用后剩余包数')
|
||||
idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键')
|
||||
request_hash: str = Field(min_length=1, max_length=64, pattern=r'\S', description='请求摘要')
|
||||
operator_id: int = Field(gt=0, description='操作人ID')
|
||||
occurred_at: datetime = Field(description='发生时间')
|
||||
fifo_skip_reason: str | None = Field(default=None, max_length=500, description='跳过先到托盘原因')
|
||||
|
||||
|
||||
class ReturnItemModel(BaseModel):
|
||||
@@ -329,6 +382,9 @@ class ReturnItemModel(BaseModel):
|
||||
return_id: int = Field(gt=0, description='退回单ID')
|
||||
pallet_id: int = Field(gt=0, description='托盘ID')
|
||||
active_filling_id_snapshot: int | None = Field(default=None, gt=0, description='申请时活动装填记录快照')
|
||||
tunnel_id_snapshot: int | None = Field(default=None, gt=0, description='申请时隧道ID快照')
|
||||
trace_no_snapshot: str | None = Field(default=None, max_length=64, description='申请时物料溯源号快照')
|
||||
batch_no_snapshot: str | None = Field(default=None, max_length=64, description='申请时生产批次快照')
|
||||
remaining_packages_snapshot: int = Field(strict=True, ge=0, description='申请时剩余包数快照')
|
||||
create_by: str | None = Field(default=None, max_length=64, description='创建者')
|
||||
create_time: datetime | None = Field(default=None, description='创建时间')
|
||||
@@ -420,6 +476,8 @@ class UsagePageQueryModel(BaseModel):
|
||||
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
|
||||
usage_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='使用记录号')
|
||||
site_id: int | None = Field(default=None, gt=0, description='现场ID')
|
||||
tunnel_id: int | None = Field(default=None, gt=0, description='隧道ID')
|
||||
trace_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='物料溯源号')
|
||||
line_id: int | None = Field(default=None, gt=0, description='产线ID')
|
||||
pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
|
||||
begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间')
|
||||
@@ -496,15 +554,20 @@ class PalletScanResponseModel(BaseModel):
|
||||
|
||||
pallet_id: int = Field(gt=0, description='托盘ID')
|
||||
pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
|
||||
qr_token: str | None = Field(default=None, min_length=1, max_length=128, description='解析后的固定二维码标识,写入时使用')
|
||||
flow_state: PalletFlowState = Field(description='托盘流转状态')
|
||||
warehouse_id: int | None = Field(default=None, gt=0, description='当前仓库ID')
|
||||
site_id: int | None = Field(default=None, gt=0, description='当前现场ID')
|
||||
tunnel_id: int | None = Field(default=None, gt=0, description='当前隧道ID')
|
||||
trace_no: str | None = Field(default=None, max_length=64, description='当前物料溯源号')
|
||||
current_line_id: int | None = Field(default=None, gt=0, description='当前绑定产线ID')
|
||||
active_filling_id: int | None = Field(default=None, gt=0, description='当前活动装填记录ID')
|
||||
strain_id: int | None = Field(default=None, gt=0, description='菌种ID')
|
||||
strain_code: str | None = Field(default=None, max_length=64, description='菌种编码')
|
||||
strain_name: str | None = Field(default=None, max_length=100, description='菌种名称')
|
||||
batch_no: str | None = Field(default=None, max_length=64, description='生产批次')
|
||||
production_date: date | None = Field(default=None, description='生产日期')
|
||||
expiry_date: date | None = Field(default=None, description='有效期至')
|
||||
package_spec: str | None = Field(default=None, max_length=64, description='包装规格')
|
||||
capacity_packages: int = Field(strict=True, gt=0, description='容量包数')
|
||||
remaining_packages: int = Field(strict=True, ge=0, description='当前剩余包数')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
from module_business.domain.enums import DocumentStatus, PalletEventType, PalletFlowState, ReturnStatus
|
||||
@@ -28,7 +28,18 @@ def _validate_report_query_date_range(value: BaseModel) -> BaseModel:
|
||||
return value
|
||||
|
||||
|
||||
class PalletInventoryReportQueryModel(BaseModel):
|
||||
class TunnelReportQueryModel(BaseModel):
|
||||
"""隧道报表共享筛选;编号/名称可直接输入,溯源号按完整值匹配。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
tunnel_id: int | None = Field(default=None, gt=0, description='隧道ID')
|
||||
tunnel_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='隧道编号')
|
||||
tunnel_name: str | None = Field(default=None, min_length=1, max_length=100, pattern=r'\S', description='隧道名称')
|
||||
trace_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='内部溯源号(精确匹配)')
|
||||
|
||||
|
||||
class PalletInventoryReportQueryModel(TunnelReportQueryModel):
|
||||
"""托盘库存报表查询模型。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
@@ -69,6 +80,12 @@ class PalletInventoryReportModel(BaseModel):
|
||||
initial_packages: int | None = Field(default=None, strict=True, gt=0, description='初始包数')
|
||||
remaining_packages: int = Field(strict=True, ge=0, description='当前剩余包数')
|
||||
updated_at: datetime = Field(description='库存更新时间')
|
||||
tunnel_id: int | None = Field(default=None, gt=0, description='当前隧道ID')
|
||||
tunnel_code: str | None = Field(default=None, max_length=64, description='当前隧道编号')
|
||||
tunnel_name: str | None = Field(default=None, max_length=100, description='当前隧道名称')
|
||||
trace_no: str | None = Field(default=None, max_length=64, description='当前内部溯源号')
|
||||
production_date: date | None = Field(default=None, description='当前物料生产日期,历史未登记为空')
|
||||
expiry_date: date | None = Field(default=None, description='当前物料有效期至,历史未登记为空')
|
||||
|
||||
|
||||
class FillingReportQueryModel(BaseModel):
|
||||
@@ -148,8 +165,8 @@ class ShipmentReportModel(BaseModel):
|
||||
package_qty: int = Field(strict=True, gt=0, description='发货时包数快照')
|
||||
|
||||
|
||||
class ConsumptionReportQueryModel(BaseModel):
|
||||
"""产线消耗报表查询模型。"""
|
||||
class ConsumptionReportQueryModel(TunnelReportQueryModel):
|
||||
"""隧道消耗报表查询模型,兼容历史产线筛选。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
@@ -168,7 +185,7 @@ class ConsumptionReportQueryModel(BaseModel):
|
||||
|
||||
|
||||
class ConsumptionReportModel(BaseModel):
|
||||
"""产线消耗报表行模型。"""
|
||||
"""隧道消耗报表行模型。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
@@ -179,7 +196,7 @@ class ConsumptionReportModel(BaseModel):
|
||||
customer_name: str | None = Field(default=None, max_length=100, description='客户名称')
|
||||
site_id: int = Field(gt=0, description='现场ID')
|
||||
site_name: str | None = Field(default=None, max_length=100, description='现场名称')
|
||||
line_id: int = Field(gt=0, description='产线ID')
|
||||
line_id: int | None = Field(default=None, gt=0, description='产线ID(可选)')
|
||||
line_name: str | None = Field(default=None, max_length=100, description='产线名称')
|
||||
pallet_id: int = Field(gt=0, description='托盘ID')
|
||||
pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
|
||||
@@ -191,9 +208,16 @@ class ConsumptionReportModel(BaseModel):
|
||||
remaining_packages_after: int = Field(strict=True, ge=0, description='使用后剩余包数')
|
||||
operator_id: int = Field(gt=0, description='操作人ID')
|
||||
operator_name: str | None = Field(default=None, max_length=100, description='操作人名称')
|
||||
tunnel_id: int | None = Field(default=None, gt=0, description='使用时隧道ID')
|
||||
tunnel_code: str | None = Field(default=None, max_length=64, description='使用时隧道编号')
|
||||
tunnel_name: str | None = Field(default=None, max_length=100, description='使用时隧道名称')
|
||||
trace_no: str | None = Field(default=None, max_length=64, description='使用时内部溯源号')
|
||||
batch_no_snapshot: str | None = Field(default=None, max_length=64, description='使用时生产批次快照')
|
||||
production_date: date | None = Field(default=None, description='使用时原装填物料生产日期')
|
||||
expiry_date: date | None = Field(default=None, description='使用时原装填物料有效期至')
|
||||
|
||||
|
||||
class ReturnReportQueryModel(BaseModel):
|
||||
class ReturnReportQueryModel(TunnelReportQueryModel):
|
||||
"""退回仓库报表查询模型。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
@@ -207,6 +231,7 @@ class ReturnReportQueryModel(BaseModel):
|
||||
target_warehouse_id: int | None = Field(default=None, gt=0, description='目标仓库ID')
|
||||
pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
|
||||
status: ReturnStatus | None = Field(default=None, description='退回状态')
|
||||
batch_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='退回生产批次')
|
||||
|
||||
_validate_date_range = model_validator(mode='after')(_validate_report_query_date_range)
|
||||
|
||||
@@ -229,9 +254,17 @@ class ReturnReportModel(BaseModel):
|
||||
pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
|
||||
active_filling_id_snapshot: int | None = Field(default=None, gt=0, description='活动装填记录快照')
|
||||
remaining_packages_snapshot: int = Field(strict=True, ge=0, description='剩余包数快照')
|
||||
tunnel_id: int | None = Field(default=None, gt=0, description='退回时隧道ID快照')
|
||||
tunnel_code: str | None = Field(default=None, max_length=64, description='退回隧道编号')
|
||||
tunnel_name: str | None = Field(default=None, max_length=100, description='退回隧道名称')
|
||||
trace_no: str | None = Field(default=None, max_length=64, description='退回时内部溯源号快照')
|
||||
batch_no_snapshot: str | None = Field(default=None, max_length=64, description='退回时生产批次快照')
|
||||
batch_no: str | None = Field(default=None, max_length=64, description='生产批次,历史记录回查原装填记录')
|
||||
production_date: date | None = Field(default=None, description='退回时原装填物料生产日期')
|
||||
expiry_date: date | None = Field(default=None, description='退回时原装填物料有效期至')
|
||||
|
||||
|
||||
class PalletTraceReportQueryModel(BaseModel):
|
||||
class PalletTraceReportQueryModel(TunnelReportQueryModel):
|
||||
"""托盘追溯报表查询模型。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
@@ -242,6 +275,7 @@ class PalletTraceReportQueryModel(BaseModel):
|
||||
end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间')
|
||||
pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
|
||||
event_type: PalletEventType | None = Field(default=None, description='事件类型')
|
||||
batch_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='事件生产批次快照')
|
||||
|
||||
_validate_date_range = model_validator(mode='after')(_validate_report_query_date_range)
|
||||
|
||||
@@ -277,3 +311,29 @@ class PalletTraceReportModel(BaseModel):
|
||||
operator_id: int = Field(gt=0, description='操作人ID')
|
||||
operator_name: str | None = Field(default=None, max_length=100, description='操作人名称')
|
||||
occurred_at: datetime = Field(description='发生时间')
|
||||
tunnel_id: int | None = Field(default=None, gt=0, description='事件隧道ID,优先变更后隧道')
|
||||
tunnel_code: str | None = Field(default=None, max_length=64, description='事件隧道编号')
|
||||
tunnel_name: str | None = Field(default=None, max_length=100, description='事件隧道名称')
|
||||
trace_no: str | None = Field(default=None, max_length=64, description='事件内部溯源号')
|
||||
batch_no_snapshot: str | None = Field(default=None, max_length=64, description='事件生产批次快照')
|
||||
before_tunnel_id: int | None = Field(default=None, gt=0, description='变更前隧道ID')
|
||||
before_tunnel_code: str | None = Field(default=None, max_length=64, description='变更前隧道编号')
|
||||
before_tunnel_name: str | None = Field(default=None, max_length=100, description='变更前隧道名称')
|
||||
after_tunnel_id: int | None = Field(default=None, gt=0, description='变更后隧道ID')
|
||||
after_tunnel_code: str | None = Field(default=None, max_length=64, description='变更后隧道编号')
|
||||
after_tunnel_name: str | None = Field(default=None, max_length=100, description='变更后隧道名称')
|
||||
production_date: date | None = Field(default=None, description='事件原装填物料生产日期')
|
||||
expiry_date: date | None = Field(default=None, description='事件原装填物料有效期至')
|
||||
|
||||
@computed_field(description='本事件实际使用包数,仅使用事件计入')
|
||||
@property
|
||||
def used_packages(self) -> int:
|
||||
return (
|
||||
self.before_remaining_packages - self.after_remaining_packages
|
||||
if self.event_type == PalletEventType.USAGE_RECORDED else 0
|
||||
)
|
||||
|
||||
@computed_field(description='本事件确认退回包数,退回申请不重复计入')
|
||||
@property
|
||||
def returned_packages(self) -> int:
|
||||
return self.before_remaining_packages if self.event_type == PalletEventType.RETURN_CONFIRMED else 0
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
from module_business.domain.enums import DocumentStatus
|
||||
|
||||
|
||||
class ShipmentLabelModel(BaseModel):
|
||||
"""独立打印载荷,二维码由发货查询授权后生成,无需基础资料权限。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
|
||||
shipment_no: str
|
||||
shipment_status: DocumentStatus
|
||||
pallet_code: str
|
||||
customer_name: str
|
||||
site_name: str
|
||||
strain_name: str
|
||||
batch_no: str
|
||||
production_date: date | None = None
|
||||
expiry_date: date | None = None
|
||||
package_qty: int = Field(strict=True, gt=0)
|
||||
qr_code_data_url: str
|
||||
@@ -0,0 +1,38 @@
|
||||
from datetime import date
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
from module_business.entity.vo.operation_vo import PositiveId, ShipmentItemModel, ShipmentModel, ShipmentPageQueryModel
|
||||
|
||||
|
||||
class ShipmentReceiptCommandModel(BaseModel):
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, extra='forbid')
|
||||
|
||||
scanned_pallet_ids: list[PositiveId] = Field(min_length=1, description='实际核对的托盘集合')
|
||||
idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S')
|
||||
|
||||
@field_validator('scanned_pallet_ids')
|
||||
@classmethod
|
||||
def unique_pallets(cls, value: list[int]) -> list[int]:
|
||||
if len(value) != len(set(value)):
|
||||
raise ValueError('核对托盘不能重复')
|
||||
return value
|
||||
|
||||
|
||||
class ShipmentReceiptQueryModel(ShipmentPageQueryModel):
|
||||
receipt_state: Literal['PENDING', 'RECEIVED', 'LEGACY'] | None = None
|
||||
|
||||
|
||||
class ShipmentReceiptItemModel(ShipmentItemModel):
|
||||
pallet_code: str = Field(description='原托盘号')
|
||||
qr_token: str = Field(description='规范二维码标识')
|
||||
strain_name: str | None = None
|
||||
batch_no: str | None = None
|
||||
production_date: date | None = None
|
||||
expiry_date: date | None = None
|
||||
|
||||
|
||||
class ShipmentReceiptModel(ShipmentModel):
|
||||
items: list[ShipmentReceiptItemModel] = Field(default_factory=list)
|
||||
@@ -88,6 +88,7 @@ AdoptionRootType = Literal[
|
||||
'customer',
|
||||
'site',
|
||||
'line',
|
||||
'tunnel',
|
||||
'warehouse',
|
||||
'pallet',
|
||||
'inbound',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
|
||||
class UsageFifoCandidateModel(BaseModel):
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
|
||||
pallet_id: int
|
||||
pallet_code: str
|
||||
batch_no: str
|
||||
remaining_packages: int
|
||||
site_arrived_at: datetime
|
||||
|
||||
|
||||
class UsageFifoRecommendationModel(BaseModel):
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
|
||||
recommended: UsageFifoCandidateModel | None = None
|
||||
unknown_arrival_count: int = 0
|
||||
requires_skip_reason: bool = False
|
||||
message: str
|
||||
@@ -63,6 +63,8 @@ class FillingService:
|
||||
'warehouse_id': page_object.warehouse_id,
|
||||
'strain_id': page_object.strain_id,
|
||||
'batch_no': page_object.batch_no.strip(),
|
||||
'production_date': page_object.production_date.isoformat(),
|
||||
'expiry_date': page_object.expiry_date.isoformat(),
|
||||
'package_spec': page_object.package_spec.strip(),
|
||||
'initial_packages': page_object.initial_packages,
|
||||
},
|
||||
@@ -147,10 +149,11 @@ class FillingService:
|
||||
|
||||
@staticmethod
|
||||
def _validate_package_count(initial_packages: int, pallet: BizPallet) -> None:
|
||||
if type(initial_packages) is not int or initial_packages <= 0:
|
||||
raise ServiceException(message='装填包数必须为正整数')
|
||||
if initial_packages > pallet.capacity_packages:
|
||||
raise ServiceException(message='装填包数不能超过托盘容量')
|
||||
standard_packages = 100
|
||||
if type(initial_packages) is not int or initial_packages != standard_packages:
|
||||
raise ServiceException(message='新装填包数固定为 100 包')
|
||||
if pallet.capacity_packages != standard_packages:
|
||||
raise ServiceException(message='历史非 100 包托盘不允许新增装填')
|
||||
|
||||
@staticmethod
|
||||
def _validate_active_strain(strain: BizStrain | None) -> BizStrain:
|
||||
@@ -276,10 +279,11 @@ class FillingService:
|
||||
pallet,
|
||||
inventory,
|
||||
inventory.warehouse_id,
|
||||
qr_token,
|
||||
pallet.qr_token,
|
||||
)
|
||||
return PalletScanResponseModel(
|
||||
pallet_id=pallet.pallet_id,
|
||||
qr_token=pallet.qr_token,
|
||||
pallet_code=pallet.pallet_code,
|
||||
flow_state=PalletFlowState.EMPTY_IN_WAREHOUSE,
|
||||
warehouse_id=inventory.warehouse_id,
|
||||
@@ -472,6 +476,8 @@ class FillingService:
|
||||
strain_code_snapshot=strain.strain_code,
|
||||
strain_name_snapshot=strain.strain_name,
|
||||
batch_no=page_object.batch_no.strip(),
|
||||
production_date=page_object.production_date,
|
||||
expiry_date=page_object.expiry_date,
|
||||
package_spec=page_object.package_spec.strip(),
|
||||
initial_packages=page_object.initial_packages,
|
||||
active_slot='ACTIVE',
|
||||
|
||||
@@ -349,6 +349,7 @@ class InboundService:
|
||||
cls._validate_pallet_pair(pallet, inventory)
|
||||
return PalletScanResponseModel(
|
||||
pallet_id=pallet.pallet_id,
|
||||
qr_token=pallet.qr_token,
|
||||
pallet_code=pallet.pallet_code,
|
||||
flow_state=PalletFlowState.UNSTOCKED,
|
||||
warehouse_id=None,
|
||||
|
||||
@@ -16,6 +16,7 @@ from module_business.entity.vo.master_vo import (
|
||||
DeleteLineModel,
|
||||
DeleteSiteModel,
|
||||
DeleteStrainModel,
|
||||
DeleteTunnelModel,
|
||||
DeleteWarehouseModel,
|
||||
LineModel,
|
||||
LinePageQueryModel,
|
||||
@@ -23,12 +24,14 @@ from module_business.entity.vo.master_vo import (
|
||||
SitePageQueryModel,
|
||||
StrainModel,
|
||||
StrainPageQueryModel,
|
||||
TunnelModel,
|
||||
TunnelPageQueryModel,
|
||||
WarehouseModel,
|
||||
WarehousePageQueryModel,
|
||||
)
|
||||
from module_business.service.test_batch_access_service import TestBatchAccessService
|
||||
|
||||
MasterResource = Literal['strain', 'customer', 'site', 'line', 'warehouse']
|
||||
MasterResource = Literal['strain', 'customer', 'site', 'line', 'tunnel', 'warehouse']
|
||||
|
||||
|
||||
_RESOURCE_CONFIG: dict[MasterResource, dict[str, Any]] = {
|
||||
@@ -60,6 +63,13 @@ _RESOURCE_CONFIG: dict[MasterResource, dict[str, Any]] = {
|
||||
'ids_field': 'line_ids',
|
||||
'vo': LineModel,
|
||||
},
|
||||
'tunnel': {
|
||||
'label': '隧道',
|
||||
'id_field': 'tunnel_id',
|
||||
'code_field': 'tunnel_code',
|
||||
'ids_field': 'tunnel_ids',
|
||||
'vo': TunnelModel,
|
||||
},
|
||||
'warehouse': {
|
||||
'label': '仓库',
|
||||
'id_field': 'warehouse_id',
|
||||
@@ -105,6 +115,13 @@ class MasterService:
|
||||
assert_record_in_scope(site, scope, '目标客户现场')
|
||||
if site.status != '0':
|
||||
raise ServiceException(message='目标客户现场已停用')
|
||||
elif resource == 'tunnel':
|
||||
site = await MasterDao.get_site(db, page_object.site_id, scope)
|
||||
if site is None:
|
||||
raise ServiceException(message='目标客户现场不存在')
|
||||
assert_record_in_scope(site, scope, '目标客户现场')
|
||||
if site.status != '0':
|
||||
raise ServiceException(message='目标客户现场已停用')
|
||||
|
||||
@staticmethod
|
||||
async def _find_duplicate(
|
||||
@@ -124,6 +141,12 @@ class MasterService:
|
||||
page_object.line_code,
|
||||
page_object.site_id,
|
||||
)
|
||||
if resource == 'tunnel':
|
||||
return await MasterDao.find_tunnel_by_code(
|
||||
db,
|
||||
page_object.tunnel_code,
|
||||
page_object.site_id,
|
||||
)
|
||||
code_field = _RESOURCE_CONFIG[resource]['code_field']
|
||||
return await getattr(MasterDao, f'find_{resource}_by_code')(
|
||||
db,
|
||||
@@ -413,6 +436,48 @@ class MasterService:
|
||||
) -> NoReturn:
|
||||
return await cls.reject_physical_delete()
|
||||
|
||||
@classmethod
|
||||
async def list_tunnel(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
query_object: TunnelPageQueryModel,
|
||||
scope: TestBatchScope,
|
||||
is_page: bool = True,
|
||||
) -> PageModel | list[TunnelModel]:
|
||||
return await cls._list(db, 'tunnel', query_object, scope, is_page)
|
||||
|
||||
@classmethod
|
||||
async def detail_tunnel(cls, db: AsyncSession, tunnel_id: int, scope: TestBatchScope) -> TunnelModel:
|
||||
return await cls._detail(db, 'tunnel', tunnel_id, scope)
|
||||
|
||||
@classmethod
|
||||
async def add_tunnel(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
tunnel: TunnelModel,
|
||||
current_user: CurrentUserModel,
|
||||
scope: TestBatchScope,
|
||||
) -> CrudResponseModel:
|
||||
return await cls._add(db, 'tunnel', tunnel, current_user, scope)
|
||||
|
||||
@classmethod
|
||||
async def edit_tunnel(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
tunnel: TunnelModel,
|
||||
current_user: CurrentUserModel,
|
||||
scope: TestBatchScope,
|
||||
) -> CrudResponseModel:
|
||||
return await cls._edit(db, 'tunnel', tunnel, current_user, scope)
|
||||
|
||||
@classmethod
|
||||
async def remove_tunnel(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
value: int | DeleteTunnelModel,
|
||||
) -> NoReturn:
|
||||
return await cls.reject_physical_delete()
|
||||
|
||||
@classmethod
|
||||
async def list_warehouse(
|
||||
cls,
|
||||
|
||||
@@ -116,6 +116,8 @@ class PalletService:
|
||||
) -> CrudResponseModel:
|
||||
try:
|
||||
await cls._prepare_test_write(db, current_user, scope)
|
||||
if pallet.capacity_packages != 100:
|
||||
raise ServiceException(message='新托盘容量固定为 100 包')
|
||||
if await MasterDao.find_pallet_by_code(db, pallet.pallet_code) is not None:
|
||||
raise ServiceException(message='托盘编号已存在')
|
||||
|
||||
@@ -162,7 +164,11 @@ class PalletService:
|
||||
) -> CrudResponseModel:
|
||||
try:
|
||||
await cls._prepare_test_write(db, current_user, scope)
|
||||
_, inventory = await cls._lock_pallet_inventory(db, pallet.pallet_id, scope)
|
||||
current_pallet, inventory = await cls._lock_pallet_inventory(db, pallet.pallet_id, scope)
|
||||
if current_pallet.capacity_packages == 100 and pallet.capacity_packages != 100:
|
||||
raise ServiceException(message='新规格托盘容量固定为 100 包')
|
||||
if current_pallet.capacity_packages != pallet.capacity_packages:
|
||||
raise ServiceException(message='历史托盘容量不允许修改')
|
||||
if pallet.capacity_packages < inventory.remaining_packages:
|
||||
raise ServiceException(message='托盘容量不能小于当前剩余包数')
|
||||
await MasterDao.update_pallet(
|
||||
|
||||
@@ -19,7 +19,7 @@ from module_business.dao.operation_dao import OperationDao
|
||||
from module_business.domain.enums import PalletEventType, PalletFlowState, ReturnStatus
|
||||
from module_business.domain.pallet_state_machine import PalletStateMachine
|
||||
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
|
||||
from module_business.entity.do.master_do import BizPallet
|
||||
from module_business.entity.do.master_do import BizPallet, BizTunnel
|
||||
from module_business.entity.do.operation_do import (
|
||||
BizFillingRecord,
|
||||
BizPalletEvent,
|
||||
@@ -421,16 +421,21 @@ class ReturnService:
|
||||
) -> PalletScanResponseModel:
|
||||
return PalletScanResponseModel(
|
||||
pallet_id=pallet.pallet_id,
|
||||
qr_token=pallet.qr_token,
|
||||
pallet_code=pallet.pallet_code,
|
||||
flow_state=PalletFlowState(inventory.flow_state),
|
||||
warehouse_id=inventory.warehouse_id,
|
||||
site_id=inventory.site_id,
|
||||
tunnel_id=inventory.tunnel_id,
|
||||
trace_no=inventory.trace_no,
|
||||
current_line_id=inventory.current_line_id,
|
||||
active_filling_id=inventory.active_filling_id,
|
||||
strain_id=filling.strain_id if filling is not None else None,
|
||||
strain_code=filling.strain_code_snapshot if filling is not None else None,
|
||||
strain_name=filling.strain_name_snapshot if filling is not None else None,
|
||||
batch_no=filling.batch_no if filling is not None else None,
|
||||
production_date=filling.production_date if filling is not None else None,
|
||||
expiry_date=filling.expiry_date if filling is not None else None,
|
||||
package_spec=filling.package_spec if filling is not None else None,
|
||||
capacity_packages=pallet.capacity_packages,
|
||||
remaining_packages=inventory.remaining_packages,
|
||||
@@ -543,12 +548,22 @@ class ReturnService:
|
||||
user_name: str,
|
||||
current_time: datetime,
|
||||
test_batch_id: int | None,
|
||||
filling_by_id: dict[int, BizFillingRecord | None] | None = None,
|
||||
) -> list[BizReturnItem]:
|
||||
return [
|
||||
BizReturnItem(
|
||||
return_id=return_id,
|
||||
pallet_id=inventory.pallet_id,
|
||||
active_filling_id_snapshot=inventory.active_filling_id,
|
||||
tunnel_id_snapshot=inventory.tunnel_id,
|
||||
trace_no_snapshot=inventory.trace_no,
|
||||
batch_no_snapshot=(
|
||||
filling_by_id.get(inventory.active_filling_id).batch_no
|
||||
if filling_by_id
|
||||
and inventory.active_filling_id is not None
|
||||
and filling_by_id.get(inventory.active_filling_id) is not None
|
||||
else None
|
||||
),
|
||||
remaining_packages_snapshot=inventory.remaining_packages,
|
||||
create_by=user_name,
|
||||
create_time=current_time,
|
||||
@@ -788,15 +803,23 @@ class ReturnService:
|
||||
)
|
||||
return_order = await OperationDao.add_return_order(query_db, return_order)
|
||||
return_id = int(return_order.return_id)
|
||||
await OperationDao.add_return_items(
|
||||
query_db,
|
||||
cls._build_return_items(return_id, inventories, user_name, current_time, test_scope.batch_id),
|
||||
return_items = cls._build_return_items(
|
||||
return_id,
|
||||
inventories,
|
||||
user_name,
|
||||
current_time,
|
||||
test_scope.batch_id,
|
||||
filling_by_id,
|
||||
)
|
||||
await OperationDao.add_return_items(query_db, return_items)
|
||||
return_order.items = return_items
|
||||
|
||||
for inventory in inventories:
|
||||
before_state = inventory.flow_state
|
||||
before_warehouse_id = inventory.warehouse_id
|
||||
before_site_id = inventory.site_id
|
||||
before_tunnel_id = inventory.tunnel_id
|
||||
before_trace_no = inventory.trace_no
|
||||
before_line_id = inventory.current_line_id
|
||||
before_remaining_packages = inventory.remaining_packages
|
||||
inventory.flow_state = PalletFlowState.RETURNING.value
|
||||
@@ -811,12 +834,23 @@ class ReturnService:
|
||||
after_state=PalletFlowState.RETURNING.value,
|
||||
before_warehouse_id=before_warehouse_id,
|
||||
before_site_id=before_site_id,
|
||||
before_tunnel_id=before_tunnel_id,
|
||||
before_line_id=before_line_id,
|
||||
after_warehouse_id=None,
|
||||
after_site_id=inventory.site_id,
|
||||
after_tunnel_id=inventory.tunnel_id,
|
||||
after_line_id=inventory.current_line_id,
|
||||
before_remaining_packages=before_remaining_packages,
|
||||
after_remaining_packages=inventory.remaining_packages,
|
||||
trace_no=before_trace_no,
|
||||
batch_no_snapshot=next(
|
||||
(
|
||||
item.batch_no_snapshot
|
||||
for item in getattr(return_order, 'items', [])
|
||||
if item.pallet_id == inventory.pallet_id
|
||||
),
|
||||
None,
|
||||
),
|
||||
operator_id=user_id,
|
||||
occurred_at=current_time,
|
||||
idempotency_key=page_object.idempotency_key,
|
||||
@@ -860,6 +894,8 @@ class ReturnService:
|
||||
if (
|
||||
inventory.remaining_packages != item.remaining_packages_snapshot
|
||||
or inventory.active_filling_id != item.active_filling_id_snapshot
|
||||
or inventory.tunnel_id != item.tunnel_id_snapshot
|
||||
or inventory.trace_no != item.trace_no_snapshot
|
||||
):
|
||||
raise ServiceException(message='托盘库存与退回申请快照不一致')
|
||||
if (
|
||||
@@ -881,7 +917,7 @@ class ReturnService:
|
||||
return_order: BizReturnOrder,
|
||||
items: list[BizReturnItem],
|
||||
test_scope: TestBatchScope,
|
||||
) -> tuple[list[BizPalletInventory], dict[int, BizFillingRecord]]:
|
||||
) -> tuple[list[BizPalletInventory], dict[int, BizFillingRecord], dict[int, BizTunnel]]:
|
||||
ordered_ids = sorted(item.pallet_id for item in items)
|
||||
assert_record_in_scope(return_order, test_scope, '退回单')
|
||||
for item in items:
|
||||
@@ -911,6 +947,21 @@ class ReturnService:
|
||||
inventory_by_id[pallet_id],
|
||||
)
|
||||
|
||||
tunnel_by_id: dict[int, BizTunnel] = {}
|
||||
tunnel_ids = sorted(
|
||||
{
|
||||
item.tunnel_id_snapshot
|
||||
for item in items
|
||||
if item.tunnel_id_snapshot is not None
|
||||
}
|
||||
)
|
||||
for tunnel_id in tunnel_ids:
|
||||
tunnel = await MasterDao.get_tunnel(query_db, tunnel_id, test_scope, for_update=True)
|
||||
if tunnel is None:
|
||||
raise ServiceException(message='退回单关联隧道不存在')
|
||||
assert_record_in_scope(tunnel, test_scope, '隧道')
|
||||
tunnel_by_id[tunnel_id] = tunnel
|
||||
|
||||
filling_by_id = {}
|
||||
filling_ids = sorted(
|
||||
{
|
||||
@@ -928,7 +979,11 @@ class ReturnService:
|
||||
for inventory in ordered_inventories:
|
||||
if inventory.active_filling_id is not None:
|
||||
cls._validate_active_filling(filling_by_id[inventory.active_filling_id], inventory)
|
||||
return ordered_inventories, filling_by_id
|
||||
item = item_by_id[inventory.pallet_id]
|
||||
filling = filling_by_id[inventory.active_filling_id]
|
||||
if item.batch_no_snapshot is not None and filling.batch_no != item.batch_no_snapshot:
|
||||
raise ServiceException(message='托盘生产批次与退回申请快照不一致')
|
||||
return ordered_inventories, filling_by_id, tunnel_by_id
|
||||
|
||||
@classmethod
|
||||
async def _lock_and_reauthorize_confirm_warehouse(
|
||||
@@ -1121,7 +1176,7 @@ class ReturnService:
|
||||
if scanned_ids != sorted(item_ids):
|
||||
raise ServiceException(message='确认扫码托盘集合与退回单明细不一致')
|
||||
|
||||
inventories, _ = await cls._lock_confirm_pallets(query_db, locked, items, test_scope)
|
||||
inventories, _, tunnels = await cls._lock_confirm_pallets(query_db, locked, items, test_scope)
|
||||
await cls._lock_and_reauthorize_confirm_warehouse(
|
||||
query_db,
|
||||
locked,
|
||||
@@ -1151,6 +1206,8 @@ class ReturnService:
|
||||
before_state = inventory.flow_state
|
||||
before_warehouse_id = inventory.warehouse_id
|
||||
before_site_id = inventory.site_id
|
||||
before_tunnel_id = inventory.tunnel_id
|
||||
before_trace_no = inventory.trace_no
|
||||
before_line_id = inventory.current_line_id
|
||||
before_remaining_packages = inventory.remaining_packages
|
||||
if inventory.remaining_packages > 0:
|
||||
@@ -1162,6 +1219,9 @@ class ReturnService:
|
||||
inventory.flow_state = target_state
|
||||
inventory.warehouse_id = locked.target_warehouse_id
|
||||
inventory.site_id = None
|
||||
inventory.site_arrived_at = None
|
||||
inventory.tunnel_id = None
|
||||
inventory.trace_no = None
|
||||
inventory.current_line_id = None
|
||||
inventory.version += 1
|
||||
inventory.updated_at = current_time
|
||||
@@ -1176,10 +1236,21 @@ class ReturnService:
|
||||
before_site_id=before_site_id,
|
||||
before_line_id=before_line_id,
|
||||
after_warehouse_id=locked.target_warehouse_id,
|
||||
before_tunnel_id=before_tunnel_id,
|
||||
after_site_id=None,
|
||||
after_tunnel_id=None,
|
||||
after_line_id=None,
|
||||
before_remaining_packages=before_remaining_packages,
|
||||
after_remaining_packages=inventory.remaining_packages,
|
||||
trace_no=before_trace_no,
|
||||
batch_no_snapshot=next(
|
||||
(
|
||||
item.batch_no_snapshot
|
||||
for item in items
|
||||
if item.pallet_id == inventory.pallet_id
|
||||
),
|
||||
None,
|
||||
),
|
||||
operator_id=user_id,
|
||||
occurred_at=current_time,
|
||||
idempotency_key=page_object.idempotency_key,
|
||||
@@ -1188,6 +1259,27 @@ class ReturnService:
|
||||
)
|
||||
await InventoryDao.add_event(query_db, inventory, event, test_scope)
|
||||
|
||||
for tunnel_id, tunnel in tunnels.items():
|
||||
trace_nos = {
|
||||
item.trace_no_snapshot
|
||||
for item in items
|
||||
if item.tunnel_id_snapshot == tunnel_id and item.trace_no_snapshot
|
||||
}
|
||||
if (
|
||||
tunnel.current_trace_no in trace_nos
|
||||
and await MasterDao.count_tunnel_trace_members(
|
||||
query_db,
|
||||
tunnel_id,
|
||||
tunnel.current_trace_no,
|
||||
test_scope,
|
||||
)
|
||||
== 0
|
||||
):
|
||||
tunnel.current_trace_no = None
|
||||
tunnel.current_trace_started_at = None
|
||||
tunnel.update_by = user_name
|
||||
tunnel.update_time = current_time
|
||||
|
||||
await query_db.commit()
|
||||
return cls._success('确认成功', return_id)
|
||||
except IntegrityError:
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import base64
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from exceptions.exception import ServiceException
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
from module_business.dao.shipment_label_dao import ShipmentLabelDao
|
||||
from module_business.domain.enums import DocumentStatus
|
||||
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
|
||||
from module_business.entity.vo.shipment_label_vo import ShipmentLabelModel
|
||||
from module_business.service.pallet_service import PalletService
|
||||
from module_business.service.shipment_service import ShipmentService
|
||||
|
||||
|
||||
class ShipmentLabelService:
|
||||
"""复用发货双数据范围读取原始物料资料,不改变托盘、批号或库存。"""
|
||||
|
||||
@staticmethod
|
||||
async def get_labels_services(
|
||||
query_db: AsyncSession,
|
||||
shipment_id: int,
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
) -> list[ShipmentLabelModel]:
|
||||
warehouse_ids, site_ids = await ShipmentService._scopes(query_db, current_user)
|
||||
shipment = await OperationDao.get_shipment_by_id(
|
||||
query_db, shipment_id, test_scope, warehouse_ids=warehouse_ids, site_ids=site_ids,
|
||||
)
|
||||
if shipment is None:
|
||||
ShipmentService._raise_shipment_not_accessible(current_user)
|
||||
assert_record_in_scope(shipment, test_scope, '发货单')
|
||||
if shipment.status not in (DocumentStatus.DRAFT.value, DocumentStatus.CONFIRMED.value):
|
||||
raise ServiceException(message='当前发货单状态不支持打印标签')
|
||||
if not shipment.items:
|
||||
raise ServiceException(message='发货单没有可打印的托盘')
|
||||
for item in shipment.items:
|
||||
assert_record_in_scope(item, test_scope, '发货明细')
|
||||
|
||||
# 先授权单据,再读取其目标现场与所属客户,避免通过标签枚举跨范围资料。
|
||||
site = await MasterDao.get_site(query_db, shipment.target_site_id, test_scope)
|
||||
assert_record_in_scope(site, test_scope, '发货现场')
|
||||
customer = await MasterDao.get_customer(query_db, site.customer_id, test_scope)
|
||||
assert_record_in_scope(customer, test_scope, '发货客户')
|
||||
rows = await ShipmentLabelDao.get_label_sources(query_db, shipment, test_scope)
|
||||
if (
|
||||
len(rows) != len(shipment.items)
|
||||
or {row[0].shipment_item_id for row in rows} != {item.shipment_item_id for item in shipment.items}
|
||||
):
|
||||
raise ServiceException(message='发货明细已变化,请刷新后重新打印')
|
||||
|
||||
draft = shipment.status == DocumentStatus.DRAFT.value
|
||||
# 整单全部验证后才生成任何二维码,缺少关联资料时不得输出部分标签。
|
||||
for item, pallet, filling, quantity in rows:
|
||||
for record, name in ((item, '发货明细'), (pallet, '托盘'), (filling, '装填记录')):
|
||||
assert_record_in_scope(record, test_scope, name)
|
||||
if (
|
||||
item.shipment_id != shipment.shipment_id
|
||||
or item.pallet_id != pallet.pallet_id
|
||||
or filling.pallet_id != pallet.pallet_id
|
||||
or type(quantity) is not int
|
||||
or quantity <= 0
|
||||
or not pallet.qr_token
|
||||
):
|
||||
raise ServiceException(message='发货标签资料不完整,请核对托盘与装填记录')
|
||||
if draft:
|
||||
if pallet.asset_status != '0' or filling.active_slot != 'ACTIVE' or quantity > pallet.capacity_packages:
|
||||
raise ServiceException(message='草稿托盘已不满足发货条件,请刷新后重新打印')
|
||||
elif item.active_filling_id != filling.filling_id or quantity != item.package_qty:
|
||||
raise ServiceException(message='发货快照不完整,无法打印标签')
|
||||
|
||||
return [
|
||||
ShipmentLabelModel(
|
||||
shipment_no=shipment.shipment_no,
|
||||
shipment_status=shipment.status,
|
||||
pallet_code=pallet.pallet_code,
|
||||
customer_name=customer.customer_name,
|
||||
site_name=site.site_name,
|
||||
strain_name=filling.strain_name_snapshot,
|
||||
batch_no=filling.batch_no,
|
||||
production_date=filling.production_date,
|
||||
expiry_date=filling.expiry_date,
|
||||
package_qty=quantity,
|
||||
qr_code_data_url='data:image/png;base64,' + base64.b64encode(
|
||||
PalletService.render_qr_png(pallet.qr_token),
|
||||
).decode('ascii'),
|
||||
)
|
||||
for item, pallet, filling, quantity in rows
|
||||
]
|
||||
@@ -0,0 +1,264 @@
|
||||
"""接收方按原发货单核对收货;不重新编托盘号、批号或装填记录。"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import NoReturn
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.vo import CrudResponseModel, PageModel
|
||||
from exceptions.exception import PermissionException, ServiceException
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dao.access_dao import AccessDao
|
||||
from module_business.dao.inventory_dao import InventoryDao
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
from module_business.dao.shipment_receipt_dao import ShipmentReceiptDao
|
||||
from module_business.domain.enums import PalletEventType, PalletFlowState
|
||||
from module_business.domain.pallet_state_machine import PalletStateMachine
|
||||
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
|
||||
from module_business.entity.do.master_do import BizPallet
|
||||
from module_business.entity.do.operation_do import BizPalletEvent, BizPalletInventory, BizShipmentItem, BizShipmentOrder
|
||||
from module_business.entity.vo.operation_vo import ShipmentModel
|
||||
from module_business.entity.vo.shipment_receipt_vo import (
|
||||
ShipmentReceiptCommandModel,
|
||||
ShipmentReceiptItemModel,
|
||||
ShipmentReceiptModel,
|
||||
ShipmentReceiptQueryModel,
|
||||
)
|
||||
from module_business.service.access_service import AccessService
|
||||
from module_business.service.test_batch_access_service import TestBatchAccessService
|
||||
|
||||
|
||||
class ShipmentReceiptService:
|
||||
@staticmethod
|
||||
async def _site_scope(db: AsyncSession, user: CurrentUserModel) -> list[int] | None:
|
||||
if user.user is None or user.user.user_id is None:
|
||||
raise PermissionException(data='', message='当前用户信息无效')
|
||||
if user.user.admin:
|
||||
return None
|
||||
return sorted(set(await AccessDao.list_user_site_ids(db, user.user.user_id)))
|
||||
|
||||
@staticmethod
|
||||
def _missing() -> NoReturn:
|
||||
raise PermissionException(data='', message='无权访问该收货单或单据不存在')
|
||||
|
||||
@classmethod
|
||||
async def list_services(
|
||||
cls, db: AsyncSession, query: ShipmentReceiptQueryModel, user: CurrentUserModel, scope: TestBatchScope
|
||||
) -> PageModel:
|
||||
site_ids = await cls._site_scope(db, user)
|
||||
if query.target_site_id and site_ids is not None and query.target_site_id not in site_ids:
|
||||
cls._missing()
|
||||
result = await ShipmentReceiptDao.list_orders(db, query, scope, site_ids)
|
||||
return result.model_copy(update={'rows': [ShipmentModel.model_validate(row) for row in result.rows]})
|
||||
|
||||
@classmethod
|
||||
async def _order(
|
||||
cls, db: AsyncSession, shipment_id: int, user: CurrentUserModel, scope: TestBatchScope
|
||||
) -> BizShipmentOrder:
|
||||
site_ids = await cls._site_scope(db, user)
|
||||
order = await OperationDao.get_shipment_by_id(db, shipment_id, scope, warehouse_ids=None, site_ids=site_ids)
|
||||
if order is None:
|
||||
cls._missing()
|
||||
assert_record_in_scope(order, scope, '发货单')
|
||||
await AccessService.assert_site_access(db, user, order.target_site_id)
|
||||
if order.status != 'CONFIRMED':
|
||||
raise ServiceException(message='发货草稿尚未出库,不能核对收货')
|
||||
return order
|
||||
|
||||
@staticmethod
|
||||
async def _item_model(db: AsyncSession, item: BizShipmentItem, scope: TestBatchScope) -> ShipmentReceiptItemModel:
|
||||
assert_record_in_scope(item, scope, '发货明细')
|
||||
pallet = await MasterDao.get_pallet(db, item.pallet_id, scope)
|
||||
if pallet is None or item.active_filling_id is None or item.package_qty is None:
|
||||
raise ServiceException(message='发货物料快照不完整')
|
||||
assert_record_in_scope(pallet, scope, '托盘')
|
||||
filling = await OperationDao.get_filling_by_id(db, item.active_filling_id, scope)
|
||||
if filling is None:
|
||||
raise ServiceException(message='原发货装填记录不存在')
|
||||
assert_record_in_scope(filling, scope, '装填记录')
|
||||
if filling.pallet_id != item.pallet_id:
|
||||
raise ServiceException(message='原发货托盘与批次不一致')
|
||||
return ShipmentReceiptItemModel.model_validate(
|
||||
{
|
||||
'shipment_item_id': item.shipment_item_id,
|
||||
'shipment_id': item.shipment_id,
|
||||
'pallet_id': item.pallet_id,
|
||||
'active_filling_id': item.active_filling_id,
|
||||
'package_qty': item.package_qty,
|
||||
'pallet_code': pallet.pallet_code,
|
||||
'qr_token': pallet.qr_token,
|
||||
'strain_name': filling.strain_name_snapshot,
|
||||
'batch_no': filling.batch_no,
|
||||
'production_date': filling.production_date,
|
||||
'expiry_date': filling.expiry_date,
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def detail_services(
|
||||
cls, db: AsyncSession, shipment_id: int, user: CurrentUserModel, scope: TestBatchScope
|
||||
) -> ShipmentReceiptModel:
|
||||
order = await cls._order(db, shipment_id, user, scope)
|
||||
data = ShipmentModel.model_validate(order).model_dump()
|
||||
data['items'] = [await cls._item_model(db, item, scope) for item in order.items]
|
||||
return ShipmentReceiptModel.model_validate(data)
|
||||
|
||||
@staticmethod
|
||||
def _validate_inventory(
|
||||
order: BizShipmentOrder,
|
||||
item: BizShipmentItem,
|
||||
inventory: BizPalletInventory,
|
||||
pallet: BizPallet,
|
||||
scope: TestBatchScope,
|
||||
) -> None:
|
||||
assert_record_in_scope(item, scope, '发货明细')
|
||||
assert_record_in_scope(inventory, scope, '库存')
|
||||
assert_record_in_scope(pallet, scope, '托盘')
|
||||
if (
|
||||
item.shipment_id != order.shipment_id
|
||||
or item.pallet_id != pallet.pallet_id
|
||||
or inventory.pallet_id != pallet.pallet_id
|
||||
or pallet.asset_status != '0'
|
||||
or inventory.flow_state != PalletFlowState.IN_TRANSIT.value
|
||||
or inventory.site_id != order.target_site_id
|
||||
or inventory.warehouse_id is not None
|
||||
or inventory.tunnel_id is not None
|
||||
or inventory.trace_no is not None
|
||||
or inventory.current_line_id is not None
|
||||
or inventory.active_filling_id != item.active_filling_id
|
||||
or item.active_filling_id is None
|
||||
or type(item.package_qty) is not int
|
||||
or item.package_qty <= 0
|
||||
or inventory.remaining_packages != item.package_qty
|
||||
or type(inventory.version) is not int
|
||||
or inventory.version < 0
|
||||
):
|
||||
raise ServiceException(message='待收货托盘状态、批次或包数与原发货单不一致')
|
||||
|
||||
@classmethod
|
||||
async def scan_services(
|
||||
cls, db: AsyncSession, shipment_id: int, identifier: str, user: CurrentUserModel, scope: TestBatchScope
|
||||
) -> ShipmentReceiptItemModel:
|
||||
order = await cls._order(db, shipment_id, user, scope)
|
||||
if order.receipt_state != 'PENDING':
|
||||
raise ServiceException(message='该发货单无需再次核对收货')
|
||||
pallet = await InventoryDao.get_pallet_by_qr_token(db, identifier, scope)
|
||||
if pallet is None:
|
||||
raise ServiceException(message='扫码托盘不属于该发货单')
|
||||
items = [item for item in order.items if item.pallet_id == pallet.pallet_id]
|
||||
if len(items) != 1:
|
||||
raise ServiceException(message='扫码托盘不属于该发货单')
|
||||
inventory = await MasterDao.get_pallet_inventory(db, pallet.pallet_id, scope)
|
||||
if inventory is None:
|
||||
raise ServiceException(message='托盘库存不存在')
|
||||
cls._validate_inventory(order, items[0], inventory, pallet, scope)
|
||||
return await cls._item_model(db, items[0], scope)
|
||||
|
||||
@staticmethod
|
||||
def _request_hash(shipment_id: int, command: ShipmentReceiptCommandModel) -> str:
|
||||
payload = {'shipment_id': shipment_id, 'scanned_pallet_ids': sorted(command.scanned_pallet_ids)}
|
||||
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
||||
|
||||
@classmethod
|
||||
async def confirm_services( # noqa: PLR0912, PLR0915
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
shipment_id: int,
|
||||
command: ShipmentReceiptCommandModel,
|
||||
user: CurrentUserModel,
|
||||
scope: TestBatchScope,
|
||||
) -> CrudResponseModel:
|
||||
try:
|
||||
await TestBatchAccessService.lock_active_batch(db, scope)
|
||||
site_ids = await cls._site_scope(db, user)
|
||||
order = await OperationDao.get_shipment_for_update(
|
||||
db, shipment_id, scope, warehouse_ids=None, site_ids=site_ids
|
||||
)
|
||||
if order is None:
|
||||
cls._missing()
|
||||
assert_record_in_scope(order, scope, '发货单')
|
||||
# 已有发货查询权限不能替代收货方的目标现场权限;锁后再次核验。
|
||||
await AccessService.assert_site_access(db, user, order.target_site_id, for_update=True)
|
||||
request_hash = cls._request_hash(shipment_id, command)
|
||||
if order.status != 'CONFIRMED':
|
||||
raise ServiceException(message='仅已出库发货单可核对收货')
|
||||
if order.receipt_state == 'RECEIVED':
|
||||
if (
|
||||
order.receipt_idempotency_key != command.idempotency_key
|
||||
or order.receipt_request_hash != request_hash
|
||||
):
|
||||
raise ServiceException(message='发货单已收货,幂等信息不匹配')
|
||||
await db.rollback()
|
||||
return CrudResponseModel(is_success=True, message='收货核对成功', result=shipment_id)
|
||||
if order.receipt_state != 'PENDING':
|
||||
raise ServiceException(message='历史发货数据不重复确认收货')
|
||||
items = list(order.items)
|
||||
expected_ids = sorted(item.pallet_id for item in items)
|
||||
if (
|
||||
not expected_ids
|
||||
or len(expected_ids) != len(set(expected_ids))
|
||||
or expected_ids != sorted(command.scanned_pallet_ids)
|
||||
):
|
||||
raise ServiceException(message='核对托盘集合与原发货明细不一致')
|
||||
inventories = await InventoryDao.get_inventories_for_update(db, expected_ids, scope)
|
||||
pallets = await InventoryDao.get_pallets_by_ids(db, expected_ids, scope)
|
||||
if (
|
||||
sorted(i.pallet_id for i in inventories) != expected_ids
|
||||
or sorted(p.pallet_id for p in pallets) != expected_ids
|
||||
):
|
||||
raise ServiceException(message='待收货托盘或库存缺失')
|
||||
site = await MasterDao.get_site(db, order.target_site_id, scope, for_update=True)
|
||||
if site is None:
|
||||
raise ServiceException(message='收货现场不存在')
|
||||
assert_record_in_scope(site, scope, '收货现场')
|
||||
if site.status != '0':
|
||||
raise ServiceException(message='收货现场已停用')
|
||||
by_inventory = {i.pallet_id: i for i in inventories}
|
||||
by_pallet = {p.pallet_id: p for p in pallets}
|
||||
for item in items:
|
||||
cls._validate_inventory(order, item, by_inventory[item.pallet_id], by_pallet[item.pallet_id], scope)
|
||||
PalletStateMachine.ensure_transition(PalletFlowState.IN_TRANSIT, PalletFlowState.AT_SITE_UNUSED)
|
||||
|
||||
now = datetime.now()
|
||||
order.receipt_state = 'RECEIVED'
|
||||
order.received_at = now
|
||||
order.received_by = user.user.user_id
|
||||
order.receipt_idempotency_key = command.idempotency_key
|
||||
order.receipt_request_hash = request_hash
|
||||
order.update_by = user.user.user_name
|
||||
order.update_time = now
|
||||
for inventory in inventories:
|
||||
inventory.flow_state = PalletFlowState.AT_SITE_UNUSED.value
|
||||
inventory.site_arrived_at = now
|
||||
inventory.version += 1
|
||||
inventory.updated_at = now
|
||||
event = BizPalletEvent(
|
||||
pallet_id=inventory.pallet_id,
|
||||
event_type=PalletEventType.SHIPMENT_RECEIVED.value,
|
||||
source_type='SHIPMENT',
|
||||
source_id=shipment_id,
|
||||
before_state=PalletFlowState.IN_TRANSIT.value,
|
||||
after_state=inventory.flow_state,
|
||||
before_site_id=inventory.site_id,
|
||||
after_site_id=inventory.site_id,
|
||||
before_remaining_packages=inventory.remaining_packages,
|
||||
after_remaining_packages=inventory.remaining_packages,
|
||||
operator_id=user.user.user_id,
|
||||
occurred_at=now,
|
||||
idempotency_key=command.idempotency_key,
|
||||
request_hash=request_hash,
|
||||
test_batch_id=scope.batch_id,
|
||||
)
|
||||
await InventoryDao.add_event(db, inventory, event, scope)
|
||||
await db.commit()
|
||||
return CrudResponseModel(is_success=True, message='收货核对成功', result=shipment_id)
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise ServiceException(message='收货确认冲突,请刷新核对单后重试') from None
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
@@ -460,6 +460,7 @@ class ShipmentService:
|
||||
)
|
||||
return PalletScanResponseModel(
|
||||
pallet_id=pallet.pallet_id,
|
||||
qr_token=pallet.qr_token,
|
||||
pallet_code=pallet.pallet_code,
|
||||
flow_state=PalletFlowState.FILLED_IN_WAREHOUSE,
|
||||
warehouse_id=inventory.warehouse_id,
|
||||
@@ -470,6 +471,8 @@ class ShipmentService:
|
||||
strain_code=filling.strain_code_snapshot,
|
||||
strain_name=filling.strain_name_snapshot,
|
||||
batch_no=filling.batch_no,
|
||||
production_date=filling.production_date,
|
||||
expiry_date=filling.expiry_date,
|
||||
package_spec=filling.package_spec,
|
||||
capacity_packages=pallet.capacity_packages,
|
||||
remaining_packages=inventory.remaining_packages,
|
||||
@@ -902,12 +905,13 @@ class ShipmentService:
|
||||
for inventory in inventories:
|
||||
PalletStateMachine.ensure_transition(
|
||||
PalletFlowState(inventory.flow_state),
|
||||
PalletFlowState.AT_SITE_UNUSED,
|
||||
PalletFlowState.IN_TRANSIT,
|
||||
)
|
||||
|
||||
inventory_by_id = {inventory.pallet_id: inventory for inventory in inventories}
|
||||
current_time = datetime.now()
|
||||
locked_shipment.status = DocumentStatus.CONFIRMED.value
|
||||
locked_shipment.receipt_state = 'PENDING'
|
||||
locked_shipment.confirm_idempotency_key = idempotency_key
|
||||
locked_shipment.confirm_request_hash = request_hash
|
||||
locked_shipment.confirmed_by = user_id
|
||||
@@ -927,7 +931,8 @@ class ShipmentService:
|
||||
before_line_id = inventory.current_line_id
|
||||
before_remaining_packages = inventory.remaining_packages
|
||||
|
||||
inventory.flow_state = PalletFlowState.AT_SITE_UNUSED.value
|
||||
inventory.flow_state = PalletFlowState.IN_TRANSIT.value
|
||||
inventory.site_arrived_at = None
|
||||
inventory.warehouse_id = None
|
||||
inventory.site_id = locked_shipment.target_site_id
|
||||
inventory.current_line_id = None
|
||||
@@ -939,7 +944,7 @@ class ShipmentService:
|
||||
source_type='SHIPMENT',
|
||||
source_id=shipment_id,
|
||||
before_state=before_state,
|
||||
after_state=PalletFlowState.AT_SITE_UNUSED.value,
|
||||
after_state=PalletFlowState.IN_TRANSIT.value,
|
||||
before_warehouse_id=before_warehouse_id,
|
||||
before_site_id=before_site_id,
|
||||
before_line_id=before_line_id,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from exceptions.exception import ServiceException
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from module_business.dao.usage_fifo_dao import UsageFifoDao
|
||||
from module_business.domain.fifo_policy import requires_fifo_skip_reason
|
||||
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
|
||||
from module_business.entity.vo.usage_fifo_vo import UsageFifoRecommendationModel
|
||||
from module_business.service.usage_service import UsageService
|
||||
|
||||
|
||||
class UsageFifoService:
|
||||
@staticmethod
|
||||
async def recommendation(
|
||||
db: AsyncSession, identifier: str, user: CurrentUserModel, scope: TestBatchScope,
|
||||
*, tunnel_id: int | None = None,
|
||||
) -> UsageFifoRecommendationModel:
|
||||
# 复用扫码入口的现场授权、当前批次与可用状态校验;客户端不指定查询现场。
|
||||
pallet = await UsageService.scan_usage_pallet_services(db, identifier, user, scope)
|
||||
inventory = await MasterDao.get_pallet_inventory(db, pallet.pallet_id, scope)
|
||||
if inventory is None:
|
||||
raise ServiceException(message='托盘库存不存在')
|
||||
assert_record_in_scope(inventory, scope, '库存')
|
||||
target_tunnel_id = pallet.tunnel_id
|
||||
trace_no = pallet.trace_no
|
||||
if tunnel_id is not None:
|
||||
tunnel = await MasterDao.get_tunnel(db, tunnel_id, scope)
|
||||
if tunnel is None or tunnel.site_id != pallet.site_id or tunnel.status != '0':
|
||||
raise ServiceException(message='请选择托盘当前现场的启用隧道')
|
||||
assert_record_in_scope(tunnel, scope, '隧道')
|
||||
if pallet.tunnel_id is not None and pallet.tunnel_id != tunnel_id:
|
||||
raise ServiceException(message='托盘已入其他隧道')
|
||||
target_tunnel_id, trace_no = tunnel_id, tunnel.current_trace_no
|
||||
candidate, unknown = await UsageFifoDao.recommendation(
|
||||
db,
|
||||
site_id=pallet.site_id,
|
||||
strain_id=pallet.strain_id,
|
||||
tunnel_id=target_tunnel_id,
|
||||
trace_no=trace_no,
|
||||
scope=scope,
|
||||
)
|
||||
message = '当前无可确定到货顺序的同品种托盘'
|
||||
if candidate:
|
||||
message = f'先到先用建议:托盘 {candidate["pallet_code"]},批次 {candidate["batch_no"]},余量 {candidate["remaining_packages"]} 包'
|
||||
if unknown:
|
||||
message += f';另有 {unknown} 托盘未登记实际收货时间,请人工核对先后顺序'
|
||||
needs_reason = requires_fifo_skip_reason(pallet.pallet_id, inventory.site_arrived_at, candidate)
|
||||
if needs_reason:
|
||||
message += ';使用当前托盘需填写跳过先到托盘的原因'
|
||||
return UsageFifoRecommendationModel(
|
||||
recommended=candidate, unknown_arrival_count=unknown,
|
||||
requires_skip_reason=needs_reason, message=message,
|
||||
)
|
||||
@@ -16,14 +16,18 @@ from module_business.dao.access_dao import AccessDao
|
||||
from module_business.dao.inventory_dao import InventoryDao
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
from module_business.dao.usage_fifo_dao import UsageFifoDao
|
||||
from module_business.domain.enums import PalletEventType, PalletFlowState
|
||||
from module_business.domain.fifo_policy import requires_fifo_skip_reason
|
||||
from module_business.domain.pallet_state_machine import PalletStateMachine
|
||||
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
|
||||
from module_business.entity.do.operation_do import BizPalletEvent, BizUsageRecord
|
||||
from module_business.entity.vo.master_vo import LinePageQueryModel
|
||||
from module_business.entity.do.operation_do import BizFillingRecord, BizPalletEvent, BizPalletInventory, BizUsageRecord
|
||||
from module_business.entity.vo.master_vo import LinePageQueryModel, TunnelPageQueryModel
|
||||
from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
PalletScanResponseModel,
|
||||
TunnelEntryCreateModel,
|
||||
TunnelEntrySuccessResponseModel,
|
||||
UsageCreateModel,
|
||||
UsageModel,
|
||||
UsagePageQueryModel,
|
||||
@@ -31,6 +35,7 @@ from module_business.entity.vo.operation_vo import (
|
||||
)
|
||||
from module_business.service.access_service import AccessService
|
||||
from module_business.service.test_batch_access_service import TestBatchAccessService
|
||||
from module_business.service.usage_transaction import retry_mysql_lock_conflicts
|
||||
|
||||
|
||||
class UsageService:
|
||||
@@ -110,17 +115,52 @@ class UsageService:
|
||||
|
||||
@staticmethod
|
||||
def _build_request_hash(page_object: UsageCreateModel) -> str:
|
||||
fields = {
|
||||
'qr_token': page_object.qr_token.strip(),
|
||||
'tunnel_id': page_object.tunnel_id,
|
||||
'line_id': page_object.line_id,
|
||||
'used_packages': page_object.used_packages,
|
||||
}
|
||||
# 无原因时保留原摘要,迁移前已成功的请求仍可按原键安全重放。
|
||||
if page_object.fifo_skip_reason:
|
||||
fields['fifo_skip_reason'] = page_object.fifo_skip_reason
|
||||
payload = json.dumps(
|
||||
{
|
||||
'qr_token': page_object.qr_token.strip(),
|
||||
'line_id': page_object.line_id,
|
||||
'used_packages': page_object.used_packages,
|
||||
},
|
||||
fields,
|
||||
sort_keys=True,
|
||||
separators=(',', ':'),
|
||||
).encode()
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
async def _check_fifo_skip(
|
||||
query_db: AsyncSession,
|
||||
inventory: BizPalletInventory,
|
||||
filling: BizFillingRecord,
|
||||
tunnel_id: int,
|
||||
trace_no: str | None,
|
||||
reason: str | None,
|
||||
test_scope: TestBatchScope,
|
||||
) -> dict[str, Any] | None:
|
||||
if inventory.site_arrived_at is None:
|
||||
return None
|
||||
candidate, _ = await UsageFifoDao.recommendation(
|
||||
query_db, site_id=inventory.site_id, strain_id=filling.strain_id,
|
||||
tunnel_id=tunnel_id, trace_no=trace_no, scope=test_scope, for_update=True,
|
||||
)
|
||||
if not requires_fifo_skip_reason(inventory.pallet_id, inventory.site_arrived_at, candidate):
|
||||
return None
|
||||
if not reason:
|
||||
raise ServiceException(
|
||||
data='FIFO_SKIP_REASON_REQUIRED',
|
||||
message=f'存在先到托盘 {candidate["pallet_code"]},请填写跳过原因后重新提交',
|
||||
)
|
||||
return {
|
||||
'palletId': candidate['pallet_id'], 'palletCode': candidate['pallet_code'],
|
||||
'batchNo': candidate['batch_no'], 'remainingPackages': candidate['remaining_packages'],
|
||||
'siteArrivedAt': candidate['site_arrived_at'].isoformat(),
|
||||
'selectedSiteArrivedAt': inventory.site_arrived_at.isoformat(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _is_exact_idempotent_result(
|
||||
usage: BizUsageRecord,
|
||||
@@ -143,6 +183,32 @@ class UsageService:
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _tunnel_entry_success(pallet_id: int, tunnel_id: int, trace_no: str) -> CrudResponseModel:
|
||||
return CrudResponseModel(
|
||||
is_success=True,
|
||||
message='扫码入隧成功',
|
||||
result=TunnelEntrySuccessResponseModel(
|
||||
pallet_id=pallet_id,
|
||||
tunnel_id=tunnel_id,
|
||||
trace_no=trace_no,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_exact_tunnel_entry_result(
|
||||
event: BizPalletEvent,
|
||||
page_object: TunnelEntryCreateModel,
|
||||
request_hash: str,
|
||||
) -> bool:
|
||||
return (
|
||||
event.event_type == PalletEventType.TUNNEL_ENTERED.value
|
||||
and event.after_tunnel_id == page_object.tunnel_id
|
||||
and event.idempotency_key == page_object.idempotency_key
|
||||
and event.request_hash == request_hash
|
||||
and bool(event.trace_no)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _lock_and_reauthorize_usage_site(
|
||||
cls,
|
||||
@@ -294,7 +360,274 @@ class UsageService:
|
||||
return sorted(options, key=lambda option: option.id)
|
||||
|
||||
@classmethod
|
||||
async def scan_usage_pallet_services(
|
||||
async def get_tunnel_options_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
site_id: int,
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
) -> list[BusinessOptionModel]:
|
||||
cls._current_identity(current_user)
|
||||
await AccessService.assert_site_access(query_db, current_user, site_id)
|
||||
site = await MasterDao.get_site(query_db, site_id, test_scope)
|
||||
if site is None:
|
||||
raise ServiceException(message='客户现场不存在')
|
||||
assert_record_in_scope(site, test_scope, '现场')
|
||||
if site.status != '0':
|
||||
raise ServiceException(message='客户现场已停用')
|
||||
tunnels = await MasterDao.list_tunnel(
|
||||
query_db,
|
||||
TunnelPageQueryModel(siteId=site_id, status='0'),
|
||||
test_scope,
|
||||
False,
|
||||
)
|
||||
options = [
|
||||
BusinessOptionModel(
|
||||
id=cls._record_value(tunnel, 'tunnel_id'),
|
||||
code=cls._record_value(tunnel, 'tunnel_code'),
|
||||
name=cls._record_value(tunnel, 'tunnel_name'),
|
||||
)
|
||||
for tunnel in tunnels
|
||||
if cls._record_value(tunnel, 'site_id') == site_id and cls._record_value(tunnel, 'status') == '0'
|
||||
]
|
||||
return sorted(options, key=lambda option: option.id)
|
||||
|
||||
@staticmethod
|
||||
def _build_tunnel_entry_request_hash(page_object: TunnelEntryCreateModel) -> str:
|
||||
fields = {'qr_token': page_object.qr_token.strip(), 'tunnel_id': page_object.tunnel_id}
|
||||
if page_object.fifo_skip_reason:
|
||||
fields['fifo_skip_reason'] = page_object.fifo_skip_reason
|
||||
payload = json.dumps(
|
||||
fields,
|
||||
sort_keys=True,
|
||||
separators=(',', ':'),
|
||||
).encode()
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
@classmethod
|
||||
async def _recover_tunnel_entry_integrity_error(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
page_object: TunnelEntryCreateModel,
|
||||
request_hash: str,
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
) -> CrudResponseModel:
|
||||
try:
|
||||
await TestBatchAccessService.lock_active_batch(query_db, test_scope)
|
||||
pallet = await InventoryDao.get_pallet_by_qr_token(
|
||||
query_db,
|
||||
page_object.qr_token.strip(),
|
||||
test_scope,
|
||||
)
|
||||
if pallet is None:
|
||||
cls._raise_pallet_not_accessible(current_user)
|
||||
event = await InventoryDao.get_event_by_idempotency_key(
|
||||
query_db,
|
||||
pallet.pallet_id,
|
||||
page_object.idempotency_key,
|
||||
test_scope,
|
||||
)
|
||||
if event is None or not cls._is_exact_tunnel_entry_result(event, page_object, request_hash):
|
||||
raise ServiceException(message='扫码入隧失败,幂等键已被其他请求使用')
|
||||
return cls._tunnel_entry_success(pallet.pallet_id, page_object.tunnel_id, event.trace_no)
|
||||
finally:
|
||||
await query_db.rollback()
|
||||
|
||||
@classmethod
|
||||
@retry_mysql_lock_conflicts
|
||||
async def enter_tunnel_services( # noqa: PLR0912, PLR0915
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
page_object: TunnelEntryCreateModel,
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
) -> CrudResponseModel:
|
||||
try:
|
||||
await TestBatchAccessService.lock_active_batch(query_db, test_scope)
|
||||
request_hash = cls._build_tunnel_entry_request_hash(page_object)
|
||||
user_id, user_name = cls._current_identity(current_user)
|
||||
pallet = await InventoryDao.get_pallet_by_qr_token(
|
||||
query_db,
|
||||
page_object.qr_token.strip(),
|
||||
test_scope,
|
||||
)
|
||||
if pallet is None:
|
||||
cls._raise_pallet_not_accessible(current_user)
|
||||
assert_record_in_scope(pallet, test_scope, '托盘')
|
||||
tunnel = await MasterDao.get_tunnel(query_db, page_object.tunnel_id, test_scope)
|
||||
if tunnel is None:
|
||||
raise ServiceException(message='隧道不存在')
|
||||
assert_record_in_scope(tunnel, test_scope, '隧道')
|
||||
if tunnel.status != '0':
|
||||
raise ServiceException(message='隧道已停用')
|
||||
preflight_tunnel_site_id = tunnel.site_id
|
||||
await cls._assert_pallet_site_access(query_db, current_user, tunnel.site_id)
|
||||
|
||||
existing = await InventoryDao.get_event_by_idempotency_key(
|
||||
query_db,
|
||||
pallet.pallet_id,
|
||||
page_object.idempotency_key,
|
||||
test_scope,
|
||||
)
|
||||
if existing is not None:
|
||||
assert_record_in_scope(existing, test_scope, '托盘事件')
|
||||
if not cls._is_exact_tunnel_entry_result(existing, page_object, request_hash):
|
||||
raise ServiceException(message='扫码入隧失败,幂等键已被其他请求使用')
|
||||
result = cls._tunnel_entry_success(pallet.pallet_id, page_object.tunnel_id, existing.trace_no)
|
||||
await query_db.rollback()
|
||||
return result
|
||||
|
||||
# rollback 会令 ORM 属性过期;跨事务只传普通值,不触发隐式异步加载。
|
||||
scanned_pallet_id = pallet.pallet_id
|
||||
await query_db.rollback()
|
||||
await TestBatchAccessService.lock_active_batch(query_db, test_scope)
|
||||
inventory = await InventoryDao.get_inventory_for_update(query_db, scanned_pallet_id, test_scope)
|
||||
if inventory is None:
|
||||
cls._raise_pallet_not_accessible(current_user, '托盘库存不存在')
|
||||
assert_record_in_scope(inventory, test_scope, '库存')
|
||||
if inventory.site_id is None:
|
||||
cls._raise_pallet_not_accessible(current_user, '托盘不在客户现场')
|
||||
await cls._assert_pallet_site_access(query_db, current_user, inventory.site_id)
|
||||
if inventory.site_id != preflight_tunnel_site_id:
|
||||
raise ServiceException(message='隧道不属于托盘当前客户现场')
|
||||
|
||||
locked_pallets = await InventoryDao.get_pallets_by_ids(
|
||||
query_db,
|
||||
[inventory.pallet_id],
|
||||
test_scope,
|
||||
)
|
||||
if len(locked_pallets) != 1 or locked_pallets[0].pallet_id != inventory.pallet_id:
|
||||
cls._raise_pallet_not_accessible(current_user)
|
||||
pallet = locked_pallets[0]
|
||||
assert_record_in_scope(pallet, test_scope, '托盘')
|
||||
tunnel = await MasterDao.get_tunnel(
|
||||
query_db,
|
||||
page_object.tunnel_id,
|
||||
test_scope,
|
||||
for_update=True,
|
||||
)
|
||||
if tunnel is None:
|
||||
raise ServiceException(message='隧道不存在')
|
||||
assert_record_in_scope(tunnel, test_scope, '隧道')
|
||||
if tunnel.status != '0':
|
||||
raise ServiceException(message='隧道已停用')
|
||||
if tunnel.site_id != inventory.site_id:
|
||||
raise ServiceException(message='隧道不属于托盘当前客户现场')
|
||||
existing = await InventoryDao.get_event_by_idempotency_key(
|
||||
query_db,
|
||||
pallet.pallet_id,
|
||||
page_object.idempotency_key,
|
||||
test_scope,
|
||||
)
|
||||
if existing is not None:
|
||||
assert_record_in_scope(existing, test_scope, '托盘事件')
|
||||
if not cls._is_exact_tunnel_entry_result(existing, page_object, request_hash):
|
||||
raise ServiceException(message='扫码入隧失败,幂等键已被其他请求使用')
|
||||
result = cls._tunnel_entry_success(pallet.pallet_id, page_object.tunnel_id, existing.trace_no)
|
||||
await query_db.rollback()
|
||||
return result
|
||||
|
||||
if pallet.asset_status != '0':
|
||||
raise ServiceException(message='托盘资产已停用,不允许入隧')
|
||||
if (
|
||||
inventory.flow_state
|
||||
not in {
|
||||
PalletFlowState.AT_SITE_UNUSED.value,
|
||||
PalletFlowState.AT_SITE_PARTIAL.value,
|
||||
}
|
||||
or inventory.warehouse_id is not None
|
||||
or inventory.active_filling_id is None
|
||||
or type(inventory.remaining_packages) is not int
|
||||
or inventory.remaining_packages <= 0
|
||||
or inventory.remaining_packages > pallet.capacity_packages
|
||||
):
|
||||
raise ServiceException(message='托盘当前状态不允许入隧')
|
||||
filling = await OperationDao.get_active_filling_for_update(
|
||||
query_db,
|
||||
inventory.active_filling_id,
|
||||
test_scope,
|
||||
)
|
||||
if filling is None:
|
||||
raise ServiceException(message='托盘活动装填周期不存在或已关闭')
|
||||
assert_record_in_scope(filling, test_scope, '活动装填')
|
||||
if (
|
||||
filling.pallet_id != pallet.pallet_id
|
||||
or filling.active_slot != 'ACTIVE'
|
||||
or filling.closed_at is not None
|
||||
):
|
||||
raise ServiceException(message='托盘活动装填周期无效')
|
||||
if inventory.tunnel_id is not None:
|
||||
if inventory.tunnel_id == tunnel.tunnel_id and inventory.trace_no == tunnel.current_trace_no:
|
||||
raise ServiceException(message='托盘已入当前隧道')
|
||||
raise ServiceException(message='托盘已入其他隧道')
|
||||
if inventory.trace_no is not None:
|
||||
raise ServiceException(message='托盘隧道溯源状态不一致')
|
||||
fifo_snapshot = await cls._check_fifo_skip(
|
||||
query_db, inventory, filling, tunnel.tunnel_id, tunnel.current_trace_no,
|
||||
page_object.fifo_skip_reason, test_scope,
|
||||
)
|
||||
if tunnel.current_trace_no is None:
|
||||
tunnel.current_trace_no = f'TN-{secrets.token_hex(16)}'
|
||||
tunnel.current_trace_started_at = datetime.now()
|
||||
elif not tunnel.current_trace_no.strip():
|
||||
raise ServiceException(message='隧道当前溯源号无效')
|
||||
|
||||
current_time = datetime.now()
|
||||
before_state = inventory.flow_state
|
||||
before_remaining_packages = inventory.remaining_packages
|
||||
inventory.tunnel_id = tunnel.tunnel_id
|
||||
inventory.trace_no = tunnel.current_trace_no
|
||||
inventory.version += 1
|
||||
inventory.updated_at = current_time
|
||||
event = BizPalletEvent(
|
||||
pallet_id=inventory.pallet_id,
|
||||
event_type=PalletEventType.TUNNEL_ENTERED.value,
|
||||
source_type='TUNNEL',
|
||||
source_id=tunnel.tunnel_id,
|
||||
fifo_skip_reason=page_object.fifo_skip_reason,
|
||||
fifo_recommendation=fifo_snapshot,
|
||||
before_state=before_state,
|
||||
after_state=inventory.flow_state,
|
||||
before_warehouse_id=inventory.warehouse_id,
|
||||
before_site_id=inventory.site_id,
|
||||
before_tunnel_id=None,
|
||||
before_line_id=inventory.current_line_id,
|
||||
after_warehouse_id=inventory.warehouse_id,
|
||||
after_site_id=inventory.site_id,
|
||||
after_tunnel_id=inventory.tunnel_id,
|
||||
after_line_id=inventory.current_line_id,
|
||||
before_remaining_packages=before_remaining_packages,
|
||||
after_remaining_packages=inventory.remaining_packages,
|
||||
trace_no=inventory.trace_no,
|
||||
batch_no_snapshot=filling.batch_no,
|
||||
operator_id=user_id,
|
||||
occurred_at=current_time,
|
||||
idempotency_key=page_object.idempotency_key,
|
||||
request_hash=request_hash,
|
||||
test_batch_id=test_scope.batch_id,
|
||||
)
|
||||
await InventoryDao.add_event(query_db, inventory, event, test_scope)
|
||||
tunnel.update_by = user_name
|
||||
tunnel.update_time = current_time
|
||||
result = cls._tunnel_entry_success(pallet.pallet_id, tunnel.tunnel_id, tunnel.current_trace_no)
|
||||
await query_db.commit()
|
||||
return result
|
||||
except IntegrityError:
|
||||
await query_db.rollback()
|
||||
return await cls._recover_tunnel_entry_integrity_error(
|
||||
query_db,
|
||||
page_object,
|
||||
cls._build_tunnel_entry_request_hash(page_object),
|
||||
current_user,
|
||||
test_scope,
|
||||
)
|
||||
except Exception:
|
||||
await query_db.rollback()
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
async def scan_usage_pallet_services( # noqa: PLR0912
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
qr_token: str,
|
||||
@@ -314,7 +647,7 @@ class UsageService:
|
||||
cls._raise_pallet_not_accessible(current_user, '托盘不在客户现场')
|
||||
await cls._assert_pallet_site_access(query_db, current_user, inventory.site_id)
|
||||
|
||||
if pallet.qr_token != normalized_qr or pallet.pallet_id != inventory.pallet_id:
|
||||
if normalized_qr not in {pallet.qr_token, pallet.pallet_code} or pallet.pallet_id != inventory.pallet_id:
|
||||
raise ServiceException(message='托盘二维码与库存不一致')
|
||||
if pallet.asset_status != '0':
|
||||
raise ServiceException(message='托盘资产已停用,不允许使用')
|
||||
@@ -328,7 +661,7 @@ class UsageService:
|
||||
or inventory.active_filling_id is None
|
||||
or type(inventory.remaining_packages) is not int
|
||||
or inventory.remaining_packages <= 0
|
||||
or inventory.remaining_packages > pallet.capacity_packages
|
||||
or inventory.remaining_packages > pallet.capacity_packages
|
||||
):
|
||||
raise ServiceException(message='托盘当前状态不允许使用')
|
||||
filling = await OperationDao.get_filling_by_id(query_db, inventory.active_filling_id, test_scope)
|
||||
@@ -342,25 +675,43 @@ class UsageService:
|
||||
or filling.closed_at is not None
|
||||
):
|
||||
raise ServiceException(message='托盘活动装填周期无效')
|
||||
allowed_actions = ['usage']
|
||||
if inventory.tunnel_id is None:
|
||||
allowed_actions = ['enter_tunnel']
|
||||
else:
|
||||
if inventory.trace_no is None:
|
||||
raise ServiceException(message='托盘隧道溯源状态不一致')
|
||||
tunnel = await MasterDao.get_tunnel(query_db, inventory.tunnel_id, test_scope)
|
||||
if tunnel is None or tunnel.site_id != inventory.site_id:
|
||||
raise ServiceException(message='托盘当前隧道不存在或现场不一致')
|
||||
assert_record_in_scope(tunnel, test_scope, '隧道')
|
||||
if tunnel.status != '0' or tunnel.current_trace_no != inventory.trace_no:
|
||||
raise ServiceException(message='托盘当前隧道已停用或溯源状态不一致')
|
||||
return PalletScanResponseModel(
|
||||
pallet_id=pallet.pallet_id,
|
||||
qr_token=pallet.qr_token,
|
||||
pallet_code=pallet.pallet_code,
|
||||
flow_state=PalletFlowState(inventory.flow_state),
|
||||
warehouse_id=None,
|
||||
site_id=inventory.site_id,
|
||||
tunnel_id=inventory.tunnel_id,
|
||||
trace_no=inventory.trace_no,
|
||||
current_line_id=inventory.current_line_id,
|
||||
active_filling_id=inventory.active_filling_id,
|
||||
strain_id=filling.strain_id,
|
||||
strain_code=filling.strain_code_snapshot,
|
||||
strain_name=filling.strain_name_snapshot,
|
||||
batch_no=filling.batch_no,
|
||||
production_date=filling.production_date,
|
||||
expiry_date=filling.expiry_date,
|
||||
package_spec=filling.package_spec,
|
||||
capacity_packages=pallet.capacity_packages,
|
||||
remaining_packages=inventory.remaining_packages,
|
||||
allowed_actions=['usage'],
|
||||
allowed_actions=allowed_actions,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@retry_mysql_lock_conflicts
|
||||
async def record_usage_services( # noqa: PLR0912, PLR0915
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
@@ -374,11 +725,16 @@ class UsageService:
|
||||
request_hash = cls._build_request_hash(page_object)
|
||||
user_id, user_name = cls._current_identity(current_user)
|
||||
preflight_site_ids = await cls._site_scope(query_db, current_user)
|
||||
# 产线归属是请求内稳定路由;锁事务仍以库存中的实际现场复核。
|
||||
preflight_line = await MasterDao.get_line(query_db, page_object.line_id, test_scope)
|
||||
if preflight_line is not None:
|
||||
assert_record_in_scope(preflight_line, test_scope, '产线')
|
||||
preflight_site_id = preflight_line.site_id if preflight_line is not None else None
|
||||
preflight_tunnel = await MasterDao.get_tunnel(query_db, page_object.tunnel_id, test_scope)
|
||||
if preflight_tunnel is None:
|
||||
raise ServiceException(message='隧道不存在')
|
||||
assert_record_in_scope(preflight_tunnel, test_scope, '隧道')
|
||||
preflight_site_id = preflight_tunnel.site_id
|
||||
if page_object.line_id is not None:
|
||||
# 仅在预检阶段读取产线以确定批次范围;状态和实际现场归属在锁定库存后复核。
|
||||
preflight_line = await MasterDao.get_line(query_db, page_object.line_id, test_scope)
|
||||
if preflight_line is not None:
|
||||
assert_record_in_scope(preflight_line, test_scope, '产线')
|
||||
existing = None
|
||||
if preflight_site_id is not None and (
|
||||
preflight_site_ids is None or preflight_site_id in preflight_site_ids
|
||||
@@ -494,14 +850,34 @@ class UsageService:
|
||||
assert_record_in_scope(site, test_scope, '现场')
|
||||
if site.status != '0':
|
||||
raise ServiceException(message='客户现场已停用')
|
||||
line = await MasterDao.get_line(query_db, page_object.line_id, test_scope, for_update=True)
|
||||
if line is None:
|
||||
raise ServiceException(message='产线不存在')
|
||||
assert_record_in_scope(line, test_scope, '产线')
|
||||
if line.status != '0':
|
||||
raise ServiceException(message='产线已停用')
|
||||
if line.site_id != inventory.site_id:
|
||||
raise ServiceException(message='产线不属于托盘当前客户现场')
|
||||
tunnel = await MasterDao.get_tunnel(
|
||||
query_db,
|
||||
page_object.tunnel_id,
|
||||
test_scope,
|
||||
for_update=True,
|
||||
)
|
||||
if tunnel is None:
|
||||
raise ServiceException(message='隧道不存在')
|
||||
assert_record_in_scope(tunnel, test_scope, '隧道')
|
||||
if tunnel.status != '0':
|
||||
raise ServiceException(message='隧道已停用')
|
||||
if tunnel.site_id != inventory.site_id:
|
||||
raise ServiceException(message='隧道不属于托盘当前客户现场')
|
||||
if inventory.tunnel_id != tunnel.tunnel_id or not inventory.trace_no:
|
||||
raise ServiceException(message='托盘尚未扫码入当前隧道')
|
||||
if tunnel.current_trace_no != inventory.trace_no:
|
||||
raise ServiceException(message='托盘与隧道的物料溯源号不一致')
|
||||
|
||||
line = None
|
||||
if page_object.line_id is not None:
|
||||
line = await MasterDao.get_line(query_db, page_object.line_id, test_scope, for_update=True)
|
||||
if line is None:
|
||||
raise ServiceException(message='产线不存在')
|
||||
assert_record_in_scope(line, test_scope, '产线')
|
||||
if line.status != '0':
|
||||
raise ServiceException(message='产线已停用')
|
||||
if line.site_id != inventory.site_id:
|
||||
raise ServiceException(message='产线不属于托盘当前客户现场')
|
||||
|
||||
if pallet.pallet_id != inventory.pallet_id or pallet.qr_token != page_object.qr_token.strip():
|
||||
raise ServiceException(message='托盘二维码与库存不一致')
|
||||
@@ -527,13 +903,22 @@ class UsageService:
|
||||
or filling.closed_at is not None
|
||||
):
|
||||
raise ServiceException(message='托盘活动装填周期无效')
|
||||
if inventory.current_line_id is not None and inventory.current_line_id != line.line_id:
|
||||
if (
|
||||
line is not None
|
||||
and inventory.current_line_id is not None
|
||||
and inventory.current_line_id != line.line_id
|
||||
):
|
||||
raise ServiceException(message='托盘本次驻场周期已绑定其他产线')
|
||||
if type(page_object.used_packages) is not int or page_object.used_packages <= 0:
|
||||
raise ServiceException(message='使用包数必须为正整数')
|
||||
if page_object.used_packages > inventory.remaining_packages:
|
||||
raise ServiceException(message='使用包数不能超过当前余量')
|
||||
|
||||
fifo_snapshot = await cls._check_fifo_skip(
|
||||
query_db, inventory, filling, tunnel.tunnel_id, inventory.trace_no,
|
||||
page_object.fifo_skip_reason, test_scope,
|
||||
)
|
||||
|
||||
remaining_after = inventory.remaining_packages - page_object.used_packages
|
||||
target_state = PalletFlowState.AT_SITE_EMPTY if remaining_after == 0 else PalletFlowState.AT_SITE_PARTIAL
|
||||
PalletStateMachine.ensure_transition(
|
||||
@@ -543,11 +928,16 @@ class UsageService:
|
||||
|
||||
current_time = datetime.now()
|
||||
usage = BizUsageRecord(
|
||||
fifo_skip_reason=page_object.fifo_skip_reason,
|
||||
fifo_recommendation=fifo_snapshot,
|
||||
usage_no=f'US-{secrets.token_hex(16)}',
|
||||
pallet_id=pallet.pallet_id,
|
||||
active_filling_id=filling.filling_id,
|
||||
site_id=inventory.site_id,
|
||||
line_id=line.line_id,
|
||||
tunnel_id=tunnel.tunnel_id,
|
||||
trace_no=inventory.trace_no,
|
||||
batch_no_snapshot=filling.batch_no,
|
||||
line_id=line.line_id if line is not None else inventory.current_line_id,
|
||||
used_packages=page_object.used_packages,
|
||||
remaining_packages_after=remaining_after,
|
||||
idempotency_key=page_object.idempotency_key,
|
||||
@@ -564,9 +954,12 @@ class UsageService:
|
||||
before_state = inventory.flow_state
|
||||
before_warehouse_id = inventory.warehouse_id
|
||||
before_site_id = inventory.site_id
|
||||
before_tunnel_id = inventory.tunnel_id
|
||||
before_trace_no = inventory.trace_no
|
||||
before_line_id = inventory.current_line_id
|
||||
before_remaining_packages = inventory.remaining_packages
|
||||
inventory.current_line_id = line.line_id
|
||||
if line is not None:
|
||||
inventory.current_line_id = line.line_id
|
||||
inventory.remaining_packages = remaining_after
|
||||
inventory.flow_state = target_state.value
|
||||
if target_state is PalletFlowState.AT_SITE_EMPTY:
|
||||
@@ -583,16 +976,22 @@ class UsageService:
|
||||
event_type=PalletEventType.USAGE_RECORDED.value,
|
||||
source_type='USAGE',
|
||||
source_id=usage_id,
|
||||
fifo_skip_reason=page_object.fifo_skip_reason,
|
||||
fifo_recommendation=fifo_snapshot,
|
||||
before_state=before_state,
|
||||
after_state=target_state.value,
|
||||
before_warehouse_id=before_warehouse_id,
|
||||
before_site_id=before_site_id,
|
||||
before_tunnel_id=before_tunnel_id,
|
||||
before_line_id=before_line_id,
|
||||
after_warehouse_id=inventory.warehouse_id,
|
||||
after_site_id=inventory.site_id,
|
||||
after_tunnel_id=inventory.tunnel_id,
|
||||
after_line_id=inventory.current_line_id,
|
||||
before_remaining_packages=before_remaining_packages,
|
||||
after_remaining_packages=remaining_after,
|
||||
trace_no=before_trace_no,
|
||||
batch_no_snapshot=filling.batch_no,
|
||||
operator_id=user_id,
|
||||
occurred_at=current_time,
|
||||
idempotency_key=page_object.idempotency_key,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""入隧/使用命令的有限整事务锁冲突重试,不重放结果未知的连接错误。"""
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
from collections.abc import Awaitable, Callable
|
||||
from functools import wraps
|
||||
from typing import ParamSpec, TypeVar
|
||||
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
P = ParamSpec('P')
|
||||
R = TypeVar('R')
|
||||
|
||||
|
||||
def retry_mysql_lock_conflicts(operation: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
|
||||
"""仅用于异常出口已 rollback 的完整业务命令;所有重试沿用原payload/key。"""
|
||||
max_attempts = 3
|
||||
|
||||
@wraps(operation)
|
||||
async def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
return await operation(*args, **kwargs)
|
||||
except OperationalError as exc: # noqa: PERF203 -- 有限整事务重试必须逐次捕获锁冲突
|
||||
errno = exc.orig.args[0] if getattr(exc.orig, 'args', ()) else None
|
||||
# 3572=NOWAIT冲突,1213=死锁,1205=锁等待超时;不重试断连/未知提交结果。
|
||||
if errno not in {3572, 1213, 1205} or attempt == max_attempts - 1:
|
||||
raise
|
||||
await asyncio.sleep(0.05 * (attempt + 1) + secrets.randbelow(20) / 1000)
|
||||
raise AssertionError('锁冲突重试未返回结果')
|
||||
|
||||
return wrapped
|
||||
@@ -0,0 +1,111 @@
|
||||
-- 隧道物料流转:MySQL 增量补丁
|
||||
-- 前置条件:已执行 20260922_test_data_batch_mysql.sql,且数据库中存在 biz_test_batch。
|
||||
-- 本补丁只生成脚本,不在应用启动时自动执行;执行前请备份数据库并由 DBA 审核。
|
||||
-- 业务层将新托盘/新装填固定为 100 包,数据库不增加 capacity_packages = 100
|
||||
-- 约束,以兼容已有历史非 100 包托盘数据。
|
||||
|
||||
-- 隧道主数据:一个客户现场下的隧道编号唯一;current_trace_no 表示当前混批溯源号。
|
||||
CREATE TABLE biz_tunnel (
|
||||
tunnel_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '隧道ID',
|
||||
test_batch_id BIGINT NULL COMMENT '测试数据批次ID;NULL表示正式数据',
|
||||
site_id BIGINT NOT NULL COMMENT '现场ID',
|
||||
tunnel_code VARCHAR(64) NOT NULL COMMENT '隧道编号',
|
||||
tunnel_name VARCHAR(100) NOT NULL COMMENT '隧道名称',
|
||||
current_trace_no VARCHAR(64) NULL COMMENT '当前隧道物料溯源号',
|
||||
current_trace_started_at DATETIME NULL COMMENT '当前溯源批次开始时间',
|
||||
status CHAR(1) NOT NULL DEFAULT '0' COMMENT '状态(0正常 1停用)',
|
||||
create_by VARCHAR(64) NULL DEFAULT '' COMMENT '创建者',
|
||||
create_time DATETIME NULL COMMENT '创建时间',
|
||||
update_by VARCHAR(64) NULL DEFAULT '' COMMENT '更新者',
|
||||
update_time DATETIME NULL COMMENT '更新时间',
|
||||
remark VARCHAR(500) NULL COMMENT '备注',
|
||||
PRIMARY KEY (tunnel_id),
|
||||
KEY idx_biz_tunnel_test_batch_id (test_batch_id),
|
||||
CONSTRAINT uq_biz_tunnel_site_code UNIQUE (site_id, tunnel_code),
|
||||
CONSTRAINT fk_biz_tunnel_test_batch_id FOREIGN KEY (test_batch_id) REFERENCES biz_test_batch (batch_id),
|
||||
CONSTRAINT fk_biz_tunnel_site FOREIGN KEY (site_id) REFERENCES biz_customer_site (site_id),
|
||||
CONSTRAINT ck_biz_tunnel_status CHECK (status IN ('0', '1'))
|
||||
) ENGINE=InnoDB COMMENT='客户现场隧道主数据表';
|
||||
|
||||
-- 使用记录:保留入隧隧道、溯源号和生产批次快照;产线改为可选。
|
||||
ALTER TABLE biz_usage_record
|
||||
ADD COLUMN tunnel_id BIGINT NULL COMMENT '隧道ID;历史记录可能为空' AFTER site_id,
|
||||
ADD COLUMN trace_no VARCHAR(64) NULL COMMENT '隧道物料内部溯源号;历史记录可能为空' AFTER tunnel_id,
|
||||
ADD COLUMN batch_no_snapshot VARCHAR(64) NULL COMMENT '生产批次快照;历史记录可能为空' AFTER trace_no,
|
||||
MODIFY COLUMN line_id BIGINT NULL COMMENT '产线ID(可选)',
|
||||
ADD CONSTRAINT fk_biz_usage_record_tunnel FOREIGN KEY (tunnel_id) REFERENCES biz_tunnel (tunnel_id);
|
||||
|
||||
-- 退回明细:保存申请时的隧道、溯源号和批次快照。
|
||||
ALTER TABLE biz_return_item
|
||||
ADD COLUMN tunnel_id_snapshot BIGINT NULL COMMENT '申请时隧道ID快照' AFTER active_filling_id_snapshot,
|
||||
ADD COLUMN trace_no_snapshot VARCHAR(64) NULL COMMENT '申请时物料溯源号快照' AFTER tunnel_id_snapshot,
|
||||
ADD COLUMN batch_no_snapshot VARCHAR(64) NULL COMMENT '申请时生产批次快照' AFTER trace_no_snapshot,
|
||||
ADD CONSTRAINT fk_biz_return_item_tunnel FOREIGN KEY (tunnel_id_snapshot) REFERENCES biz_tunnel (tunnel_id);
|
||||
|
||||
-- 当前库存:隧道归属和当前溯源号随托盘一起锁定维护。
|
||||
ALTER TABLE biz_pallet_inventory
|
||||
ADD COLUMN tunnel_id BIGINT NULL COMMENT '当前隧道ID' AFTER site_id,
|
||||
ADD COLUMN trace_no VARCHAR(64) NULL COMMENT '当前隧道物料溯源号' AFTER tunnel_id,
|
||||
ADD CONSTRAINT fk_biz_pallet_inventory_tunnel FOREIGN KEY (tunnel_id) REFERENCES biz_tunnel (tunnel_id);
|
||||
|
||||
-- 流转事件:为入隧、使用、退回保留隧道和溯源快照。
|
||||
ALTER TABLE biz_pallet_event
|
||||
ADD COLUMN before_tunnel_id BIGINT NULL COMMENT '变更前隧道ID' AFTER before_site_id,
|
||||
ADD COLUMN after_tunnel_id BIGINT NULL COMMENT '变更后隧道ID' AFTER after_site_id,
|
||||
ADD COLUMN trace_no VARCHAR(64) NULL COMMENT '事件关联物料溯源号' AFTER after_remaining_packages,
|
||||
ADD COLUMN batch_no_snapshot VARCHAR(64) NULL COMMENT '事件生产批次快照' AFTER trace_no,
|
||||
ADD CONSTRAINT fk_biz_pallet_event_before_tunnel FOREIGN KEY (before_tunnel_id) REFERENCES biz_tunnel (tunnel_id),
|
||||
ADD CONSTRAINT fk_biz_pallet_event_after_tunnel FOREIGN KEY (after_tunnel_id) REFERENCES biz_tunnel (tunnel_id);
|
||||
|
||||
ALTER TABLE biz_pallet_event
|
||||
DROP CHECK ck_biz_pallet_event_event_type,
|
||||
ADD CONSTRAINT ck_biz_pallet_event_event_type CHECK (
|
||||
event_type IN (
|
||||
'INBOUND_CONFIRMED',
|
||||
'FILLING_CONFIRMED',
|
||||
'SHIPMENT_CONFIRMED',
|
||||
'TUNNEL_ENTERED',
|
||||
'USAGE_RECORDED',
|
||||
'RETURN_REQUESTED',
|
||||
'RETURN_CONFIRMED'
|
||||
)
|
||||
);
|
||||
|
||||
-- 菜单:只提供隧道查询/新增/修改权限,不提供物理删除权限。
|
||||
INSERT INTO sys_menu (
|
||||
menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
|
||||
is_frame, is_cache, menu_type, visible, status, perms, icon, create_by,
|
||||
create_time, update_by, update_time, remark
|
||||
)
|
||||
SELECT 520, '隧道资料', 121, 7, 'tunnel', 'master/tunnel/index', '', '',
|
||||
1, 0, 'C', '0', '0', 'master:tunnel:list', '#', 'admin', SYSDATE(), '', NULL, '隧道资料菜单'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 520);
|
||||
|
||||
INSERT INTO sys_menu (
|
||||
menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
|
||||
is_frame, is_cache, menu_type, visible, status, perms, icon, create_by,
|
||||
create_time, update_by, update_time, remark
|
||||
)
|
||||
SELECT 1116, '隧道查询', 520, 1, '#', '', '', '',
|
||||
1, 0, 'F', '0', '0', 'master:tunnel:query', '#', 'admin', SYSDATE(), '', NULL, ''
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 1116);
|
||||
|
||||
INSERT INTO sys_menu (
|
||||
menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
|
||||
is_frame, is_cache, menu_type, visible, status, perms, icon, create_by,
|
||||
create_time, update_by, update_time, remark
|
||||
)
|
||||
SELECT 1117, '隧道新增', 520, 2, '#', '', '', '',
|
||||
1, 0, 'F', '0', '0', 'master:tunnel:add', '#', 'admin', SYSDATE(), '', NULL, ''
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 1117);
|
||||
|
||||
INSERT INTO sys_menu (
|
||||
menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
|
||||
is_frame, is_cache, menu_type, visible, status, perms, icon, create_by,
|
||||
create_time, update_by, update_time, remark
|
||||
)
|
||||
SELECT 1118, '隧道修改', 520, 3, '#', '', '', '',
|
||||
1, 0, 'F', '0', '0', 'master:tunnel:edit', '#', 'admin', SYSDATE(), '', NULL, ''
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 1118);
|
||||
|
||||
-- 重要:DDL 会隐式提交,不能依赖事务整体回滚;本文件未执行任何数据库操作。
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 生产日期与有效期补齐;执行前备份,且仅执行一次。
|
||||
-- 历史日期保留 NULL,禁止使用装填/发货时间自动推算真实生产日期。
|
||||
-- 需在部署包含日期字段的新后端前执行;本文件不会由应用自动执行。
|
||||
ALTER TABLE biz_filling_record
|
||||
ADD COLUMN production_date DATE NULL COMMENT '来源生产日期;历史未登记为空' AFTER batch_no,
|
||||
ADD COLUMN expiry_date DATE NULL COMMENT '有效期至;历史未登记为空' AFTER production_date,
|
||||
ADD CONSTRAINT ck_biz_filling_record_material_dates CHECK (
|
||||
(production_date IS NULL AND expiry_date IS NULL) OR
|
||||
(production_date IS NOT NULL AND expiry_date IS NOT NULL AND expiry_date >= production_date)
|
||||
);
|
||||
@@ -0,0 +1,125 @@
|
||||
-- 发货收货核对:MySQL 8 单次增量迁移(最低 8.0.16,需强制执行 CHECK)。
|
||||
-- 执行顺序:20260922_test_data_batch_mysql.sql
|
||||
-- -> 20261005_tunnel_material_flow_mysql.sql
|
||||
-- -> 20261008_material_dates_mysql.sql -> 本脚本 -> 部署收货核对版本。
|
||||
-- 执行前必须停写(业务接口、后台任务、导入及其他写入来源),完成备份并验证可恢复。
|
||||
-- 由 DBA 审核后在明确选定的目标库单次执行;不得使用 --force 或遇错继续的 SQL 客户端模式。
|
||||
-- 本脚本仅随代码交付,不在应用启动时执行;本次开发未连接数据库、未运行 SQL。
|
||||
-- 警告:MySQL DDL 会隐式提交,下面的 ROLLBACK 仅能撤销 DML,不能整体回滚已完成的 DDL。
|
||||
-- 中途失败应保持停写,依据备份和已完成步骤人工恢复;禁止直接重跑或启动新旧应用。
|
||||
-- 旧 CONFIRMED 单只标 LEGACY;不迁移旧库存状态、不补扣库存、不造收货事件、不伪造收货时间。
|
||||
-- 正式数据与各测试批次采用相同历史标记规则;所有原 test_batch_id 保持不变。
|
||||
-- 权限只新增 sys_menu,不写 sys_role_menu;上线后由管理员按现场职责手动授权。
|
||||
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE migrate_shipment_receipt_20261008()
|
||||
BEGIN
|
||||
DECLARE EXIT HANDLER FOR SQLEXCEPTION
|
||||
BEGIN
|
||||
ROLLBACK;
|
||||
RESIGNAL;
|
||||
END;
|
||||
|
||||
-- 所有环境、前置结构、菜单冲突检查均先于业务表 DDL。
|
||||
IF DATABASE() IS NULL THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Select the target database before migration';
|
||||
END IF;
|
||||
IF SUBSTRING_INDEX(VERSION(), '.', 1) <> '8'
|
||||
OR (CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(VERSION(), '.', 2), '.', -1) AS UNSIGNED) = 0
|
||||
AND CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(VERSION(), '.', 3), '.', -1) AS UNSIGNED) < 16) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'MySQL 8.0.16 or newer MySQL 8 is required';
|
||||
END IF;
|
||||
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND engine = 'InnoDB'
|
||||
AND table_name IN ('biz_shipment_order', 'biz_pallet_inventory', 'biz_pallet_event',
|
||||
'biz_filling_record', 'biz_tunnel', 'sys_menu', 'sys_role_menu', 'sys_user')) <> 8 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Required InnoDB tables or tunnel migration are missing';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND ((table_name = 'biz_filling_record' AND column_name IN ('production_date', 'expiry_date'))
|
||||
OR (table_name = 'biz_pallet_inventory' AND column_name IN ('tunnel_id', 'trace_no'))
|
||||
OR (table_name = 'biz_pallet_event'
|
||||
AND column_name IN ('before_tunnel_id', 'after_tunnel_id', 'trace_no', 'batch_no_snapshot')))) <> 8 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Apply tunnel and material date migrations first';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.check_constraints
|
||||
WHERE constraint_schema = DATABASE() AND constraint_name = 'ck_biz_filling_record_material_dates')
|
||||
OR NOT EXISTS (SELECT 1 FROM information_schema.check_constraints
|
||||
WHERE constraint_schema = DATABASE() AND constraint_name = 'ck_biz_pallet_event_event_type'
|
||||
AND UPPER(check_clause) LIKE '%TUNNEL_ENTERED%')
|
||||
OR NOT EXISTS (SELECT 1 FROM information_schema.check_constraints
|
||||
WHERE constraint_schema = DATABASE() AND constraint_name = 'ck_biz_pallet_inventory_flow_state') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Required material date or flow CHECK constraints are missing';
|
||||
END IF;
|
||||
|
||||
-- 任何新增字段已存在均说明已执行或部分执行,禁止将已收货数据重新标成 LEGACY。
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND ((table_name = 'biz_shipment_order'
|
||||
AND column_name IN ('receipt_state', 'received_by', 'received_at',
|
||||
'receipt_idempotency_key', 'receipt_request_hash'))
|
||||
OR (table_name = 'biz_pallet_inventory' AND column_name = 'site_arrived_at'))) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Receipt columns already exist; do not rerun this migration';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 1119 OR perms = 'business:shipment:receive') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Menu 1119 or shipment receive permission already exists';
|
||||
END IF;
|
||||
-- 避免已有孤立授权在插入 1119 时自动变成有效收货权限。
|
||||
IF EXISTS (SELECT 1 FROM sys_role_menu WHERE menu_id = 1119) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Orphan role grants for menu 1119 must be reviewed first';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 510 AND perms = 'business:shipment:list'
|
||||
AND menu_type = 'C') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Shipment parent menu 510 does not match the expected contract';
|
||||
END IF;
|
||||
|
||||
ALTER TABLE biz_shipment_order
|
||||
ADD COLUMN receipt_state VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '收货状态;LEGACY表示历史未登记' AFTER confirmed_at,
|
||||
ADD COLUMN received_by BIGINT NULL COMMENT '收货核对人ID' AFTER receipt_state,
|
||||
ADD COLUMN received_at DATETIME NULL COMMENT '实际收货核对时间' AFTER received_by,
|
||||
ADD COLUMN receipt_idempotency_key VARCHAR(64) NULL COMMENT '收货确认幂等键' AFTER received_at,
|
||||
ADD COLUMN receipt_request_hash VARCHAR(64) NULL COMMENT '收货核对请求摘要' AFTER receipt_idempotency_key,
|
||||
ADD CONSTRAINT uq_biz_shipment_receipt_site_key UNIQUE (target_site_id, receipt_idempotency_key),
|
||||
ADD CONSTRAINT fk_biz_shipment_order_received_by FOREIGN KEY (received_by) REFERENCES sys_user (user_id),
|
||||
ADD CONSTRAINT ck_biz_shipment_order_receipt_state CHECK (receipt_state IN ('PENDING', 'RECEIVED', 'LEGACY'));
|
||||
|
||||
ALTER TABLE biz_pallet_inventory
|
||||
ADD COLUMN site_arrived_at DATETIME NULL COMMENT '本轮实际收货核对时间;历史未登记为空' AFTER remaining_packages,
|
||||
DROP CHECK ck_biz_pallet_inventory_flow_state,
|
||||
ADD CONSTRAINT ck_biz_pallet_inventory_flow_state CHECK (
|
||||
flow_state IN ('UNSTOCKED', 'EMPTY_IN_WAREHOUSE', 'FILLED_IN_WAREHOUSE', 'IN_TRANSIT',
|
||||
'AT_SITE_UNUSED', 'AT_SITE_PARTIAL', 'AT_SITE_EMPTY', 'RETURNING')
|
||||
);
|
||||
|
||||
ALTER TABLE biz_pallet_event
|
||||
DROP CHECK ck_biz_pallet_event_event_type,
|
||||
ADD CONSTRAINT ck_biz_pallet_event_event_type CHECK (
|
||||
event_type IN ('INBOUND_CONFIRMED', 'FILLING_CONFIRMED', 'SHIPMENT_CONFIRMED', 'SHIPMENT_RECEIVED',
|
||||
'TUNNEL_ENTERED', 'USAGE_RECORDED', 'RETURN_REQUESTED', 'RETURN_CONFIRMED')
|
||||
);
|
||||
|
||||
-- DDL 已隐式提交;以下事务只覆盖历史标记及权限菜单插入。
|
||||
START TRANSACTION;
|
||||
UPDATE biz_shipment_order SET receipt_state = 'LEGACY' WHERE status = 'CONFIRMED';
|
||||
INSERT INTO sys_menu (
|
||||
menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
|
||||
is_frame, is_cache, menu_type, visible, status, perms, icon, create_by,
|
||||
create_time, update_by, update_time, remark
|
||||
) VALUES (
|
||||
1119, '收货核对', 510, 6, '', '', '', '',
|
||||
1, 0, 'F', '0', '0', 'business:shipment:receive', '#', 'admin',
|
||||
SYSDATE(), '', NULL, ''
|
||||
);
|
||||
COMMIT;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
CALL migrate_shipment_receipt_20261008();
|
||||
DROP PROCEDURE migrate_shipment_receipt_20261008;
|
||||
|
||||
-- 核验:历史确认单应为 LEGACY,新草稿为 PENDING,收货时间/人员和原库存保持未登记。
|
||||
SELECT status, receipt_state, COUNT(*) AS order_count
|
||||
FROM biz_shipment_order GROUP BY status, receipt_state;
|
||||
SELECT menu_id, parent_id, perms FROM sys_menu WHERE menu_id = 1119;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- 允许跳过先到托盘,但需填写原因并保存当时的推荐快照。
|
||||
-- 在20261008日期、收货补丁之后执行;先停写、备份、恢复演练。
|
||||
-- 单次增量;不修改历史事实,不为旧记录补造原因或真实收货时间。
|
||||
-- DDL隐式提交,中途失败不得盲目重跑。
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE migrate_usage_fifo_20261008()
|
||||
BEGIN
|
||||
IF DATABASE() IS NULL THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Select target database first';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND engine = 'InnoDB'
|
||||
AND table_name IN ('biz_usage_record', 'biz_pallet_event')) <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Required InnoDB usage and event tables are missing';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'biz_pallet_inventory'
|
||||
AND column_name = 'site_arrived_at') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Apply shipment receipt migration first';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name IN ('biz_usage_record', 'biz_pallet_event')
|
||||
AND column_name IN ('fifo_skip_reason', 'fifo_recommendation')) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FIFO columns already exist; do not rerun';
|
||||
END IF;
|
||||
ALTER TABLE biz_usage_record
|
||||
ADD COLUMN fifo_skip_reason VARCHAR(500) NULL COMMENT '跳过先到托盘原因',
|
||||
ADD COLUMN fifo_recommendation JSON NULL COMMENT '跳过时先到托盘身份及到货时间快照';
|
||||
ALTER TABLE biz_pallet_event
|
||||
ADD COLUMN fifo_skip_reason VARCHAR(500) NULL COMMENT '跳过先到托盘原因',
|
||||
ADD COLUMN fifo_recommendation JSON NULL COMMENT '跳过时先到托盘身份及到货时间快照';
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL migrate_usage_fifo_20261008();
|
||||
DROP PROCEDURE migrate_usage_fifo_20261008;
|
||||
@@ -303,6 +303,7 @@ insert into sys_menu values('504', '客户现场', '121', '3', 'site', 'master/s
|
||||
insert into sys_menu values('505', '产线资料', '121', '4', 'line', 'master/line/index', '', '', 1, 0, 'C', '0', '0', 'master:line:list', '#', 'admin', sysdate(), '', null, '产线资料菜单');
|
||||
insert into sys_menu values('506', '仓库资料', '121', '5', 'warehouse', 'master/warehouse/index', '', '', 1, 0, 'C', '0', '0', 'master:warehouse:list', '#', 'admin', sysdate(), '', null, '仓库资料菜单');
|
||||
insert into sys_menu values('507', '托盘资料', '121', '6', 'pallet', 'master/pallet/index', '', '', 1, 0, 'C', '0', '0', 'master:pallet:list', '#', 'admin', sysdate(), '', null, '托盘资料菜单');
|
||||
insert into sys_menu values('520', '隧道资料', '121', '7', 'tunnel', 'master/tunnel/index', '', '', 1, 0, 'C', '0', '0', 'master:tunnel:list', '#', 'admin', sysdate(), '', null, '隧道资料菜单');
|
||||
insert into sys_menu values('508', '仓库入库', '122', '1', 'inbound', 'business/inbound/index', '', '', 1, 0, 'C', '0', '0', 'business:inbound:list', '#', 'admin', sysdate(), '', null, '仓库入库菜单');
|
||||
insert into sys_menu values('509', '工厂装填', '122', '2', 'filling', 'business/filling/index', '', '', 1, 0, 'C', '0', '0', 'business:filling:list', '#', 'admin', sysdate(), '', null, '工厂装填菜单');
|
||||
insert into sys_menu values('510', '发货出库', '122', '3', 'shipment', 'business/shipment/index', '', '', 1, 0, 'C', '0', '0', 'business:shipment:list', '#', 'admin', sysdate(), '', null, '发货出库菜单');
|
||||
@@ -338,6 +339,9 @@ insert into sys_menu values('1087', '托盘查询', '507', '1', '', '', '', '',
|
||||
insert into sys_menu values('1088', '托盘新增', '507', '2', '', '', '', '', 1, 0, 'F', '0', '0', 'master:pallet:add', '#', 'admin', sysdate(), '', null, '');
|
||||
insert into sys_menu values('1089', '托盘修改', '507', '3', '', '', '', '', 1, 0, 'F', '0', '0', 'master:pallet:edit', '#', 'admin', sysdate(), '', null, '');
|
||||
insert into sys_menu values('1091', '托盘打印', '507', '5', '', '', '', '', 1, 0, 'F', '0', '0', 'master:pallet:print', '#', 'admin', sysdate(), '', null, '');
|
||||
insert into sys_menu values('1116', '隧道查询', '520', '1', '', '', '', '', 1, 0, 'F', '0', '0', 'master:tunnel:query', '#', 'admin', sysdate(), '', null, '');
|
||||
insert into sys_menu values('1117', '隧道新增', '520', '2', '', '', '', '', 1, 0, 'F', '0', '0', 'master:tunnel:add', '#', 'admin', sysdate(), '', null, '');
|
||||
insert into sys_menu values('1118', '隧道修改', '520', '3', '', '', '', '', 1, 0, 'F', '0', '0', 'master:tunnel:edit', '#', 'admin', sysdate(), '', null, '');
|
||||
insert into sys_menu values('1092', '入库查询', '508', '1', '', '', '', '', 1, 0, 'F', '0', '0', 'business:inbound:query', '#', 'admin', sysdate(), '', null, '');
|
||||
insert into sys_menu values('1093', '入库新增', '508', '2', '', '', '', '', 1, 0, 'F', '0', '0', 'business:inbound:add', '#', 'admin', sysdate(), '', null, '');
|
||||
insert into sys_menu values('1094', '入库修改', '508', '3', '', '', '', '', 1, 0, 'F', '0', '0', 'business:inbound:edit', '#', 'admin', sysdate(), '', null, '');
|
||||
@@ -348,6 +352,7 @@ insert into sys_menu values('1099', '发货查询', '510', '1', '', '', '', '',
|
||||
insert into sys_menu values('1100', '发货新增', '510', '2', '', '', '', '', 1, 0, 'F', '0', '0', 'business:shipment:add', '#', 'admin', sysdate(), '', null, '');
|
||||
insert into sys_menu values('1101', '发货修改', '510', '3', '', '', '', '', 1, 0, 'F', '0', '0', 'business:shipment:edit', '#', 'admin', sysdate(), '', null, '');
|
||||
insert into sys_menu values('1103', '发货确认', '510', '5', '', '', '', '', 1, 0, 'F', '0', '0', 'business:shipment:confirm', '#', 'admin', sysdate(), '', null, '');
|
||||
insert into sys_menu values('1119', '收货核对', '510', '6', '', '', '', '', 1, 0, 'F', '0', '0', 'business:shipment:receive', '#', 'admin', sysdate(), '', null, '');
|
||||
insert into sys_menu values('1104', '使用查询', '511', '1', '', '', '', '', 1, 0, 'F', '0', '0', 'business:usage:query', '#', 'admin', sysdate(), '', null, '');
|
||||
insert into sys_menu values('1105', '使用新增', '511', '2', '', '', '', '', 1, 0, 'F', '0', '0', 'business:usage:add', '#', 'admin', sysdate(), '', null, '');
|
||||
insert into sys_menu values('1106', '退回查询', '512', '1', '', '', '', '', 1, 0, 'F', '0', '0', 'business:return:query', '#', 'admin', sysdate(), '', null, '');
|
||||
@@ -960,6 +965,29 @@ create table biz_production_line (
|
||||
constraint ck_biz_production_line_status check (status in ('0', '1'))
|
||||
) engine=innodb comment = '客户现场产线主数据表';
|
||||
|
||||
-- 客户现场隧道主数据表
|
||||
create table biz_tunnel (
|
||||
tunnel_id bigint(20) not null auto_increment comment '隧道ID',
|
||||
test_batch_id bigint(20) null comment '测试数据批次ID;NULL表示正式数据',
|
||||
site_id bigint(20) not null comment '现场ID',
|
||||
tunnel_code varchar(64) not null comment '隧道编号',
|
||||
tunnel_name varchar(100) not null comment '隧道名称',
|
||||
current_trace_no varchar(64) comment '当前隧道物料溯源号',
|
||||
current_trace_started_at datetime comment '当前溯源批次开始时间',
|
||||
status char(1) not null default '0' comment '状态(0正常 1停用)',
|
||||
create_by varchar(64) default '' comment '创建者',
|
||||
create_time datetime comment '创建时间',
|
||||
update_by varchar(64) default '' comment '更新者',
|
||||
update_time datetime comment '更新时间',
|
||||
remark varchar(500) default null comment '备注',
|
||||
primary key (tunnel_id),
|
||||
key idx_biz_tunnel_test_batch_id (test_batch_id),
|
||||
constraint uq_biz_tunnel_site_code unique (site_id, tunnel_code),
|
||||
constraint fk_biz_tunnel_test_batch_id foreign key (test_batch_id) references biz_test_batch (batch_id),
|
||||
constraint fk_biz_tunnel_site foreign key (site_id) references biz_customer_site (site_id),
|
||||
constraint ck_biz_tunnel_status check (status in ('0', '1'))
|
||||
) engine=innodb comment = '客户现场隧道主数据表';
|
||||
|
||||
-- 托盘资产主数据表
|
||||
create table biz_pallet (
|
||||
pallet_id bigint(20) not null auto_increment comment '托盘ID',
|
||||
@@ -1057,6 +1085,8 @@ create table biz_filling_record (
|
||||
strain_code_snapshot varchar(64) not null comment '菌种编码快照',
|
||||
strain_name_snapshot varchar(100) not null comment '菌种名称快照',
|
||||
batch_no varchar(64) not null comment '生产批次',
|
||||
production_date date comment '来源生产日期;历史未登记为空',
|
||||
expiry_date date comment '有效期至;历史未登记为空',
|
||||
package_spec varchar(64) not null comment '包装规格',
|
||||
initial_packages int not null comment '初始包数',
|
||||
active_slot varchar(16) comment '活动装填槽',
|
||||
@@ -1081,6 +1111,10 @@ create table biz_filling_record (
|
||||
constraint fk_biz_filling_record_operator foreign key (operator_id) references sys_user (user_id),
|
||||
constraint fk_biz_filling_record_test_batch_id foreign key (test_batch_id) references biz_test_batch (batch_id),
|
||||
constraint ck_biz_filling_record_initial_packages check (initial_packages > 0),
|
||||
constraint ck_biz_filling_record_material_dates check (
|
||||
(production_date is null and expiry_date is null) or
|
||||
(production_date is not null and expiry_date is not null and expiry_date >= production_date)
|
||||
),
|
||||
constraint ck_biz_filling_record_active_slot check (active_slot is null or active_slot = 'ACTIVE')
|
||||
) engine=innodb comment = '托盘装填记录';
|
||||
|
||||
@@ -1096,6 +1130,11 @@ create table biz_shipment_order (
|
||||
confirm_request_hash varchar(64) comment '确认请求摘要',
|
||||
confirmed_by bigint(20) comment '确认人ID',
|
||||
confirmed_at datetime comment '确认时间',
|
||||
receipt_state varchar(16) not null default 'PENDING' comment '收货状态;LEGACY表示历史未登记',
|
||||
received_by bigint(20) null comment '收货核对人ID',
|
||||
received_at datetime null comment '实际收货核对时间',
|
||||
receipt_idempotency_key varchar(64) null comment '收货确认幂等键',
|
||||
receipt_request_hash varchar(64) null comment '收货核对请求摘要',
|
||||
create_by varchar(64) default '' comment '创建者',
|
||||
create_time datetime comment '创建时间',
|
||||
update_by varchar(64) default '' comment '更新者',
|
||||
@@ -1105,11 +1144,14 @@ create table biz_shipment_order (
|
||||
key idx_biz_shipment_order_test_batch_id (test_batch_id),
|
||||
constraint uq_biz_shipment_order_no unique (shipment_no),
|
||||
constraint uq_biz_shipment_order_route_confirm_idempotency unique (source_warehouse_id, target_site_id, confirm_idempotency_key),
|
||||
constraint uq_biz_shipment_receipt_site_key unique (target_site_id, receipt_idempotency_key),
|
||||
constraint fk_biz_shipment_order_warehouse foreign key (source_warehouse_id) references biz_warehouse (warehouse_id),
|
||||
constraint fk_biz_shipment_order_site foreign key (target_site_id) references biz_customer_site (site_id),
|
||||
constraint fk_biz_shipment_order_confirmed_by foreign key (confirmed_by) references sys_user (user_id),
|
||||
constraint fk_biz_shipment_order_received_by foreign key (received_by) references sys_user (user_id),
|
||||
constraint fk_biz_shipment_order_test_batch_id foreign key (test_batch_id) references biz_test_batch (batch_id),
|
||||
constraint ck_biz_shipment_order_status check (status in ('DRAFT', 'CONFIRMED'))
|
||||
constraint ck_biz_shipment_order_status check (status in ('DRAFT', 'CONFIRMED')),
|
||||
constraint ck_biz_shipment_order_receipt_state check (receipt_state in ('PENDING', 'RECEIVED', 'LEGACY'))
|
||||
) engine=innodb comment = '整托发货单';
|
||||
|
||||
-- 整托发货明细
|
||||
@@ -1140,8 +1182,13 @@ create table biz_usage_record (
|
||||
pallet_id bigint(20) not null comment '托盘ID',
|
||||
active_filling_id bigint(20) not null comment '活动装填记录ID',
|
||||
site_id bigint(20) not null comment '现场ID',
|
||||
line_id bigint(20) not null comment '产线ID',
|
||||
tunnel_id bigint(20) comment '隧道ID;历史记录可能为空',
|
||||
trace_no varchar(64) comment '隧道物料内部溯源号;历史记录可能为空',
|
||||
batch_no_snapshot varchar(64) comment '生产批次快照;历史记录可能为空',
|
||||
line_id bigint(20) comment '产线ID(可选)',
|
||||
used_packages int not null comment '本次使用包数',
|
||||
fifo_skip_reason varchar(500) comment '跳过先到托盘原因',
|
||||
fifo_recommendation json comment '跳过时先到托盘身份及到货时间快照',
|
||||
remaining_packages_after int not null comment '使用后剩余包数',
|
||||
idempotency_key varchar(64) not null comment '幂等键',
|
||||
request_hash varchar(64) not null comment '请求摘要',
|
||||
@@ -1154,6 +1201,7 @@ create table biz_usage_record (
|
||||
constraint fk_biz_usage_record_pallet foreign key (pallet_id) references biz_pallet (pallet_id),
|
||||
constraint fk_biz_usage_record_filling foreign key (active_filling_id) references biz_filling_record (filling_id),
|
||||
constraint fk_biz_usage_record_site foreign key (site_id) references biz_customer_site (site_id),
|
||||
constraint fk_biz_usage_record_tunnel foreign key (tunnel_id) references biz_tunnel (tunnel_id),
|
||||
constraint fk_biz_usage_record_line foreign key (line_id) references biz_production_line (line_id),
|
||||
constraint fk_biz_usage_record_operator foreign key (operator_id) references sys_user (user_id),
|
||||
constraint fk_biz_usage_record_test_batch_id foreign key (test_batch_id) references biz_test_batch (batch_id),
|
||||
@@ -1202,6 +1250,9 @@ create table biz_return_item (
|
||||
return_id bigint(20) not null comment '退回单ID',
|
||||
pallet_id bigint(20) not null comment '托盘ID',
|
||||
active_filling_id_snapshot bigint(20) comment '申请时活动装填记录快照',
|
||||
tunnel_id_snapshot bigint(20) comment '申请时隧道ID快照',
|
||||
trace_no_snapshot varchar(64) comment '申请时物料溯源号快照',
|
||||
batch_no_snapshot varchar(64) comment '申请时生产批次快照',
|
||||
remaining_packages_snapshot int not null comment '申请时剩余包数快照',
|
||||
create_by varchar(64) default '' comment '创建者',
|
||||
create_time datetime comment '创建时间',
|
||||
@@ -1211,6 +1262,7 @@ create table biz_return_item (
|
||||
constraint fk_biz_return_item_order foreign key (return_id) references biz_return_order (return_id),
|
||||
constraint fk_biz_return_item_pallet foreign key (pallet_id) references biz_pallet (pallet_id),
|
||||
constraint fk_biz_return_item_filling foreign key (active_filling_id_snapshot) references biz_filling_record (filling_id),
|
||||
constraint fk_biz_return_item_tunnel foreign key (tunnel_id_snapshot) references biz_tunnel (tunnel_id),
|
||||
constraint fk_biz_return_item_test_batch_id foreign key (test_batch_id) references biz_test_batch (batch_id),
|
||||
constraint ck_biz_return_item_remaining_packages_snapshot check (remaining_packages_snapshot >= 0)
|
||||
) engine=innodb comment = '托盘退回明细';
|
||||
@@ -1228,12 +1280,18 @@ create table biz_pallet_event (
|
||||
after_state varchar(32) not null comment '变更后状态',
|
||||
before_warehouse_id bigint(20) comment '变更前仓库ID',
|
||||
before_site_id bigint(20) comment '变更前现场ID',
|
||||
before_tunnel_id bigint(20) comment '变更前隧道ID',
|
||||
before_line_id bigint(20) comment '变更前产线ID',
|
||||
after_warehouse_id bigint(20) comment '变更后仓库ID',
|
||||
after_site_id bigint(20) comment '变更后现场ID',
|
||||
after_tunnel_id bigint(20) comment '变更后隧道ID',
|
||||
after_line_id bigint(20) comment '变更后产线ID',
|
||||
before_remaining_packages int not null comment '变更前剩余包数',
|
||||
after_remaining_packages int not null comment '变更后剩余包数',
|
||||
trace_no varchar(64) comment '事件关联物料溯源号',
|
||||
batch_no_snapshot varchar(64) comment '事件生产批次快照',
|
||||
fifo_skip_reason varchar(500) comment '跳过先到托盘原因',
|
||||
fifo_recommendation json comment '跳过时先到托盘身份及到货时间快照',
|
||||
operator_id bigint(20) not null comment '操作人ID',
|
||||
occurred_at datetime not null comment '发生时间',
|
||||
idempotency_key varchar(64) not null comment '幂等键',
|
||||
@@ -1245,9 +1303,11 @@ create table biz_pallet_event (
|
||||
constraint fk_biz_pallet_event_pallet foreign key (pallet_id) references biz_pallet (pallet_id),
|
||||
constraint fk_biz_pallet_event_before_warehouse foreign key (before_warehouse_id) references biz_warehouse (warehouse_id),
|
||||
constraint fk_biz_pallet_event_before_site foreign key (before_site_id) references biz_customer_site (site_id),
|
||||
constraint fk_biz_pallet_event_before_tunnel foreign key (before_tunnel_id) references biz_tunnel (tunnel_id),
|
||||
constraint fk_biz_pallet_event_before_line foreign key (before_line_id) references biz_production_line (line_id),
|
||||
constraint fk_biz_pallet_event_after_warehouse foreign key (after_warehouse_id) references biz_warehouse (warehouse_id),
|
||||
constraint fk_biz_pallet_event_after_site foreign key (after_site_id) references biz_customer_site (site_id),
|
||||
constraint fk_biz_pallet_event_after_tunnel foreign key (after_tunnel_id) references biz_tunnel (tunnel_id),
|
||||
constraint fk_biz_pallet_event_after_line foreign key (after_line_id) references biz_production_line (line_id),
|
||||
constraint fk_biz_pallet_event_operator foreign key (operator_id) references sys_user (user_id),
|
||||
constraint fk_biz_pallet_event_test_batch_id foreign key (test_batch_id) references biz_test_batch (batch_id),
|
||||
@@ -1256,6 +1316,8 @@ create table biz_pallet_event (
|
||||
'INBOUND_CONFIRMED',
|
||||
'FILLING_CONFIRMED',
|
||||
'SHIPMENT_CONFIRMED',
|
||||
'SHIPMENT_RECEIVED',
|
||||
'TUNNEL_ENTERED',
|
||||
'USAGE_RECORDED',
|
||||
'RETURN_REQUESTED',
|
||||
'RETURN_CONFIRMED'
|
||||
@@ -1270,9 +1332,12 @@ create table biz_pallet_inventory (
|
||||
active_filling_id bigint(20) comment '当前活动装填记录ID',
|
||||
warehouse_id bigint(20) comment '当前仓库ID',
|
||||
site_id bigint(20) comment '当前现场ID',
|
||||
tunnel_id bigint(20) comment '当前隧道ID',
|
||||
trace_no varchar(64) comment '当前隧道物料溯源号',
|
||||
current_line_id bigint(20) comment '当前驻场周期绑定产线ID',
|
||||
flow_state varchar(32) not null default 'UNSTOCKED' comment '托盘流转状态',
|
||||
remaining_packages int not null default 0 comment '当前剩余包数',
|
||||
site_arrived_at datetime null comment '本轮实际收货核对时间;历史未登记为空',
|
||||
last_event_id bigint(20) comment '最后事件ID',
|
||||
version int not null default 0 comment '并发版本号',
|
||||
updated_at datetime not null comment '更新时间',
|
||||
@@ -1282,6 +1347,7 @@ create table biz_pallet_inventory (
|
||||
constraint fk_biz_pallet_inventory_filling foreign key (active_filling_id) references biz_filling_record (filling_id),
|
||||
constraint fk_biz_pallet_inventory_warehouse foreign key (warehouse_id) references biz_warehouse (warehouse_id),
|
||||
constraint fk_biz_pallet_inventory_site foreign key (site_id) references biz_customer_site (site_id),
|
||||
constraint fk_biz_pallet_inventory_tunnel foreign key (tunnel_id) references biz_tunnel (tunnel_id),
|
||||
constraint fk_biz_pallet_inventory_line foreign key (current_line_id) references biz_production_line (line_id),
|
||||
constraint fk_biz_pallet_inventory_event foreign key (last_event_id) references biz_pallet_event (event_id),
|
||||
constraint fk_biz_pallet_inventory_test_batch_id foreign key (test_batch_id) references biz_test_batch (batch_id),
|
||||
@@ -1290,6 +1356,7 @@ create table biz_pallet_inventory (
|
||||
'UNSTOCKED',
|
||||
'EMPTY_IN_WAREHOUSE',
|
||||
'FILLED_IN_WAREHOUSE',
|
||||
'IN_TRANSIT',
|
||||
'AT_SITE_UNUSED',
|
||||
'AT_SITE_PARTIAL',
|
||||
'AT_SITE_EMPTY',
|
||||
|
||||
@@ -21,10 +21,14 @@ CONTROLLER_NAMES = (
|
||||
'inbound',
|
||||
'filling',
|
||||
'shipment',
|
||||
'shipment_receipt',
|
||||
'usage',
|
||||
'usage_fifo',
|
||||
'return',
|
||||
'report',
|
||||
'test_batch',
|
||||
'dashboard',
|
||||
'tunnel',
|
||||
)
|
||||
TARGET_PREFIXES = (
|
||||
'/master',
|
||||
@@ -43,6 +47,7 @@ EXPECTED_PAGE_PERMISSIONS = frozenset(
|
||||
'master:line:list',
|
||||
'master:warehouse:list',
|
||||
'master:pallet:list',
|
||||
'master:tunnel:list',
|
||||
'business:inbound:list',
|
||||
'business:filling:list',
|
||||
'business:shipment:list',
|
||||
@@ -80,6 +85,10 @@ EXPECTED_BUTTON_PERMISSIONS = frozenset(
|
||||
'master:pallet:add',
|
||||
'master:pallet:edit',
|
||||
'master:pallet:print',
|
||||
'master:tunnel:query',
|
||||
'master:tunnel:add',
|
||||
'master:tunnel:edit',
|
||||
'master:tunnel:remove',
|
||||
'business:inbound:query',
|
||||
'business:inbound:add',
|
||||
'business:inbound:edit',
|
||||
@@ -90,6 +99,7 @@ EXPECTED_BUTTON_PERMISSIONS = frozenset(
|
||||
'business:shipment:add',
|
||||
'business:shipment:edit',
|
||||
'business:shipment:confirm',
|
||||
'business:shipment:receive',
|
||||
'business:usage:query',
|
||||
'business:usage:add',
|
||||
'business:return:query',
|
||||
@@ -108,7 +118,10 @@ EXPECTED_PERMISSIONS = EXPECTED_PAGE_PERMISSIONS | EXPECTED_BUTTON_PERMISSIONS
|
||||
EXPECTED_WEB_PERMISSIONS = frozenset(
|
||||
permission
|
||||
for permission in EXPECTED_PERMISSIONS
|
||||
if not permission.startswith('system:testbatch:') or permission == 'system:testbatch:adopt'
|
||||
if (
|
||||
(not permission.startswith('system:testbatch:') or permission == 'system:testbatch:adopt')
|
||||
and permission != 'master:tunnel:remove'
|
||||
)
|
||||
)
|
||||
VUE_PAGE_PATHS = (
|
||||
'src/views/master/strain/index.vue',
|
||||
@@ -117,6 +130,7 @@ VUE_PAGE_PATHS = (
|
||||
'src/views/master/line/index.vue',
|
||||
'src/views/master/warehouse/index.vue',
|
||||
'src/views/master/pallet/index.vue',
|
||||
'src/views/master/tunnel/index.vue',
|
||||
'src/views/business/inbound/index.vue',
|
||||
'src/views/business/filling/index.vue',
|
||||
'src/views/business/shipment/index.vue',
|
||||
@@ -130,7 +144,10 @@ VUE_PAGE_PATHS = (
|
||||
'src/views/report/palletTrace/index.vue',
|
||||
)
|
||||
PERMISSION_LITERAL_PATTERN = re.compile(r'(?:master|business|report|system):[a-z]+:[a-z]+')
|
||||
UNGUARDED_AUTHENTICATED_ROUTES = {('/system/test-batch/options', 'GET')}
|
||||
UNGUARDED_AUTHENTICATED_ROUTES = {
|
||||
('/system/test-batch/options', 'GET'),
|
||||
('/business/dashboard/scan-exceptions', 'GET'),
|
||||
}
|
||||
REMOVED_DELETE_SYMBOLS = (
|
||||
'delStrain',
|
||||
'delCustomer',
|
||||
@@ -170,6 +187,16 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
|
||||
}
|
||||
)
|
||||
|
||||
expected.update(
|
||||
{
|
||||
('/master/tunnel/list', 'GET'): 'master:tunnel:list',
|
||||
('/master/tunnel/{tunnel_id}', 'GET'): 'master:tunnel:query',
|
||||
('/master/tunnel', 'POST'): 'master:tunnel:add',
|
||||
('/master/tunnel', 'PUT'): 'master:tunnel:edit',
|
||||
('/master/tunnel/{tunnel_ids}', 'DELETE'): 'master:tunnel:remove',
|
||||
}
|
||||
)
|
||||
|
||||
expected.update(
|
||||
{
|
||||
('/master/site/{site_id}/users', 'GET'): 'master:site:assign',
|
||||
@@ -195,12 +222,20 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
|
||||
('/business/shipment/site-options', 'GET'): 'business:shipment:query',
|
||||
('/business/shipment/scan/{qr_token}', 'GET'): 'business:shipment:query',
|
||||
('/business/shipment/{shipment_id}', 'GET'): 'business:shipment:query',
|
||||
('/business/shipment/{shipment_id}/labels', 'GET'): 'business:shipment:query',
|
||||
('/business/shipment-receipt/list', 'GET'): 'business:shipment:receive',
|
||||
('/business/shipment-receipt/{shipment_id}', 'GET'): 'business:shipment:receive',
|
||||
('/business/shipment-receipt/{shipment_id}/scan/{identifier}', 'GET'): 'business:shipment:receive',
|
||||
('/business/shipment-receipt/{shipment_id}/confirm', 'PUT'): 'business:shipment:receive',
|
||||
('/business/usage-fifo/recommendation', 'GET'): 'business:usage:query',
|
||||
('/business/shipment', 'POST'): 'business:shipment:add',
|
||||
('/business/shipment', 'PUT'): 'business:shipment:edit',
|
||||
('/business/shipment/{shipment_ids}', 'DELETE'): 'business:shipment:list',
|
||||
('/business/shipment/{shipment_id}/confirm', 'PUT'): 'business:shipment:confirm',
|
||||
('/business/usage/list', 'GET'): 'business:usage:list',
|
||||
('/business/usage/line-options', 'GET'): 'business:usage:query',
|
||||
('/business/usage/tunnel-options', 'GET'): 'business:usage:query',
|
||||
('/business/usage/enter-tunnel', 'POST'): 'business:usage:add',
|
||||
('/business/usage/scan/{qr_token}', 'GET'): 'business:usage:query',
|
||||
('/business/usage/{usage_id}', 'GET'): 'business:usage:query',
|
||||
('/business/usage', 'POST'): 'business:usage:add',
|
||||
@@ -249,6 +284,7 @@ STATIC_READS_BEFORE_DETAIL = {
|
||||
),
|
||||
'/business/usage/{usage_id}': (
|
||||
'/business/usage/line-options',
|
||||
'/business/usage/tunnel-options',
|
||||
'/business/usage/scan/{qr_token}',
|
||||
),
|
||||
'/business/return/{return_id}': (
|
||||
@@ -264,6 +300,7 @@ EXACT_QUERY_PARAMETERS = {
|
||||
('/business/inbound/scan/{qr_token}', 'GET'): {'warehouseId'},
|
||||
('/business/shipment/scan/{qr_token}', 'GET'): {'sourceWarehouseId', 'targetSiteId'},
|
||||
('/business/usage/line-options', 'GET'): {'siteId'},
|
||||
('/business/usage/tunnel-options', 'GET'): {'siteId'},
|
||||
('/business/return/scan/{qr_token}', 'GET'): {'mode', 'returnId'},
|
||||
}
|
||||
REQUIRED_QUERY_PARAMETERS = {
|
||||
@@ -286,7 +323,7 @@ def _auth_dependencies(route: APIRoute) -> list[CheckUserInterfaceAuth]:
|
||||
|
||||
def _assert_controller_permission_literals(test_case: unittest.TestCase, literals: list[str]) -> None:
|
||||
expected = Counter(EXPECTED_ROUTE_PERMISSIONS.values())
|
||||
test_case.assertEqual(85, expected.total())
|
||||
test_case.assertEqual(98, expected.total())
|
||||
test_case.assertEqual(expected, Counter(literals))
|
||||
test_case.assertEqual(EXPECTED_PERMISSIONS, frozenset(expected))
|
||||
|
||||
@@ -409,7 +446,7 @@ class ApiPermissionContractTest(unittest.TestCase):
|
||||
cls.app = create_app()
|
||||
cls.schema = cls.app.openapi()
|
||||
cls.target_routes = [
|
||||
route for route in cls.app.routes if isinstance(route, APIRoute) and _is_target_path(route.path)
|
||||
route for route in cls.app.routes if isinstance(route, APIRoute) and _is_target_path(route.path_format)
|
||||
]
|
||||
|
||||
def test_target_path_selector_uses_namespace_boundaries(self) -> None:
|
||||
@@ -438,11 +475,11 @@ class ApiPermissionContractTest(unittest.TestCase):
|
||||
}
|
||||
|
||||
self.assertEqual(set(EXPECTED_ROUTE_PERMISSIONS) | UNGUARDED_AUTHENTICATED_ROUTES, actual_operations)
|
||||
self.assertEqual(86, len(actual_operations))
|
||||
self.assertEqual(100, len(actual_operations))
|
||||
|
||||
openapi_ai_paths = sorted(path for path in self.schema['paths'] if path == '/ai' or path.startswith('/ai/'))
|
||||
app_ai_paths = sorted(
|
||||
route.path
|
||||
route.path_format
|
||||
for route in self.app.routes
|
||||
if getattr(route, 'path', None) == '/ai' or getattr(route, 'path', '').startswith('/ai/')
|
||||
)
|
||||
@@ -454,8 +491,8 @@ class ApiPermissionContractTest(unittest.TestCase):
|
||||
operation_keys: list[tuple[str, str]] = []
|
||||
for route in self.target_routes:
|
||||
methods = route.methods & ROUTE_HTTP_METHODS
|
||||
self.assertEqual(1, len(methods), route.path)
|
||||
key = (route.path, next(iter(methods)))
|
||||
self.assertEqual(1, len(methods), route.path_format)
|
||||
key = (route.path_format, next(iter(methods)))
|
||||
operation_keys.append(key)
|
||||
|
||||
auth_dependencies = _auth_dependencies(route)
|
||||
@@ -491,19 +528,33 @@ class ApiPermissionContractTest(unittest.TestCase):
|
||||
literals.append(node.args[0].value)
|
||||
|
||||
self.assertEqual([], malformed_calls)
|
||||
self.assertEqual(18, len(EXPECTED_PAGE_PERMISSIONS))
|
||||
self.assertEqual(43, len(EXPECTED_BUTTON_PERMISSIONS))
|
||||
self.assertEqual(19, len(EXPECTED_PAGE_PERMISSIONS))
|
||||
self.assertEqual(48, len(EXPECTED_BUTTON_PERMISSIONS))
|
||||
self.assertFalse(EXPECTED_PAGE_PERMISSIONS & EXPECTED_BUTTON_PERMISSIONS)
|
||||
self.assertEqual(61, len(EXPECTED_PERMISSIONS))
|
||||
self.assertEqual(67, len(EXPECTED_PERMISSIONS))
|
||||
_assert_controller_permission_literals(self, literals)
|
||||
self.assertEqual(
|
||||
{'add', 'adopt', 'assign', 'close', 'confirm', 'edit', 'list', 'print', 'purge', 'query', 'request'},
|
||||
{
|
||||
'add',
|
||||
'adopt',
|
||||
'assign',
|
||||
'close',
|
||||
'confirm',
|
||||
'edit',
|
||||
'list',
|
||||
'print',
|
||||
'purge',
|
||||
'query',
|
||||
'remove',
|
||||
'request',
|
||||
'receive',
|
||||
},
|
||||
{permission.rsplit(':', maxsplit=1)[1] for permission in literals},
|
||||
)
|
||||
|
||||
def test_controller_ast_permission_contract_rejects_duplicate_call(self) -> None:
|
||||
literals = list(EXPECTED_ROUTE_PERMISSIONS.values())
|
||||
self.assertEqual(85, len(literals))
|
||||
self.assertEqual(98, len(literals))
|
||||
literals.append(literals[0])
|
||||
|
||||
with self.assertRaises(AssertionError):
|
||||
@@ -521,8 +572,8 @@ class ApiPermissionContractTest(unittest.TestCase):
|
||||
|
||||
def test_vue_pages_have_exact_guarded_permission_contract(self) -> None:
|
||||
actual_permissions = set()
|
||||
self.assertEqual(17, len(VUE_PAGE_PATHS))
|
||||
self.assertEqual(17, len(set(VUE_PAGE_PATHS)))
|
||||
self.assertEqual(18, len(VUE_PAGE_PATHS))
|
||||
self.assertEqual(18, len(set(VUE_PAGE_PATHS)))
|
||||
for relative_path in VUE_PAGE_PATHS:
|
||||
source = (FRONTEND_ROOT / relative_path).read_text(encoding='utf-8')
|
||||
guarded = _extract_guarded_vue_permissions(source)
|
||||
@@ -663,7 +714,7 @@ class ApiPermissionContractTest(unittest.TestCase):
|
||||
|
||||
def test_static_scan_and_option_routes_precede_dynamic_details(self) -> None:
|
||||
route_order = {
|
||||
route.path: index
|
||||
route.path_format: index
|
||||
for index, route in enumerate(self.app.routes)
|
||||
if isinstance(route, APIRoute) and 'GET' in route.methods
|
||||
}
|
||||
@@ -705,9 +756,9 @@ class ApiPermissionContractTest(unittest.TestCase):
|
||||
actual_write_routes: dict[tuple[str, str], APIRoute] = {}
|
||||
for route in self.target_routes:
|
||||
for method in route.methods & WRITE_METHODS:
|
||||
actual_write_routes[(route.path, method)] = route
|
||||
actual_write_routes[(route.path_format, method)] = route
|
||||
|
||||
self.assertEqual(41, len(expected_write_permissions))
|
||||
self.assertEqual(46, len(expected_write_permissions))
|
||||
self.assertEqual(set(expected_write_permissions), set(actual_write_routes))
|
||||
for key, expected_permission in expected_write_permissions.items():
|
||||
with self.subTest(operation=key):
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""FIFO 跳过原因必须在真正写入处验证,并随业务事实留痕。"""
|
||||
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from exceptions.exception import ServiceException
|
||||
from module_business.dao.usage_fifo_dao import UsageFifoDao
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.operation_vo import TunnelEntryCreateModel, UsageCreateModel, UsageModel
|
||||
from module_business.service.usage_service import UsageService
|
||||
from tests.module_business import test_usage_service as fixtures
|
||||
|
||||
|
||||
class TestFifoSkipPolicy(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
fixtures.TestRecordUsageCore.setUp(self)
|
||||
self.inventory.site_arrived_at = datetime(2026, 10, 8, 10)
|
||||
self.candidate = {
|
||||
'pallet_id': 50, 'pallet_code': 'P-050', 'batch_no': 'B-EARLY',
|
||||
'remaining_packages': 80, 'site_arrived_at': datetime(2026, 10, 7, 10),
|
||||
}
|
||||
|
||||
_patch_record_path = fixtures.TestRecordUsageCore._patch_record_path
|
||||
|
||||
def command(self, reason=None):
|
||||
values = self.page.model_dump(by_alias=True)
|
||||
if reason is not None:
|
||||
values['fifoSkipReason'] = reason
|
||||
return UsageCreateModel(**values)
|
||||
|
||||
async def test_earlier_available_pallet_requires_reason_before_any_write(self):
|
||||
stack, calls = self._patch_record_path()
|
||||
with stack, patch.object(UsageFifoDao, 'recommendation', new=AsyncMock(return_value=(self.candidate, 0))):
|
||||
with self.assertRaises(ServiceException) as caught:
|
||||
await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal())
|
||||
self.assertEqual(caught.exception.data, 'FIFO_SKIP_REASON_REQUIRED')
|
||||
self.assertEqual(self.inventory.remaining_packages, 8)
|
||||
calls['add_usage'].assert_not_awaited()
|
||||
calls['add_event'].assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
async def test_reason_and_recommendation_are_saved_with_usage_and_event(self):
|
||||
command = self.command(' 工艺需要混批补充 ')
|
||||
stack, calls = self._patch_record_path()
|
||||
fifo = AsyncMock(return_value=(self.candidate, 0))
|
||||
with stack, patch.object(UsageFifoDao, 'recommendation', new=fifo):
|
||||
await UsageService.record_usage_services(self.db, command, self.user, TestBatchScope.formal())
|
||||
usage = calls['add_usage'].call_args.args[1]
|
||||
event = calls['add_event'].call_args.args[2]
|
||||
for record in (usage, event):
|
||||
self.assertEqual(record.fifo_skip_reason, '工艺需要混批补充')
|
||||
self.assertEqual(record.fifo_recommendation['palletCode'], 'P-050')
|
||||
self.assertEqual(record.fifo_recommendation['batchNo'], 'B-EARLY')
|
||||
self.assertEqual(record.fifo_recommendation['siteArrivedAt'], '2026-10-07T10:00:00')
|
||||
self.assertTrue(fifo.call_args.kwargs['for_update'])
|
||||
self.assertEqual(self.inventory.remaining_packages, 5)
|
||||
self.assertEqual(UsageModel.model_validate(usage).fifo_skip_reason, '工艺需要混批补充')
|
||||
|
||||
async def test_equal_arrival_does_not_force_pallet_id_order(self):
|
||||
self.candidate['site_arrived_at'] = self.inventory.site_arrived_at
|
||||
stack, calls = self._patch_record_path()
|
||||
with stack, patch.object(UsageFifoDao, 'recommendation', new=AsyncMock(return_value=(self.candidate, 0))):
|
||||
result = await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal())
|
||||
self.assertTrue(result.is_success)
|
||||
self.assertIsNone(calls['add_usage'].call_args.args[1].fifo_skip_reason)
|
||||
|
||||
async def test_unknown_current_arrival_does_not_fabricate_order(self):
|
||||
self.inventory.site_arrived_at = None
|
||||
stack, _ = self._patch_record_path()
|
||||
with stack, patch.object(UsageFifoDao, 'recommendation', new=AsyncMock(return_value=(self.candidate, 1))):
|
||||
result = await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal())
|
||||
self.assertTrue(result.is_success)
|
||||
|
||||
async def test_idempotent_retry_never_rechecks_fifo_or_deducts_again(self):
|
||||
command = self.command('工艺需要')
|
||||
existing = fixtures.make_usage()
|
||||
existing.request_hash = UsageService._build_request_hash(command)
|
||||
stack, calls = self._patch_record_path(key_read=existing)
|
||||
fifo = AsyncMock(side_effect=AssertionError('成功重试不能重做FIFO判断'))
|
||||
with stack, patch.object(UsageFifoDao, 'recommendation', new=fifo):
|
||||
result = await UsageService.record_usage_services(self.db, command, self.user, TestBatchScope.formal())
|
||||
self.assertTrue(result.is_success)
|
||||
calls['add_usage'].assert_not_awaited()
|
||||
fifo.assert_not_awaited()
|
||||
|
||||
def test_reason_changes_hash_but_absent_reason_keeps_legacy_hash(self):
|
||||
original = UsageService._build_request_hash(self.page)
|
||||
self.assertEqual(original, UsageService._build_request_hash(self.command(' ')))
|
||||
self.assertNotEqual(original, UsageService._build_request_hash(self.command('工艺需要')))
|
||||
self.assertNotEqual(UsageService._build_request_hash(self.command('工艺需要')),
|
||||
UsageService._build_request_hash(self.command('质量检查')))
|
||||
|
||||
def test_entry_reason_is_validated_and_part_of_idempotent_identity(self):
|
||||
fields = {'qrToken': 'qr-2', 'tunnelId': 9, 'idempotencyKey': 'entry-key'}
|
||||
original = TunnelEntryCreateModel(**fields)
|
||||
reason = TunnelEntryCreateModel(**fields, fifoSkipReason=' 工艺需要 ')
|
||||
self.assertEqual(reason.fifo_skip_reason, '工艺需要')
|
||||
self.assertNotEqual(UsageService._build_tunnel_entry_request_hash(original),
|
||||
UsageService._build_tunnel_entry_request_hash(reason))
|
||||
with self.assertRaises(ValidationError):
|
||||
TunnelEntryCreateModel(**fields, fifoSkipReason='字' * 501)
|
||||
|
||||
async def test_entry_refuses_skip_before_inventory_binding(self):
|
||||
self.inventory.tunnel_id = None
|
||||
self.inventory.trace_no = None
|
||||
self.tunnel.current_trace_no = 'TRACE-9'
|
||||
command = TunnelEntryCreateModel(qrToken='qr-2', tunnelId=9, idempotencyKey='entry-key')
|
||||
stack, calls = self._patch_record_path()
|
||||
with stack, patch.object(fixtures.InventoryDao, 'get_event_by_idempotency_key', new=AsyncMock(return_value=None)), \
|
||||
patch.object(UsageFifoDao, 'recommendation', new=AsyncMock(return_value=(self.candidate, 0))):
|
||||
with self.assertRaises(ServiceException) as caught:
|
||||
await UsageService.enter_tunnel_services(self.db, command, self.user, TestBatchScope.formal())
|
||||
self.assertEqual(caught.exception.data, 'FIFO_SKIP_REASON_REQUIRED')
|
||||
self.assertIsNone(self.inventory.tunnel_id)
|
||||
calls['add_event'].assert_not_awaited()
|
||||
|
||||
async def test_fifo_lock_conflict_retries_whole_usage_with_original_command(self):
|
||||
stack, calls = self._patch_record_path()
|
||||
conflict = OperationalError('candidate lock', {}, RuntimeError(3572, 'NOWAIT'))
|
||||
fifo = AsyncMock(side_effect=[conflict, (self.candidate, 0)])
|
||||
command = self.command('工艺需要')
|
||||
with stack, patch.object(UsageFifoDao, 'recommendation', new=fifo):
|
||||
result = await UsageService.record_usage_services(self.db, command, self.user, TestBatchScope.formal())
|
||||
self.assertTrue(result.is_success)
|
||||
self.assertEqual(calls['inventory_lock'].await_count, 2)
|
||||
self.assertEqual(calls['add_usage'].await_count, 1)
|
||||
self.assertEqual(calls['add_usage'].call_args.args[1].idempotency_key, 'usage-key')
|
||||
self.assertEqual(self.inventory.remaining_packages, 5)
|
||||
|
||||
async def test_persistent_lock_conflict_is_bounded_and_other_db_errors_are_not_retried(self):
|
||||
for code, attempts in ((3572, 3), (1213, 3), (1205, 3), (1054, 1), (2013, 1)):
|
||||
with self.subTest(code=code):
|
||||
stack, calls = self._patch_record_path()
|
||||
fifo = AsyncMock(side_effect=OperationalError('candidate lock', {}, RuntimeError(code, 'test')))
|
||||
with stack, patch.object(UsageFifoDao, 'recommendation', new=fifo), self.assertRaises(OperationalError):
|
||||
await UsageService.record_usage_services(self.db, self.command('工艺需要'), self.user, TestBatchScope.formal())
|
||||
self.assertEqual(fifo.await_count, attempts)
|
||||
calls['add_usage'].assert_not_awaited()
|
||||
|
||||
async def test_fifo_lock_conflict_retries_entry_before_binding(self):
|
||||
self.inventory.tunnel_id = None
|
||||
self.inventory.trace_no = None
|
||||
command = TunnelEntryCreateModel(qrToken='qr-2', tunnelId=9, idempotencyKey='entry-key', fifoSkipReason='工艺需要')
|
||||
stack, calls = self._patch_record_path()
|
||||
fifo = AsyncMock(side_effect=[OperationalError('candidate lock', {}, RuntimeError(3572, 'NOWAIT')), (self.candidate, 0)])
|
||||
with stack, patch.object(fixtures.InventoryDao, 'get_event_by_idempotency_key', new=AsyncMock(return_value=None)), \
|
||||
patch.object(UsageFifoDao, 'recommendation', new=fifo):
|
||||
result = await UsageService.enter_tunnel_services(self.db, command, self.user, TestBatchScope.formal())
|
||||
self.assertTrue(result.is_success)
|
||||
self.assertEqual(calls['inventory_lock'].await_count, 2)
|
||||
self.assertEqual(calls['add_event'].await_count, 1)
|
||||
@@ -2,7 +2,7 @@ import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, get_type_hints
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@@ -60,7 +60,7 @@ def make_page(
|
||||
strain_id: int = 11,
|
||||
batch_no: str = 'B-20260726',
|
||||
package_spec: str = '20kg',
|
||||
initial_packages: int = 18,
|
||||
initial_packages: int = 100,
|
||||
idempotency_key: str = 'fill-key',
|
||||
) -> FillingCreateModel:
|
||||
return FillingCreateModel(
|
||||
@@ -68,6 +68,8 @@ def make_page(
|
||||
warehouseId=warehouse_id,
|
||||
strainId=strain_id,
|
||||
batchNo=batch_no,
|
||||
productionDate='2026-07-20',
|
||||
expiryDate='2027-01-20',
|
||||
packageSpec=package_spec,
|
||||
initialPackages=initial_packages,
|
||||
idempotencyKey=idempotency_key,
|
||||
@@ -78,7 +80,7 @@ def make_pallet(
|
||||
pallet_id: int = 2,
|
||||
*,
|
||||
qr_token: str = 'qr-2',
|
||||
capacity_packages: int = 20,
|
||||
capacity_packages: int = 100,
|
||||
asset_status: str = '0',
|
||||
test_batch_id: int | None = None,
|
||||
) -> BizPallet:
|
||||
@@ -200,7 +202,9 @@ class TestFillingPublicContract(unittest.TestCase):
|
||||
expected_payload = json.dumps(
|
||||
{
|
||||
'batch_no': 'B-20260726',
|
||||
'initial_packages': 18,
|
||||
'production_date': '2026-07-20',
|
||||
'expiry_date': '2027-01-20',
|
||||
'initial_packages': 100,
|
||||
'package_spec': '20kg',
|
||||
'qr_token': 'qr-2',
|
||||
'strain_id': 11,
|
||||
@@ -364,7 +368,7 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase):
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.assertEqual(PalletFlowState.EMPTY_IN_WAREHOUSE, result.flow_state)
|
||||
self.assertEqual(['filling'], result.allowed_actions)
|
||||
self.assertEqual(20, result.capacity_packages)
|
||||
self.assertEqual(100, result.capacity_packages)
|
||||
self.assertEqual(0, result.remaining_packages)
|
||||
|
||||
async def test_scan_checks_access_before_exposing_invalid_state(self) -> None:
|
||||
@@ -534,8 +538,10 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual('S-011', filling.strain_code_snapshot)
|
||||
self.assertEqual('香菇 11', filling.strain_name_snapshot)
|
||||
self.assertEqual('B-20260726', filling.batch_no)
|
||||
self.assertEqual(date(2026, 7, 20), filling.production_date)
|
||||
self.assertEqual(date(2027, 1, 20), filling.expiry_date)
|
||||
self.assertEqual('20kg', filling.package_spec)
|
||||
self.assertEqual(18, filling.initial_packages)
|
||||
self.assertEqual(100, filling.initial_packages)
|
||||
self.assertEqual('ACTIVE', filling.active_slot)
|
||||
self.assertEqual('fill-key', filling.idempotency_key)
|
||||
self.assertEqual(FillingService._build_request_hash(self.page), filling.request_hash)
|
||||
@@ -547,7 +553,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(self.fixed_time, filling.update_time)
|
||||
|
||||
self.assertEqual(31, self.inventory.active_filling_id)
|
||||
self.assertEqual(18, self.inventory.remaining_packages)
|
||||
self.assertEqual(100, self.inventory.remaining_packages)
|
||||
self.assertEqual(PalletFlowState.FILLED_IN_WAREHOUSE.value, self.inventory.flow_state)
|
||||
self.assertEqual(4, self.inventory.version)
|
||||
self.assertEqual(self.fixed_time, self.inventory.updated_at)
|
||||
@@ -566,7 +572,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIsNone(event.before_line_id)
|
||||
self.assertIsNone(event.after_line_id)
|
||||
self.assertEqual(0, event.before_remaining_packages)
|
||||
self.assertEqual(18, event.after_remaining_packages)
|
||||
self.assertEqual(100, event.after_remaining_packages)
|
||||
self.assertEqual(7, event.operator_id)
|
||||
self.assertEqual(self.fixed_time, event.occurred_at)
|
||||
self.assertEqual('fill-key', event.idempotency_key)
|
||||
@@ -632,8 +638,8 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_fill_rejects_packages_over_persisted_pallet_capacity(self) -> None:
|
||||
await self._assert_fill_rejected(page=make_page(initial_packages=21))
|
||||
async def test_fill_rejects_historical_nonstandard_pallet_capacity(self) -> None:
|
||||
await self._assert_fill_rejected(pallet=make_pallet(capacity_packages=20))
|
||||
|
||||
async def test_fill_rejects_stopped_warehouse_only_after_core_locks(self) -> None:
|
||||
actions: list[str] = []
|
||||
@@ -1152,7 +1158,7 @@ class TestFillingController(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _routes() -> dict[tuple[str, str], APIRoute]:
|
||||
return {
|
||||
(route.path, next(iter(route.methods))): route
|
||||
(route.path_format, next(iter(route.methods))): route
|
||||
for route in filling_controller_module.filling_controller.routes
|
||||
if isinstance(route, APIRoute)
|
||||
}
|
||||
@@ -1189,7 +1195,7 @@ class TestFillingController(unittest.TestCase):
|
||||
self.assertEqual(ResponseBaseModel, routes[('/business/filling', 'POST')].response_model)
|
||||
|
||||
def test_static_routes_are_declared_before_dynamic_detail(self) -> None:
|
||||
paths = [route.path for route in filling_controller_module.filling_controller.routes]
|
||||
paths = [route.path_format for route in filling_controller_module.filling_controller.routes]
|
||||
detail_index = paths.index('/business/filling/{filling_id}')
|
||||
self.assertLess(paths.index('/business/filling/strain-options'), detail_index)
|
||||
self.assertLess(paths.index('/business/filling/scan/{qr_token}'), detail_index)
|
||||
@@ -1218,7 +1224,7 @@ class TestFillingController(unittest.TestCase):
|
||||
set(actual.values()),
|
||||
)
|
||||
|
||||
def test_all_routes_inject_current_user_and_only_post_has_log(self) -> None:
|
||||
def test_all_routes_inject_current_user_and_expected_routes_have_log(self) -> None:
|
||||
for key, route in self._routes().items():
|
||||
self.assertTrue(
|
||||
any(
|
||||
@@ -1227,7 +1233,11 @@ class TestFillingController(unittest.TestCase):
|
||||
),
|
||||
key,
|
||||
)
|
||||
self.assertEqual(key == ('/business/filling', 'POST'), hasattr(route.endpoint, '__wrapped__'))
|
||||
expected_logged = {
|
||||
('/business/filling/scan/{qr_token}', 'GET'),
|
||||
('/business/filling', 'POST'),
|
||||
}
|
||||
self.assertEqual(key in expected_logged, hasattr(route.endpoint, '__wrapped__'))
|
||||
|
||||
def test_controller_does_not_add_scan_export_or_draft_permissions(self) -> None:
|
||||
source = inspect.getsource(filling_controller_module)
|
||||
|
||||
@@ -1889,7 +1889,7 @@ class TestInboundController(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _routes() -> dict[tuple[str, str], APIRoute]:
|
||||
return {
|
||||
(route.path, next(iter(route.methods))): route
|
||||
(route.path_format, next(iter(route.methods))): route
|
||||
for route in inbound_controller_module.inbound_controller.routes
|
||||
if isinstance(route, APIRoute)
|
||||
}
|
||||
@@ -1935,7 +1935,7 @@ class TestInboundController(unittest.TestCase):
|
||||
self.assertEqual(ResponseBaseModel, routes[key].response_model)
|
||||
|
||||
def test_static_routes_are_declared_before_dynamic_detail(self) -> None:
|
||||
paths = [route.path for route in inbound_controller_module.inbound_controller.routes]
|
||||
paths = [route.path_format for route in inbound_controller_module.inbound_controller.routes]
|
||||
detail_index = paths.index('/business/inbound/{inbound_id}')
|
||||
self.assertLess(paths.index('/business/inbound/warehouse-options'), detail_index)
|
||||
self.assertLess(paths.index('/business/inbound/scan/{qr_token}'), detail_index)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""物料日期是发货来源数据,不能由装填操作时间推算。"""
|
||||
|
||||
import unittest
|
||||
from datetime import date
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from module_business.entity.vo.operation_vo import FillingCreateModel, FillingModel
|
||||
from module_business.service.filling_service import FillingService
|
||||
from tests.module_business.test_filling_service import make_filling
|
||||
|
||||
|
||||
def command_data(**changes):
|
||||
return {
|
||||
'qrToken': 'qr-material',
|
||||
'warehouseId': 7,
|
||||
'strainId': 11,
|
||||
'batchNo': 'B-20261001',
|
||||
'packageSpec': '标准包',
|
||||
'initialPackages': 100,
|
||||
'idempotencyKey': 'material-date-key',
|
||||
**changes,
|
||||
}
|
||||
|
||||
|
||||
class TestMaterialDates(unittest.TestCase):
|
||||
def test_new_filling_requires_source_dates(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
FillingCreateModel(**command_data())
|
||||
|
||||
def test_dates_round_trip_without_time_or_timezone_conversion(self):
|
||||
command = FillingCreateModel(**command_data(productionDate='2026-10-01', expiryDate='2027-03-31'))
|
||||
self.assertEqual(command.production_date, date(2026, 10, 1))
|
||||
self.assertEqual(command.model_dump(mode='json', by_alias=True)['expiryDate'], '2027-03-31')
|
||||
|
||||
def test_expiry_cannot_precede_production(self):
|
||||
with self.assertRaises(ValidationError) as raised:
|
||||
FillingCreateModel(**command_data(productionDate='2026-10-01', expiryDate='2026-09-30'))
|
||||
self.assertIn('有效期', str(raised.exception))
|
||||
|
||||
def test_invalid_calendar_dates_are_rejected(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
FillingCreateModel(**command_data(productionDate='2026-02-30', expiryDate='2027-03-31'))
|
||||
|
||||
def test_changing_either_source_date_changes_idempotency_content(self):
|
||||
original = FillingCreateModel(**command_data(productionDate='2026-10-01', expiryDate='2027-03-31'))
|
||||
expected = FillingService._build_request_hash(original)
|
||||
for field, value in [('productionDate', '2026-10-02'), ('expiryDate', '2027-04-01')]:
|
||||
with self.subTest(field=field):
|
||||
changed = original.model_dump(by_alias=True)
|
||||
changed[field] = value
|
||||
self.assertNotEqual(expected, FillingService._build_request_hash(FillingCreateModel(**changed)))
|
||||
|
||||
def test_historical_missing_dates_remain_unknown(self):
|
||||
record = make_filling()
|
||||
record.production_date = None
|
||||
record.expiry_date = None
|
||||
result = FillingModel.model_validate(record).model_dump(mode='json', by_alias=True)
|
||||
self.assertIsNone(result['productionDate'])
|
||||
self.assertIsNone(result['expiryDate'])
|
||||
@@ -0,0 +1,301 @@
|
||||
"""真实 MySQL 验收:仅允许显式指定的隔离演练库,绝不写开发/生产库。
|
||||
|
||||
运行前将备份恢复并迁移到 shierfeng_rehearsal_20261008_* 库,再设置
|
||||
SHIERFENG_MYSQL_FLOW_TEST_DATABASE。测试只新建独立测试批次,保留现场用于复核。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import unittest
|
||||
from datetime import date, datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.engine import URL
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from config.env import DataBaseConfig
|
||||
from exceptions.exception import PermissionException, ServiceException
|
||||
from module_admin.entity.do import user_do # noqa: F401
|
||||
from module_business.entity.do.master_do import (
|
||||
BizCustomer, BizCustomerSite, BizPallet, BizStrain, BizTunnel, BizWarehouse,
|
||||
)
|
||||
from module_business.entity.do.operation_do import (
|
||||
BizPalletEvent, BizPalletInventory, BizReturnOrder, BizShipmentOrder, BizUsageRecord,
|
||||
)
|
||||
from module_business.entity.do.test_batch_do import BizTestBatch
|
||||
from module_business.dao.inventory_dao import InventoryDao
|
||||
from module_business.dao.usage_fifo_dao import UsageFifoDao
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.operation_vo import (
|
||||
ConfirmCommandModel, FillingCreateModel, ReturnConfirmModel, ReturnRequestModel,
|
||||
ShipmentCreateModel, TunnelEntryCreateModel, UsageCreateModel,
|
||||
)
|
||||
from module_business.entity.vo.shipment_receipt_vo import ShipmentReceiptCommandModel
|
||||
from module_business.entity.vo.report_vo import (
|
||||
ConsumptionReportQueryModel, PalletInventoryReportQueryModel, PalletTraceReportQueryModel, ReturnReportQueryModel,
|
||||
)
|
||||
from module_business.service.filling_service import FillingService
|
||||
from module_business.service.return_service import ReturnService
|
||||
from module_business.service.report_service import ReportService
|
||||
from module_business.service.shipment_label_service import ShipmentLabelService
|
||||
from module_business.service.shipment_receipt_service import ShipmentReceiptService
|
||||
from module_business.service.shipment_service import ShipmentService
|
||||
from module_business.service.usage_fifo_service import UsageFifoService
|
||||
from module_business.service.usage_service import UsageService
|
||||
from tests.module_business.test_batch_fixtures import make_admin, make_current_user, make_test_scope
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get('SHIERFENG_MYSQL_FLOW_TEST_DATABASE'), '需显式指定隔离MySQL演练库')
|
||||
class TestMaterialFlowMysql(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
database = os.environ['SHIERFENG_MYSQL_FLOW_TEST_DATABASE']
|
||||
if not re.fullmatch(r'shierfeng_rehearsal_20261008_[a-zA-Z0-9_]+', database):
|
||||
raise RuntimeError('只允许隔离演练库,拒绝写开发/生产数据库')
|
||||
if DataBaseConfig.db_host not in ('127.0.0.1', 'localhost'):
|
||||
raise RuntimeError('只允许本机MySQL')
|
||||
self.engine = create_async_engine(URL.create(
|
||||
'mysql+asyncmy', username=DataBaseConfig.db_username, password=DataBaseConfig.db_password,
|
||||
host=DataBaseConfig.db_host, port=DataBaseConfig.db_port, database=database,
|
||||
query={'charset': 'utf8mb4'},
|
||||
), echo=False)
|
||||
self.sessions = async_sessionmaker(self.engine, autoflush=False)
|
||||
self.admin = make_admin()
|
||||
self.tag = 'FIFO-' + secrets.token_hex(6)
|
||||
async with self.sessions() as db:
|
||||
batch = BizTestBatch(batch_code=self.tag, batch_name='迁移隔离验收', status='ACTIVE')
|
||||
db.add(batch)
|
||||
await db.flush()
|
||||
self.scope = make_test_scope(batch.batch_id)
|
||||
self.batch_id = batch.batch_id
|
||||
warehouse = BizWarehouse(warehouse_code=self.tag, warehouse_name='验收仓库', test_batch_id=self.batch_id)
|
||||
customer = BizCustomer(customer_code=self.tag, customer_name='验收客户', test_batch_id=self.batch_id)
|
||||
strain = BizStrain(strain_code=self.tag, strain_name='验收品种', test_batch_id=self.batch_id)
|
||||
db.add_all([warehouse, customer, strain])
|
||||
await db.flush()
|
||||
site = BizCustomerSite(customer_id=customer.customer_id, site_code=self.tag,
|
||||
site_name='验收现场', test_batch_id=self.batch_id)
|
||||
db.add(site)
|
||||
await db.flush()
|
||||
tunnel = BizTunnel(site_id=site.site_id, tunnel_code=self.tag, tunnel_name='验收隧道',
|
||||
test_batch_id=self.batch_id)
|
||||
db.add(tunnel)
|
||||
self.pallets = []
|
||||
for index in (1, 2):
|
||||
pallet = BizPallet(pallet_code=f'{self.tag}-{index}', qr_token=f'{self.tag}/qr/{index}',
|
||||
capacity_packages=100, asset_status='0', test_batch_id=self.batch_id)
|
||||
db.add(pallet)
|
||||
await db.flush()
|
||||
db.add(BizPalletInventory(pallet_id=pallet.pallet_id, warehouse_id=warehouse.warehouse_id,
|
||||
flow_state='EMPTY_IN_WAREHOUSE', remaining_packages=0, version=0,
|
||||
updated_at=datetime.now(), test_batch_id=self.batch_id))
|
||||
self.pallets.append((pallet.pallet_id, pallet.pallet_code, pallet.qr_token))
|
||||
await db.flush()
|
||||
self.warehouse_id, self.site_id = warehouse.warehouse_id, site.site_id
|
||||
self.strain_id, self.tunnel_id = strain.strain_id, tunnel.tunnel_id
|
||||
await db.commit()
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.engine.dispose()
|
||||
|
||||
async def call(self, method, *args, user=None):
|
||||
async with self.sessions() as db:
|
||||
return await method(db, *args, user or self.admin, self.scope)
|
||||
|
||||
async def scalar(self, statement):
|
||||
async with self.sessions() as db:
|
||||
return await db.scalar(statement)
|
||||
|
||||
async def test_full_receipt_fifo_mix_return_and_concurrent_retry(self):
|
||||
p1, p2 = self.pallets
|
||||
for index, pallet in enumerate(self.pallets, 1):
|
||||
await self.call(FillingService.fill_pallet_services, FillingCreateModel(
|
||||
qrToken=pallet[2], warehouseId=self.warehouse_id, strainId=self.strain_id,
|
||||
batchNo=f'{self.tag}-B{index}', productionDate='2026-10-01', expiryDate='2027-04-01',
|
||||
packageSpec='标准包', initialPackages=100, idempotencyKey=f'{self.tag}-fill-{index}',
|
||||
))
|
||||
await self.call(ShipmentService.add_shipment_services, ShipmentCreateModel(
|
||||
sourceWarehouseId=self.warehouse_id, targetSiteId=self.site_id, palletIds=[p1[0], p2[0]],
|
||||
))
|
||||
shipment_id = await self.scalar(select(BizShipmentOrder.shipment_id).where(
|
||||
BizShipmentOrder.test_batch_id == self.batch_id))
|
||||
await self.call(ShipmentService.confirm_shipment_services, shipment_id,
|
||||
ConfirmCommandModel(idempotencyKey=self.tag + '-ship'))
|
||||
labels = await self.call(ShipmentLabelService.get_labels_services, shipment_id)
|
||||
self.assertEqual({l.pallet_code for l in labels}, {p1[1], p2[1]})
|
||||
self.assertTrue(all(l.production_date == date(2026, 10, 1) and l.package_qty == 100 for l in labels))
|
||||
|
||||
entry = TunnelEntryCreateModel(qrToken=p2[2], tunnelId=self.tunnel_id, idempotencyKey=self.tag+'-entry2')
|
||||
with self.assertRaises(ServiceException):
|
||||
await self.call(UsageService.enter_tunnel_services, entry) # 在途不允许使用
|
||||
receipt = ShipmentReceiptCommandModel(scannedPalletIds=[p1[0], p2[0]], idempotencyKey=self.tag+'-receive')
|
||||
with self.assertRaises(PermissionException):
|
||||
await self.call(ShipmentReceiptService.confirm_services, shipment_id, receipt, user=make_current_user(7))
|
||||
await self.call(ShipmentReceiptService.confirm_services, shipment_id, receipt)
|
||||
# 两次实际到货的排序边界夹具,仅改本演练批次,不造开发库历史时间。
|
||||
async with self.sessions() as db:
|
||||
inventory = await db.get(BizPalletInventory, p1[0])
|
||||
inventory.site_arrived_at -= timedelta(days=1)
|
||||
await db.commit()
|
||||
async with self.sessions() as db:
|
||||
advice = await UsageFifoService.recommendation(
|
||||
db, p2[1], self.admin, self.scope, tunnel_id=self.tunnel_id)
|
||||
self.assertTrue(advice.requires_skip_reason)
|
||||
self.assertEqual(advice.recommended.pallet_id, p1[0])
|
||||
with self.assertRaises(ServiceException) as error:
|
||||
await self.call(UsageService.enter_tunnel_services, entry)
|
||||
self.assertEqual(error.exception.data, 'FIFO_SKIP_REASON_REQUIRED')
|
||||
self.assertIsNone(await self.scalar(select(BizPalletInventory.tunnel_id).where(BizPalletInventory.pallet_id==p2[0])))
|
||||
entry = entry.model_copy(update={'fifo_skip_reason': '工艺混批'})
|
||||
await self.call(UsageService.enter_tunnel_services, entry)
|
||||
await self.call(UsageService.enter_tunnel_services, entry) # 已提交入隧幂等分支也不得触发过期加载
|
||||
|
||||
usage = UsageCreateModel(qrToken=p2[2], tunnelId=self.tunnel_id,
|
||||
usedPackages=20, idempotencyKey=self.tag+'-usage2')
|
||||
with self.assertRaises(ServiceException) as error:
|
||||
await self.call(UsageService.record_usage_services, usage)
|
||||
self.assertEqual(error.exception.data, 'FIFO_SKIP_REASON_REQUIRED')
|
||||
self.assertEqual(await self.scalar(select(BizPalletInventory.remaining_packages).where(BizPalletInventory.pallet_id==p2[0])),100)
|
||||
usage = usage.model_copy(update={'fifo_skip_reason': '工艺混批'})
|
||||
# 独立MySQL连接同时重放相同业务命令,只能产生一条事实和一次扣减。
|
||||
results = await asyncio.wait_for(asyncio.gather(*(
|
||||
self.call(UsageService.record_usage_services, usage) for _ in range(3)
|
||||
)), timeout=20)
|
||||
self.assertTrue(all(result.result.remaining_packages == 80 for result in results))
|
||||
self.assertEqual(await self.scalar(select(func.count()).select_from(BizUsageRecord).where(BizUsageRecord.test_batch_id==self.batch_id)),1)
|
||||
record = await self.scalar(select(BizUsageRecord).where(BizUsageRecord.test_batch_id==self.batch_id))
|
||||
self.assertEqual(record.fifo_skip_reason, '工艺混批')
|
||||
self.assertEqual(record.fifo_recommendation['palletCode'], p1[1])
|
||||
with self.assertRaises(ServiceException):
|
||||
await self.call(UsageService.record_usage_services, usage.model_copy(update={'fifo_skip_reason':'更换原因'}))
|
||||
|
||||
await self.call(UsageService.enter_tunnel_services, TunnelEntryCreateModel(
|
||||
qrToken=p1[2], tunnelId=self.tunnel_id, idempotencyKey=self.tag+'-entry1'))
|
||||
await self.call(UsageService.record_usage_services, UsageCreateModel(
|
||||
qrToken=p1[2], tunnelId=self.tunnel_id, usedPackages=20, idempotencyKey=self.tag+'-usage1'))
|
||||
traces = [await self.scalar(select(BizPalletInventory.trace_no).where(BizPalletInventory.pallet_id==p[0])) for p in self.pallets]
|
||||
self.assertTrue(traces[0] and traces[0] == traces[1])
|
||||
await self.call(ShipmentReceiptService.confirm_services, shipment_id, receipt)
|
||||
self.assertEqual(await self.scalar(select(BizPalletInventory.remaining_packages).where(BizPalletInventory.pallet_id==p2[0])),80)
|
||||
|
||||
await self.call(ReturnService.request_return_services, ReturnRequestModel(
|
||||
sourceSiteId=self.site_id, targetWarehouseId=self.warehouse_id,
|
||||
palletIds=[p2[0]], idempotencyKey=self.tag+'-return'))
|
||||
return_id = await self.scalar(select(BizReturnOrder.return_id).where(BizReturnOrder.test_batch_id==self.batch_id))
|
||||
await self.call(ReturnService.confirm_return_services, return_id, ReturnConfirmModel(
|
||||
scannedPalletIds=[p2[0]], idempotencyKey=self.tag+'-return-confirm'))
|
||||
returned = await self.scalar(select(BizPalletInventory).where(BizPalletInventory.pallet_id==p2[0]))
|
||||
self.assertEqual((returned.remaining_packages,returned.warehouse_id,returned.flow_state),
|
||||
(80,self.warehouse_id,'FILLED_IN_WAREHOUSE'))
|
||||
self.assertIsNone(returned.site_arrived_at)
|
||||
self.assertEqual(await self.scalar(select(func.count()).select_from(BizPallet).where(BizPallet.test_batch_id==self.batch_id)),2)
|
||||
labels_after = await self.call(ShipmentLabelService.get_labels_services, shipment_id)
|
||||
self.assertEqual({(l.pallet_code,l.batch_no,l.package_qty) for l in labels_after},
|
||||
{(p1[1],self.tag+'-B1',100),(p2[1],self.tag+'-B2',100)})
|
||||
events = await self.scalar(select(func.count()).select_from(BizPalletEvent).where(
|
||||
BizPalletEvent.test_batch_id==self.batch_id, BizPalletEvent.fifo_skip_reason=='工艺混批'))
|
||||
self.assertEqual(events,2)
|
||||
consumption = await self.call(ReportService.get_consumption_report_list_services,
|
||||
ConsumptionReportQueryModel(tunnelId=self.tunnel_id))
|
||||
self.assertEqual(consumption.total, 2)
|
||||
self.assertEqual(sum(row.used_packages for row in consumption.rows), 40)
|
||||
self.assertTrue(all(row.line_id is None and row.trace_no == traces[0] for row in consumption.rows))
|
||||
self.assertEqual({row.batch_no for row in consumption.rows}, {self.tag+'-B1', self.tag+'-B2'})
|
||||
returned_report = await self.call(ReportService.get_return_report_list_services,
|
||||
ReturnReportQueryModel(tunnelId=self.tunnel_id))
|
||||
self.assertEqual(returned_report.total, 1)
|
||||
self.assertEqual(returned_report.rows[0].remaining_packages_snapshot, 80)
|
||||
inventory_report = await self.call(ReportService.get_pallet_inventory_list_services,
|
||||
PalletInventoryReportQueryModel())
|
||||
self.assertEqual(inventory_report.total, 2)
|
||||
self.assertTrue(all(row.remaining_packages == 80 and row.production_date == date(2026, 10, 1)
|
||||
for row in inventory_report.rows))
|
||||
trace_report = await self.call(ReportService.get_pallet_trace_list_services,
|
||||
PalletTraceReportQueryModel(palletCode=p2[1], pageSize=100))
|
||||
entered = [row for row in trace_report.rows if row.event_type.value == 'TUNNEL_ENTERED']
|
||||
self.assertEqual(len(entered), 1)
|
||||
self.assertEqual(entered[0].production_date, date(2026, 10, 1))
|
||||
|
||||
async def test_formal_two_pallet_lock_inversion_retries_without_wait_cycle(self):
|
||||
# 仅在隔离演练库将本方法新建的夹具设为正式模式;排除测试批次总锁掩盖并发问题。
|
||||
async with self.sessions() as db:
|
||||
for model in (BizPalletInventory, BizPallet, BizTunnel, BizCustomerSite, BizCustomer, BizWarehouse, BizStrain):
|
||||
await db.execute(update(model).where(model.test_batch_id == self.batch_id).values(test_batch_id=None))
|
||||
await db.commit()
|
||||
self.scope = TestBatchScope.formal()
|
||||
p1, p2 = self.pallets
|
||||
for index, pallet in enumerate(self.pallets):
|
||||
await self.call(FillingService.fill_pallet_services, FillingCreateModel(
|
||||
qrToken=pallet[2], warehouseId=self.warehouse_id, strainId=self.strain_id,
|
||||
batchNo=f'{self.tag}-B{index}', productionDate='2026-10-01', expiryDate='2027-04-01',
|
||||
packageSpec='标准包', initialPackages=100, idempotencyKey=f'{self.tag}-fill-{index}',
|
||||
))
|
||||
# 已收货夹具,仍使用真实表、约束与后续全部真实SQL。
|
||||
async with self.sessions() as db:
|
||||
for index, pallet in enumerate(self.pallets):
|
||||
inventory = await db.get(BizPalletInventory, pallet[0])
|
||||
inventory.site_id, inventory.warehouse_id = self.site_id, None
|
||||
inventory.site_arrived_at = datetime(2026, 10, 6 + index)
|
||||
inventory.flow_state = 'AT_SITE_UNUSED'
|
||||
await db.commit()
|
||||
|
||||
async def overlap(method, earlier, later):
|
||||
late_at_fifo, early_has_inventory = asyncio.Event(), asyncio.Event()
|
||||
real_fifo = UsageFifoDao.recommendation
|
||||
real_inventory = InventoryDao.get_inventory_for_update
|
||||
late_calls = 0
|
||||
observations = []
|
||||
inventory_attempts = []
|
||||
|
||||
async def scheduled_fifo(*args, **kwargs):
|
||||
nonlocal late_calls
|
||||
if asyncio.current_task().get_name() == 'fifo-later':
|
||||
late_calls += 1
|
||||
if late_calls == 1:
|
||||
late_at_fifo.set()
|
||||
await early_has_inventory.wait()
|
||||
try:
|
||||
result = await real_fifo(*args, **kwargs)
|
||||
observations.append((asyncio.current_task().get_name(), 'candidate', result[0]))
|
||||
return result
|
||||
except Exception as error:
|
||||
observations.append((asyncio.current_task().get_name(), type(error).__name__, str(error)))
|
||||
raise
|
||||
|
||||
async def scheduled_inventory(db, pallet_id, scope):
|
||||
inventory_attempts.append((asyncio.current_task().get_name(), pallet_id))
|
||||
result = await real_inventory(db, pallet_id, scope)
|
||||
if asyncio.current_task().get_name() == 'fifo-earlier':
|
||||
early_has_inventory.set()
|
||||
return result
|
||||
|
||||
with patch.object(UsageFifoDao, 'recommendation', new=scheduled_fifo), \
|
||||
patch.object(InventoryDao, 'get_inventory_for_update', new=scheduled_inventory):
|
||||
late = asyncio.create_task(self.call(method, later), name='fifo-later')
|
||||
early = None
|
||||
try:
|
||||
await asyncio.wait_for(late_at_fifo.wait(), 5)
|
||||
early = asyncio.create_task(self.call(method, earlier), name='fifo-earlier')
|
||||
results = await asyncio.wait_for(asyncio.gather(late, early), 5)
|
||||
self.assertTrue(all(result.is_success for result in results))
|
||||
# InnoDB 可以选择任一请求回滚;不能把“必须晚到请求重试”当业务约束。
|
||||
self.assertGreaterEqual(len(inventory_attempts), 3, (observations, inventory_attempts))
|
||||
for name in ('fifo-later', 'fifo-earlier'):
|
||||
self.assertLessEqual(sum(task_name == name for task_name, _ in inventory_attempts), 3)
|
||||
finally:
|
||||
for task in (late, early):
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(*(task for task in (late, early) if task is not None), return_exceptions=True)
|
||||
|
||||
await overlap(UsageService.enter_tunnel_services,
|
||||
TunnelEntryCreateModel(qrToken=p1[2], tunnelId=self.tunnel_id, idempotencyKey=self.tag+'-entry1'),
|
||||
TunnelEntryCreateModel(qrToken=p2[2], tunnelId=self.tunnel_id, idempotencyKey=self.tag+'-entry2',fifoSkipReason='工艺混批'))
|
||||
await overlap(UsageService.record_usage_services,
|
||||
UsageCreateModel(qrToken=p1[2], tunnelId=self.tunnel_id, usedPackages=20,idempotencyKey=self.tag+'-usage1'),
|
||||
UsageCreateModel(qrToken=p2[2], tunnelId=self.tunnel_id, usedPackages=20,idempotencyKey=self.tag+'-usage2',fifoSkipReason='工艺混批'))
|
||||
for pallet in self.pallets:
|
||||
self.assertEqual(await self.scalar(select(BizPalletInventory.remaining_packages).where(BizPalletInventory.pallet_id==pallet[0])),80)
|
||||
self.assertEqual(await self.scalar(select(func.count()).select_from(BizUsageRecord).where(BizUsageRecord.pallet_id==pallet[0])),1)
|
||||
@@ -1,6 +1,6 @@
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import CHAR, BigInteger, CheckConstraint, DateTime, Integer, String, Table, Text, UniqueConstraint
|
||||
from sqlalchemy import JSON, CHAR, BigInteger, CheckConstraint, Date, DateTime, Integer, String, Table, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects import mysql
|
||||
from sqlalchemy.schema import CreateColumn, CreateTable
|
||||
|
||||
@@ -14,6 +14,7 @@ TEST_BATCH_OWNED_TABLES = {
|
||||
'biz_customer',
|
||||
'biz_customer_site',
|
||||
'biz_production_line',
|
||||
'biz_tunnel',
|
||||
'biz_warehouse',
|
||||
'biz_pallet',
|
||||
'biz_inbound_order',
|
||||
@@ -88,6 +89,20 @@ EXPECTED_COLUMNS = {
|
||||
'update_time',
|
||||
'remark',
|
||||
},
|
||||
'biz_tunnel': {
|
||||
'tunnel_id',
|
||||
'site_id',
|
||||
'tunnel_code',
|
||||
'tunnel_name',
|
||||
'current_trace_no',
|
||||
'current_trace_started_at',
|
||||
'status',
|
||||
'create_by',
|
||||
'create_time',
|
||||
'update_by',
|
||||
'update_time',
|
||||
'remark',
|
||||
},
|
||||
'biz_pallet': {
|
||||
'pallet_id',
|
||||
'pallet_code',
|
||||
@@ -133,6 +148,8 @@ EXPECTED_COLUMNS = {
|
||||
'strain_code_snapshot',
|
||||
'strain_name_snapshot',
|
||||
'batch_no',
|
||||
'production_date',
|
||||
'expiry_date',
|
||||
'package_spec',
|
||||
'initial_packages',
|
||||
'active_slot',
|
||||
@@ -178,6 +195,9 @@ EXPECTED_COLUMNS = {
|
||||
'pallet_id',
|
||||
'active_filling_id',
|
||||
'site_id',
|
||||
'tunnel_id',
|
||||
'trace_no',
|
||||
'batch_no_snapshot',
|
||||
'line_id',
|
||||
'used_packages',
|
||||
'remaining_packages_after',
|
||||
@@ -211,6 +231,9 @@ EXPECTED_COLUMNS = {
|
||||
'return_id',
|
||||
'pallet_id',
|
||||
'active_filling_id_snapshot',
|
||||
'tunnel_id_snapshot',
|
||||
'trace_no_snapshot',
|
||||
'batch_no_snapshot',
|
||||
'remaining_packages_snapshot',
|
||||
'create_by',
|
||||
'create_time',
|
||||
@@ -220,6 +243,8 @@ EXPECTED_COLUMNS = {
|
||||
'active_filling_id',
|
||||
'warehouse_id',
|
||||
'site_id',
|
||||
'tunnel_id',
|
||||
'trace_no',
|
||||
'current_line_id',
|
||||
'flow_state',
|
||||
'remaining_packages',
|
||||
@@ -238,12 +263,16 @@ EXPECTED_COLUMNS = {
|
||||
'after_state',
|
||||
'before_warehouse_id',
|
||||
'before_site_id',
|
||||
'before_tunnel_id',
|
||||
'before_line_id',
|
||||
'after_warehouse_id',
|
||||
'after_site_id',
|
||||
'after_tunnel_id',
|
||||
'after_line_id',
|
||||
'before_remaining_packages',
|
||||
'after_remaining_packages',
|
||||
'trace_no',
|
||||
'batch_no_snapshot',
|
||||
'operator_id',
|
||||
'occurred_at',
|
||||
'idempotency_key',
|
||||
@@ -263,6 +292,20 @@ EXPECTED_COLUMN_TYPES = {
|
||||
'update_time': (DateTime, None),
|
||||
'remark': (String, 500),
|
||||
},
|
||||
'biz_tunnel': {
|
||||
'tunnel_id': (BigInteger, None),
|
||||
'site_id': (BigInteger, None),
|
||||
'tunnel_code': (String, 64),
|
||||
'tunnel_name': (String, 100),
|
||||
'current_trace_no': (String, 64),
|
||||
'current_trace_started_at': (DateTime, None),
|
||||
'status': (CHAR, 1),
|
||||
'create_by': (String, 64),
|
||||
'create_time': (DateTime, None),
|
||||
'update_by': (String, 64),
|
||||
'update_time': (DateTime, None),
|
||||
'remark': (String, 500),
|
||||
},
|
||||
'biz_warehouse': {
|
||||
'warehouse_id': (BigInteger, None),
|
||||
'warehouse_code': (String, 64),
|
||||
@@ -366,6 +409,8 @@ EXPECTED_COLUMN_TYPES = {
|
||||
'strain_code_snapshot': (String, 64),
|
||||
'strain_name_snapshot': (String, 100),
|
||||
'batch_no': (String, 64),
|
||||
'production_date': (Date, None),
|
||||
'expiry_date': (Date, None),
|
||||
'package_spec': (String, 64),
|
||||
'initial_packages': (Integer, None),
|
||||
'active_slot': (String, 16),
|
||||
@@ -411,6 +456,9 @@ EXPECTED_COLUMN_TYPES = {
|
||||
'pallet_id': (BigInteger, None),
|
||||
'active_filling_id': (BigInteger, None),
|
||||
'site_id': (BigInteger, None),
|
||||
'tunnel_id': (BigInteger, None),
|
||||
'trace_no': (String, 64),
|
||||
'batch_no_snapshot': (String, 64),
|
||||
'line_id': (BigInteger, None),
|
||||
'used_packages': (Integer, None),
|
||||
'remaining_packages_after': (Integer, None),
|
||||
@@ -444,6 +492,9 @@ EXPECTED_COLUMN_TYPES = {
|
||||
'return_id': (BigInteger, None),
|
||||
'pallet_id': (BigInteger, None),
|
||||
'active_filling_id_snapshot': (BigInteger, None),
|
||||
'tunnel_id_snapshot': (BigInteger, None),
|
||||
'trace_no_snapshot': (String, 64),
|
||||
'batch_no_snapshot': (String, 64),
|
||||
'remaining_packages_snapshot': (Integer, None),
|
||||
'create_by': (String, 64),
|
||||
'create_time': (DateTime, None),
|
||||
@@ -453,6 +504,8 @@ EXPECTED_COLUMN_TYPES = {
|
||||
'active_filling_id': (BigInteger, None),
|
||||
'warehouse_id': (BigInteger, None),
|
||||
'site_id': (BigInteger, None),
|
||||
'tunnel_id': (BigInteger, None),
|
||||
'trace_no': (String, 64),
|
||||
'current_line_id': (BigInteger, None),
|
||||
'flow_state': (String, 32),
|
||||
'remaining_packages': (Integer, None),
|
||||
@@ -471,12 +524,16 @@ EXPECTED_COLUMN_TYPES = {
|
||||
'after_state': (String, 32),
|
||||
'before_warehouse_id': (BigInteger, None),
|
||||
'before_site_id': (BigInteger, None),
|
||||
'before_tunnel_id': (BigInteger, None),
|
||||
'before_line_id': (BigInteger, None),
|
||||
'after_warehouse_id': (BigInteger, None),
|
||||
'after_site_id': (BigInteger, None),
|
||||
'after_tunnel_id': (BigInteger, None),
|
||||
'after_line_id': (BigInteger, None),
|
||||
'before_remaining_packages': (Integer, None),
|
||||
'after_remaining_packages': (Integer, None),
|
||||
'trace_no': (String, 64),
|
||||
'batch_no_snapshot': (String, 64),
|
||||
'operator_id': (BigInteger, None),
|
||||
'occurred_at': (DateTime, None),
|
||||
'idempotency_key': (String, 64),
|
||||
@@ -496,6 +553,20 @@ EXPECTED_NULLABILITY = {
|
||||
'update_time': True,
|
||||
'remark': True,
|
||||
},
|
||||
'biz_tunnel': {
|
||||
'tunnel_id': False,
|
||||
'site_id': False,
|
||||
'tunnel_code': False,
|
||||
'tunnel_name': False,
|
||||
'current_trace_no': True,
|
||||
'current_trace_started_at': True,
|
||||
'status': False,
|
||||
'create_by': True,
|
||||
'create_time': True,
|
||||
'update_by': True,
|
||||
'update_time': True,
|
||||
'remark': True,
|
||||
},
|
||||
'biz_warehouse': {
|
||||
'warehouse_id': False,
|
||||
'warehouse_code': False,
|
||||
@@ -599,6 +670,8 @@ EXPECTED_NULLABILITY = {
|
||||
'strain_code_snapshot': False,
|
||||
'strain_name_snapshot': False,
|
||||
'batch_no': False,
|
||||
'production_date': True,
|
||||
'expiry_date': True,
|
||||
'package_spec': False,
|
||||
'initial_packages': False,
|
||||
'active_slot': True,
|
||||
@@ -644,7 +717,10 @@ EXPECTED_NULLABILITY = {
|
||||
'pallet_id': False,
|
||||
'active_filling_id': False,
|
||||
'site_id': False,
|
||||
'line_id': False,
|
||||
'tunnel_id': True,
|
||||
'trace_no': True,
|
||||
'batch_no_snapshot': True,
|
||||
'line_id': True,
|
||||
'used_packages': False,
|
||||
'remaining_packages_after': False,
|
||||
'idempotency_key': False,
|
||||
@@ -677,6 +753,9 @@ EXPECTED_NULLABILITY = {
|
||||
'return_id': False,
|
||||
'pallet_id': False,
|
||||
'active_filling_id_snapshot': True,
|
||||
'tunnel_id_snapshot': True,
|
||||
'trace_no_snapshot': True,
|
||||
'batch_no_snapshot': True,
|
||||
'remaining_packages_snapshot': False,
|
||||
'create_by': True,
|
||||
'create_time': True,
|
||||
@@ -686,6 +765,8 @@ EXPECTED_NULLABILITY = {
|
||||
'active_filling_id': True,
|
||||
'warehouse_id': True,
|
||||
'site_id': True,
|
||||
'tunnel_id': True,
|
||||
'trace_no': True,
|
||||
'current_line_id': True,
|
||||
'flow_state': False,
|
||||
'remaining_packages': False,
|
||||
@@ -704,12 +785,16 @@ EXPECTED_NULLABILITY = {
|
||||
'after_state': False,
|
||||
'before_warehouse_id': True,
|
||||
'before_site_id': True,
|
||||
'before_tunnel_id': True,
|
||||
'before_line_id': True,
|
||||
'after_warehouse_id': True,
|
||||
'after_site_id': True,
|
||||
'after_tunnel_id': True,
|
||||
'after_line_id': True,
|
||||
'before_remaining_packages': False,
|
||||
'after_remaining_packages': False,
|
||||
'trace_no': True,
|
||||
'batch_no_snapshot': True,
|
||||
'operator_id': False,
|
||||
'occurred_at': False,
|
||||
'idempotency_key': False,
|
||||
@@ -728,6 +813,8 @@ EXPECTED_CALLABLE_DATETIME_DEFAULTS = {
|
||||
('biz_customer_site', 'update_time'),
|
||||
('biz_production_line', 'create_time'),
|
||||
('biz_production_line', 'update_time'),
|
||||
('biz_tunnel', 'create_time'),
|
||||
('biz_tunnel', 'update_time'),
|
||||
('biz_pallet', 'create_time'),
|
||||
('biz_pallet', 'update_time'),
|
||||
('biz_user_warehouse', 'create_time'),
|
||||
@@ -757,6 +844,8 @@ EXPECTED_EMPTY_AUDIT_SERVER_DEFAULTS = {
|
||||
('biz_customer_site', 'update_by'),
|
||||
('biz_production_line', 'create_by'),
|
||||
('biz_production_line', 'update_by'),
|
||||
('biz_tunnel', 'create_by'),
|
||||
('biz_tunnel', 'update_by'),
|
||||
('biz_pallet', 'create_by'),
|
||||
('biz_pallet', 'update_by'),
|
||||
('biz_user_warehouse', 'create_by'),
|
||||
@@ -780,6 +869,7 @@ EXPECTED_PRIMARY_KEYS = {
|
||||
'biz_customer': ('customer_id',),
|
||||
'biz_customer_site': ('site_id',),
|
||||
'biz_production_line': ('line_id',),
|
||||
'biz_tunnel': ('tunnel_id',),
|
||||
'biz_pallet': ('pallet_id',),
|
||||
'biz_user_warehouse': ('user_id', 'warehouse_id'),
|
||||
'biz_user_site': ('user_id', 'site_id'),
|
||||
@@ -801,6 +891,9 @@ EXPECTED_FOREIGN_KEYS = {
|
||||
'biz_customer': set(),
|
||||
'biz_customer_site': {('customer_id', 'biz_customer.customer_id')},
|
||||
'biz_production_line': {('site_id', 'biz_customer_site.site_id')},
|
||||
'biz_tunnel': {
|
||||
('site_id', 'biz_customer_site.site_id'),
|
||||
},
|
||||
'biz_pallet': set(),
|
||||
'biz_user_warehouse': {
|
||||
('user_id', 'sys_user.user_id'),
|
||||
@@ -838,6 +931,7 @@ EXPECTED_FOREIGN_KEYS = {
|
||||
('pallet_id', 'biz_pallet.pallet_id'),
|
||||
('active_filling_id', 'biz_filling_record.filling_id'),
|
||||
('site_id', 'biz_customer_site.site_id'),
|
||||
('tunnel_id', 'biz_tunnel.tunnel_id'),
|
||||
('line_id', 'biz_production_line.line_id'),
|
||||
('operator_id', 'sys_user.user_id'),
|
||||
},
|
||||
@@ -851,12 +945,14 @@ EXPECTED_FOREIGN_KEYS = {
|
||||
('return_id', 'biz_return_order.return_id'),
|
||||
('pallet_id', 'biz_pallet.pallet_id'),
|
||||
('active_filling_id_snapshot', 'biz_filling_record.filling_id'),
|
||||
('tunnel_id_snapshot', 'biz_tunnel.tunnel_id'),
|
||||
},
|
||||
'biz_pallet_inventory': {
|
||||
('pallet_id', 'biz_pallet.pallet_id'),
|
||||
('active_filling_id', 'biz_filling_record.filling_id'),
|
||||
('warehouse_id', 'biz_warehouse.warehouse_id'),
|
||||
('site_id', 'biz_customer_site.site_id'),
|
||||
('tunnel_id', 'biz_tunnel.tunnel_id'),
|
||||
('current_line_id', 'biz_production_line.line_id'),
|
||||
('last_event_id', 'biz_pallet_event.event_id'),
|
||||
},
|
||||
@@ -864,9 +960,11 @@ EXPECTED_FOREIGN_KEYS = {
|
||||
('pallet_id', 'biz_pallet.pallet_id'),
|
||||
('before_warehouse_id', 'biz_warehouse.warehouse_id'),
|
||||
('before_site_id', 'biz_customer_site.site_id'),
|
||||
('before_tunnel_id', 'biz_tunnel.tunnel_id'),
|
||||
('before_line_id', 'biz_production_line.line_id'),
|
||||
('after_warehouse_id', 'biz_warehouse.warehouse_id'),
|
||||
('after_site_id', 'biz_customer_site.site_id'),
|
||||
('after_tunnel_id', 'biz_tunnel.tunnel_id'),
|
||||
('after_line_id', 'biz_production_line.line_id'),
|
||||
('operator_id', 'sys_user.user_id'),
|
||||
},
|
||||
@@ -878,6 +976,7 @@ EXPECTED_UNIQUE_SETS = {
|
||||
'biz_customer': {frozenset({'customer_code'})},
|
||||
'biz_customer_site': {frozenset({'customer_id', 'site_code'})},
|
||||
'biz_production_line': {frozenset({'site_id', 'line_code'})},
|
||||
'biz_tunnel': {frozenset({'site_id', 'tunnel_code'})},
|
||||
'biz_pallet': {frozenset({'pallet_code'}), frozenset({'qr_token'})},
|
||||
'biz_user_warehouse': set(),
|
||||
'biz_user_site': set(),
|
||||
@@ -913,6 +1012,36 @@ EXPECTED_UNIQUE_SETS = {
|
||||
},
|
||||
}
|
||||
|
||||
# 收货补齐:与既有日期、隧道契约并存,不用当前时间填充历史收货记录。
|
||||
EXPECTED_COLUMNS['biz_shipment_order'].update({
|
||||
'receipt_state', 'received_by', 'received_at', 'receipt_idempotency_key', 'receipt_request_hash',
|
||||
})
|
||||
EXPECTED_COLUMN_TYPES['biz_shipment_order'].update({
|
||||
'receipt_state': (String, 16),
|
||||
'received_by': (BigInteger, None),
|
||||
'received_at': (DateTime, None),
|
||||
'receipt_idempotency_key': (String, 64),
|
||||
'receipt_request_hash': (String, 64),
|
||||
})
|
||||
EXPECTED_NULLABILITY['biz_shipment_order'].update({
|
||||
'receipt_state': False,
|
||||
'received_by': True,
|
||||
'received_at': True,
|
||||
'receipt_idempotency_key': True,
|
||||
'receipt_request_hash': True,
|
||||
})
|
||||
EXPECTED_FOREIGN_KEYS['biz_shipment_order'].add(('received_by', 'sys_user.user_id'))
|
||||
EXPECTED_UNIQUE_SETS['biz_shipment_order'].add(frozenset({'target_site_id', 'receipt_idempotency_key'}))
|
||||
EXPECTED_COLUMNS['biz_pallet_inventory'].add('site_arrived_at')
|
||||
EXPECTED_COLUMN_TYPES['biz_pallet_inventory']['site_arrived_at'] = (DateTime, None)
|
||||
EXPECTED_NULLABILITY['biz_pallet_inventory']['site_arrived_at'] = True
|
||||
|
||||
for fifo_table in ('biz_usage_record', 'biz_pallet_event'):
|
||||
EXPECTED_COLUMNS[fifo_table].update({'fifo_skip_reason', 'fifo_recommendation'})
|
||||
EXPECTED_NULLABILITY[fifo_table].update({'fifo_skip_reason': True, 'fifo_recommendation': True})
|
||||
EXPECTED_COLUMN_TYPES[fifo_table]['fifo_skip_reason'] = (String, 500)
|
||||
EXPECTED_COLUMN_TYPES[fifo_table]['fifo_recommendation'] = (JSON, None)
|
||||
|
||||
for table_name in TEST_BATCH_OWNED_TABLES:
|
||||
EXPECTED_COLUMNS[table_name].add('test_batch_id')
|
||||
EXPECTED_COLUMN_TYPES[table_name]['test_batch_id'] = (BigInteger, None)
|
||||
@@ -1064,7 +1193,7 @@ class BusinessModelContractTest(unittest.TestCase):
|
||||
def test_batch_related_tables_compile_for_mysql(self) -> None:
|
||||
self.assertIs(Base.metadata.tables['sys_user'], user_do.SysUser.__table__)
|
||||
batch_tables = EXPECTED_TEST_BATCH_TABLES | TEST_BATCH_OWNED_TABLES
|
||||
self.assertEqual(18, len(batch_tables))
|
||||
self.assertEqual(19, len(batch_tables))
|
||||
for table_name in batch_tables:
|
||||
for dialect_name, dialect in (('mysql', mysql.dialect()),):
|
||||
with self.subTest(table_name=table_name, dialect=dialect_name):
|
||||
@@ -1078,6 +1207,7 @@ class BusinessModelContractTest(unittest.TestCase):
|
||||
'biz_customer',
|
||||
'biz_customer_site',
|
||||
'biz_production_line',
|
||||
'biz_tunnel',
|
||||
'biz_pallet',
|
||||
'biz_user_warehouse',
|
||||
'biz_user_site',
|
||||
@@ -1102,6 +1232,7 @@ class BusinessModelContractTest(unittest.TestCase):
|
||||
master_do.BizCustomer: 'biz_customer',
|
||||
master_do.BizCustomerSite: 'biz_customer_site',
|
||||
master_do.BizProductionLine: 'biz_production_line',
|
||||
master_do.BizTunnel: 'biz_tunnel',
|
||||
master_do.BizPallet: 'biz_pallet',
|
||||
master_do.BizUserWarehouse: 'biz_user_warehouse',
|
||||
master_do.BizUserSite: 'biz_user_site',
|
||||
@@ -1258,6 +1389,7 @@ class BusinessModelContractTest(unittest.TestCase):
|
||||
'biz_customer': {'ck_biz_customer_status': "statusIN('0','1')"},
|
||||
'biz_customer_site': {'ck_biz_customer_site_status': "statusIN('0','1')"},
|
||||
'biz_production_line': {'ck_biz_production_line_status': "statusIN('0','1')"},
|
||||
'biz_tunnel': {'ck_biz_tunnel_status': "statusIN('0','1')"},
|
||||
'biz_pallet': {
|
||||
'ck_biz_pallet_capacity_packages': 'capacity_packages>0',
|
||||
'ck_biz_pallet_asset_status': "asset_statusIN('0','1')",
|
||||
@@ -1267,10 +1399,12 @@ class BusinessModelContractTest(unittest.TestCase):
|
||||
},
|
||||
'biz_filling_record': {
|
||||
'ck_biz_filling_record_initial_packages': 'initial_packages>0',
|
||||
'ck_biz_filling_record_material_dates': '(production_dateISNULLANDexpiry_dateISNULL)OR(production_dateISNOTNULLANDexpiry_dateISNOTNULLANDexpiry_date>=production_date)',
|
||||
'ck_biz_filling_record_active_slot': "active_slotISNULLORactive_slot='ACTIVE'",
|
||||
},
|
||||
'biz_shipment_order': {
|
||||
'ck_biz_shipment_order_status': "statusIN('DRAFT','CONFIRMED')",
|
||||
'ck_biz_shipment_order_receipt_state': "receipt_stateIN('PENDING','RECEIVED','LEGACY')",
|
||||
},
|
||||
'biz_shipment_item': {
|
||||
'ck_biz_shipment_item_package_qty': 'package_qtyISNULLORpackage_qty>0',
|
||||
@@ -1288,7 +1422,7 @@ class BusinessModelContractTest(unittest.TestCase):
|
||||
'biz_pallet_inventory': {
|
||||
'ck_biz_pallet_inventory_flow_state': (
|
||||
"flow_stateIN('UNSTOCKED','EMPTY_IN_WAREHOUSE','FILLED_IN_WAREHOUSE',"
|
||||
"'AT_SITE_UNUSED','AT_SITE_PARTIAL','AT_SITE_EMPTY','RETURNING')"
|
||||
"'IN_TRANSIT','AT_SITE_UNUSED','AT_SITE_PARTIAL','AT_SITE_EMPTY','RETURNING')"
|
||||
),
|
||||
'ck_biz_pallet_inventory_remaining_packages': 'remaining_packages>=0',
|
||||
'ck_biz_pallet_inventory_version': 'version>=0',
|
||||
@@ -1296,7 +1430,7 @@ class BusinessModelContractTest(unittest.TestCase):
|
||||
},
|
||||
'biz_pallet_event': {
|
||||
'ck_biz_pallet_event_event_type': (
|
||||
"event_typeIN('INBOUND_CONFIRMED','FILLING_CONFIRMED','SHIPMENT_CONFIRMED',"
|
||||
"event_typeIN('INBOUND_CONFIRMED','FILLING_CONFIRMED','SHIPMENT_CONFIRMED','SHIPMENT_RECEIVED','TUNNEL_ENTERED',"
|
||||
"'USAGE_RECORDED','RETURN_REQUESTED','RETURN_CONFIRMED')"
|
||||
),
|
||||
},
|
||||
@@ -1312,6 +1446,7 @@ class BusinessModelContractTest(unittest.TestCase):
|
||||
'biz_customer': 'status',
|
||||
'biz_customer_site': 'status',
|
||||
'biz_production_line': 'status',
|
||||
'biz_tunnel': 'status',
|
||||
'biz_pallet': 'asset_status',
|
||||
}
|
||||
for table_name, column_name in char_statuses.items():
|
||||
@@ -1375,15 +1510,19 @@ class BusinessModelContractTest(unittest.TestCase):
|
||||
'active_filling_id',
|
||||
'warehouse_id',
|
||||
'site_id',
|
||||
'tunnel_id',
|
||||
'trace_no',
|
||||
'current_line_id',
|
||||
'last_event_id',
|
||||
},
|
||||
'biz_pallet_event': {
|
||||
'before_warehouse_id',
|
||||
'before_site_id',
|
||||
'before_tunnel_id',
|
||||
'before_line_id',
|
||||
'after_warehouse_id',
|
||||
'after_site_id',
|
||||
'after_tunnel_id',
|
||||
'after_line_id',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""在隔离内存库执行真实解析查询,不接触业务数据库。"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from sqlalchemy import Column, MetaData, Table, create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from exceptions.exception import PermissionException, ServiceException
|
||||
from module_business.dao.inventory_dao import InventoryDao
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.do.master_do import BizPallet
|
||||
from module_business.service.access_service import AccessService
|
||||
from module_business.service.filling_service import FillingService
|
||||
from tests.module_business.test_batch_fixtures import make_test_scope
|
||||
from tests.module_business.test_filling_service import make_inventory, make_pallet, make_user
|
||||
|
||||
|
||||
class TestPalletScanIdentity(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.engine = create_engine('sqlite:///:memory:')
|
||||
metadata = MetaData()
|
||||
# 本测试只执行托盘查询,不复制业务数据库或外键依赖。
|
||||
self.table = Table('biz_pallet', metadata, *[
|
||||
Column(column.name, column.type, primary_key=column.primary_key)
|
||||
for column in BizPallet.__table__.columns
|
||||
])
|
||||
metadata.create_all(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
self.db = MagicMock()
|
||||
self.db.execute = AsyncMock(side_effect=self.session.execute)
|
||||
self.session.execute(self.table.insert(), [
|
||||
{'pallet_id': 1, 'pallet_code': '123', 'qr_token': 'qr-one', 'test_batch_id': None},
|
||||
{'pallet_id': 2, 'pallet_code': '456', 'qr_token': 'qr-two', 'test_batch_id': 7},
|
||||
])
|
||||
|
||||
def tearDown(self):
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
async def test_printed_number_and_qrcode_resolve_same_pallet(self):
|
||||
by_code = await InventoryDao.get_pallet_by_qr_token(self.db, '123', TestBatchScope.formal())
|
||||
by_qr = await InventoryDao.get_pallet_by_qr_token(self.db, 'qr-one', TestBatchScope.formal())
|
||||
self.assertIsNotNone(by_code)
|
||||
self.assertEqual(by_code.pallet_id, by_qr.pallet_id)
|
||||
self.assertEqual(by_code.qr_token, 'qr-one')
|
||||
|
||||
async def test_resolver_never_crosses_formal_and_test_scopes(self):
|
||||
self.assertIsNone(await InventoryDao.get_pallet_by_qr_token(self.db, '456', TestBatchScope.formal()))
|
||||
self.assertIsNone(await InventoryDao.get_pallet_by_qr_token(self.db, '123', make_test_scope(7)))
|
||||
pallet = await InventoryDao.get_pallet_by_qr_token(self.db, '456', make_test_scope(7))
|
||||
self.assertIsNotNone(pallet)
|
||||
self.assertEqual(pallet.pallet_id, 2)
|
||||
|
||||
async def test_ambiguous_number_and_token_are_rejected(self):
|
||||
self.session.execute(self.table.insert(), {'pallet_id': 3, 'pallet_code': '789', 'qr_token': '123', 'test_batch_id': None})
|
||||
with self.assertRaises(ServiceException):
|
||||
await InventoryDao.get_pallet_by_qr_token(self.db, '123', TestBatchScope.formal())
|
||||
|
||||
async def test_scan_returns_canonical_token_after_warehouse_authorization(self):
|
||||
with (
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=make_pallet())),
|
||||
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=make_inventory())),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
):
|
||||
result = await FillingService.scan_filling_pallet_services(self.db, 'P-002', make_user(), TestBatchScope.formal())
|
||||
self.assertEqual(result.qr_token, 'qr-2')
|
||||
|
||||
async def test_printed_number_does_not_bypass_warehouse_access(self):
|
||||
with (
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=make_pallet())),
|
||||
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=make_inventory())),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock(side_effect=PermissionException(data='', message='无权访问'))),
|
||||
self.assertRaises(PermissionException),
|
||||
):
|
||||
await FillingService.scan_filling_pallet_services(self.db, 'P-002', make_user(), TestBatchScope.formal())
|
||||
@@ -60,7 +60,7 @@ def integrity_error() -> IntegrityError:
|
||||
|
||||
def make_pallet_record(
|
||||
pallet_id: int,
|
||||
capacity_packages: int = 20,
|
||||
capacity_packages: int = 100,
|
||||
test_batch_id: int | None = None,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
@@ -113,7 +113,7 @@ class TestPalletVoContract(unittest.TestCase):
|
||||
BusinessUserOptionModel.model_validate(value)
|
||||
|
||||
def test_pallet_edit_rejects_code_and_qr_token(self) -> None:
|
||||
base = {'palletId': 1, 'capacityPackages': 20, 'assetStatus': '0'}
|
||||
base = {'palletId': 1, 'capacityPackages': 100, 'assetStatus': '0'}
|
||||
for identity_field in ({'palletCode': 'P-001'}, {'qrToken': 'fixed-token'}):
|
||||
with self.subTest(identity_field=identity_field), self.assertRaises(ValidationError):
|
||||
PalletEditModel.model_validate({**base, **identity_field})
|
||||
@@ -147,7 +147,7 @@ class TestPalletMasterDao(unittest.IsolatedAsyncioTestCase):
|
||||
pallet = BizPallet(
|
||||
pallet_code='P-001',
|
||||
qr_token='fixed-token',
|
||||
capacity_packages=20,
|
||||
capacity_packages=100,
|
||||
asset_status='0',
|
||||
)
|
||||
inventory = BizPalletInventory(
|
||||
@@ -179,7 +179,7 @@ class TestPalletMasterDao(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertNotIn('test_batch_id', compile_sql(statement.whereclause))
|
||||
|
||||
async def test_update_pallet_sql_never_contains_identity_fields(self) -> None:
|
||||
edit = PalletEditModel(palletId=3, capacityPackages=18, assetStatus='1', remark='停用')
|
||||
edit = PalletEditModel(palletId=3, capacityPackages=100, assetStatus='1', remark='停用')
|
||||
updated_at = datetime(2026, 7, 26, 10, 30)
|
||||
|
||||
await MasterDao.update_pallet(
|
||||
@@ -193,7 +193,7 @@ class TestPalletMasterDao(unittest.IsolatedAsyncioTestCase):
|
||||
statement = self.db.execute.await_args.args[0]
|
||||
sql = compile_sql(statement)
|
||||
self.assertIn('UPDATE biz_pallet', sql)
|
||||
self.assertIn('capacity_packages=18', sql)
|
||||
self.assertIn('capacity_packages=100', sql)
|
||||
self.assertIn("asset_status='1'", sql)
|
||||
self.assertIn("remark='停用'", sql)
|
||||
self.assertIn("update_by='admin'", sql)
|
||||
@@ -357,7 +357,7 @@ class TestPalletCreationService(unittest.IsolatedAsyncioTestCase):
|
||||
self.db = MagicMock(spec=AsyncSession)
|
||||
self.admin = make_admin()
|
||||
self.scope = TestBatchScope.formal()
|
||||
self.create = PalletCreateModel(palletCode='P-001', capacityPackages=20, remark='测试托盘')
|
||||
self.create = PalletCreateModel(palletCode='P-001', capacityPackages=100, remark='测试托盘')
|
||||
self.fixed_time = datetime(2026, 7, 26, 11, 30)
|
||||
|
||||
async def test_add_generates_server_token_and_initial_inventory_in_one_transaction(self) -> None:
|
||||
@@ -393,7 +393,7 @@ class TestPalletCreationService(unittest.IsolatedAsyncioTestCase):
|
||||
pallet = captured['pallet']
|
||||
self.assertEqual('P-001', pallet.pallet_code)
|
||||
self.assertEqual('server-fixed-token', pallet.qr_token)
|
||||
self.assertEqual(20, pallet.capacity_packages)
|
||||
self.assertEqual(100, pallet.capacity_packages)
|
||||
self.assertEqual('0', pallet.asset_status)
|
||||
self.assertEqual('admin', pallet.create_by)
|
||||
self.assertEqual(self.fixed_time, pallet.create_time)
|
||||
@@ -549,7 +549,7 @@ class TestPalletQueryAndEditService(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual('托盘不存在', raised.exception.message)
|
||||
|
||||
async def test_edit_updates_only_mutable_fields_with_server_audit(self) -> None:
|
||||
edit = PalletEditModel(palletId=3, capacityPackages=12, assetStatus='1', remark='停用')
|
||||
edit = PalletEditModel(palletId=3, capacityPackages=100, assetStatus='1', remark='停用')
|
||||
updated_at = datetime(2026, 7, 26, 12, 30)
|
||||
update_pallet = AsyncMock()
|
||||
lock_actions: list[str] = []
|
||||
@@ -591,7 +591,11 @@ class TestPalletQueryAndEditService(unittest.IsolatedAsyncioTestCase):
|
||||
edit = PalletEditModel(palletId=3, capacityPackages=11, assetStatus='0')
|
||||
update_pallet = AsyncMock()
|
||||
with (
|
||||
patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=make_pallet_record(3))),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_pallet',
|
||||
new=AsyncMock(return_value=make_pallet_record(3, capacity_packages=11)),
|
||||
),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_pallet_inventory',
|
||||
@@ -608,7 +612,7 @@ class TestPalletQueryAndEditService(unittest.IsolatedAsyncioTestCase):
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_edit_rejects_missing_inventory(self) -> None:
|
||||
edit = PalletEditModel(palletId=3, capacityPackages=20, assetStatus='0')
|
||||
edit = PalletEditModel(palletId=3, capacityPackages=100, assetStatus='0')
|
||||
with (
|
||||
patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=make_pallet_record(3))),
|
||||
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=None)),
|
||||
@@ -975,7 +979,7 @@ class TestPalletBatchScopeService(unittest.IsolatedAsyncioTestCase):
|
||||
self.db = MagicMock(spec=AsyncSession)
|
||||
self.admin = make_admin()
|
||||
self.scope = make_test_scope(9)
|
||||
self.create = PalletCreateModel(palletCode='P-T09', capacityPackages=20)
|
||||
self.create = PalletCreateModel(palletCode='P-T09', capacityPackages=100)
|
||||
|
||||
async def test_list_detail_and_qrcode_forward_scope_to_scoped_dao_reads(self) -> None:
|
||||
query = PalletPageQueryModel()
|
||||
@@ -1049,7 +1053,7 @@ class TestPalletBatchScopeService(unittest.IsolatedAsyncioTestCase):
|
||||
events.append('get-pallet')
|
||||
return make_pallet_record(7, test_batch_id=9)
|
||||
|
||||
edit = PalletEditModel(palletId=7, capacityPackages=20, assetStatus='0')
|
||||
edit = PalletEditModel(palletId=7, capacityPackages=100, assetStatus='0')
|
||||
update_pallet = AsyncMock()
|
||||
with (
|
||||
patch.object(TestBatchAccessService, 'lock_active_batch', new=AsyncMock(side_effect=lock_batch)),
|
||||
@@ -1205,7 +1209,7 @@ class TestPalletDaoBatchScope(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn('FOR UPDATE', sql)
|
||||
|
||||
async def test_pallet_update_is_scoped_and_never_updates_batch_id(self) -> None:
|
||||
edit = PalletEditModel(palletId=7, capacityPackages=20, assetStatus='0')
|
||||
edit = PalletEditModel(palletId=7, capacityPackages=100, assetStatus='0')
|
||||
await MasterDao.update_pallet(
|
||||
self.db,
|
||||
edit,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""日期报表查询与响应回归,仅读取生成的查询,不连接数据库。"""
|
||||
|
||||
import unittest
|
||||
from datetime import date
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from common.vo import PageModel
|
||||
from module_business.dao.report_dao import ReportDao
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.report_vo import (
|
||||
ConsumptionReportModel, ConsumptionReportQueryModel,
|
||||
PalletInventoryReportModel, PalletInventoryReportQueryModel,
|
||||
PalletTraceReportModel, PalletTraceReportQueryModel,
|
||||
ReturnReportModel, ReturnReportQueryModel,
|
||||
)
|
||||
from module_business.service.report_service import ReportService
|
||||
from tests.module_business.test_report_service import make_user, report_row
|
||||
from tests.module_business.test_report_tunnel import joins, sql
|
||||
|
||||
|
||||
REPORTS = (
|
||||
('pallet_inventory', PalletInventoryReportModel, PalletInventoryReportQueryModel, 'active_filling'),
|
||||
('consumption_report', ConsumptionReportModel, ConsumptionReportQueryModel, 'biz_filling_record'),
|
||||
('return_report', ReturnReportModel, ReturnReportQueryModel, 'return_filling'),
|
||||
('pallet_trace', PalletTraceReportModel, PalletTraceReportQueryModel, 'trace_filling'),
|
||||
)
|
||||
|
||||
|
||||
class TestReportDates(unittest.IsolatedAsyncioTestCase):
|
||||
async def statement(self, name, query, batch_id=None):
|
||||
page = PageModel(rows=[], total=0, pageNum=1, pageSize=10, hasNext=False)
|
||||
with patch.object(ReportDao, '_paginate', AsyncMock(return_value=page)) as paginate:
|
||||
await getattr(ReportDao, f'get_{name}_list')(
|
||||
None, query, TestBatchScope(batch_id, 'TEST' if batch_id else 'FORMAL', None, False),
|
||||
)
|
||||
return paginate.await_args.args[1]
|
||||
|
||||
async def test_dates_are_selected_from_the_referenced_filling_not_operation_time(self):
|
||||
for name, _, query_type, alias in REPORTS:
|
||||
with self.subTest(report=name):
|
||||
statement = await self.statement(name, query_type())
|
||||
for field in ('production_date', 'expiry_date'):
|
||||
self.assertIn(field, statement.selected_columns.keys())
|
||||
self.assertEqual(f'{alias}.{field}', sql(statement.selected_columns[field].element))
|
||||
if name != 'pallet_inventory':
|
||||
self.assertNotIn('biz_pallet_inventory', sql(statement))
|
||||
expected_join = {
|
||||
'pallet_inventory': 'active_filling.filling_id = biz_pallet_inventory.active_filling_id',
|
||||
'consumption_report': 'biz_filling_record.filling_id = biz_usage_record.active_filling_id',
|
||||
'return_report': 'return_filling.filling_id = biz_return_item.active_filling_id_snapshot',
|
||||
}.get(name)
|
||||
if expected_join:
|
||||
self.assertIn(expected_join, sql(statement))
|
||||
|
||||
async def test_trace_resolves_original_filling_by_event_source_and_same_pallet(self):
|
||||
statement = await self.statement('pallet_trace', PalletTraceReportQueryModel())
|
||||
self.assertIn('production_date', statement.selected_columns.keys())
|
||||
query = sql(statement)
|
||||
for source, reference in (
|
||||
('FILLING', 'biz_pallet_event.source_id'),
|
||||
('USAGE', 'trace_usage.active_filling_id'),
|
||||
('RETURN', 'trace_return_item.active_filling_id_snapshot'),
|
||||
('SHIPMENT', 'trace_shipment_item.active_filling_id'),
|
||||
):
|
||||
self.assertIn(f"WHEN (biz_pallet_event.source_type = '{source}') THEN {reference}", query)
|
||||
for alias, id_field in (('trace_usage', 'usage_id'), ('trace_shipment_item', 'shipment_id')):
|
||||
self.assertIn(f'{alias}.{id_field} = biz_pallet_event.source_id', query)
|
||||
self.assertIn(f'{alias}.pallet_id = biz_pallet_event.pallet_id', query)
|
||||
self.assertIn('trace_filling.pallet_id = biz_pallet_event.pallet_id', query)
|
||||
self.assertNotIn('biz_pallet_inventory', query)
|
||||
|
||||
async def test_tunnel_entry_resolves_prior_filling_event_not_later_refill(self):
|
||||
statement = await self.statement('pallet_trace', PalletTraceReportQueryModel())
|
||||
query = sql(statement)
|
||||
self.assertIn('SELECT prior_filling_event.source_id', query)
|
||||
self.assertIn('prior_filling_event.pallet_id = biz_pallet_event.pallet_id', query)
|
||||
self.assertIn('prior_filling_event.event_seq < biz_pallet_event.event_seq', query)
|
||||
self.assertIn("prior_filling_event.event_type = 'FILLING_CONFIRMED'", query)
|
||||
self.assertIn('ORDER BY prior_filling_event.event_seq DESC', query)
|
||||
self.assertIn('LIMIT 1', query)
|
||||
|
||||
async def test_trace_date_joins_are_optional_and_batch_isolated(self):
|
||||
for batch_id, predicate in ((None, 'IS NULL'), (41, '= 41')):
|
||||
with self.subTest(batch_id=batch_id):
|
||||
statement = await self.statement('pallet_trace', PalletTraceReportQueryModel(), batch_id)
|
||||
all_joins = {join.right.name: join for join in joins(statement)}
|
||||
for alias, key in (
|
||||
('trace_usage', 'usage_id'), ('trace_shipment_item', 'shipment_item_id'),
|
||||
('trace_filling', 'filling_id'),
|
||||
):
|
||||
self.assertIn(alias, all_joins)
|
||||
self.assertTrue(all_joins[alias].isouter)
|
||||
self.assertIn(f'({alias}.{key} IS NULL OR {alias}.test_batch_id {predicate})', sql(statement))
|
||||
self.assertIn(f'prior_filling_event.test_batch_id {predicate}', sql(statement))
|
||||
|
||||
async def test_response_preserves_original_date_and_null_as_iso_calendar_dates(self):
|
||||
for name, model_type, query_type, _ in REPORTS:
|
||||
for production, expiry, expected in (
|
||||
(date(2025, 12, 31), date(2026, 12, 31), ('2025-12-31', '2026-12-31')),
|
||||
(None, None, (None, None)),
|
||||
):
|
||||
with self.subTest(report=name, production=production):
|
||||
row = dict(report_row(model_type), production_date=production, expiry_date=expiry)
|
||||
page = PageModel(rows=[row], total=1, pageNum=1, pageSize=10, hasNext=False)
|
||||
with patch.object(ReportDao, f'get_{name}_list', AsyncMock(return_value=page)):
|
||||
result = await getattr(ReportService, f'get_{name}_list_services')(
|
||||
None, query_type(), make_user(1), TestBatchScope.formal(),
|
||||
)
|
||||
actual = result.rows[0].model_dump(mode='json', by_alias=True)
|
||||
self.assertIn('productionDate', actual)
|
||||
self.assertIn('expiryDate', actual)
|
||||
self.assertEqual(expected, (actual['productionDate'], actual['expiryDate']))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1026,6 +1026,13 @@ class TestReportControllerContract(unittest.TestCase):
|
||||
'eventType',
|
||||
},
|
||||
}
|
||||
for path in (
|
||||
'/report/pallet-inventory/list', '/report/consumption/list',
|
||||
'/report/return/list', '/report/pallet-trace/list',
|
||||
):
|
||||
expected_parameters[path] |= {'tunnelId', 'tunnelCode', 'tunnelName', 'traceNo'}
|
||||
for path in ('/report/return/list', '/report/pallet-trace/list'):
|
||||
expected_parameters[path].add('batchNo')
|
||||
for path, parameters in expected_parameters.items():
|
||||
with self.subTest(path=path):
|
||||
operation_parameters = schema['paths'][path]['get']['parameters']
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""报表隧道回归:检查真实生成的查询与服务输出,全程不连接数据库。"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from common.vo import PageModel
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.dialects import mysql
|
||||
from sqlalchemy.sql.selectable import Join
|
||||
|
||||
from module_business.dao.report_dao import ReportDao
|
||||
from module_business.domain.enums import PalletEventType
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.report_vo import (
|
||||
ConsumptionReportModel, ConsumptionReportQueryModel,
|
||||
PalletInventoryReportQueryModel, PalletTraceReportModel,
|
||||
PalletTraceReportQueryModel, ReturnReportQueryModel,
|
||||
)
|
||||
from module_business.service.report_service import ReportService
|
||||
from tests.module_business.test_report_service import make_user, report_row
|
||||
|
||||
|
||||
def sql(expression):
|
||||
return ' '.join(str(expression.compile(
|
||||
dialect=mysql.dialect(), compile_kwargs={'literal_binds': True},
|
||||
)).split())
|
||||
|
||||
|
||||
def joins(statement):
|
||||
def walk(node):
|
||||
if isinstance(node, Join):
|
||||
yield from walk(node.left)
|
||||
yield node
|
||||
return list(walk(statement.get_final_froms()[0]))
|
||||
|
||||
|
||||
class TestReportTunnelQueries(unittest.IsolatedAsyncioTestCase):
|
||||
async def statement(self, method, query, scope=None, **access):
|
||||
# 只替换分页执行边界,保留 DAO 的真实 SQLAlchemy 查询生成。
|
||||
paginate = AsyncMock(return_value=PageModel(rows=[], total=0, pageNum=1, pageSize=10, hasNext=False))
|
||||
with patch.object(ReportDao, '_paginate', paginate):
|
||||
await method(None, query, scope or TestBatchScope.formal(), **access)
|
||||
return paginate.await_args.args[1]
|
||||
|
||||
async def test_usage_without_line_is_not_removed_by_inner_join(self):
|
||||
statement = await self.statement(ReportDao.get_consumption_report_list, ConsumptionReportQueryModel())
|
||||
line_join = next(join for join in joins(statement) if join.right.name == 'biz_production_line')
|
||||
self.assertTrue(line_join.isouter, 'line_id=NULL 的使用记录会被 INNER JOIN 丢弃')
|
||||
|
||||
async def test_optional_line_rejects_existing_cross_batch_association(self):
|
||||
for batch_id, condition in ((None, 'IS NULL'), (41, '= 41')):
|
||||
with self.subTest(batch_id=batch_id):
|
||||
scope = TestBatchScope(batch_id=batch_id, mode='TEST' if batch_id else 'FORMAL', status=None, writable=False)
|
||||
statement = await self.statement(
|
||||
ReportDao.get_consumption_report_list, ConsumptionReportQueryModel(), scope, site_ids=[9],
|
||||
)
|
||||
where = sql(statement.whereclause)
|
||||
self.assertIn(f'(biz_production_line.line_id IS NULL OR biz_production_line.test_batch_id {condition})', where)
|
||||
self.assertIn(f'biz_usage_record.test_batch_id {condition}', where)
|
||||
self.assertIn('biz_usage_record.site_id IN (9)', where)
|
||||
|
||||
async def test_four_reports_filter_tunnel_and_trace_without_requiring_pallet(self):
|
||||
for method, query_type, trace_column in (
|
||||
(ReportDao.get_pallet_inventory_list, PalletInventoryReportQueryModel, 'biz_pallet_inventory.trace_no'),
|
||||
(ReportDao.get_consumption_report_list, ConsumptionReportQueryModel, 'biz_usage_record.trace_no'),
|
||||
(ReportDao.get_return_report_list, ReturnReportQueryModel, 'biz_return_item.trace_no_snapshot'),
|
||||
(ReportDao.get_pallet_trace_list, PalletTraceReportQueryModel, 'coalesce(biz_pallet_event.trace_no, trace_return_item.trace_no_snapshot)'),
|
||||
):
|
||||
with self.subTest(report=method.__name__):
|
||||
query = query_type(tunnelId=17, tunnelCode='T-01', tunnelName='一号隧道', traceNo='TR-MIX')
|
||||
statement = await self.statement(method, query)
|
||||
where = sql(statement.whereclause)
|
||||
self.assertIn(f"{trace_column} = 'TR-MIX'", where)
|
||||
self.assertIn("tunnel_code LIKE '%%T-01%%'", where)
|
||||
self.assertIn("tunnel_name LIKE '%%一号隧道%%'", where)
|
||||
self.assertIn('tunnel_id', where)
|
||||
self.assertIn('= 17', where)
|
||||
self.assertNotIn('pallet_code LIKE', where)
|
||||
self.assertTrue({'tunnel_id', 'tunnel_code', 'tunnel_name', 'trace_no'}.issubset(statement.selected_columns.keys()))
|
||||
for join in joins(statement):
|
||||
if 'tunnel' in join.right.name:
|
||||
self.assertTrue(join.isouter)
|
||||
self.assertIn(f'({join.right.name}.tunnel_id IS NULL OR {join.right.name}.test_batch_id IS NULL)', where)
|
||||
|
||||
async def test_usage_batch_filter_and_display_prefer_record_snapshot(self):
|
||||
statement = await self.statement(ReportDao.get_consumption_report_list, ConsumptionReportQueryModel(batchNo='B-OLD'))
|
||||
self.assertEqual(
|
||||
'coalesce(biz_usage_record.batch_no_snapshot, biz_filling_record.batch_no)',
|
||||
sql(statement.selected_columns.batch_no.element),
|
||||
)
|
||||
self.assertIn("coalesce(biz_usage_record.batch_no_snapshot, biz_filling_record.batch_no) LIKE '%%B-OLD%%'", sql(statement.whereclause))
|
||||
|
||||
async def test_return_reads_frozen_tunnel_trace_and_batch_after_inventory_changes(self):
|
||||
statement = await self.statement(ReportDao.get_return_report_list, ReturnReportQueryModel(batchNo='B-OLD'))
|
||||
self.assertIn('tunnel_id', statement.selected_columns.keys())
|
||||
self.assertEqual('biz_return_item.tunnel_id_snapshot', sql(statement.selected_columns.get('tunnel_id')).split(' AS ')[0])
|
||||
self.assertEqual('biz_return_item.trace_no_snapshot', sql(statement.selected_columns.get('trace_no')).split(' AS ')[0])
|
||||
self.assertIn('coalesce(biz_return_item.batch_no_snapshot, return_filling.batch_no)', sql(statement))
|
||||
self.assertIn('return_filling.filling_id = biz_return_item.active_filling_id_snapshot', sql(statement))
|
||||
self.assertIn('(return_filling.filling_id IS NULL OR return_filling.test_batch_id IS NULL)', sql(statement.whereclause))
|
||||
self.assertNotIn('biz_pallet_inventory', sql(statement))
|
||||
|
||||
async def test_trace_filters_both_historical_tunnels_and_event_batch(self):
|
||||
statement = await self.statement(ReportDao.get_pallet_trace_list, PalletTraceReportQueryModel(tunnelId=17, batchNo='B-OLD'))
|
||||
where = sql(statement.whereclause)
|
||||
self.assertIn('(coalesce(biz_pallet_event.before_tunnel_id, trace_return_item.tunnel_id_snapshot) = 17 OR biz_pallet_event.after_tunnel_id = 17)', where)
|
||||
self.assertIn("coalesce(biz_pallet_event.batch_no_snapshot, trace_return_item.batch_no_snapshot) LIKE '%%B-OLD%%'", where)
|
||||
self.assertIn('biz_pallet_event.batch_no_snapshot', sql(statement))
|
||||
self.assertNotIn('biz_pallet_inventory', sql(statement))
|
||||
|
||||
async def test_return_event_missing_tunnel_reads_same_pallet_return_snapshot(self):
|
||||
statement = await self.statement(ReportDao.get_pallet_trace_list, PalletTraceReportQueryModel())
|
||||
self.assertEqual(
|
||||
'coalesce(biz_pallet_event.before_tunnel_id, trace_return_item.tunnel_id_snapshot)',
|
||||
sql(statement.selected_columns.before_tunnel_id.element),
|
||||
)
|
||||
query = sql(statement)
|
||||
self.assertIn("biz_pallet_event.source_type = 'RETURN'", query)
|
||||
self.assertIn('trace_return_item.return_id = biz_pallet_event.source_id', query)
|
||||
self.assertIn('trace_return_item.pallet_id = biz_pallet_event.pallet_id', query)
|
||||
self.assertIn('(trace_return_item.return_item_id IS NULL OR trace_return_item.test_batch_id IS NULL)', query)
|
||||
|
||||
|
||||
class TestReportTunnelOutput(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_consumption_service_keeps_line_less_mixed_pallet_details(self):
|
||||
rows = [dict(report_row(ConsumptionReportModel), usage_id=i, pallet_id=i,
|
||||
pallet_code=f'P-{i}', line_id=None, line_name=None, tunnel_id=17,
|
||||
tunnel_code='T-01', tunnel_name='一号隧道', trace_no='TR-MIX',
|
||||
batch_no=batch, batch_no_snapshot=batch, used_packages=qty)
|
||||
for i, batch, qty in ((1, 'B-A', 30), (2, 'B-B', 20))]
|
||||
page = PageModel(rows=rows, total=2, pageNum=1, pageSize=10, hasNext=False)
|
||||
with patch.object(ReportDao, 'get_consumption_report_list', AsyncMock(return_value=page)):
|
||||
try:
|
||||
result = await ReportService.get_consumption_report_list_services(
|
||||
None, ConsumptionReportQueryModel(traceNo='TR-MIX'), make_user(1), TestBatchScope.formal(),
|
||||
)
|
||||
except ValidationError as exc:
|
||||
self.fail(f'合法的无产线使用记录被响应模型拒绝:{exc}')
|
||||
actual = [row.model_dump(by_alias=True) for row in result.rows]
|
||||
self.assertEqual([30, 20], [row['usedPackages'] for row in actual])
|
||||
self.assertEqual(['B-A', 'B-B'], [row['batchNoSnapshot'] for row in actual])
|
||||
self.assertEqual(['TR-MIX', 'TR-MIX'], [row['traceNo'] for row in actual])
|
||||
self.assertEqual([None, None], [row['lineId'] for row in actual])
|
||||
|
||||
def test_trace_quantities_separate_usage_and_confirmed_return(self):
|
||||
for event, before, after, used, returned in (
|
||||
('TUNNEL_ENTERED', 100, 100, 0, 0),
|
||||
('USAGE_RECORDED', 100, 70, 30, 0),
|
||||
('USAGE_RECORDED', 100, 80, 20, 0),
|
||||
('RETURN_REQUESTED', 70, 70, 0, 0),
|
||||
('RETURN_CONFIRMED', 70, 70, 0, 70),
|
||||
('RETURN_CONFIRMED', 0, 0, 0, 0),
|
||||
):
|
||||
with self.subTest(event=event, before=before):
|
||||
model = PalletTraceReportModel.model_validate(dict(
|
||||
report_row(PalletTraceReportModel), event_type=PalletEventType(event),
|
||||
before_remaining_packages=before, after_remaining_packages=after,
|
||||
)).model_dump(by_alias=True)
|
||||
self.assertEqual(used, model.get('usedPackages'))
|
||||
self.assertEqual(returned, model.get('returnedPackages'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -429,7 +429,7 @@ class ReturnControllerContractRedTest(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _routes() -> dict[tuple[str, str], APIRoute]:
|
||||
return {
|
||||
(route.path, next(iter(route.methods))): route
|
||||
(route.path_format, next(iter(route.methods))): route
|
||||
for route in return_controller_module.return_controller.routes
|
||||
if isinstance(route, APIRoute)
|
||||
}
|
||||
@@ -468,7 +468,7 @@ class ReturnControllerContractRedTest(unittest.TestCase):
|
||||
|
||||
def test_static_routes_permissions_current_user_and_write_logs(self) -> None:
|
||||
routes = self._routes()
|
||||
paths = [route.path for route in return_controller_module.return_controller.routes]
|
||||
paths = [route.path_format for route in return_controller_module.return_controller.routes]
|
||||
detail_index = paths.index('/business/return/{return_id}')
|
||||
for static_path in (
|
||||
'/business/return/list',
|
||||
@@ -1731,6 +1731,14 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
pallets = [make_pallet(2), make_pallet(5)]
|
||||
filling = make_filling(2, warehouse_id=3)
|
||||
events: list[BizPalletEvent] = []
|
||||
for inventory in inventories:
|
||||
inventory.tunnel_id = 31
|
||||
inventory.trace_no = 'TRACE-ORIGINAL'
|
||||
inventory.site_arrived_at = datetime(2026, 7, 20)
|
||||
for item in order.items:
|
||||
item.tunnel_id_snapshot = 31
|
||||
item.trace_no_snapshot = 'TRACE-ORIGINAL'
|
||||
tunnel = SimpleNamespace(tunnel_id=31, site_id=9, current_trace_no='TRACE-ORIGINAL', test_batch_id=None)
|
||||
|
||||
async def add_event(
|
||||
_db: AsyncSession,
|
||||
@@ -1771,6 +1779,8 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
),
|
||||
),
|
||||
patch.object(OperationDao, 'update_return_order', new=AsyncMock()),
|
||||
patch.object(MasterDao, 'get_tunnel', new=AsyncMock(return_value=tunnel)),
|
||||
patch.object(MasterDao, 'count_tunnel_trace_members', new=AsyncMock(return_value=0)),
|
||||
patch.object(OperationDao, 'update_filling_record', new=AsyncMock()) as update_filling,
|
||||
patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=add_event)),
|
||||
patch.object(return_service_module, 'datetime') as service_datetime,
|
||||
@@ -1804,6 +1814,9 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual([PalletEventType.RETURN_CONFIRMED.value] * 2, [event.event_type for event in events])
|
||||
self.assertEqual([7, 7], [event.after_warehouse_id for event in events])
|
||||
self.assertEqual([None, None], [event.after_site_id for event in events])
|
||||
self.assertEqual([31, 31], [event.before_tunnel_id for event in events])
|
||||
self.assertEqual(['TRACE-ORIGINAL', 'TRACE-ORIGINAL'], [event.trace_no for event in events])
|
||||
self.assertTrue(all(inventory.site_arrived_at is None for inventory in inventories))
|
||||
self.db.commit.assert_awaited_once()
|
||||
|
||||
async def test_confirm_resolves_authorized_return_route_before_key_lookup(self) -> None:
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import unittest
|
||||
|
||||
from starlette.routing import Match
|
||||
|
||||
from module_business.controller.filling_controller import filling_controller
|
||||
from module_business.controller.inbound_controller import inbound_controller
|
||||
from module_business.controller.return_controller import return_controller
|
||||
from module_business.controller.shipment_controller import shipment_controller
|
||||
from module_business.controller.usage_controller import usage_controller
|
||||
|
||||
|
||||
class TestScanPathIdentity(unittest.TestCase):
|
||||
def test_scan_paths_preserve_printed_pallet_number_with_slashes(self) -> None:
|
||||
for router in (
|
||||
inbound_controller,
|
||||
filling_controller,
|
||||
shipment_controller,
|
||||
usage_controller,
|
||||
return_controller,
|
||||
):
|
||||
route = next(route for route in router.routes if '/scan/' in route.path)
|
||||
# ASGI path 已由HTTP层解码,查询和片段字符不能再被当成URL语法。
|
||||
path = route.path.split('/scan/')[0] + '/scan/托盘/2026?批#1'
|
||||
with self.subTest(path=path):
|
||||
match, child = route.matches({'type': 'http', 'method': 'GET', 'path': path, 'root_path': ''})
|
||||
self.assertEqual(match, Match.FULL)
|
||||
self.assertEqual(child['path_params']['qr_token'], '托盘/2026?批#1')
|
||||
@@ -0,0 +1,244 @@
|
||||
"""标签只读回归:执行真实服务/DAO,以记录型会话替代数据库连接。"""
|
||||
|
||||
import base64
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import unittest
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
from common.aspect.interface_auth import CheckUserInterfaceAuth
|
||||
from exceptions.exception import PermissionException, ServiceException
|
||||
from module_business.controller import shipment_controller as shipment_controller_module
|
||||
from module_business.dependency.test_batch_dependency import TestBatchScopeResolver
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.do.master_do import BizCustomer, BizCustomerSite, BizPallet
|
||||
from module_business.entity.do.operation_do import BizFillingRecord, BizShipmentItem, BizShipmentOrder
|
||||
from module_business.service.pallet_service import PalletService
|
||||
from tests.module_business.test_batch_fixtures import make_admin, make_current_user, make_test_scope
|
||||
|
||||
LabelRecords = tuple[BizShipmentOrder, BizShipmentItem, BizPallet, BizFillingRecord, BizCustomerSite, BizCustomer]
|
||||
|
||||
|
||||
def sql(statement: Any) -> str:
|
||||
return ' '.join(str(statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True})).split())
|
||||
|
||||
|
||||
class ReadOnlySession:
|
||||
"""仅支持 SELECT,不提供写入、提交或真实连接能力。"""
|
||||
|
||||
def __init__(self, *results: Any) -> None:
|
||||
self.results = list(results)
|
||||
self.statements = []
|
||||
|
||||
async def execute(self, statement: Any) -> MagicMock:
|
||||
assert statement.is_select, '标签不得产生业务写入'
|
||||
self.statements.append(sql(statement))
|
||||
assert self.results, '出现非预期查询'
|
||||
value = self.results.pop(0)
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none.return_value = value
|
||||
result.scalars.return_value.all.return_value = value
|
||||
result.all.return_value = value
|
||||
return result
|
||||
|
||||
|
||||
class ShipmentLabelsTest(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self) -> None:
|
||||
spec = importlib.util.find_spec('module_business.service.shipment_label_service')
|
||||
self.assertIsNotNone(spec, '缺少可执行的发货标签服务')
|
||||
self.service = importlib.import_module('module_business.service.shipment_label_service').ShipmentLabelService
|
||||
|
||||
def records(self, status: str = 'CONFIRMED', batch_id: int | None = None, dates: bool = True) -> LabelRecords:
|
||||
order = BizShipmentOrder(shipment_id=12, shipment_no='原发货-12', source_warehouse_id=7,
|
||||
target_site_id=9, status=status, test_batch_id=batch_id)
|
||||
item = BizShipmentItem(shipment_item_id=1, shipment_id=12, pallet_id=5,
|
||||
active_filling_id=105, package_qty=37, test_batch_id=batch_id)
|
||||
pallet = BizPallet(pallet_id=5, pallet_code='原托盘-005', qr_token='fixed-qr-005',
|
||||
asset_status='0', capacity_packages=100, test_batch_id=batch_id)
|
||||
filling = BizFillingRecord(filling_id=105, pallet_id=5, strain_id=3,
|
||||
strain_name_snapshot='<img src=x onerror=alert(1)>',
|
||||
strain_code_snapshot='S03', batch_no='原生产批号-03',
|
||||
active_slot='ACTIVE' if status == 'DRAFT' else None,
|
||||
production_date=date(2026, 9, 1) if dates else None,
|
||||
expiry_date=date(2027, 9, 1) if dates else None,
|
||||
test_batch_id=batch_id)
|
||||
site = BizCustomerSite(site_id=9, customer_id=4, site_name='原现场', test_batch_id=batch_id)
|
||||
customer = BizCustomer(customer_id=4, customer_name='客户 <script>攻击</script>', test_batch_id=batch_id)
|
||||
order.items = [item]
|
||||
return order, item, pallet, filling, site, customer
|
||||
|
||||
def session(self, records: LabelRecords, *, admin: bool = True, qty: int | None = None) -> ReadOnlySession:
|
||||
order, item, pallet, filling, site, customer = records
|
||||
current_qty = 62 if qty is None else qty
|
||||
detail_items = [(item, current_qty)] if order.status == 'DRAFT' else [item]
|
||||
rows = [(item, pallet, filling, current_qty if order.status == 'DRAFT' else item.package_qty)]
|
||||
values = [order, detail_items, site, customer, rows]
|
||||
return ReadOnlySession(*([] if admin else [[7], [9]]), *values)
|
||||
|
||||
async def test_confirmed_uses_original_filling_and_package_snapshot_without_inventory(self) -> None:
|
||||
records = self.records()
|
||||
db = self.session(records)
|
||||
labels = await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
|
||||
self.assertEqual(1, len(labels))
|
||||
payload = labels[0].model_dump(mode='json', by_alias=True)
|
||||
self.assertEqual('原托盘-005', payload['palletCode'])
|
||||
self.assertEqual('原发货-12', payload['shipmentNo'])
|
||||
self.assertEqual('原生产批号-03', payload['batchNo'])
|
||||
self.assertEqual('客户 <script>攻击</script>', payload['customerName'])
|
||||
self.assertEqual('原现场', payload['siteName'])
|
||||
self.assertEqual('<img src=x onerror=alert(1)>', payload['strainName'])
|
||||
self.assertEqual(37, payload['packageQty'])
|
||||
self.assertEqual('2026-09-01', payload['productionDate'])
|
||||
self.assertEqual('2027-09-01', payload['expiryDate'])
|
||||
prefix, encoded = payload['qrCodeDataUrl'].split(',', 1)
|
||||
self.assertEqual('data:image/png;base64', prefix)
|
||||
self.assertEqual(PalletService.render_qr_png('fixed-qr-005'), base64.b64decode(encoded))
|
||||
self.assertNotIn('biz_pallet_inventory', ' '.join(db.statements))
|
||||
self.assertIn('biz_shipment_item.active_filling_id = biz_filling_record.filling_id', db.statements[-1])
|
||||
self.assertNotIn("active_slot = 'ACTIVE'", db.statements[-1])
|
||||
self.assertEqual([], db.results)
|
||||
|
||||
async def test_draft_uses_current_quantity_and_active_filling_with_null_dates(self) -> None:
|
||||
records = self.records('DRAFT', dates=False)
|
||||
records[1].active_filling_id = None
|
||||
records[1].package_qty = None
|
||||
db = self.session(records)
|
||||
labels = await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
|
||||
self.assertEqual(62, labels[0].package_qty)
|
||||
self.assertIsNone(labels[0].production_date)
|
||||
self.assertIsNone(labels[0].expiry_date)
|
||||
statement = db.statements[-1]
|
||||
for fragment in (
|
||||
'biz_pallet_inventory.active_filling_id = biz_filling_record.filling_id',
|
||||
'biz_pallet_inventory.warehouse_id = 7',
|
||||
"biz_pallet_inventory.flow_state = 'FILLED_IN_WAREHOUSE'",
|
||||
'biz_pallet_inventory.site_id IS NULL',
|
||||
'biz_pallet_inventory.current_line_id IS NULL',
|
||||
'biz_pallet_inventory.remaining_packages > 0',
|
||||
"biz_filling_record.active_slot = 'ACTIVE'",
|
||||
):
|
||||
self.assertIn(fragment, statement)
|
||||
|
||||
async def test_both_business_scopes_and_each_related_batch_are_in_real_queries(self) -> None:
|
||||
for scope in (TestBatchScope.formal(), make_test_scope(31)):
|
||||
for status in ('DRAFT', 'CONFIRMED'):
|
||||
with self.subTest(scope=scope, status=status):
|
||||
db = self.session(self.records(status, scope.batch_id), admin=False)
|
||||
await self.service.get_labels_services(db, 12, make_current_user(), scope)
|
||||
self.assertIn('source_warehouse_id IN (7)', db.statements[2])
|
||||
self.assertIn('target_site_id IN (9)', db.statements[2])
|
||||
combined = ' '.join(db.statements)
|
||||
tables = ['biz_shipment_order', 'biz_shipment_item', 'biz_pallet',
|
||||
'biz_filling_record', 'biz_customer_site', 'biz_customer']
|
||||
if status == 'DRAFT':
|
||||
tables.append('biz_pallet_inventory')
|
||||
for table in tables:
|
||||
predicate = 'IS NULL' if scope.batch_id is None else '= 31'
|
||||
self.assertIn(f'{table}.test_batch_id {predicate}', combined)
|
||||
self.assertNotIn('FOR UPDATE', combined)
|
||||
|
||||
async def test_inaccessible_order_stops_before_related_queries_or_qr_rendering(self) -> None:
|
||||
for warehouses, sites in (([], [9]), ([7], []), ([8], [10])):
|
||||
with self.subTest(warehouses=warehouses, sites=sites):
|
||||
db = ReadOnlySession(warehouses, sites, None)
|
||||
with (
|
||||
patch.object(PalletService, 'render_qr_png', side_effect=AssertionError('未授权生成二维码')),
|
||||
self.assertRaises(PermissionException),
|
||||
):
|
||||
await self.service.get_labels_services(db, 12, make_current_user(), TestBatchScope.formal())
|
||||
self.assertEqual(3, len(db.statements))
|
||||
|
||||
async def test_cross_batch_relations_are_rejected_before_qr_generation(self) -> None:
|
||||
for index in range(6):
|
||||
with self.subTest(record=index):
|
||||
records = self.records()
|
||||
records[index].test_batch_id = 31
|
||||
db = self.session(records)
|
||||
with (
|
||||
patch.object(PalletService, 'render_qr_png', side_effect=AssertionError('跨批次生成二维码')),
|
||||
self.assertRaises(ServiceException),
|
||||
):
|
||||
await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
|
||||
|
||||
async def test_incomplete_label_does_not_print_partial_or_fall_back_to_current_inventory(self) -> None:
|
||||
for status in ('DRAFT', 'CONFIRMED'):
|
||||
for missing in ('pallet', 'filling', 'quantity', 'snapshot'):
|
||||
with self.subTest(status=status, missing=missing):
|
||||
records = self.records(status)
|
||||
if missing == 'snapshot':
|
||||
records[3].pallet_id = 99
|
||||
db = self.session(records)
|
||||
row = list(db.results[-1][0])
|
||||
if missing != 'snapshot':
|
||||
row[{'pallet': 1, 'filling': 2, 'quantity': 3}[missing]] = None
|
||||
db.results[-1] = [tuple(row)]
|
||||
with self.assertRaises(ServiceException):
|
||||
await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
|
||||
|
||||
async def test_missing_later_pallet_prevents_any_qr_generation(self) -> None:
|
||||
records = self.records()
|
||||
db = self.session(records)
|
||||
second = BizShipmentItem(shipment_item_id=2, shipment_id=12, pallet_id=6,
|
||||
active_filling_id=106, package_qty=20, test_batch_id=None)
|
||||
db.results[1].append(second)
|
||||
db.results[-1].append((second, None, None, 20))
|
||||
with (
|
||||
patch.object(PalletService, 'render_qr_png', side_effect=AssertionError('未完成校验已生成二维码')),
|
||||
self.assertRaises(ServiceException),
|
||||
):
|
||||
await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
|
||||
|
||||
async def test_empty_shipment_is_not_printable(self) -> None:
|
||||
records = self.records()
|
||||
db = self.session(records)
|
||||
db.results[1] = []
|
||||
db.results[-1] = []
|
||||
with self.assertRaises(ServiceException):
|
||||
await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
|
||||
|
||||
async def test_route_serializes_label_dates_and_prevents_cross_scope_browser_cache(self) -> None:
|
||||
db = self.session(self.records())
|
||||
response = await shipment_controller_module.get_business_shipment_labels(
|
||||
Request({'type': 'http'}), 12, db, make_admin(), TestBatchScope.formal(),
|
||||
)
|
||||
payload = json.loads(response.body)
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual('2026-09-01', payload['data'][0]['productionDate'])
|
||||
self.assertEqual('CONFIRMED', payload['data'][0]['shipmentStatus'])
|
||||
self.assertTrue(payload['data'][0]['qrCodeDataUrl'].startswith('data:image/png;base64,'))
|
||||
self.assertEqual('no-store', response.headers.get('Cache-Control'))
|
||||
|
||||
async def test_draft_rejects_disabled_closed_or_excess_quantity(self) -> None:
|
||||
for field in ('asset_status', 'active_slot', 'capacity_packages'):
|
||||
with self.subTest(field=field):
|
||||
records = self.records('DRAFT')
|
||||
target = records[3] if field == 'active_slot' else records[2]
|
||||
setattr(target, field, {'asset_status': '1', 'active_slot': None, 'capacity_packages': 20}[field])
|
||||
with self.assertRaises(ServiceException):
|
||||
await self.service.get_labels_services(
|
||||
self.session(records), 12, make_admin(), TestBatchScope.formal(),
|
||||
)
|
||||
|
||||
|
||||
class ShipmentLabelRouteTest(unittest.TestCase):
|
||||
def test_label_route_reuses_query_permission_and_read_scope(self) -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(shipment_controller_module.shipment_controller)
|
||||
routes = [route for route in app.routes if route.path == '/business/shipment/{shipment_id}/labels']
|
||||
self.assertEqual(1, len(routes), '缺少标签 GET 路由')
|
||||
route = routes[0]
|
||||
self.assertEqual({'GET'}, route.methods)
|
||||
checks = [dep.call for dep in route.dependant.dependencies if isinstance(dep.call, CheckUserInterfaceAuth)]
|
||||
self.assertEqual(['business:shipment:query'], [check.perm for check in checks])
|
||||
scopes = [dep.call for dep in route.dependant.dependencies if isinstance(dep.call, TestBatchScopeResolver)]
|
||||
self.assertEqual([False], [scope.write for scope in scopes])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,140 @@
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from exceptions.exception import PermissionException, ServiceException
|
||||
from module_business.dao.access_dao import AccessDao
|
||||
from module_business.dao.inventory_dao import InventoryDao
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.do.master_do import BizCustomerSite
|
||||
from module_business.entity.vo.shipment_receipt_vo import ShipmentReceiptCommandModel
|
||||
from module_business.service.shipment_receipt_service import ShipmentReceiptService
|
||||
from tests.module_business.test_shipment_service import make_inventory, make_pallet, make_shipment, make_user
|
||||
|
||||
|
||||
class TestShipmentReceipt(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self) -> None:
|
||||
self.db = MagicMock(spec=AsyncSession)
|
||||
self.user = make_user()
|
||||
self.scope = TestBatchScope.formal()
|
||||
self.order = make_shipment(status='CONFIRMED', pallet_ids=(2, 5))
|
||||
self.order.receipt_state = 'PENDING'
|
||||
self.order.confirmed_at = datetime(2026, 10, 8, 8)
|
||||
self.inventories = [
|
||||
make_inventory(i, warehouse_id=None, site_id=9, flow_state='IN_TRANSIT', remaining_packages=100)
|
||||
for i in (2, 5)
|
||||
]
|
||||
for item in self.order.items:
|
||||
item.active_filling_id = 100 + item.pallet_id
|
||||
item.package_qty = 100
|
||||
self.events = []
|
||||
self.site_access = AsyncMock(return_value=True)
|
||||
self.patches = [
|
||||
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
|
||||
patch.object(AccessDao, 'user_has_site', new=self.site_access),
|
||||
patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(return_value=self.order)),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_site',
|
||||
new=AsyncMock(return_value=BizCustomerSite(site_id=9, status='0', test_batch_id=None)),
|
||||
),
|
||||
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=self.inventories)),
|
||||
patch.object(
|
||||
InventoryDao,
|
||||
'get_pallets_by_ids',
|
||||
new=AsyncMock(return_value=[make_pallet(i, capacity=100) for i in (2, 5)]),
|
||||
),
|
||||
patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=self.capture_event)),
|
||||
]
|
||||
for patcher in self.patches:
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
async def capture_event(self, _db, inventory, event, _scope):
|
||||
self.events.append(event)
|
||||
return event
|
||||
|
||||
def command(self, ids=None, key='receipt-key'):
|
||||
return ShipmentReceiptCommandModel(scannedPalletIds=ids if ids is not None else [5, 2], idempotencyKey=key)
|
||||
|
||||
async def receive(self, command=None):
|
||||
return await ShipmentReceiptService.confirm_services(
|
||||
self.db, 12, command or self.command(), self.user, self.scope
|
||||
)
|
||||
|
||||
async def test_receipt_keeps_original_pallet_filling_and_quantity(self) -> None:
|
||||
await self.receive()
|
||||
self.assertEqual(self.order.receipt_state, 'RECEIVED')
|
||||
self.assertEqual(self.order.received_by, 7)
|
||||
self.assertIsInstance(self.order.received_at, datetime)
|
||||
self.assertEqual(
|
||||
[(i.pallet_id, i.active_filling_id, i.remaining_packages, i.flow_state) for i in self.inventories],
|
||||
[
|
||||
(2, 102, 100, 'AT_SITE_UNUSED'),
|
||||
(5, 105, 100, 'AT_SITE_UNUSED'),
|
||||
],
|
||||
)
|
||||
self.assertTrue(all(i.site_arrived_at == self.order.received_at for i in self.inventories))
|
||||
self.assertEqual(
|
||||
[(e.event_type, e.before_state, e.after_state) for e in self.events],
|
||||
[
|
||||
('SHIPMENT_RECEIVED', 'IN_TRANSIT', 'AT_SITE_UNUSED'),
|
||||
('SHIPMENT_RECEIVED', 'IN_TRANSIT', 'AT_SITE_UNUSED'),
|
||||
],
|
||||
)
|
||||
self.db.commit.assert_awaited_once()
|
||||
|
||||
async def test_retry_after_later_consumption_does_not_restore_quantity(self) -> None:
|
||||
await self.receive()
|
||||
self.inventories[0].remaining_packages = 80
|
||||
self.inventories[0].flow_state = 'AT_SITE_PARTIAL'
|
||||
await self.receive(self.command(ids=[2, 5]))
|
||||
self.assertEqual(self.inventories[0].remaining_packages, 80)
|
||||
self.assertEqual(len(self.events), 2)
|
||||
self.db.commit.assert_awaited_once()
|
||||
|
||||
async def test_received_order_rejects_different_confirmation_key(self) -> None:
|
||||
await self.receive()
|
||||
with self.assertRaises(ServiceException):
|
||||
await self.receive(self.command(key='different'))
|
||||
self.assertEqual(len(self.events), 2)
|
||||
|
||||
async def test_missing_or_unexpected_scans_do_not_change_inventory(self) -> None:
|
||||
for ids in ([2], [2, 5, 6], [2, 6]):
|
||||
with self.subTest(ids=ids), self.assertRaises(ServiceException):
|
||||
await self.receive(self.command(ids=ids))
|
||||
self.assertEqual(self.order.receipt_state, 'PENDING')
|
||||
self.assertFalse(self.events)
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
async def test_target_site_access_is_required_even_with_warehouse_permissions(self) -> None:
|
||||
self.site_access.return_value = False
|
||||
with self.assertRaises(PermissionException):
|
||||
await self.receive()
|
||||
self.assertFalse(self.events)
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
async def test_draft_and_legacy_cannot_be_received_as_new_shipments(self) -> None:
|
||||
for status, receipt_state in [('DRAFT', 'PENDING'), ('CONFIRMED', 'LEGACY')]:
|
||||
self.order.status = status
|
||||
self.order.receipt_state = receipt_state
|
||||
with self.subTest(status=status, receipt_state=receipt_state), self.assertRaises(ServiceException):
|
||||
await self.receive()
|
||||
self.assertFalse(self.events)
|
||||
|
||||
async def test_changed_material_snapshot_is_rejected_before_any_mutation(self) -> None:
|
||||
self.inventories[1].active_filling_id = 999
|
||||
with self.assertRaises(ServiceException):
|
||||
await self.receive()
|
||||
self.assertEqual(self.inventories[0].flow_state, 'IN_TRANSIT')
|
||||
self.assertFalse(self.events)
|
||||
|
||||
async def test_cross_test_batch_inventory_is_rejected(self) -> None:
|
||||
self.inventories[1].test_batch_id = 3
|
||||
with self.assertRaises(ServiceException):
|
||||
await self.receive()
|
||||
self.assertFalse(self.events)
|
||||
@@ -0,0 +1,467 @@
|
||||
"""收货独立回归:真实内存查询/序列化,以及确认服务的确定性并发边界。
|
||||
|
||||
从 backend 目录运行(不连接业务数据库、不生成日志文件或 pyc):
|
||||
PYTHONDONTWRITEBYTECODE=1 LOG_FILE_ENABLED=false <python> -B -m unittest \
|
||||
tests.module_business.test_shipment_receipt_reads -v
|
||||
|
||||
SQLite 仅验证读取;确认测试的锁替身不代表 MySQL 的实际锁调度。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy import create_engine, event, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.schema import CreateTable
|
||||
|
||||
from common.annotation.log_annotation import Log
|
||||
from common.aspect.pre_auth import PreAuth
|
||||
from common.context import RequestContext
|
||||
from config.database import async_engine as business_engine
|
||||
from config.get_db import get_db
|
||||
from exceptions.exception import PermissionException, ServiceException
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel
|
||||
from module_admin.service.log_service import LogQueueService
|
||||
from module_admin.service.login_service import LoginService
|
||||
from module_business.controller.shipment_receipt_controller import shipment_receipt_controller
|
||||
from module_business.dao.access_dao import AccessDao
|
||||
from module_business.dao.inventory_dao import InventoryDao
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
from module_business.dependency.test_batch_dependency import TestBatchScopeResolver
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.do.master_do import (
|
||||
BizCustomerSite, BizPallet, BizUserSite, BizUserWarehouse, BizWarehouse,
|
||||
)
|
||||
from module_business.entity.do.operation_do import (
|
||||
BizFillingRecord, BizPalletInventory, BizShipmentItem, BizShipmentOrder,
|
||||
)
|
||||
from module_business.entity.vo.shipment_receipt_vo import ShipmentReceiptCommandModel, ShipmentReceiptQueryModel
|
||||
from module_business.service.shipment_receipt_service import ShipmentReceiptService
|
||||
from module_business.service.test_batch_access_service import TestBatchAccessService
|
||||
|
||||
|
||||
NOW = datetime(2026, 10, 8, 8)
|
||||
|
||||
|
||||
def forbid_business_connection(*_args, **_kwargs):
|
||||
raise AssertionError('收货测试禁止连接业务数据库')
|
||||
|
||||
|
||||
def setUpModule():
|
||||
event.listen(business_engine.sync_engine, 'do_connect', forbid_business_connection)
|
||||
|
||||
|
||||
def tearDownModule():
|
||||
event.remove(business_engine.sync_engine, 'do_connect', forbid_business_connection)
|
||||
|
||||
|
||||
def user(user_id=7):
|
||||
return CurrentUserModel(
|
||||
permissions=['business:shipment:receive'], roles=[],
|
||||
user=UserInfoModel(userId=user_id, userName=f'user-{user_id}'),
|
||||
)
|
||||
|
||||
|
||||
def batch_scope(batch_id=None):
|
||||
if batch_id is None:
|
||||
return TestBatchScope.formal()
|
||||
return TestBatchScope(batch_id=batch_id, mode='TEST', status='ACTIVE', writable=True)
|
||||
|
||||
|
||||
def order_values(shipment_id=12, site_id=9, batch_id=None, state='PENDING', status='CONFIRMED'):
|
||||
return dict(
|
||||
shipment_id=shipment_id, shipment_no=f'SH-{shipment_id}', source_warehouse_id=7,
|
||||
target_site_id=site_id, test_batch_id=batch_id, status=status, receipt_state=state,
|
||||
confirmed_at=NOW, confirmed_by=7,
|
||||
)
|
||||
|
||||
|
||||
def item_values(shipment_id, pallet_id, batch_id=None):
|
||||
return dict(
|
||||
shipment_item_id=shipment_id * 100 + pallet_id, shipment_id=shipment_id,
|
||||
pallet_id=pallet_id, active_filling_id=100 + pallet_id, package_qty=100, test_batch_id=batch_id,
|
||||
)
|
||||
|
||||
|
||||
def pallet_values(pallet_id, batch_id=None):
|
||||
return dict(
|
||||
pallet_id=pallet_id, pallet_code=f'P-{pallet_id}', qr_token=f'QR-{pallet_id}',
|
||||
capacity_packages=100, asset_status='0', test_batch_id=batch_id,
|
||||
)
|
||||
|
||||
|
||||
def filling_values(pallet_id, batch_id=None):
|
||||
return dict(
|
||||
filling_id=100 + pallet_id, filling_no=f'F-{pallet_id}', pallet_id=pallet_id,
|
||||
warehouse_id=7, strain_id=1, strain_code_snapshot='S-1', strain_name_snapshot='原菌种',
|
||||
batch_no=f'B-OLD-{pallet_id}', production_date=date(2026, 10, 1), expiry_date=date(2026, 11, 1),
|
||||
package_spec='标准包', initial_packages=100, active_slot='ACTIVE',
|
||||
operator_id=7, operated_at=NOW, idempotency_key=f'fill-{pallet_id}', request_hash='f' * 64,
|
||||
test_batch_id=batch_id,
|
||||
)
|
||||
|
||||
|
||||
def inventory_values(pallet_id, site_id=9, batch_id=None):
|
||||
return dict(
|
||||
pallet_id=pallet_id, active_filling_id=100 + pallet_id, site_id=site_id,
|
||||
warehouse_id=None, tunnel_id=None, trace_no=None, current_line_id=None,
|
||||
flow_state='IN_TRANSIT', remaining_packages=100, version=3, test_batch_id=batch_id,
|
||||
)
|
||||
|
||||
|
||||
class MemorySession:
|
||||
"""保留真实 SQLAlchemy ORM Row,仅适配 DAO 所需的异步 execute 接口。"""
|
||||
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
async def execute(self, statement):
|
||||
return self.session.execute(statement)
|
||||
|
||||
|
||||
class TestShipmentReceiptReads(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.engine = create_engine('sqlite+pysqlite:///:memory:')
|
||||
self.addCleanup(self.engine.dispose)
|
||||
# 仅建读取涉及的表;外键/迁移契约由独立 SQL 测试负责。
|
||||
models = (BizCustomerSite, BizUserSite, BizUserWarehouse, BizWarehouse, BizPallet,
|
||||
BizFillingRecord, BizPalletInventory, BizShipmentOrder, BizShipmentItem)
|
||||
with self.engine.begin() as connection:
|
||||
for model in models:
|
||||
connection.execute(CreateTable(model.__table__, include_foreign_key_constraints=[]))
|
||||
for site_id, batch_id in ((9, None), (10, None), (19, 41), (29, 42)):
|
||||
connection.execute(BizCustomerSite.__table__.insert(), dict(
|
||||
site_id=site_id, customer_id=1, site_code=f'SITE-{site_id}',
|
||||
site_name=f'现场{site_id}', status='0', test_batch_id=batch_id,
|
||||
))
|
||||
connection.execute(BizWarehouse.__table__.insert(), dict(
|
||||
warehouse_id=7, warehouse_code='W-7', warehouse_name='原仓库', status='0',
|
||||
))
|
||||
for site_id in (9, 19, 29):
|
||||
connection.execute(BizUserSite.__table__.insert(), dict(user_id=7, site_id=site_id))
|
||||
connection.execute(BizUserWarehouse.__table__.insert(), dict(user_id=8, warehouse_id=7))
|
||||
for pallet_id, site_id, batch_id in ((2, 9, None), (5, 9, None), (3, 10, None), (52, 19, 41), (62, 29, 42)):
|
||||
connection.execute(BizPallet.__table__.insert(), pallet_values(pallet_id, batch_id))
|
||||
connection.execute(BizFillingRecord.__table__.insert(), filling_values(pallet_id, batch_id))
|
||||
connection.execute(BizPalletInventory.__table__.insert(), inventory_values(pallet_id, site_id, batch_id))
|
||||
orders = [order_values(), order_values(13, 10), order_values(14, status='DRAFT'),
|
||||
order_values(15, 19, 41), order_values(16, 29, 42),
|
||||
order_values(17, state='RECEIVED'), order_values(18, state='LEGACY')]
|
||||
for order in orders:
|
||||
connection.execute(BizShipmentOrder.__table__.insert(), order)
|
||||
for shipment_id, pallet_ids, batch_id in ((12, (2, 5), None), (13, (3,), None),
|
||||
(15, (52,), 41), (16, (62,), 42),
|
||||
(17, (2,), None), (18, (5,), None)):
|
||||
for pallet_id in pallet_ids:
|
||||
connection.execute(BizShipmentItem.__table__.insert(), item_values(shipment_id, pallet_id, batch_id))
|
||||
self.session = Session(self.engine, autoflush=False)
|
||||
self.addCleanup(self.session.close)
|
||||
self.db = MemorySession(self.session)
|
||||
self.user = user()
|
||||
self.scope = batch_scope()
|
||||
|
||||
async def listing(self, **query):
|
||||
return await ShipmentReceiptService.list_services(
|
||||
self.db, ShipmentReceiptQueryModel(**query), self.user, self.scope,
|
||||
)
|
||||
|
||||
async def detail(self, shipment_id=12):
|
||||
return await ShipmentReceiptService.detail_services(self.db, shipment_id, self.user, self.scope)
|
||||
|
||||
async def scan(self, identifier='QR-2', shipment_id=12):
|
||||
return await ShipmentReceiptService.scan_services(self.db, shipment_id, identifier, self.user, self.scope)
|
||||
|
||||
async def test_list_executes_real_query_and_pageutil_serialization(self):
|
||||
page = await self.listing(pageSize=1)
|
||||
payload = page.model_dump(by_alias=True, mode='json')
|
||||
self.assertEqual(payload['total'], 3)
|
||||
self.assertTrue(payload['hasNext'])
|
||||
self.assertEqual(payload['rows'][0]['shipmentId'], 12)
|
||||
self.assertEqual(payload['rows'][0]['receiptState'], 'PENDING')
|
||||
self.assertNotIn('testBatchId', json.dumps(payload))
|
||||
next_page = await self.listing(pageSize=1, pageNum=2)
|
||||
self.assertEqual(next_page.rows[0].shipment_id, 17)
|
||||
|
||||
async def test_list_filters_original_route_receipt_state_and_confirmed_date(self):
|
||||
page = await self.listing(shipmentNo='SH-12', sourceWarehouseId=7, targetSiteId=9,
|
||||
receiptState='PENDING', beginTime='2026-10-08', endTime='2026-10-08')
|
||||
self.assertEqual([row.shipment_id for row in page.rows], [12])
|
||||
self.assertEqual((await self.listing(beginTime='2026-10-09')).total, 0)
|
||||
|
||||
async def test_list_keeps_formal_and_each_test_batch_separate(self):
|
||||
for batch_id, expected in ((None, [12, 17, 18]), (41, [15]), (42, [16])):
|
||||
with self.subTest(batch_id=batch_id):
|
||||
self.scope = batch_scope(batch_id)
|
||||
self.assertEqual([row.shipment_id for row in (await self.listing()).rows], expected)
|
||||
|
||||
async def test_warehouse_only_user_cannot_list_detail_or_scan_receipts(self):
|
||||
self.user = user(8)
|
||||
self.assertEqual((await self.listing()).total, 0)
|
||||
for operation in (self.detail, self.scan):
|
||||
with self.subTest(operation=operation.__name__), self.assertRaises(PermissionException):
|
||||
await operation()
|
||||
|
||||
async def test_target_site_filter_cannot_expand_user_access(self):
|
||||
with self.assertRaises(PermissionException):
|
||||
await self.listing(targetSiteId=10)
|
||||
with self.assertRaises(PermissionException):
|
||||
await self.detail(13)
|
||||
with self.assertRaises(PermissionException):
|
||||
await self.scan('QR-3', 13)
|
||||
|
||||
async def test_detail_and_scan_cannot_read_other_batch_order(self):
|
||||
with self.assertRaises(PermissionException):
|
||||
await self.detail(15)
|
||||
with self.assertRaises(PermissionException):
|
||||
await self.scan('QR-52', 15)
|
||||
self.scope = batch_scope(41)
|
||||
self.assertEqual((await self.detail(15)).items[0].pallet_id, 52)
|
||||
self.assertEqual((await self.scan('QR-52', 15)).pallet_id, 52)
|
||||
with self.assertRaises(PermissionException):
|
||||
await self.detail(16)
|
||||
|
||||
async def test_detail_reads_frozen_filling_after_pallet_refill(self):
|
||||
self.session.execute(update(BizFillingRecord).where(BizFillingRecord.filling_id == 102).values(
|
||||
active_slot=None, closed_at=NOW,
|
||||
))
|
||||
replacement = filling_values(2)
|
||||
replacement.update(filling_id=202, filling_no='F-NEW-2', batch_no='B-NEW', idempotency_key='new-fill')
|
||||
self.session.execute(BizFillingRecord.__table__.insert(), replacement)
|
||||
self.session.execute(update(BizPalletInventory).where(BizPalletInventory.pallet_id == 2).values(
|
||||
active_filling_id=202, remaining_packages=20, flow_state='AT_SITE_PARTIAL',
|
||||
))
|
||||
item = (await self.detail(17)).items[0]
|
||||
self.assertEqual((item.active_filling_id, item.package_qty, item.batch_no), (102, 100, 'B-OLD-2'))
|
||||
self.assertEqual(item.production_date, date(2026, 10, 1))
|
||||
self.assertEqual(item.expiry_date, date(2026, 11, 1))
|
||||
|
||||
async def test_legacy_detail_preserves_unknown_dates(self):
|
||||
self.session.execute(update(BizFillingRecord).where(BizFillingRecord.filling_id == 105).values(
|
||||
production_date=None, expiry_date=None,
|
||||
))
|
||||
detail = await self.detail(18)
|
||||
self.assertEqual(detail.receipt_state, 'LEGACY')
|
||||
self.assertIsNone(detail.items[0].production_date)
|
||||
self.assertIsNone(detail.items[0].expiry_date)
|
||||
|
||||
async def test_detail_rejects_original_filling_from_another_batch(self):
|
||||
self.session.execute(update(BizFillingRecord).where(BizFillingRecord.filling_id == 102).values(test_batch_id=41))
|
||||
with self.assertRaises(ServiceException):
|
||||
await self.detail()
|
||||
|
||||
async def test_detail_rejects_filling_belonging_to_another_pallet(self):
|
||||
self.session.execute(update(BizShipmentItem).where(BizShipmentItem.shipment_item_id == 1202).values(active_filling_id=105))
|
||||
with self.assertRaises(ServiceException):
|
||||
await self.detail()
|
||||
|
||||
async def test_scan_accepts_printed_pallet_and_returns_canonical_identity(self):
|
||||
for identifier in ('QR-2', ' P-2 '):
|
||||
with self.subTest(identifier=identifier):
|
||||
item = await self.scan(identifier)
|
||||
self.assertEqual((item.pallet_id, item.qr_token, item.package_qty), (2, 'QR-2', 100))
|
||||
|
||||
async def test_scan_rejects_wrong_order_and_cross_batch_pallet(self):
|
||||
for identifier in ('QR-3', 'QR-52', 'MISSING'):
|
||||
with self.subTest(identifier=identifier), self.assertRaises(ServiceException):
|
||||
await self.scan(identifier)
|
||||
|
||||
async def test_scan_rejects_changed_quantity_before_receiving(self):
|
||||
self.session.execute(update(BizPalletInventory).where(BizPalletInventory.pallet_id == 2).values(remaining_packages=90))
|
||||
with self.assertRaises(ServiceException):
|
||||
await self.scan()
|
||||
|
||||
async def test_scan_rejects_received_and_legacy_orders(self):
|
||||
for shipment_id in (17, 18):
|
||||
with self.subTest(shipment_id=shipment_id), self.assertRaises(ServiceException):
|
||||
await self.scan('QR-2', shipment_id)
|
||||
|
||||
async def test_controller_list_detail_scan_run_through_asgi(self):
|
||||
app = FastAPI()
|
||||
app.include_router(shipment_receipt_controller)
|
||||
|
||||
async def current_user():
|
||||
RequestContext.set_current_user(self.user)
|
||||
RequestContext.set_current_exclude_patterns([])
|
||||
return self.user
|
||||
|
||||
async def current_db():
|
||||
return self.db
|
||||
|
||||
async def current_scope():
|
||||
return self.scope
|
||||
|
||||
app.dependency_overrides[get_db] = current_db
|
||||
app.dependency_overrides[LoginService.get_current_user] = current_user
|
||||
for route in shipment_receipt_controller.routes:
|
||||
for dependency in route.dependant.dependencies:
|
||||
if isinstance(dependency.call, PreAuth):
|
||||
app.dependency_overrides[dependency.call] = current_user
|
||||
if isinstance(dependency.call, TestBatchScopeResolver):
|
||||
app.dependency_overrides[dependency.call] = current_scope
|
||||
# 只替换登录来源、批次解析、日志队列和 IP 归属;接口权限校验仍真实执行。
|
||||
with patch.object(Log, '_get_oper_location', new=AsyncMock(return_value='内存测试')), \
|
||||
patch.object(LogQueueService, 'enqueue_operation_log', new=AsyncMock()):
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://receipt.test') as client:
|
||||
listing = await client.get('/business/shipment-receipt/list', params={'receiptState': 'PENDING'})
|
||||
self.assertEqual(listing.status_code, 200)
|
||||
self.assertEqual([r['shipmentId'] for r in listing.json()['rows']], [12])
|
||||
detail = await client.get('/business/shipment-receipt/12')
|
||||
self.assertEqual(detail.status_code, 200)
|
||||
self.assertEqual(detail.json()['data']['items'][0]['batchNo'], 'B-OLD-2')
|
||||
scanned = await client.get('/business/shipment-receipt/12/scan/P-2')
|
||||
self.assertEqual(scanned.status_code, 200)
|
||||
self.assertEqual(scanned.json()['data']['qrToken'], 'QR-2')
|
||||
|
||||
|
||||
class TestShipmentReceiptConfirmBoundaries(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.user = user()
|
||||
self.scope = batch_scope()
|
||||
self.db = MagicMock(spec=AsyncSession)
|
||||
self.order = BizShipmentOrder(**order_values())
|
||||
self.order.items = [BizShipmentItem(**item_values(12, pallet_id)) for pallet_id in (2, 5)]
|
||||
self.inventories = [BizPalletInventory(**inventory_values(pallet_id)) for pallet_id in (2, 5)]
|
||||
self.events = []
|
||||
self.batch_lock = self.patch(TestBatchAccessService, 'lock_active_batch', AsyncMock())
|
||||
self.site_ids = self.patch(AccessDao, 'list_user_site_ids', AsyncMock(return_value=[9]))
|
||||
self.site_access = self.patch(AccessDao, 'user_has_site', AsyncMock(return_value=True))
|
||||
self.order_lock = self.patch(OperationDao, 'get_shipment_for_update', AsyncMock(return_value=self.order))
|
||||
self.inventory_lock = self.patch(InventoryDao, 'get_inventories_for_update', AsyncMock(return_value=self.inventories))
|
||||
self.patch(InventoryDao, 'get_pallets_by_ids', AsyncMock(return_value=[BizPallet(**pallet_values(i)) for i in (2, 5)]))
|
||||
self.patch(MasterDao, 'get_site', AsyncMock(return_value=BizCustomerSite(site_id=9, status='0', test_batch_id=None)))
|
||||
self.patch(InventoryDao, 'add_event', AsyncMock(side_effect=self.capture_event))
|
||||
|
||||
def patch(self, owner, name, replacement):
|
||||
patcher = patch.object(owner, name, new=replacement)
|
||||
result = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
return result
|
||||
|
||||
async def capture_event(self, _db, _inventory, receipt_event, _scope):
|
||||
self.events.append(receipt_event)
|
||||
await asyncio.sleep(0)
|
||||
return receipt_event
|
||||
|
||||
def command(self, ids=(5, 2), key='receipt-key'):
|
||||
return ShipmentReceiptCommandModel(scannedPalletIds=list(ids), idempotencyKey=key)
|
||||
|
||||
async def confirm(self, command=None, db=None):
|
||||
return await ShipmentReceiptService.confirm_services(
|
||||
db if db is not None else self.db, 12,
|
||||
command if command is not None else self.command(), self.user, self.scope,
|
||||
)
|
||||
|
||||
async def test_permission_revoked_after_order_lock_prevents_any_mutation(self):
|
||||
async def revoked(_db, _user_id, _site_id, *, for_update=False):
|
||||
self.order_lock.assert_awaited_once()
|
||||
self.assertTrue(for_update)
|
||||
return False
|
||||
self.site_access.side_effect = revoked
|
||||
with self.assertRaises(PermissionException):
|
||||
await self.confirm()
|
||||
self.inventory_lock.assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
self.assertEqual(self.order.receipt_state, 'PENDING')
|
||||
|
||||
async def test_idempotent_replay_also_requires_current_target_permission(self):
|
||||
await self.confirm()
|
||||
self.site_access.return_value = False
|
||||
with self.assertRaises(PermissionException):
|
||||
await self.confirm()
|
||||
self.assertEqual(len(self.events), 2)
|
||||
self.assertEqual(self.db.commit.await_count, 1)
|
||||
|
||||
async def test_same_key_different_payload_is_rejected(self):
|
||||
await self.confirm()
|
||||
with self.assertRaises(ServiceException):
|
||||
await self.confirm(self.command(ids=(2,)))
|
||||
self.assertEqual(len(self.events), 2)
|
||||
self.assertEqual(self.db.commit.await_count, 1)
|
||||
|
||||
async def test_same_key_permuted_set_replays_without_restoring_used_inventory(self):
|
||||
await self.confirm()
|
||||
self.inventories[0].remaining_packages = 80
|
||||
self.inventories[0].flow_state = 'AT_SITE_PARTIAL'
|
||||
await self.confirm(self.command(ids=(2, 5)))
|
||||
self.assertEqual(self.inventories[0].remaining_packages, 80)
|
||||
self.assertEqual(len(self.events), 2)
|
||||
self.assertEqual(self.db.commit.await_count, 1)
|
||||
|
||||
async def test_closed_test_batch_is_rejected_before_order_lock(self):
|
||||
self.scope = batch_scope(41)
|
||||
self.batch_lock.side_effect = PermissionException(data='', message='测试批次已关闭')
|
||||
with self.assertRaises(PermissionException):
|
||||
await self.confirm()
|
||||
self.order_lock.assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_cross_batch_order_is_rejected_even_if_dao_returns_it(self):
|
||||
self.order.test_batch_id = 41
|
||||
with self.assertRaises(ServiceException):
|
||||
await self.confirm()
|
||||
self.assertFalse(self.events)
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
async def race(self, second_command):
|
||||
"""模拟 DAO 的父单排他锁,第二个请求拿锁后必须重读已提交状态。"""
|
||||
gate = asyncio.Lock()
|
||||
attempted = []
|
||||
|
||||
async def lock_order(db, _shipment_id, _scope, **access):
|
||||
self.assertEqual(access, {'warehouse_ids': None, 'site_ids': [9]})
|
||||
attempted.append(db)
|
||||
await gate.acquire()
|
||||
db.owns_lock = True
|
||||
return self.order
|
||||
|
||||
def connection():
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.owns_lock = False
|
||||
|
||||
async def release():
|
||||
if db.owns_lock:
|
||||
db.owns_lock = False
|
||||
gate.release()
|
||||
|
||||
db.commit = AsyncMock(side_effect=release)
|
||||
db.rollback = AsyncMock(side_effect=release)
|
||||
return db
|
||||
|
||||
first, second = connection(), connection()
|
||||
self.order_lock.side_effect = lock_order
|
||||
results = await asyncio.wait_for(asyncio.gather(
|
||||
self.confirm(db=first), self.confirm(second_command, db=second), return_exceptions=True,
|
||||
), timeout=2)
|
||||
self.assertEqual(len(attempted), 2)
|
||||
self.assertEqual(first.commit.await_count + second.commit.await_count, 1)
|
||||
self.assertEqual(len(self.events), 2)
|
||||
self.assertEqual([inventory.version for inventory in self.inventories], [4, 4])
|
||||
self.assertTrue(all(inventory.remaining_packages == 100 for inventory in self.inventories))
|
||||
return results
|
||||
|
||||
async def test_concurrent_identical_confirmations_append_events_once(self):
|
||||
results = await self.race(self.command(ids=(2, 5)))
|
||||
self.assertTrue(all(not isinstance(result, Exception) and result.is_success for result in results))
|
||||
|
||||
async def test_concurrent_same_key_different_payload_rejects_second_request(self):
|
||||
results = await self.race(self.command(ids=(2,)))
|
||||
self.assertTrue(results[0].is_success)
|
||||
self.assertIsInstance(results[1], ServiceException)
|
||||
|
||||
async def test_concurrent_different_keys_do_not_confirm_twice(self):
|
||||
results = await self.race(self.command(key='other-key'))
|
||||
self.assertTrue(results[0].is_success)
|
||||
self.assertIsInstance(results[1], ServiceException)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -52,6 +52,7 @@ from module_business.entity.vo.operation_vo import (
|
||||
ShipmentScanQueryModel,
|
||||
ShipmentUpdateModel,
|
||||
)
|
||||
from module_business.entity.vo.shipment_label_vo import ShipmentLabelModel
|
||||
from module_business.service import shipment_service as shipment_service_module
|
||||
from module_business.service.access_service import AccessService
|
||||
from module_business.service.shipment_service import ShipmentService
|
||||
@@ -395,12 +396,12 @@ class ShipmentControllerContractTest(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _routes() -> dict[tuple[str, str], APIRoute]:
|
||||
return {
|
||||
(route.path, next(iter(route.methods))): route
|
||||
(route.path_format, next(iter(route.methods))): route
|
||||
for route in shipment_controller_module.shipment_controller.routes
|
||||
if isinstance(route, APIRoute)
|
||||
}
|
||||
|
||||
def test_openapi_has_nine_operations_aliases_and_response_models(self) -> None:
|
||||
def test_openapi_has_ten_operations_aliases_and_response_models(self) -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(shipment_controller_module.shipment_controller)
|
||||
schema = app.openapi()
|
||||
@@ -410,6 +411,7 @@ class ShipmentControllerContractTest(unittest.TestCase):
|
||||
('/business/shipment/site-options', 'get'),
|
||||
('/business/shipment/scan/{qr_token}', 'get'),
|
||||
('/business/shipment/{shipment_id}', 'get'),
|
||||
('/business/shipment/{shipment_id}/labels', 'get'),
|
||||
('/business/shipment', 'post'),
|
||||
('/business/shipment', 'put'),
|
||||
('/business/shipment/{shipment_ids}', 'delete'),
|
||||
@@ -437,6 +439,10 @@ class ShipmentControllerContractTest(unittest.TestCase):
|
||||
DataResponseModel[ShipmentModel],
|
||||
routes[('/business/shipment/{shipment_id}', 'GET')].response_model,
|
||||
)
|
||||
self.assertEqual(
|
||||
DataResponseModel[list[ShipmentLabelModel]],
|
||||
routes[('/business/shipment/{shipment_id}/labels', 'GET')].response_model,
|
||||
)
|
||||
for key in (
|
||||
('/business/shipment', 'POST'),
|
||||
('/business/shipment', 'PUT'),
|
||||
@@ -446,7 +452,7 @@ class ShipmentControllerContractTest(unittest.TestCase):
|
||||
self.assertEqual(ResponseBaseModel, routes[key].response_model)
|
||||
|
||||
def test_static_options_and_scan_precede_dynamic_detail(self) -> None:
|
||||
paths = [route.path for route in shipment_controller_module.shipment_controller.routes]
|
||||
paths = [route.path_format for route in shipment_controller_module.shipment_controller.routes]
|
||||
detail_index = paths.index('/business/shipment/{shipment_id}')
|
||||
for static_path in (
|
||||
'/business/shipment/warehouse-options',
|
||||
@@ -462,6 +468,7 @@ class ShipmentControllerContractTest(unittest.TestCase):
|
||||
('/business/shipment/site-options', 'GET'): 'business:shipment:query',
|
||||
('/business/shipment/scan/{qr_token}', 'GET'): 'business:shipment:query',
|
||||
('/business/shipment/{shipment_id}', 'GET'): 'business:shipment:query',
|
||||
('/business/shipment/{shipment_id}/labels', 'GET'): 'business:shipment:query',
|
||||
('/business/shipment', 'POST'): 'business:shipment:add',
|
||||
('/business/shipment', 'PUT'): 'business:shipment:edit',
|
||||
('/business/shipment/{shipment_ids}', 'DELETE'): 'business:shipment:list',
|
||||
@@ -1822,8 +1829,8 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
|
||||
'warehouse-mapping-lock',
|
||||
'site-lock',
|
||||
'site-mapping-lock',
|
||||
'transition-FILLED_IN_WAREHOUSE-AT_SITE_UNUSED',
|
||||
'transition-FILLED_IN_WAREHOUSE-AT_SITE_UNUSED',
|
||||
'transition-FILLED_IN_WAREHOUSE-IN_TRANSIT',
|
||||
'transition-FILLED_IN_WAREHOUSE-IN_TRANSIT',
|
||||
'update-order',
|
||||
'event-2',
|
||||
'event-5',
|
||||
@@ -1843,6 +1850,9 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(self.request_hash, shipment.confirm_request_hash)
|
||||
self.assertEqual(7, shipment.confirmed_by)
|
||||
self.assertEqual(self.fixed_time, shipment.confirmed_at)
|
||||
self.assertEqual('PENDING', shipment.receipt_state)
|
||||
self.assertIsNone(shipment.received_by)
|
||||
self.assertIsNone(shipment.received_at)
|
||||
snapshots = {item.pallet_id: (item.active_filling_id, item.package_qty) for item in shipment.items}
|
||||
self.assertEqual({2: (102, 8), 5: (105, 13)}, snapshots)
|
||||
for inventory, original_version, remaining in zip(
|
||||
@@ -1851,7 +1861,8 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
|
||||
(8, 13),
|
||||
strict=True,
|
||||
):
|
||||
self.assertEqual('AT_SITE_UNUSED', inventory.flow_state)
|
||||
self.assertEqual('IN_TRANSIT', inventory.flow_state)
|
||||
self.assertIsNone(inventory.site_arrived_at)
|
||||
self.assertIsNone(inventory.warehouse_id)
|
||||
self.assertEqual(9, inventory.site_id)
|
||||
self.assertIsNone(inventory.current_line_id)
|
||||
@@ -1865,7 +1876,7 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual('SHIPMENT', event.source_type)
|
||||
self.assertEqual(12, event.source_id)
|
||||
self.assertEqual('FILLED_IN_WAREHOUSE', event.before_state)
|
||||
self.assertEqual('AT_SITE_UNUSED', event.after_state)
|
||||
self.assertEqual('IN_TRANSIT', event.after_state)
|
||||
self.assertEqual(7, event.before_warehouse_id)
|
||||
self.assertIsNone(event.before_site_id)
|
||||
self.assertIsNone(event.after_warehouse_id)
|
||||
|
||||
@@ -10,6 +10,7 @@ PATCH_PATH = SQL_ROOT / 'patches/20260724_pallet_workflow_mysql.sql'
|
||||
MENU_PROMOTION_PATCH_PATH = SQL_ROOT / 'patches/20260727_promote_business_menus_mysql.sql'
|
||||
BUSINESS_MENU_ICON_PATCH_PATH = SQL_ROOT / 'patches/20260728_add_business_menu_icons_mysql.sql'
|
||||
TEST_BATCH_MYSQL_PATCH_PATH = SQL_ROOT / 'patches/20260922_test_data_batch_mysql.sql'
|
||||
SHIPMENT_RECEIPT_PATCH_PATH = SQL_ROOT / 'patches/20261008_shipment_receipt_mysql.sql'
|
||||
SYS_MENU_VALUE_COUNT = 20
|
||||
SYS_MENU_PERMS_INDEX = 13
|
||||
SYS_MENU_ICON_INDEX = 14
|
||||
@@ -374,8 +375,79 @@ LEGACY_EXPECTED_UNIQUE_KEYS = {table: set(keys) for table, keys in EXPECTED_UNIQ
|
||||
LEGACY_EXPECTED_FOREIGN_KEYS = {table: set(keys) for table, keys in EXPECTED_FOREIGN_KEYS.items()}
|
||||
LEGACY_EXPECTED_CHECKS = {table: set(checks) for table, checks in EXPECTED_CHECKS.items()}
|
||||
|
||||
EXPECTED_COLUMNS['biz_filling_record'].update({'production_date', 'expiry_date'})
|
||||
EXPECTED_CHECKS['biz_filling_record'].add(
|
||||
'(production_dateisnullandexpiry_dateisnull)or(production_dateisnotnullandexpiry_dateisnotnullandexpiry_date>=production_date)'
|
||||
)
|
||||
|
||||
CURRENT_TEST_BATCH_OWNED_TABLES = TEST_BATCH_OWNED_TABLES | {'biz_tunnel'}
|
||||
EXPECTED_TABLES.add('biz_tunnel')
|
||||
EXPECTED_COLUMNS.update(
|
||||
{
|
||||
'biz_tunnel': {
|
||||
'tunnel_id',
|
||||
'site_id',
|
||||
'tunnel_code',
|
||||
'tunnel_name',
|
||||
'current_trace_no',
|
||||
'current_trace_started_at',
|
||||
'status',
|
||||
}
|
||||
| AUDIT_COLUMNS,
|
||||
'biz_usage_record': EXPECTED_COLUMNS['biz_usage_record']
|
||||
| {'tunnel_id', 'trace_no', 'batch_no_snapshot'},
|
||||
'biz_return_item': EXPECTED_COLUMNS['biz_return_item']
|
||||
| {'tunnel_id_snapshot', 'trace_no_snapshot', 'batch_no_snapshot'},
|
||||
'biz_pallet_inventory': EXPECTED_COLUMNS['biz_pallet_inventory'] | {'tunnel_id', 'trace_no'},
|
||||
'biz_pallet_event': EXPECTED_COLUMNS['biz_pallet_event']
|
||||
| {'before_tunnel_id', 'after_tunnel_id', 'trace_no', 'batch_no_snapshot'},
|
||||
}
|
||||
)
|
||||
EXPECTED_DATETIME_COLUMNS['biz_tunnel'] = {'current_trace_started_at', 'create_time', 'update_time'}
|
||||
EXPECTED_PRIMARY_KEYS['biz_tunnel'] = ('tunnel_id',)
|
||||
EXPECTED_UNIQUE_KEYS['biz_tunnel'] = {('site_id', 'tunnel_code')}
|
||||
EXPECTED_FOREIGN_KEYS.update(
|
||||
{
|
||||
'biz_tunnel': {('site_id', 'biz_customer_site.site_id')},
|
||||
'biz_usage_record': EXPECTED_FOREIGN_KEYS['biz_usage_record']
|
||||
| {('tunnel_id', 'biz_tunnel.tunnel_id')},
|
||||
'biz_return_item': EXPECTED_FOREIGN_KEYS['biz_return_item']
|
||||
| {('tunnel_id_snapshot', 'biz_tunnel.tunnel_id')},
|
||||
'biz_pallet_inventory': EXPECTED_FOREIGN_KEYS['biz_pallet_inventory']
|
||||
| {('tunnel_id', 'biz_tunnel.tunnel_id')},
|
||||
'biz_pallet_event': EXPECTED_FOREIGN_KEYS['biz_pallet_event']
|
||||
| {
|
||||
('before_tunnel_id', 'biz_tunnel.tunnel_id'),
|
||||
('after_tunnel_id', 'biz_tunnel.tunnel_id'),
|
||||
},
|
||||
}
|
||||
)
|
||||
EXPECTED_CHECKS['biz_tunnel'] = {"statusin('0','1')"}
|
||||
EXPECTED_CHECKS['biz_pallet_event'] = {
|
||||
"event_typein('inbound_confirmed','filling_confirmed','shipment_confirmed','shipment_received','tunnel_entered','usage_recorded',"
|
||||
"'return_requested','return_confirmed')"
|
||||
}
|
||||
|
||||
# 新契约必须位于 LEGACY_EXPECTED_* 的独立副本之后,旧补丁保持原始结构。
|
||||
for fifo_table in ('biz_usage_record', 'biz_pallet_event'):
|
||||
EXPECTED_COLUMNS[fifo_table].update({'fifo_skip_reason', 'fifo_recommendation'})
|
||||
|
||||
EXPECTED_COLUMNS['biz_shipment_order'].update({
|
||||
'receipt_state', 'received_by', 'received_at', 'receipt_idempotency_key', 'receipt_request_hash',
|
||||
})
|
||||
EXPECTED_DATETIME_COLUMNS['biz_shipment_order'].add('received_at')
|
||||
EXPECTED_UNIQUE_KEYS['biz_shipment_order'].add(('target_site_id', 'receipt_idempotency_key'))
|
||||
EXPECTED_FOREIGN_KEYS['biz_shipment_order'].add(('received_by', 'sys_user.user_id'))
|
||||
EXPECTED_CHECKS['biz_shipment_order'].add("receipt_statein('pending','received','legacy')")
|
||||
EXPECTED_COLUMNS['biz_pallet_inventory'].add('site_arrived_at')
|
||||
EXPECTED_DATETIME_COLUMNS['biz_pallet_inventory'].add('site_arrived_at')
|
||||
EXPECTED_CHECKS['biz_pallet_inventory'] = {
|
||||
check.replace("'filled_in_warehouse','at_site_unused'", "'filled_in_warehouse','in_transit','at_site_unused'")
|
||||
for check in EXPECTED_CHECKS['biz_pallet_inventory']
|
||||
}
|
||||
|
||||
EXPECTED_TABLES |= EXPECTED_TEST_BATCH_TABLES
|
||||
for table_name in TEST_BATCH_OWNED_TABLES:
|
||||
for table_name in CURRENT_TEST_BATCH_OWNED_TABLES:
|
||||
EXPECTED_COLUMNS[table_name].add('test_batch_id')
|
||||
EXPECTED_FOREIGN_KEYS[table_name].add(('test_batch_id', 'biz_test_batch.batch_id'))
|
||||
|
||||
@@ -452,6 +524,7 @@ EXPECTED_PAGES = {
|
||||
505: ('产线资料', 121, 4, 'line', 'master/line/index', 'master:line:list'),
|
||||
506: ('仓库资料', 121, 5, 'warehouse', 'master/warehouse/index', 'master:warehouse:list'),
|
||||
507: ('托盘资料', 121, 6, 'pallet', 'master/pallet/index', 'master:pallet:list'),
|
||||
520: ('隧道资料', 121, 7, 'tunnel', 'master/tunnel/index', 'master:tunnel:list'),
|
||||
508: ('仓库入库', 122, 1, 'inbound', 'business/inbound/index', 'business:inbound:list'),
|
||||
509: ('工厂装填', 122, 2, 'filling', 'business/filling/index', 'business:filling:list'),
|
||||
510: ('发货出库', 122, 3, 'shipment', 'business/shipment/index', 'business:shipment:list'),
|
||||
@@ -494,6 +567,9 @@ BUTTON_SPECS = """
|
||||
1088 507 2 托盘新增 master:pallet:add
|
||||
1089 507 3 托盘修改 master:pallet:edit
|
||||
1091 507 5 托盘打印 master:pallet:print
|
||||
1116 520 1 隧道查询 master:tunnel:query
|
||||
1117 520 2 隧道新增 master:tunnel:add
|
||||
1118 520 3 隧道修改 master:tunnel:edit
|
||||
1092 508 1 入库查询 business:inbound:query
|
||||
1093 508 2 入库新增 business:inbound:add
|
||||
1094 508 3 入库修改 business:inbound:edit
|
||||
@@ -532,6 +608,7 @@ EXPECTED_PAGE_PERMISSIONS = frozenset(
|
||||
'master:line:list',
|
||||
'master:warehouse:list',
|
||||
'master:pallet:list',
|
||||
'master:tunnel:list',
|
||||
'business:inbound:list',
|
||||
'business:filling:list',
|
||||
'business:shipment:list',
|
||||
@@ -568,6 +645,9 @@ EXPECTED_BUTTON_PERMISSIONS = frozenset(
|
||||
'master:pallet:add',
|
||||
'master:pallet:edit',
|
||||
'master:pallet:print',
|
||||
'master:tunnel:query',
|
||||
'master:tunnel:add',
|
||||
'master:tunnel:edit',
|
||||
'business:inbound:query',
|
||||
'business:inbound:add',
|
||||
'business:inbound:edit',
|
||||
@@ -604,6 +684,7 @@ BUSINESS_MENU_SPECS = """
|
||||
505|121|产线资料|line||4|master:line:list|master/line/index
|
||||
506|121|仓库资料|warehouse||5|master:warehouse:list|master/warehouse/index
|
||||
507|121|托盘资料|pallet||6|master:pallet:list|master/pallet/index
|
||||
520|121|隧道资料|tunnel||7|master:tunnel:list|master/tunnel/index
|
||||
508|122|仓库入库|inbound||1|business:inbound:list|business/inbound/index
|
||||
509|122|工厂装填|filling||2|business:filling:list|business/filling/index
|
||||
510|122|发货出库|shipment||3|business:shipment:list|business/shipment/index
|
||||
@@ -636,6 +717,9 @@ BUSINESS_MENU_SPECS = """
|
||||
1088|507|托盘新增|||2|master:pallet:add|
|
||||
1089|507|托盘修改|||3|master:pallet:edit|
|
||||
1091|507|托盘打印|||5|master:pallet:print|
|
||||
1116|520|隧道查询|||1|master:tunnel:query|
|
||||
1117|520|隧道新增|||2|master:tunnel:add|
|
||||
1118|520|隧道修改|||3|master:tunnel:edit|
|
||||
1092|508|入库查询|||1|business:inbound:query|
|
||||
1093|508|入库新增|||2|business:inbound:add|
|
||||
1094|508|入库修改|||3|business:inbound:edit|
|
||||
@@ -699,9 +783,11 @@ LEGACY_EXPECTED_BUSINESS_MENU_FIELDS.update(
|
||||
for menu_id, (name, parent_id, order_num, permission) in LEGACY_REMOVED_BUTTON_SPECS.items()
|
||||
}
|
||||
)
|
||||
LEGACY_EXPECTED_PAGE_PERMISSIONS = EXPECTED_PAGE_PERMISSIONS - {'system:testbatch:list'}
|
||||
LEGACY_EXPECTED_PAGE_PERMISSIONS = EXPECTED_PAGE_PERMISSIONS - {'system:testbatch:list', 'master:tunnel:list'}
|
||||
LEGACY_EXPECTED_BUTTON_PERMISSIONS = EXPECTED_BUTTON_PERMISSIONS - {
|
||||
permission for permission in EXPECTED_BUTTON_PERMISSIONS if permission.startswith('system:testbatch:')
|
||||
permission
|
||||
for permission in EXPECTED_BUTTON_PERMISSIONS
|
||||
if permission.startswith(('system:testbatch:', 'master:tunnel:'))
|
||||
} | {spec[3] for spec in LEGACY_REMOVED_BUTTON_SPECS.values()}
|
||||
LEGACY_EXPECTED_PERMISSIONS = LEGACY_EXPECTED_PAGE_PERMISSIONS | LEGACY_EXPECTED_BUTTON_PERMISSIONS
|
||||
LEGACY_PATCH_BUSINESS_MENU_FIELDS = LEGACY_EXPECTED_BUSINESS_MENU_FIELDS | {
|
||||
@@ -709,6 +795,11 @@ LEGACY_PATCH_BUSINESS_MENU_FIELDS = LEGACY_EXPECTED_BUSINESS_MENU_FIELDS | {
|
||||
122: (1, '业务管理', 'businessOperation', '', 11, '', None),
|
||||
123: (1, '报表查询', 'businessReport', '', 12, '', None),
|
||||
}
|
||||
# 同样在菜单旧快照生成后加入收货权限,避免改变历史迁移的权限清单。
|
||||
EXPECTED_BUTTONS[1119] = ('收货核对', 510, 6, 'business:shipment:receive')
|
||||
EXPECTED_BUSINESS_MENU_FIELDS[1119] = (510, '收货核对', '', '', 6, 'business:shipment:receive', '')
|
||||
EXPECTED_BUTTON_PERMISSIONS |= {'business:shipment:receive'}
|
||||
EXPECTED_PERMISSIONS = EXPECTED_PAGE_PERMISSIONS | EXPECTED_BUTTON_PERMISSIONS
|
||||
EXPECTED_TOP_LEVEL_MENU_POSITIONS = {
|
||||
121: ('基础资料维护', 0, 1),
|
||||
122: ('业务管理', 0, 2),
|
||||
@@ -734,7 +825,7 @@ MENU_PROMOTION_IDS = set(EXPECTED_TOP_LEVEL_MENU_POSITIONS)
|
||||
BUSINESS_MENU_IDS = (
|
||||
set(range(121, 124))
|
||||
| set(range(502, 519))
|
||||
| {519}
|
||||
| {519, 520, 1119}
|
||||
| set(range(1065, 1068))
|
||||
| set(range(1069, 1072))
|
||||
| set(range(1073, 1076))
|
||||
@@ -750,7 +841,7 @@ BUSINESS_MENU_IDS = (
|
||||
| set(range(1099, 1102))
|
||||
| {1103}
|
||||
| set(range(1104, 1109))
|
||||
| set(range(1109, 1116))
|
||||
| set(range(1109, 1119))
|
||||
)
|
||||
TEST_BATCH_MENU_IDS = {519, 1109, 1110, 1111, 1112, 1113, 1114, 1115}
|
||||
MYSQL_MENU_STABLE_COLUMNS = (
|
||||
@@ -1033,11 +1124,12 @@ def _assert_test_batch_sql_contract(
|
||||
create_batch = normalized.index('create table biz_test_batch (')
|
||||
create_batch_user = normalized.index('create table biz_test_batch_user (')
|
||||
owned_marker = 'alter table' if is_patch else 'create table'
|
||||
first_owned = min(normalized.index(f'{owned_marker} {table_name} ') for table_name in TEST_BATCH_OWNED_TABLES)
|
||||
owned_tables = TEST_BATCH_OWNED_TABLES if is_patch else CURRENT_TEST_BATCH_OWNED_TABLES
|
||||
first_owned = min(normalized.index(f'{owned_marker} {table_name} ') for table_name in owned_tables)
|
||||
test_case.assertLess(create_batch, create_batch_user)
|
||||
test_case.assertLess(create_batch_user, first_owned)
|
||||
|
||||
for table_name in TEST_BATCH_OWNED_TABLES:
|
||||
for table_name in owned_tables:
|
||||
with test_case.subTest(dialect=dialect, patch=is_patch, table=table_name):
|
||||
if is_patch:
|
||||
test_case.assertRegex(
|
||||
@@ -1319,6 +1411,103 @@ def _assert_business_menu_icon_guard_structure(test_case: unittest.TestCase, sql
|
||||
return pre_guard, post_guard
|
||||
|
||||
|
||||
class ShipmentReceiptSqlContractTest(unittest.TestCase):
|
||||
def patch_sql(self) -> str:
|
||||
self.assertTrue(SHIPMENT_RECEIPT_PATCH_PATH.is_file(), '缺少收货单次迁移脚本')
|
||||
return SHIPMENT_RECEIPT_PATCH_PATH.read_text(encoding='utf-8')
|
||||
|
||||
def test_patch_additions_match_initializer_columns_and_constraints(self) -> None:
|
||||
def normalize(value: str) -> str:
|
||||
return re.sub(r'\b(bigint)\(20\)', r'\1', value).split(' comment ')[0]
|
||||
|
||||
raw = _strip_sql_comments(self.patch_sql())
|
||||
initial = _create_table_bodies((SQL_ROOT / INITIALIZERS[0]).read_text(encoding='utf-8'))
|
||||
alters = re.findall(r'\balter\s+table\s+(\w+)\s+(.*?);', raw, re.I | re.S)
|
||||
self.assertEqual(['biz_shipment_order', 'biz_pallet_inventory', 'biz_pallet_event'], [table for table, _ in alters])
|
||||
expected_columns = {
|
||||
'biz_shipment_order': {'receipt_state', 'received_by', 'received_at', 'receipt_idempotency_key', 'receipt_request_hash'},
|
||||
'biz_pallet_inventory': {'site_arrived_at'},
|
||||
'biz_pallet_event': set(),
|
||||
}
|
||||
for table, body in alters:
|
||||
with self.subTest(table=table):
|
||||
additions = ','.join(
|
||||
re.sub(r'^add\s+(?:column\s+)?', '', clause.strip(), flags=re.I)
|
||||
for clause in _split_values(body) if clause.strip().lower().startswith('add ')
|
||||
)
|
||||
columns, _, uniques, foreign_keys, checks = _table_contract(additions)
|
||||
self.assertEqual(expected_columns[table], columns)
|
||||
self.assertEqual(
|
||||
{('target_site_id', 'receipt_idempotency_key')} if table == 'biz_shipment_order' else set(), uniques,
|
||||
)
|
||||
self.assertEqual({('received_by', 'sys_user.user_id')} if table == 'biz_shipment_order' else set(), foreign_keys)
|
||||
expected_checks = (
|
||||
{"receipt_statein('pending','received','legacy')"} if table == 'biz_shipment_order'
|
||||
else {check for check in EXPECTED_CHECKS[table] if check.startswith(('flow_statein', 'event_typein'))}
|
||||
)
|
||||
self.assertEqual(expected_checks, checks)
|
||||
initializer_columns = _column_definitions(initial[table])
|
||||
for column, definition in _column_definitions(additions).items():
|
||||
normalized = _normalized_sql_statement(re.sub(r'\s+after\s+\w+\s*$', '', definition, flags=re.I))
|
||||
# 类型、可空性和默认值均应与初始化定义一致,注释不参与比较。
|
||||
self.assertEqual(normalize(_normalized_sql_statement(initializer_columns[column])), normalize(normalized))
|
||||
|
||||
def test_patch_only_marks_confirmed_legacy_and_does_not_touch_inventory_or_receipt_time(self) -> None:
|
||||
raw = _strip_sql_comments(self.patch_sql())
|
||||
updates = re.findall(r'\bupdate\s+([a-z_]+)\s+set\s+(.*?);', raw, re.I | re.S)
|
||||
self.assertEqual(1, len(updates))
|
||||
self.assertEqual('biz_shipment_order', updates[0][0].lower())
|
||||
self.assertEqual("receipt_state = 'legacy' where status = 'confirmed'", _normalized_sql_statement(updates[0][1]))
|
||||
inserts = re.findall(r'\binsert\s+into\s+(\w+)', raw, re.I)
|
||||
self.assertEqual(['sys_menu'], inserts)
|
||||
self.assertNotRegex(raw.lower(), r'\b(?:delete\s+from|replace\s+into|truncate\s+table)\b')
|
||||
self.assertNotRegex(raw.lower(), r'\b(?:received_at|site_arrived_at)\s*=')
|
||||
|
||||
def test_patch_guards_conflicting_menu_ids_and_permissions_before_any_alter(self) -> None:
|
||||
raw = _strip_sql_comments(self.patch_sql()).lower()
|
||||
guard = raw[:raw.index('alter table')]
|
||||
for expression in (
|
||||
r'from\s+sys_menu\s+where\s+menu_id\s*=\s*1119\s+or\s+perms\s*=\s*\'business:shipment:receive\'',
|
||||
r'from\s+sys_role_menu\s+where\s+menu_id\s*=\s*1119',
|
||||
r'menu_id\s*=\s*510\s+and\s+perms\s*=\s*\'business:shipment:list\'',
|
||||
):
|
||||
self.assertRegex(guard, expression)
|
||||
self.assertIn("signal sqlstate '45000'", guard)
|
||||
self.assertIn('resignal', guard)
|
||||
rows = _insert_rows(raw, 'sys_menu')
|
||||
self.assertEqual(1, len(rows))
|
||||
self.assertEqual(SYS_MENU_VALUE_COUNT, len(rows[0]))
|
||||
self.assertEqual(['1119', '收货核对', '510', '6'], rows[0][:4])
|
||||
self.assertEqual('business:shipment:receive', rows[0][SYS_MENU_PERMS_INDEX])
|
||||
self.assertNotRegex(raw, r'\b(?:insert\s+into|update|delete\s+from)\s+sys_role_menu\b')
|
||||
|
||||
def test_patch_requires_prerequisites_and_rejects_repeated_or_partial_execution(self) -> None:
|
||||
raw = self.patch_sql()
|
||||
for phrase in ('MySQL 8', '停写', '备份', '单次', 'DDL', '隐式提交', '20261005_tunnel_material_flow_mysql.sql', '20261008_material_dates_mysql.sql'):
|
||||
self.assertIn(phrase, raw)
|
||||
guard = _strip_sql_comments(raw).lower().split('alter table', maxsplit=1)[0]
|
||||
self.assertIn('version()', guard)
|
||||
self.assertIn('information_schema.columns', guard)
|
||||
for field in ('production_date', 'expiry_date', 'tunnel_id', 'trace_no', 'receipt_state', 'received_by',
|
||||
'received_at', 'receipt_idempotency_key', 'receipt_request_hash', 'site_arrived_at'):
|
||||
self.assertIn(f"'{field}'", guard)
|
||||
self.assertIn('information_schema.check_constraints', guard)
|
||||
self.assertIn("'ck_biz_filling_record_material_dates'", guard)
|
||||
self.assertIn("'ck_biz_pallet_event_event_type'", guard)
|
||||
self.assertIn('tunnel_entered', guard)
|
||||
self.assertLess(raw.lower().rindex('alter table'), raw.lower().index('start transaction'))
|
||||
|
||||
def test_current_expectations_leave_legacy_schema_and_menu_snapshots_unchanged(self) -> None:
|
||||
self.assertNotIn('receipt_state', LEGACY_EXPECTED_COLUMNS['biz_shipment_order'])
|
||||
self.assertNotIn('site_arrived_at', LEGACY_EXPECTED_COLUMNS['biz_pallet_inventory'])
|
||||
self.assertNotIn(('target_site_id', 'receipt_idempotency_key'), LEGACY_EXPECTED_UNIQUE_KEYS['biz_shipment_order'])
|
||||
self.assertNotIn(('received_by', 'sys_user.user_id'), LEGACY_EXPECTED_FOREIGN_KEYS['biz_shipment_order'])
|
||||
self.assertNotIn('in_transit', ' '.join(LEGACY_EXPECTED_CHECKS['biz_pallet_inventory']))
|
||||
self.assertNotIn('shipment_received', ' '.join(LEGACY_EXPECTED_CHECKS['biz_pallet_event']))
|
||||
self.assertNotIn('business:shipment:receive', LEGACY_EXPECTED_PERMISSIONS)
|
||||
self.assertNotIn(1119, LEGACY_EXPECTED_BUSINESS_MENU_FIELDS)
|
||||
|
||||
|
||||
class SqlContractTest(unittest.TestCase):
|
||||
def test_test_batch_initializers_and_patches_match_contract(self) -> None:
|
||||
scripts = (
|
||||
@@ -1407,7 +1596,7 @@ class SqlContractTest(unittest.TestCase):
|
||||
self.assertEqual(EXPECTED_CHECKS[table], checks)
|
||||
|
||||
def test_mysql_business_datetimes_use_native_datetime_columns(self) -> None:
|
||||
self.assertEqual(40, sum(len(columns) for columns in EXPECTED_DATETIME_COLUMNS.values()))
|
||||
self.assertEqual(45, sum(len(columns) for columns in EXPECTED_DATETIME_COLUMNS.values()))
|
||||
tables = _create_table_bodies((SQL_ROOT / 'shierfeng-fastapi.sql').read_text(encoding='utf-8'))
|
||||
invalid_columns = []
|
||||
for table, expected_columns in EXPECTED_DATETIME_COLUMNS.items():
|
||||
@@ -1425,11 +1614,11 @@ class SqlContractTest(unittest.TestCase):
|
||||
self.assertEqual([], sorted(invalid_columns))
|
||||
|
||||
def test_initializers_define_fixed_business_menus_and_permissions(self) -> None:
|
||||
self.assertEqual(18, len(EXPECTED_PAGE_PERMISSIONS))
|
||||
self.assertEqual(43, len(EXPECTED_BUTTON_PERMISSIONS))
|
||||
self.assertEqual(19, len(EXPECTED_PAGE_PERMISSIONS))
|
||||
self.assertEqual(47, len(EXPECTED_BUTTON_PERMISSIONS))
|
||||
self.assertFalse(EXPECTED_PAGE_PERMISSIONS & EXPECTED_BUTTON_PERMISSIONS)
|
||||
self.assertEqual(61, len(EXPECTED_PERMISSIONS))
|
||||
self.assertEqual(64, len(EXPECTED_BUSINESS_MENU_FIELDS))
|
||||
self.assertEqual(66, len(EXPECTED_PERMISSIONS))
|
||||
self.assertEqual(69, len(EXPECTED_BUSINESS_MENU_FIELDS))
|
||||
for filename in INITIALIZERS:
|
||||
with self.subTest(filename=filename):
|
||||
sql = (SQL_ROOT / filename).read_text(encoding='utf-8')
|
||||
|
||||
@@ -13,10 +13,18 @@ from module_business.domain.pallet_state_machine import (
|
||||
|
||||
|
||||
class PalletStateMachineTest(unittest.TestCase):
|
||||
def test_dispatch_cannot_skip_receiving_confirmation(self) -> None:
|
||||
with self.assertRaises(InvalidPalletTransition):
|
||||
PalletStateMachine.ensure_transition(
|
||||
PalletFlowState.FILLED_IN_WAREHOUSE,
|
||||
PalletFlowState.AT_SITE_UNUSED,
|
||||
)
|
||||
|
||||
ALLOWED_TRANSITIONS = (
|
||||
(PalletFlowState.UNSTOCKED, PalletFlowState.EMPTY_IN_WAREHOUSE),
|
||||
(PalletFlowState.EMPTY_IN_WAREHOUSE, PalletFlowState.FILLED_IN_WAREHOUSE),
|
||||
(PalletFlowState.FILLED_IN_WAREHOUSE, PalletFlowState.AT_SITE_UNUSED),
|
||||
(PalletFlowState.FILLED_IN_WAREHOUSE, PalletFlowState.IN_TRANSIT),
|
||||
(PalletFlowState.IN_TRANSIT, PalletFlowState.AT_SITE_UNUSED),
|
||||
(PalletFlowState.AT_SITE_UNUSED, PalletFlowState.AT_SITE_PARTIAL),
|
||||
(PalletFlowState.AT_SITE_UNUSED, PalletFlowState.AT_SITE_EMPTY),
|
||||
(PalletFlowState.AT_SITE_UNUSED, PalletFlowState.RETURNING),
|
||||
@@ -75,6 +83,7 @@ class PalletEnumTest(unittest.TestCase):
|
||||
'UNSTOCKED',
|
||||
'EMPTY_IN_WAREHOUSE',
|
||||
'FILLED_IN_WAREHOUSE',
|
||||
'IN_TRANSIT',
|
||||
'AT_SITE_UNUSED',
|
||||
'AT_SITE_PARTIAL',
|
||||
'AT_SITE_EMPTY',
|
||||
@@ -92,6 +101,8 @@ class PalletEnumTest(unittest.TestCase):
|
||||
'INBOUND_CONFIRMED',
|
||||
'FILLING_CONFIRMED',
|
||||
'SHIPMENT_CONFIRMED',
|
||||
'SHIPMENT_RECEIVED',
|
||||
'TUNNEL_ENTERED',
|
||||
'USAGE_RECORDED',
|
||||
'RETURN_REQUESTED',
|
||||
'RETURN_CONFIRMED',
|
||||
|
||||
@@ -35,6 +35,7 @@ TABLE_LABELS = {
|
||||
'biz_customer': '客户',
|
||||
'biz_customer_site': '客户现场',
|
||||
'biz_production_line': '生产线',
|
||||
'biz_tunnel': '隧道',
|
||||
'biz_warehouse': '仓库',
|
||||
'biz_pallet': '托盘',
|
||||
'biz_inbound_order': '入库单',
|
||||
@@ -187,6 +188,8 @@ class TestBatchGraphTest(unittest.IsolatedAsyncioTestCase):
|
||||
active_filling_id=21,
|
||||
warehouse_id=None,
|
||||
site_id=3,
|
||||
tunnel_id=None,
|
||||
trace_no=None,
|
||||
current_line_id=None,
|
||||
last_event_id=41,
|
||||
)
|
||||
@@ -198,9 +201,11 @@ class TestBatchGraphTest(unittest.IsolatedAsyncioTestCase):
|
||||
source_id=30,
|
||||
before_warehouse_id=2,
|
||||
before_site_id=None,
|
||||
before_tunnel_id=None,
|
||||
before_line_id=None,
|
||||
after_warehouse_id=None,
|
||||
after_site_id=3,
|
||||
after_tunnel_id=None,
|
||||
after_line_id=None,
|
||||
)
|
||||
self.db = GraphSession(self.rows)
|
||||
|
||||
@@ -41,6 +41,7 @@ EXPECTED_DELETE_ORDER = [
|
||||
'biz_pallet_event',
|
||||
'biz_filling_record',
|
||||
'biz_production_line',
|
||||
'biz_tunnel',
|
||||
'biz_customer_site',
|
||||
'biz_customer',
|
||||
'biz_warehouse',
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import unittest
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from module_business.domain.enums import PalletEventType
|
||||
from module_business.entity.do.master_do import BizTunnel
|
||||
from module_business.entity.do.operation_do import BizPalletEvent, BizPalletInventory, BizReturnItem, BizUsageRecord
|
||||
from module_business.entity.vo.master_vo import PalletCreateModel, TunnelModel, TunnelPageQueryModel
|
||||
from module_business.entity.vo.operation_vo import FillingCreateModel, TunnelEntryCreateModel, UsageCreateModel
|
||||
from module_business.service.usage_service import UsageService
|
||||
|
||||
|
||||
class TestTunnelFlowContract(unittest.TestCase):
|
||||
def test_tunnel_master_data_belongs_to_site_and_has_scoped_code(self) -> None:
|
||||
self.assertEqual(BizTunnel.__tablename__, 'biz_tunnel')
|
||||
self.assertIn('site_id', BizTunnel.__table__.c)
|
||||
self.assertIn('tunnel_code', BizTunnel.__table__.c)
|
||||
self.assertIn('current_trace_no', BizTunnel.__table__.c)
|
||||
self.assertEqual(
|
||||
{constraint.name for constraint in BizTunnel.__table__.constraints if constraint.name},
|
||||
{
|
||||
'uq_biz_tunnel_site_code',
|
||||
'ck_biz_tunnel_status',
|
||||
},
|
||||
)
|
||||
|
||||
def test_tunnel_master_vo_supports_list_and_write_contract(self) -> None:
|
||||
tunnel = TunnelModel(
|
||||
siteId=9,
|
||||
tunnelCode='T-01',
|
||||
tunnelName='一号隧道',
|
||||
)
|
||||
self.assertEqual(tunnel.model_dump(by_alias=True)['tunnelCode'], 'T-01')
|
||||
query = TunnelPageQueryModel(siteId=9, tunnelCode='T-01')
|
||||
self.assertEqual(query.site_id, 9)
|
||||
|
||||
def test_usage_requires_tunnel_but_keeps_line_optional(self) -> None:
|
||||
usage = UsageCreateModel(
|
||||
qrToken='qr-1',
|
||||
tunnelId=9,
|
||||
usedPackages=20,
|
||||
idempotencyKey='usage-key',
|
||||
)
|
||||
self.assertIsNone(usage.line_id)
|
||||
with self.assertRaises(ValidationError):
|
||||
UsageCreateModel(
|
||||
qrToken='qr-1',
|
||||
usedPackages=20,
|
||||
idempotencyKey='usage-key',
|
||||
)
|
||||
|
||||
def test_tunnel_entry_is_idempotent_command(self) -> None:
|
||||
command = TunnelEntryCreateModel(
|
||||
qrToken='qr-1',
|
||||
tunnelId=9,
|
||||
idempotencyKey='entry-key',
|
||||
)
|
||||
self.assertEqual(command.tunnel_id, 9)
|
||||
|
||||
def test_flow_records_keep_tunnel_and_trace_snapshots(self) -> None:
|
||||
self.assertIn('tunnel_id', BizPalletInventory.__table__.c)
|
||||
self.assertIn('trace_no', BizPalletInventory.__table__.c)
|
||||
self.assertIn('tunnel_id', BizUsageRecord.__table__.c)
|
||||
self.assertIn('trace_no', BizUsageRecord.__table__.c)
|
||||
self.assertIn('batch_no_snapshot', BizUsageRecord.__table__.c)
|
||||
self.assertIn('tunnel_id_snapshot', BizReturnItem.__table__.c)
|
||||
self.assertIn('trace_no_snapshot', BizReturnItem.__table__.c)
|
||||
self.assertIn('before_tunnel_id', BizPalletEvent.__table__.c)
|
||||
self.assertIn('after_tunnel_id', BizPalletEvent.__table__.c)
|
||||
self.assertIn('trace_no', BizPalletEvent.__table__.c)
|
||||
self.assertEqual(PalletEventType.TUNNEL_ENTERED.value, 'TUNNEL_ENTERED')
|
||||
|
||||
def test_new_pallet_and_filling_are_fixed_to_one_hundred_packages(self) -> None:
|
||||
PalletCreateModel(palletCode='P-001', capacityPackages=100)
|
||||
FillingCreateModel(
|
||||
qrToken='qr-1',
|
||||
warehouseId=1,
|
||||
strainId=1,
|
||||
batchNo='B-1',
|
||||
productionDate='2026-10-01',
|
||||
expiryDate='2027-04-01',
|
||||
packageSpec='标准包',
|
||||
initialPackages=100,
|
||||
idempotencyKey='fill-key',
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
PalletCreateModel(palletCode='P-002', capacityPackages=20)
|
||||
with self.assertRaises(ValidationError):
|
||||
FillingCreateModel(
|
||||
qrToken='qr-1',
|
||||
warehouseId=1,
|
||||
strainId=1,
|
||||
batchNo='B-1',
|
||||
packageSpec='标准包',
|
||||
initialPackages=20,
|
||||
idempotencyKey='fill-key',
|
||||
)
|
||||
|
||||
def test_usage_idempotency_hash_includes_tunnel(self) -> None:
|
||||
command = UsageCreateModel(
|
||||
qrToken=' qr-1 ',
|
||||
tunnelId=9,
|
||||
lineId=None,
|
||||
usedPackages=20,
|
||||
idempotencyKey='usage-key',
|
||||
)
|
||||
expected = hashlib.sha256(
|
||||
json.dumps(
|
||||
{
|
||||
'qr_token': 'qr-1',
|
||||
'tunnel_id': 9,
|
||||
'line_id': None,
|
||||
'used_packages': 20,
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(',', ':'),
|
||||
).encode()
|
||||
).hexdigest()
|
||||
self.assertEqual(expected, UsageService._build_request_hash(command))
|
||||
@@ -0,0 +1,190 @@
|
||||
"""隔离内存库验证真实 FIFO 查询,不依赖业务数据库。"""
|
||||
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from sqlalchemy import Column, MetaData, Table, create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from exceptions.exception import PermissionException
|
||||
from module_business.dao.usage_fifo_dao import UsageFifoDao
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.do.master_do import BizPallet
|
||||
from module_business.entity.do.operation_do import BizFillingRecord, BizPalletInventory
|
||||
from module_business.service.usage_fifo_service import UsageFifoService
|
||||
from module_business.service.usage_service import UsageService
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from types import SimpleNamespace
|
||||
from tests.module_business.test_batch_fixtures import make_test_scope
|
||||
|
||||
|
||||
class TestUsageFifo(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine('sqlite:///:memory:')
|
||||
metadata = MetaData()
|
||||
self.tables = {}
|
||||
for model in (BizPallet, BizFillingRecord, BizPalletInventory):
|
||||
self.tables[model] = Table(
|
||||
model.__tablename__,
|
||||
metadata,
|
||||
*[Column(c.name, c.type, primary_key=c.primary_key) for c in model.__table__.columns],
|
||||
)
|
||||
metadata.create_all(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
self.db = MagicMock()
|
||||
self.db.execute = AsyncMock(side_effect=self.session.execute)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def add(
|
||||
self,
|
||||
pallet_id,
|
||||
arrived_at,
|
||||
*,
|
||||
site=9,
|
||||
strain=4,
|
||||
batch=None,
|
||||
remaining=100,
|
||||
state='AT_SITE_UNUSED',
|
||||
tunnel=None,
|
||||
asset='0',
|
||||
filling_batch=None,
|
||||
trace='TRACE-CURRENT',
|
||||
) -> None:
|
||||
self.session.execute(
|
||||
self.tables[BizPallet].insert(),
|
||||
{
|
||||
'pallet_id': pallet_id,
|
||||
'pallet_code': f'P-{pallet_id}',
|
||||
'qr_token': f'qr-{pallet_id}',
|
||||
'test_batch_id': batch,
|
||||
'asset_status': asset,
|
||||
'capacity_packages': 100,
|
||||
},
|
||||
)
|
||||
self.session.execute(
|
||||
self.tables[BizFillingRecord].insert(),
|
||||
{
|
||||
'filling_id': pallet_id,
|
||||
'pallet_id': pallet_id,
|
||||
'strain_id': strain,
|
||||
'batch_no': f'B-{pallet_id}',
|
||||
'test_batch_id': batch if filling_batch is None else filling_batch,
|
||||
'active_slot': 'ACTIVE',
|
||||
'closed_at': None,
|
||||
},
|
||||
)
|
||||
self.session.execute(
|
||||
self.tables[BizPalletInventory].insert(),
|
||||
{
|
||||
'pallet_id': pallet_id,
|
||||
'active_filling_id': pallet_id,
|
||||
'site_id': site,
|
||||
'warehouse_id': None,
|
||||
'test_batch_id': batch,
|
||||
'tunnel_id': tunnel,
|
||||
'trace_no': trace if tunnel is not None else None,
|
||||
'flow_state': state,
|
||||
'remaining_packages': remaining,
|
||||
'site_arrived_at': arrived_at,
|
||||
},
|
||||
)
|
||||
|
||||
async def query(self, tunnel=None, scope=None):
|
||||
return await UsageFifoDao.recommendation(
|
||||
self.db,
|
||||
site_id=9,
|
||||
strain_id=4,
|
||||
tunnel_id=tunnel,
|
||||
trace_no='TRACE-CURRENT' if tunnel is not None else None,
|
||||
scope=scope or TestBatchScope.formal(),
|
||||
)
|
||||
|
||||
async def test_orders_by_real_receipt_time_not_pallet_id_and_supports_partial(self) -> None:
|
||||
self.add(1, datetime(2026, 10, 8))
|
||||
self.add(50, datetime(2026, 10, 6), remaining=80, state='AT_SITE_PARTIAL')
|
||||
candidate, unknown = await self.query()
|
||||
self.assertEqual(candidate['pallet_id'], 50)
|
||||
self.assertEqual(candidate['remaining_packages'], 80)
|
||||
self.assertEqual(candidate['batch_no'], 'B-50')
|
||||
self.assertEqual(unknown, 0)
|
||||
|
||||
async def test_excludes_unreceived_empty_returning_wrong_site_strain_and_test_scope(self) -> None:
|
||||
early = datetime(2026, 10, 1)
|
||||
for i, change in enumerate(
|
||||
[
|
||||
{'state': 'IN_TRANSIT'},
|
||||
{'remaining': 0},
|
||||
{'state': 'RETURNING'},
|
||||
{'site': 10},
|
||||
{'strain': 8},
|
||||
{'batch': 7},
|
||||
{'asset': '1'},
|
||||
{'filling_batch': 7},
|
||||
],
|
||||
1,
|
||||
):
|
||||
self.add(i, early, **change)
|
||||
self.add(99, datetime(2026, 10, 8))
|
||||
candidate, _ = await self.query()
|
||||
self.assertEqual(candidate['pallet_id'], 99)
|
||||
test_candidate, _ = await self.query(scope=make_test_scope(7))
|
||||
self.assertEqual(test_candidate['pallet_id'], 6)
|
||||
|
||||
async def test_unknown_legacy_arrivals_are_reported_not_fabricated(self) -> None:
|
||||
self.add(1, None)
|
||||
self.add(2, datetime(2026, 10, 8))
|
||||
candidate, unknown = await self.query()
|
||||
self.assertEqual(candidate['pallet_id'], 2)
|
||||
self.assertEqual(unknown, 1)
|
||||
|
||||
async def test_reserved_other_tunnels_are_not_recommended(self) -> None:
|
||||
self.add(1, datetime(2026, 10, 1), tunnel=12)
|
||||
self.add(2, datetime(2026, 10, 2), tunnel=13)
|
||||
self.add(3, datetime(2026, 10, 3))
|
||||
candidate, _ = await self.query()
|
||||
self.assertEqual(candidate['pallet_id'], 3)
|
||||
candidate, _ = await self.query(tunnel=13)
|
||||
self.assertEqual(candidate['pallet_id'], 2)
|
||||
|
||||
async def test_no_stock_returns_no_recommendation(self) -> None:
|
||||
self.assertEqual(await self.query(), (None, 0))
|
||||
|
||||
async def test_old_tunnel_trace_is_not_recommended_or_counted_as_unknown(self) -> None:
|
||||
self.add(1, datetime(2026, 10, 1), tunnel=13, trace='TRACE-OLD')
|
||||
self.add(2, None, tunnel=13, trace='TRACE-OLD')
|
||||
self.add(3, datetime(2026, 10, 8), tunnel=13)
|
||||
candidate, unknown = await self.query(tunnel=13)
|
||||
self.assertEqual(candidate['pallet_id'], 3)
|
||||
self.assertEqual(unknown, 0)
|
||||
|
||||
async def test_permission_failure_stops_before_candidate_query(self) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
UsageService,
|
||||
'scan_usage_pallet_services',
|
||||
new=AsyncMock(side_effect=PermissionException(data='', message='无权访问')),
|
||||
),
|
||||
self.assertRaises(PermissionException),
|
||||
):
|
||||
await UsageFifoService.recommendation(self.db, 'qr-other', MagicMock(), TestBatchScope.formal())
|
||||
self.db.execute.assert_not_awaited()
|
||||
|
||||
async def test_advice_uses_selected_tunnel_and_requires_reason_for_strictly_earlier(self) -> None:
|
||||
self.add(1, datetime(2026, 10, 1), tunnel=13)
|
||||
self.add(2, datetime(2026, 10, 8))
|
||||
scanned = SimpleNamespace(pallet_id=2, site_id=9, strain_id=4, tunnel_id=None, trace_no=None)
|
||||
with (
|
||||
patch.object(UsageService, 'scan_usage_pallet_services', new=AsyncMock(return_value=scanned)),
|
||||
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=SimpleNamespace(
|
||||
site_arrived_at=datetime(2026, 10, 8), test_batch_id=None))),
|
||||
patch.object(MasterDao, 'get_tunnel', new=AsyncMock(return_value=SimpleNamespace(
|
||||
site_id=9, status='0', current_trace_no='TRACE-CURRENT', test_batch_id=None))),
|
||||
):
|
||||
result = await UsageFifoService.recommendation(
|
||||
self.db, 'qr-2', MagicMock(), TestBatchScope.formal(), tunnel_id=13)
|
||||
self.assertEqual(result.recommended.pallet_id, 1)
|
||||
self.assertTrue(result.requires_skip_reason)
|
||||
@@ -32,7 +32,7 @@ from module_business.dao.master_dao import MasterDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
from module_business.domain.enums import PalletEventType, PalletFlowState
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.do.master_do import BizCustomerSite, BizPallet, BizProductionLine
|
||||
from module_business.entity.do.master_do import BizCustomerSite, BizPallet, BizProductionLine, BizTunnel
|
||||
from module_business.entity.do.operation_do import (
|
||||
BizFillingRecord,
|
||||
BizPalletEvent,
|
||||
@@ -43,6 +43,7 @@ from module_business.entity.vo.master_vo import LinePageQueryModel
|
||||
from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
PalletScanResponseModel,
|
||||
TunnelEntrySuccessResponseModel,
|
||||
UsageCreateModel,
|
||||
UsageModel,
|
||||
UsagePageQueryModel,
|
||||
@@ -82,12 +83,14 @@ def integrity_error() -> IntegrityError:
|
||||
def make_page(
|
||||
*,
|
||||
qr_token: str = 'qr-2',
|
||||
tunnel_id: int = 9,
|
||||
line_id: int = 4,
|
||||
used_packages: int = 3,
|
||||
idempotency_key: str = 'usage-key',
|
||||
) -> UsageCreateModel:
|
||||
return UsageCreateModel(
|
||||
qrToken=qr_token,
|
||||
tunnelId=tunnel_id,
|
||||
lineId=line_id,
|
||||
usedPackages=used_packages,
|
||||
idempotencyKey=idempotency_key,
|
||||
@@ -98,7 +101,7 @@ def make_pallet(
|
||||
pallet_id: int = 2,
|
||||
*,
|
||||
qr_token: str = 'qr-2',
|
||||
capacity_packages: int = 20,
|
||||
capacity_packages: int = 100,
|
||||
asset_status: str = '0',
|
||||
test_batch_id: int | None = None,
|
||||
) -> BizPallet:
|
||||
@@ -116,6 +119,8 @@ def make_inventory(
|
||||
pallet_id: int = 2,
|
||||
*,
|
||||
site_id: int | None = 9,
|
||||
tunnel_id: int | None = 9,
|
||||
trace_no: str | None = 'TRACE-9',
|
||||
current_line_id: int | None = None,
|
||||
active_filling_id: int | None = 31,
|
||||
remaining_packages: int = 8,
|
||||
@@ -128,6 +133,8 @@ def make_inventory(
|
||||
active_filling_id=active_filling_id,
|
||||
warehouse_id=None,
|
||||
site_id=site_id,
|
||||
tunnel_id=tunnel_id,
|
||||
trace_no=trace_no,
|
||||
current_line_id=current_line_id,
|
||||
remaining_packages=remaining_packages,
|
||||
flow_state=flow_state,
|
||||
@@ -155,7 +162,7 @@ def make_filling(
|
||||
strain_name_snapshot='香菇 11',
|
||||
batch_no='B-20260726',
|
||||
package_spec='20kg',
|
||||
initial_packages=20,
|
||||
initial_packages=100,
|
||||
active_slot=active_slot,
|
||||
closed_at=None,
|
||||
idempotency_key='fill-key',
|
||||
@@ -182,6 +189,25 @@ def make_site(
|
||||
)
|
||||
|
||||
|
||||
def make_tunnel(
|
||||
tunnel_id: int = 9,
|
||||
*,
|
||||
site_id: int = 9,
|
||||
status: str = '0',
|
||||
current_trace_no: str | None = 'TRACE-9',
|
||||
test_batch_id: int | None = None,
|
||||
) -> BizTunnel:
|
||||
return BizTunnel(
|
||||
tunnel_id=tunnel_id,
|
||||
site_id=site_id,
|
||||
tunnel_code=f'T-{tunnel_id}',
|
||||
tunnel_name=f'隧道 {tunnel_id}',
|
||||
current_trace_no=current_trace_no,
|
||||
status=status,
|
||||
test_batch_id=test_batch_id,
|
||||
)
|
||||
|
||||
|
||||
def make_line(
|
||||
line_id: int = 4,
|
||||
*,
|
||||
@@ -214,6 +240,9 @@ def make_usage(
|
||||
pallet_id=2,
|
||||
active_filling_id=31,
|
||||
site_id=site_id,
|
||||
tunnel_id=9,
|
||||
trace_no='TRACE-9',
|
||||
batch_no_snapshot='B-20260726',
|
||||
line_id=line_id,
|
||||
used_packages=3,
|
||||
remaining_packages_after=5,
|
||||
@@ -246,7 +275,9 @@ class TestUsagePublicContract(unittest.TestCase):
|
||||
'get_usage_list_services',
|
||||
'usage_detail_services',
|
||||
'get_line_options_services',
|
||||
'get_tunnel_options_services',
|
||||
'scan_usage_pallet_services',
|
||||
'enter_tunnel_services',
|
||||
'record_usage_services',
|
||||
):
|
||||
with self.subTest(method=method_name):
|
||||
@@ -257,7 +288,9 @@ class TestUsagePublicContract(unittest.TestCase):
|
||||
'get_usage_list_services',
|
||||
'usage_detail_services',
|
||||
'get_line_options_services',
|
||||
'get_tunnel_options_services',
|
||||
'scan_usage_pallet_services',
|
||||
'enter_tunnel_services',
|
||||
'record_usage_services',
|
||||
}
|
||||
self.assertEqual(expected, {name for name in expected if hasattr(UsageService, name)})
|
||||
@@ -276,7 +309,7 @@ class TestUsagePublicContract(unittest.TestCase):
|
||||
page = make_page(qr_token=' qr-2 ', idempotency_key='key-a')
|
||||
expected = hashlib.sha256(
|
||||
json.dumps(
|
||||
{'line_id': 4, 'qr_token': 'qr-2', 'used_packages': 3},
|
||||
{'line_id': 4, 'qr_token': 'qr-2', 'tunnel_id': 9, 'used_packages': 3},
|
||||
sort_keys=True,
|
||||
separators=(',', ':'),
|
||||
).encode()
|
||||
@@ -511,6 +544,7 @@ class TestUsageReadServices(unittest.IsolatedAsyncioTestCase):
|
||||
with (
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=pallet)),
|
||||
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=inventory)),
|
||||
patch.object(MasterDao, 'get_tunnel', new=AsyncMock(return_value=make_tunnel())),
|
||||
patch.object(AccessService, 'assert_site_access', new=access),
|
||||
patch.object(OperationDao, 'get_filling_by_id', new=get_filling),
|
||||
patch.object(InventoryDao, 'get_inventory_for_update', new=inventory_lock),
|
||||
@@ -601,18 +635,20 @@ class TestUsageControllerContract(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _routes() -> dict[tuple[str, str], APIRoute]:
|
||||
return {
|
||||
(route.path, next(iter(route.methods))): route
|
||||
(route.path_format, next(iter(route.methods))): route
|
||||
for route in usage_controller.routes
|
||||
if isinstance(route, APIRoute)
|
||||
}
|
||||
|
||||
def test_openapi_has_exact_five_operations_aliases_and_response_models(self) -> None:
|
||||
def test_openapi_has_exact_seven_operations_aliases_and_response_models(self) -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(usage_controller)
|
||||
schema = app.openapi()
|
||||
expected = {
|
||||
('/business/usage/list', 'get'),
|
||||
('/business/usage/line-options', 'get'),
|
||||
('/business/usage/tunnel-options', 'get'),
|
||||
('/business/usage/enter-tunnel', 'post'),
|
||||
('/business/usage/scan/{qr_token}', 'get'),
|
||||
('/business/usage/{usage_id}', 'get'),
|
||||
('/business/usage', 'post'),
|
||||
@@ -622,6 +658,13 @@ class TestUsageControllerContract(unittest.TestCase):
|
||||
{'siteId'},
|
||||
{parameter['name'] for parameter in schema['paths']['/business/usage/line-options']['get']['parameters']},
|
||||
)
|
||||
self.assertEqual(
|
||||
{'siteId'},
|
||||
{
|
||||
parameter['name']
|
||||
for parameter in schema['paths']['/business/usage/tunnel-options']['get']['parameters']
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
{'qr_token'},
|
||||
{
|
||||
@@ -636,6 +679,14 @@ class TestUsageControllerContract(unittest.TestCase):
|
||||
DataResponseModel[list[BusinessOptionModel]],
|
||||
routes[('/business/usage/line-options', 'GET')].response_model,
|
||||
)
|
||||
self.assertEqual(
|
||||
DataResponseModel[list[BusinessOptionModel]],
|
||||
routes[('/business/usage/tunnel-options', 'GET')].response_model,
|
||||
)
|
||||
self.assertEqual(
|
||||
DataResponseModel[TunnelEntrySuccessResponseModel],
|
||||
routes[('/business/usage/enter-tunnel', 'POST')].response_model,
|
||||
)
|
||||
self.assertEqual(
|
||||
DataResponseModel[PalletScanResponseModel],
|
||||
routes[('/business/usage/scan/{qr_token}', 'GET')].response_model,
|
||||
@@ -662,15 +713,19 @@ class TestUsageControllerContract(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_static_routes_precede_dynamic_detail(self) -> None:
|
||||
paths = [route.path for route in usage_controller.routes]
|
||||
paths = [route.path_format for route in usage_controller.routes]
|
||||
detail_index = paths.index('/business/usage/{usage_id}')
|
||||
self.assertLess(paths.index('/business/usage/line-options'), detail_index)
|
||||
self.assertLess(paths.index('/business/usage/tunnel-options'), detail_index)
|
||||
self.assertLess(paths.index('/business/usage/enter-tunnel'), detail_index)
|
||||
self.assertLess(paths.index('/business/usage/scan/{qr_token}'), detail_index)
|
||||
|
||||
def test_routes_have_exact_three_permissions_current_user_and_only_post_log(self) -> None:
|
||||
expected = {
|
||||
('/business/usage/list', 'GET'): 'business:usage:list',
|
||||
('/business/usage/line-options', 'GET'): 'business:usage:query',
|
||||
('/business/usage/tunnel-options', 'GET'): 'business:usage:query',
|
||||
('/business/usage/enter-tunnel', 'POST'): 'business:usage:add',
|
||||
('/business/usage/scan/{qr_token}', 'GET'): 'business:usage:query',
|
||||
('/business/usage/{usage_id}', 'GET'): 'business:usage:query',
|
||||
('/business/usage', 'POST'): 'business:usage:add',
|
||||
@@ -691,7 +746,12 @@ class TestUsageControllerContract(unittest.TestCase):
|
||||
),
|
||||
key,
|
||||
)
|
||||
self.assertEqual(key == ('/business/usage', 'POST'), hasattr(route.endpoint, '__wrapped__'))
|
||||
expected_logged = {
|
||||
('/business/usage/enter-tunnel', 'POST'),
|
||||
('/business/usage/scan/{qr_token}', 'GET'),
|
||||
('/business/usage', 'POST'),
|
||||
}
|
||||
self.assertEqual(key in expected_logged, hasattr(route.endpoint, '__wrapped__'))
|
||||
actual[key] = permissions[0].perm
|
||||
|
||||
self.assertEqual(expected, actual)
|
||||
@@ -763,6 +823,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
self.inventory = make_inventory()
|
||||
self.filling = make_filling()
|
||||
self.site = make_site()
|
||||
self.tunnel = make_tunnel()
|
||||
self.line = make_line()
|
||||
self.fixed_time = datetime(2026, 7, 26, 11, 30)
|
||||
|
||||
@@ -808,6 +869,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
pallet_lock = AsyncMock(return_value=[selected_pallet])
|
||||
filling_lock = AsyncMock(return_value=selected_filling)
|
||||
site_lock = AsyncMock(return_value=selected_site)
|
||||
tunnel_read = AsyncMock(return_value=self.tunnel)
|
||||
preflight_line_read = AsyncMock(return_value=selected_line)
|
||||
line_lock = AsyncMock(return_value=selected_line)
|
||||
|
||||
@@ -830,6 +892,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
stack.enter_context(patch.object(InventoryDao, 'get_pallets_by_ids', new=pallet_lock))
|
||||
stack.enter_context(patch.object(OperationDao, 'get_active_filling_for_update', new=filling_lock))
|
||||
stack.enter_context(patch.object(MasterDao, 'get_site', new=site_lock))
|
||||
stack.enter_context(patch.object(MasterDao, 'get_tunnel', new=tunnel_read))
|
||||
stack.enter_context(patch.object(MasterDao, 'get_line', new=AsyncMock(side_effect=read_line)))
|
||||
stack.enter_context(patch.object(OperationDao, 'add_usage_record', new=usage_write))
|
||||
stack.enter_context(patch.object(OperationDao, 'update_filling_record', new=filling_write))
|
||||
@@ -845,6 +908,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
'pallet_lock': pallet_lock,
|
||||
'filling_lock': filling_lock,
|
||||
'site_lock': site_lock,
|
||||
'tunnel_read': tunnel_read,
|
||||
'preflight_line_read': preflight_line_read,
|
||||
'line_lock': line_lock,
|
||||
'add_usage': usage_write,
|
||||
@@ -955,6 +1019,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(side_effect=lock_pallets)),
|
||||
patch.object(OperationDao, 'get_active_filling_for_update', new=AsyncMock(side_effect=lock_filling)),
|
||||
patch.object(MasterDao, 'get_site', new=AsyncMock(side_effect=lock_site)),
|
||||
patch.object(MasterDao, 'get_tunnel', new=AsyncMock(return_value=self.tunnel)),
|
||||
patch.object(MasterDao, 'get_line', new=AsyncMock(side_effect=lock_line)),
|
||||
patch.object(OperationDao, 'add_usage_record', new=AsyncMock(side_effect=add_usage)),
|
||||
patch.object(OperationDao, 'update_filling_record', new=update_filling),
|
||||
@@ -1158,6 +1223,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
make_inventory(remaining_packages=8),
|
||||
UsageCreateModel.model_construct(
|
||||
qr_token='qr-2',
|
||||
tunnel_id=9,
|
||||
line_id=4,
|
||||
used_packages=True,
|
||||
idempotency_key='usage-key',
|
||||
@@ -1268,6 +1334,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_record_hides_missing_and_non_site_qr_but_admin_gets_precise_error(self) -> None:
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
|
||||
patch.object(MasterDao, 'get_tunnel', new=AsyncMock(return_value=self.tunnel)),
|
||||
patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())),
|
||||
patch.object(OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=None)),
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)),
|
||||
@@ -1279,6 +1346,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
self.db.reset_mock()
|
||||
with (
|
||||
patch.object(MasterDao, 'get_tunnel', new=AsyncMock(return_value=self.tunnel)),
|
||||
patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())),
|
||||
patch.object(OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=None)),
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)),
|
||||
@@ -1308,6 +1376,14 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
|
||||
self.core.db = self.db
|
||||
self.core.user = self.user
|
||||
self.core.page = self.page
|
||||
self.tunnel = self.core.tunnel
|
||||
self._tunnel_patcher = patch.object(
|
||||
MasterDao,
|
||||
'get_tunnel',
|
||||
new=AsyncMock(return_value=self.tunnel),
|
||||
)
|
||||
self._tunnel_patcher.start()
|
||||
self.addCleanup(self._tunnel_patcher.stop)
|
||||
|
||||
def _exact_usage(self, *, request_hash: str | None = None) -> BizUsageRecord:
|
||||
return make_usage(
|
||||
|
||||
@@ -184,13 +184,15 @@ OPERATION_MODEL_FIELDS = {
|
||||
'warehouse_id',
|
||||
'strain_id',
|
||||
'batch_no',
|
||||
'production_date',
|
||||
'expiry_date',
|
||||
'package_spec',
|
||||
'initial_packages',
|
||||
'idempotency_key',
|
||||
},
|
||||
ShipmentCreateModel: {'source_warehouse_id', 'target_site_id', 'pallet_ids'},
|
||||
ShipmentUpdateModel: {'shipment_id', 'source_warehouse_id', 'target_site_id', 'pallet_ids'},
|
||||
UsageCreateModel: {'qr_token', 'line_id', 'used_packages', 'idempotency_key'},
|
||||
UsageCreateModel: {'qr_token', 'tunnel_id', 'line_id', 'used_packages', 'idempotency_key', 'fifo_skip_reason'},
|
||||
ReturnRequestModel: {'source_site_id', 'target_warehouse_id', 'pallet_ids', 'idempotency_key'},
|
||||
ReturnConfirmModel: {'scanned_pallet_ids', 'idempotency_key'},
|
||||
InboundItemModel: {'inbound_item_id', 'inbound_id', 'pallet_id', 'create_by', 'create_time'},
|
||||
@@ -219,6 +221,8 @@ OPERATION_MODEL_FIELDS = {
|
||||
'strain_code_snapshot',
|
||||
'strain_name_snapshot',
|
||||
'batch_no',
|
||||
'production_date',
|
||||
'expiry_date',
|
||||
'package_spec',
|
||||
'initial_packages',
|
||||
'active_slot',
|
||||
@@ -253,6 +257,9 @@ OPERATION_MODEL_FIELDS = {
|
||||
'confirm_request_hash',
|
||||
'confirmed_by',
|
||||
'confirmed_at',
|
||||
'receipt_state',
|
||||
'received_by',
|
||||
'received_at',
|
||||
'create_by',
|
||||
'create_time',
|
||||
'update_by',
|
||||
@@ -262,10 +269,14 @@ OPERATION_MODEL_FIELDS = {
|
||||
},
|
||||
UsageModel: {
|
||||
'usage_id',
|
||||
'fifo_skip_reason',
|
||||
'usage_no',
|
||||
'pallet_id',
|
||||
'active_filling_id',
|
||||
'site_id',
|
||||
'tunnel_id',
|
||||
'trace_no',
|
||||
'batch_no_snapshot',
|
||||
'line_id',
|
||||
'used_packages',
|
||||
'remaining_packages_after',
|
||||
@@ -279,6 +290,9 @@ OPERATION_MODEL_FIELDS = {
|
||||
'return_id',
|
||||
'pallet_id',
|
||||
'active_filling_id_snapshot',
|
||||
'tunnel_id_snapshot',
|
||||
'trace_no_snapshot',
|
||||
'batch_no_snapshot',
|
||||
'remaining_packages_snapshot',
|
||||
'create_by',
|
||||
'create_time',
|
||||
@@ -339,6 +353,8 @@ OPERATION_MODEL_FIELDS = {
|
||||
'page_size',
|
||||
'usage_no',
|
||||
'site_id',
|
||||
'tunnel_id',
|
||||
'trace_no',
|
||||
'line_id',
|
||||
'pallet_code',
|
||||
'begin_time',
|
||||
@@ -361,15 +377,20 @@ OPERATION_MODEL_FIELDS = {
|
||||
PalletScanResponseModel: {
|
||||
'pallet_id',
|
||||
'pallet_code',
|
||||
'qr_token',
|
||||
'flow_state',
|
||||
'warehouse_id',
|
||||
'site_id',
|
||||
'tunnel_id',
|
||||
'trace_no',
|
||||
'current_line_id',
|
||||
'active_filling_id',
|
||||
'strain_id',
|
||||
'strain_code',
|
||||
'strain_name',
|
||||
'batch_no',
|
||||
'production_date',
|
||||
'expiry_date',
|
||||
'package_spec',
|
||||
'capacity_packages',
|
||||
'remaining_packages',
|
||||
@@ -379,6 +400,10 @@ OPERATION_MODEL_FIELDS = {
|
||||
|
||||
REPORT_MODEL_FIELDS = {
|
||||
PalletInventoryReportQueryModel: {
|
||||
'tunnel_id',
|
||||
'tunnel_code',
|
||||
'tunnel_name',
|
||||
'trace_no',
|
||||
'page_num',
|
||||
'page_size',
|
||||
'begin_time',
|
||||
@@ -392,6 +417,12 @@ REPORT_MODEL_FIELDS = {
|
||||
'batch_no',
|
||||
},
|
||||
PalletInventoryReportModel: {
|
||||
'production_date',
|
||||
'expiry_date',
|
||||
'tunnel_id',
|
||||
'tunnel_code',
|
||||
'tunnel_name',
|
||||
'trace_no',
|
||||
'pallet_id',
|
||||
'pallet_code',
|
||||
'flow_state',
|
||||
@@ -463,6 +494,10 @@ REPORT_MODEL_FIELDS = {
|
||||
'package_qty',
|
||||
},
|
||||
ConsumptionReportQueryModel: {
|
||||
'tunnel_id',
|
||||
'tunnel_code',
|
||||
'tunnel_name',
|
||||
'trace_no',
|
||||
'page_num',
|
||||
'page_size',
|
||||
'begin_time',
|
||||
@@ -475,6 +510,13 @@ REPORT_MODEL_FIELDS = {
|
||||
'batch_no',
|
||||
},
|
||||
ConsumptionReportModel: {
|
||||
'production_date',
|
||||
'expiry_date',
|
||||
'tunnel_id',
|
||||
'tunnel_code',
|
||||
'tunnel_name',
|
||||
'trace_no',
|
||||
'batch_no_snapshot',
|
||||
'usage_id',
|
||||
'usage_no',
|
||||
'occurred_at',
|
||||
@@ -496,6 +538,11 @@ REPORT_MODEL_FIELDS = {
|
||||
'operator_name',
|
||||
},
|
||||
ReturnReportQueryModel: {
|
||||
'tunnel_id',
|
||||
'tunnel_code',
|
||||
'tunnel_name',
|
||||
'trace_no',
|
||||
'batch_no',
|
||||
'page_num',
|
||||
'page_size',
|
||||
'begin_time',
|
||||
@@ -507,6 +554,14 @@ REPORT_MODEL_FIELDS = {
|
||||
'status',
|
||||
},
|
||||
ReturnReportModel: {
|
||||
'production_date',
|
||||
'expiry_date',
|
||||
'tunnel_id',
|
||||
'tunnel_code',
|
||||
'tunnel_name',
|
||||
'trace_no',
|
||||
'batch_no_snapshot',
|
||||
'batch_no',
|
||||
'return_id',
|
||||
'return_no',
|
||||
'status',
|
||||
@@ -522,6 +577,11 @@ REPORT_MODEL_FIELDS = {
|
||||
'remaining_packages_snapshot',
|
||||
},
|
||||
PalletTraceReportQueryModel: {
|
||||
'tunnel_id',
|
||||
'tunnel_code',
|
||||
'tunnel_name',
|
||||
'trace_no',
|
||||
'batch_no',
|
||||
'page_num',
|
||||
'page_size',
|
||||
'begin_time',
|
||||
@@ -530,6 +590,19 @@ REPORT_MODEL_FIELDS = {
|
||||
'event_type',
|
||||
},
|
||||
PalletTraceReportModel: {
|
||||
'production_date',
|
||||
'expiry_date',
|
||||
'tunnel_id',
|
||||
'tunnel_code',
|
||||
'tunnel_name',
|
||||
'trace_no',
|
||||
'batch_no_snapshot',
|
||||
'before_tunnel_id',
|
||||
'before_tunnel_code',
|
||||
'before_tunnel_name',
|
||||
'after_tunnel_id',
|
||||
'after_tunnel_code',
|
||||
'after_tunnel_name',
|
||||
'event_id',
|
||||
'event_seq',
|
||||
'event_type',
|
||||
@@ -702,7 +775,7 @@ PACKAGE_MODEL_PAYLOADS = {
|
||||
'pallet_id': 1,
|
||||
'pallet_code': 'PLT-001',
|
||||
'qr_token': 'qr-12345678',
|
||||
'capacity_packages': 20,
|
||||
'capacity_packages': 100,
|
||||
'asset_status': '0',
|
||||
},
|
||||
FillingModel: {
|
||||
@@ -715,7 +788,7 @@ PACKAGE_MODEL_PAYLOADS = {
|
||||
'strain_name_snapshot': '菌种一号',
|
||||
'batch_no': 'B20260724',
|
||||
'package_spec': '20kg/包',
|
||||
'initial_packages': 20,
|
||||
'initial_packages': 100,
|
||||
'idempotency_key': 'fill-key-1',
|
||||
'request_hash': 'fill-hash-1',
|
||||
'operator_id': 1,
|
||||
@@ -734,6 +807,9 @@ PACKAGE_MODEL_PAYLOADS = {
|
||||
'pallet_id': 1,
|
||||
'active_filling_id': 1,
|
||||
'site_id': 1,
|
||||
'tunnel_id': 9,
|
||||
'trace_no': 'TRACE-9',
|
||||
'batch_no_snapshot': 'B20260724',
|
||||
'line_id': 1,
|
||||
'used_packages': 1,
|
||||
'remaining_packages_after': 19,
|
||||
@@ -746,13 +822,16 @@ PACKAGE_MODEL_PAYLOADS = {
|
||||
'return_item_id': 1,
|
||||
'return_id': 1,
|
||||
'pallet_id': 1,
|
||||
'tunnel_id_snapshot': 9,
|
||||
'trace_no_snapshot': 'TRACE-9',
|
||||
'batch_no_snapshot': 'B20260724',
|
||||
'remaining_packages_snapshot': 0,
|
||||
},
|
||||
PalletScanResponseModel: {
|
||||
'pallet_id': 1,
|
||||
'pallet_code': 'PLT-001',
|
||||
'flow_state': 'AT_SITE_PARTIAL',
|
||||
'capacity_packages': 20,
|
||||
'capacity_packages': 100,
|
||||
'remaining_packages': 10,
|
||||
'allowed_actions': [],
|
||||
},
|
||||
@@ -760,7 +839,7 @@ PACKAGE_MODEL_PAYLOADS = {
|
||||
'pallet_id': 1,
|
||||
'pallet_code': 'PLT-001',
|
||||
'flow_state': 'FILLED_IN_WAREHOUSE',
|
||||
'initial_packages': 20,
|
||||
'initial_packages': 100,
|
||||
'remaining_packages': 20,
|
||||
'updated_at': '2026-07-24T10:00:00',
|
||||
},
|
||||
@@ -776,7 +855,7 @@ PACKAGE_MODEL_PAYLOADS = {
|
||||
'strain_name_snapshot': '菌种一号',
|
||||
'batch_no': 'B20260724',
|
||||
'package_spec': '20kg/包',
|
||||
'initial_packages': 20,
|
||||
'initial_packages': 100,
|
||||
'operator_id': 1,
|
||||
},
|
||||
ShipmentReportModel: {
|
||||
@@ -862,10 +941,10 @@ class BusinessVoContractTest(unittest.TestCase):
|
||||
),
|
||||
(LineModel, {'siteId': 1, 'lineCode': 'L-1', 'lineName': '产线'}),
|
||||
(WarehouseModel, {'warehouseCode': 'W-1', 'warehouseName': '仓库'}),
|
||||
(PalletCreateModel, {'palletCode': 'P-1', 'capacityPackages': 20}),
|
||||
(PalletCreateModel, {'palletCode': 'P-1', 'capacityPackages': 100}),
|
||||
(
|
||||
PalletEditModel,
|
||||
{'palletId': 1, 'capacityPackages': 20, 'assetStatus': '0'},
|
||||
{'palletId': 1, 'capacityPackages': 100, 'assetStatus': '0'},
|
||||
),
|
||||
)
|
||||
for model_class, payload in payloads:
|
||||
@@ -979,20 +1058,23 @@ class BusinessVoContractTest(unittest.TestCase):
|
||||
'warehouseId': 1,
|
||||
'strainId': 2,
|
||||
'batchNo': 'B20260724',
|
||||
'productionDate': '2026-07-20',
|
||||
'expiryDate': '2027-01-20',
|
||||
'packageSpec': '20kg/包',
|
||||
'initialPackages': 10,
|
||||
'initialPackages': 100,
|
||||
'idempotencyKey': 'fill-key-1',
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(10, model.initial_packages)
|
||||
self.assertEqual(10, model.model_dump(by_alias=True)['initialPackages'])
|
||||
self.assertEqual(100, model.initial_packages)
|
||||
self.assertEqual(100, model.model_dump(by_alias=True)['initialPackages'])
|
||||
|
||||
def test_usage_rejects_zero_packages(self) -> None:
|
||||
with self.assertRaises(ValidationError):
|
||||
UsageCreateModel.model_validate(
|
||||
{
|
||||
'qrToken': 'qr-12345678',
|
||||
'tunnelId': 9,
|
||||
'lineId': 3,
|
||||
'usedPackages': 0,
|
||||
'idempotencyKey': 'usage-key-1',
|
||||
@@ -1003,9 +1085,10 @@ class BusinessVoContractTest(unittest.TestCase):
|
||||
for used_packages in (1.5, '1', True):
|
||||
with self.subTest(used_packages=used_packages), self.assertRaises(ValidationError):
|
||||
UsageCreateModel.model_validate(
|
||||
{
|
||||
'qrToken': 'qr-12345678',
|
||||
'lineId': 3,
|
||||
{
|
||||
'qrToken': 'qr-12345678',
|
||||
'tunnelId': 9,
|
||||
'lineId': 3,
|
||||
'usedPackages': used_packages,
|
||||
'idempotencyKey': 'usage-key-1',
|
||||
}
|
||||
@@ -1133,7 +1216,7 @@ class BusinessVoContractTest(unittest.TestCase):
|
||||
def test_pallet_edit_forbids_identity_fields(self) -> None:
|
||||
valid_payload = {
|
||||
'palletId': 1,
|
||||
'capacityPackages': 20,
|
||||
'capacityPackages': 100,
|
||||
'assetStatus': '0',
|
||||
'remark': None,
|
||||
}
|
||||
@@ -1169,7 +1252,7 @@ class BusinessVoContractTest(unittest.TestCase):
|
||||
'strainId': 2,
|
||||
'batchNo': ' ',
|
||||
'packageSpec': '20kg/包',
|
||||
'initialPackages': 10,
|
||||
'initialPackages': 100,
|
||||
'idempotencyKey': 'fill-key-1',
|
||||
}
|
||||
)
|
||||
@@ -1190,7 +1273,7 @@ class BusinessVoContractTest(unittest.TestCase):
|
||||
'palletId': 1,
|
||||
'palletCode': 'PLT-001',
|
||||
'flowState': 'AT_SITE_PARTIAL',
|
||||
'capacityPackages': 20,
|
||||
'capacityPackages': 100,
|
||||
'remainingPackages': -1,
|
||||
'allowedActions': [],
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
Reference in New Issue
Block a user