更新登录页及优化相关功能

This commit is contained in:
焦龙言
2026-10-08 14:13:05 +08:00
parent 9a14b692e3
commit 56e646bcc1
139 changed files with 77587 additions and 811 deletions
@@ -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': [],
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB