Align client surgery API with documented contract and support consumable codes.

Unify result 503 to RESULT_NOT_READY, restore resolve ASR failures as HTTP 422, accept label_id-only candidate_consumables, and document the changelog for integrators.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-25 09:36:09 +08:00
parent aae0340a1b
commit 0917109d6a
10 changed files with 157 additions and 83 deletions

View File

@@ -46,9 +46,6 @@ from app.surgery_errors import SurgeryPipelineError
router = APIRouter() router = APIRouter()
# 上传 WAV 后 ASR/解析失败HTTP 200 + status=failed待确认项仍留在 FIFO 队首,便于桌面端重试。
_RECOVERABLE_VOICE_RESOLVE_CODES = frozenset({"VOICE_ASR_FAILED", "VOICE_TEXT_EMPTY", "VOICE_PARSE_FAILED"})
def _pipeline_error_detail(exc: SurgeryPipelineError, surgery_id: str) -> dict: def _pipeline_error_detail(exc: SurgeryPipelineError, surgery_id: str) -> dict:
d: dict = { d: dict = {
@@ -671,7 +668,7 @@ async def get_pending_consumable_confirmation(
"multipart/form-data 上传单个 WAV 文件(字段名 `audio`)。" "multipart/form-data 上传单个 WAV 文件(字段名 `audio`)。"
"服务端将音频存入 MinIO、调用百度 ASR 识别、解析候选项并完成确认。" "服务端将音频存入 MinIO、调用百度 ASR 识别、解析候选项并完成确认。"
"解析并确认后记一条消耗明细;若语音表示否认全部候选则不记消耗。" "解析并确认后记一条消耗明细;若语音表示否认全部候选则不记消耗。"
"ASR/解析可重试失败时返回 HTTP 200`status`=`failed`,队首待确认项不弹出,便于桌面端重试。" "ASR/解析失败时返回 HTTP 422如 VOICE_ASR_FAILED,队首待确认项不弹出,便于客户端重试。"
), ),
) )
async def resolve_pending_consumable_confirmation( async def resolve_pending_consumable_confirmation(
@@ -723,21 +720,6 @@ async def resolve_pending_consumable_confirmation(
content_type=audio.content_type, content_type=audio.content_type,
) )
except SurgeryPipelineError as exc: except SurgeryPipelineError as exc:
if exc.code in _RECOVERABLE_VOICE_RESOLVE_CODES:
extra = exc.extra or {}
asr_txt = extra.get("asr_text")
akey = extra.get("audio_object_key")
return SurgeryPendingConfirmationResolveResponse(
surgery_id=surgery_id,
confirmation_id=confirmation_id,
status="failed",
message=exc.message,
resolved_label=None,
rejected=False,
asr_text=asr_txt if isinstance(asr_txt, str) else None,
audio_object_key=akey if isinstance(akey, str) else None,
error_code=exc.code,
)
_raise_confirmation_http(exc, surgery_id) _raise_confirmation_http(exc, surgery_id)
return SurgeryPendingConfirmationResolveResponse( return SurgeryPendingConfirmationResolveResponse(
surgery_id=surgery_id, surgery_id=surgery_id,
@@ -748,5 +730,4 @@ async def resolve_pending_consumable_confirmation(
rejected=result.rejected, rejected=result.rejected,
asr_text=result.asr_text, asr_text=result.asr_text,
audio_object_key=result.audio_object_key, audio_object_key=result.audio_object_key,
error_code=None,
) )

View File

@@ -13,10 +13,12 @@ from app.baked import algorithm as ba
def normalize_candidate_consumables_raw(value: Any) -> list[str]: def normalize_candidate_consumables_raw(value: Any) -> list[str]:
"""将开录请求里的 ``candidate_consumables`` 转为字符串列表。 """将开录请求里的 ``candidate_consumables`` 转为字符串列表(名称或编号)
除 ``list[str]`` 外,支持医院导出常见的 ``{\"消耗品编号\": ..., \"名称\": ...}`` 数组, 支持:
选取 ``名称``(或 ``name``)作为耗材显示名。 - ``list[str]``名称或产品编码label_id
- 医院导出对象:含 ``名称`` / ``name``,或仅含 ``消耗品编号`` / ``code`` / ``label_id``。
编号会在 ``effective_candidate_consumables`` 中按 yaml 解析为类名。
""" """
if not isinstance(value, list): if not isinstance(value, list):
@@ -32,11 +34,20 @@ def normalize_candidate_consumables_raw(value: Any) -> list[str]:
raw_name = item.get("名称") raw_name = item.get("名称")
if raw_name is None: if raw_name is None:
raw_name = item.get("name") raw_name = item.get("name")
if raw_name is None: if raw_name is not None:
s = str(raw_name).strip()
if s:
out.append(s)
continue continue
s = str(raw_name).strip() raw_code = item.get("消耗品编号")
if s: if raw_code is None:
out.append(s) raw_code = item.get("code")
if raw_code is None:
raw_code = item.get("label_id")
if raw_code is not None:
s = str(raw_code).strip()
if s:
out.append(s)
return out return out
@@ -117,22 +128,58 @@ def default_labels_yaml_path() -> Path:
return Path(ba.CONSUMABLE_CLASSIFIER_LABELS_YAML_PATH).expanduser() return Path(ba.CONSUMABLE_CLASSIFIER_LABELS_YAML_PATH).expanduser()
def load_label_id_to_name_from_yaml(path: Path) -> dict[str, str]:
"""产品编码 label_id → 归一化类名(与 load_name_to_label_id_from_yaml 互逆)。"""
name_to_id = load_name_to_label_id_from_yaml(path)
out: dict[str, str] = {}
for name, lid in name_to_id.items():
if lid not in out:
out[lid] = name
return out
def resolve_candidate_token(
token: str,
*,
class_names: set[str],
code_to_name: dict[str, str],
) -> str | None:
"""将请求项解析为算法使用的类名:优先名称匹配,其次 label_id。"""
n = norm_product_name((token or "").strip())
if not n:
return None
if n in class_names:
return n
if n in code_to_name:
return code_to_name[n]
return n
def effective_candidate_consumables( def effective_candidate_consumables(
requested: list[str], requested: list[str],
*, *,
labels_yaml_path: Path | None = None, labels_yaml_path: Path | None = None,
) -> list[str]: ) -> list[str]:
ypath = labels_yaml_path or default_labels_yaml_path()
class_names: set[str] = set()
code_to_name: dict[str, str] = {}
if ypath.is_file():
class_names = set(list_sorted_class_names_from_yaml(ypath))
code_to_name = load_label_id_to_name_from_yaml(ypath)
out: list[str] = [] out: list[str] = []
seen: set[str] = set() seen: set[str] = set()
for c in requested: for c in requested:
n = norm_product_name((c or "").strip()) resolved = resolve_candidate_token(
if not n or n in seen: c,
continue class_names=class_names,
seen.add(n) code_to_name=code_to_name,
out.append(n) )
if resolved and resolved not in seen:
seen.add(resolved)
out.append(resolved)
if out: if out:
return out return out
ypath = labels_yaml_path or default_labels_yaml_path()
if ypath.is_file(): if ypath.is_file():
ylist = list_sorted_class_names_from_yaml(ypath) ylist = list_sorted_class_names_from_yaml(ypath)
if ylist: if ylist:

View File

@@ -47,7 +47,9 @@ class SurgeryStartRequest(BaseModel):
"非空时仅对该清单内名称做自动记账与待确认追问。" "非空时仅对该清单内名称做自动记账与待确认追问。"
"缺省或空数组时,使用 consumable_classifier_labels.yaml 中全部类名;" "缺省或空数组时,使用 consumable_classifier_labels.yaml 中全部类名;"
"无有效 yaml 则使用分类模型全部类名。" "无有效 yaml 则使用分类模型全部类名。"
"每项可为字符串,或含「名称」(或 name的对象与耗材导出表一致)" "每项可为耗材名称字符串产品编码label_id与 yaml 中 label_id 一致)"
"或含「名称」(或 name、「消耗品编号」或 code / label_id的对象与耗材导出表一致"
"仅传编号时服务端按 yaml 解析为类名。"
), ),
) )
@@ -86,8 +88,8 @@ class SurgeryClientErrorDetail(BaseModel):
code: str = Field( code: str = Field(
description=( description=(
"业务错误码,如 SURGERY_ALREADY_RECORDING、SURGERY_NOT_STARTED" "业务错误码,如 RESULT_NOT_READYRECORDING_CANNOT_START、"
"SURGERY_IN_PROGRESS_NO_DETAILS、SURGERY_ENDED_NO_CONSUMPTION、RECORDING_CANNOT_START" "NO_PENDING_CONFIRMATION、VOICE_ASR_FAILED"
) )
) )
message: str = Field(description="人类可读说明。") message: str = Field(description="人类可读说明。")
@@ -212,13 +214,9 @@ class SurgeryPendingConfirmationResolveResponse(BaseModel):
surgery_id: str surgery_id: str
confirmation_id: str confirmation_id: str
status: str = Field( status: str = Field(
description=("``accepted``:已确认或已否认并结案;``failed``ASR/解析等可重试失败,队首待确认项未移除"), description="成功时为 ``accepted``。",
) )
message: str message: str
error_code: str | None = Field(
default=None,
description="仅 status=failed 时与错误码一致(如 VOICE_ASR_FAILED",
)
resolved_label: str | None = Field( resolved_label: str | None = Field(
default=None, default=None,
description="解析并确认后的耗材名称;否认全部候选时为 null。", description="解析并确认后的耗材名称;否认全部候选时为 null。",

View File

@@ -102,16 +102,16 @@ class SurgeryPipeline:
self._sessions.set_voice_terminal_id(surgery_id, terminal_id) self._sessions.set_voice_terminal_id(surgery_id, terminal_id)
async def classify_result_unavailable(self, surgery_id: str) -> tuple[str, str]: async def classify_result_unavailable(self, surgery_id: str) -> tuple[str, str]:
"""无至少一条消耗明细时,区分未开始 / 进行中无结果 / 已结束无消耗等""" """无至少一条消耗明细时返回 RESULT_NOT_READYmessage 保留可读原因"""
phase = self._sessions.active_recording_phase(surgery_id) phase = self._sessions.active_recording_phase(surgery_id)
if phase == "starting": if phase == "starting":
return ( return (
"SURGERY_STARTING", "RESULT_NOT_READY",
"手术正在启动,算法尚未就绪,请稍后再查。", "手术正在启动,算法尚未就绪,请稍后再查。",
) )
if phase == "recording": if phase == "recording":
return ( return (
"SURGERY_IN_PROGRESS_NO_DETAILS", "RESULT_NOT_READY",
"手术进行中,尚无至少一条消耗明细。", "手术进行中,尚无至少一条消耗明细。",
) )
async with self._session_factory() as session: async with self._session_factory() as session:
@@ -119,17 +119,17 @@ class SurgeryPipeline:
persisted = await self._repo.load_final_details(session, surgery_id) persisted = await self._repo.load_final_details(session, surgery_id)
if persisted is not None: if persisted is not None:
return ( return (
"SURGERY_ENDED_NO_CONSUMPTION", "RESULT_NOT_READY",
"手术已结束,当前无消耗明细。", "手术已结束,当前无消耗明细。",
) )
archived = await self._sessions.archived_consumption_fallback(surgery_id) archived = await self._sessions.archived_consumption_fallback(surgery_id)
if archived is not None: if archived is not None:
return ( return (
"SURGERY_ENDED_NO_CONSUMPTION", "RESULT_NOT_READY",
"手术已结束(尚未落库),当前无消耗明细。", "手术已结束(尚未落库),当前无消耗明细。",
) )
return ( return (
"SURGERY_NOT_STARTED", "RESULT_NOT_READY",
"手术未开始,请先调用开始手术接口。", "手术未开始,请先调用开始手术接口。",
) )

View File

@@ -301,26 +301,26 @@ def test_get_result_503_not_ready(api_app: FastAPI) -> None:
pipeline = MagicMock() pipeline = MagicMock()
pipeline.get_consumption_details_for_client = AsyncMock(return_value=None) pipeline.get_consumption_details_for_client = AsyncMock(return_value=None)
pipeline.classify_result_unavailable = AsyncMock( pipeline.classify_result_unavailable = AsyncMock(
return_value=("SURGERY_NOT_STARTED", "手术未开始,请先调用开始手术接口。") return_value=("RESULT_NOT_READY", "手术未开始,请先调用开始手术接口。")
) )
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
client = TestClient(api_app) client = TestClient(api_app)
r = client.get("/client/surgeries/123456/result") r = client.get("/client/surgeries/123456/result")
assert r.status_code == 503 assert r.status_code == 503
assert r.json()["detail"]["code"] == "SURGERY_NOT_STARTED" assert r.json()["detail"]["code"] == "RESULT_NOT_READY"
def test_get_result_503_empty_details(api_app: FastAPI) -> None: def test_get_result_503_empty_details(api_app: FastAPI) -> None:
pipeline = MagicMock() pipeline = MagicMock()
pipeline.get_consumption_details_for_client = AsyncMock(return_value=[]) pipeline.get_consumption_details_for_client = AsyncMock(return_value=[])
pipeline.classify_result_unavailable = AsyncMock( pipeline.classify_result_unavailable = AsyncMock(
return_value=("SURGERY_ENDED_NO_CONSUMPTION", "手术已结束,当前无消耗明细。") return_value=("RESULT_NOT_READY", "手术已结束,当前无消耗明细。")
) )
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
client = TestClient(api_app) client = TestClient(api_app)
r = client.get("/client/surgeries/123456/result") r = client.get("/client/surgeries/123456/result")
assert r.status_code == 503 assert r.status_code == 503
assert r.json()["detail"]["code"] == "SURGERY_ENDED_NO_CONSUMPTION" assert r.json()["detail"]["code"] == "RESULT_NOT_READY"
def test_pending_confirmation_200_and_404(api_app: FastAPI) -> None: def test_pending_confirmation_200_and_404(api_app: FastAPI) -> None:
@@ -403,13 +403,12 @@ def test_resolve_200(api_app: FastAPI) -> None:
assert r.status_code == 200 assert r.status_code == 200
body = r.json() body = r.json()
assert body["status"] == "accepted" assert body["status"] == "accepted"
assert body["error_code"] is None
assert body["resolved_label"] == "纱布" assert body["resolved_label"] == "纱布"
assert body["rejected"] is False assert body["rejected"] is False
assert body["asr_text"] == "第一个" assert body["asr_text"] == "第一个"
def test_resolve_voice_recoverable_error_returns_200_failed(api_app: FastAPI) -> None: def test_resolve_voice_recoverable_error_returns_422(api_app: FastAPI) -> None:
pipeline = MagicMock() pipeline = MagicMock()
pipeline.resolve_pending_confirmation_from_audio = AsyncMock( pipeline.resolve_pending_confirmation_from_audio = AsyncMock(
side_effect=SurgeryPipelineError( side_effect=SurgeryPipelineError(
@@ -423,11 +422,10 @@ def test_resolve_voice_recoverable_error_returns_200_failed(api_app: FastAPI) ->
"/client/surgeries/123456/pending-confirmation/cid/resolve", "/client/surgeries/123456/pending-confirmation/cid/resolve",
files={"audio": ("a.wav", b"RIFF", "audio/wav")}, files={"audio": ("a.wav", b"RIFF", "audio/wav")},
) )
assert r.status_code == 200 assert r.status_code == 422
body = r.json() body = r.json()
assert body["status"] == "failed" assert body["detail"]["code"] == "VOICE_ASR_FAILED"
assert body["error_code"] == "VOICE_ASR_FAILED" assert "3301" in body["detail"]["message"]
assert "3301" in body["message"]
def test_resolve_maps_surgery_pipeline_error_to_http(api_app: FastAPI) -> None: def test_resolve_maps_surgery_pipeline_error_to_http(api_app: FastAPI) -> None:

View File

@@ -20,10 +20,33 @@ def test_normalize_candidate_consumables_raw_strings_and_export_objects() -> Non
{"name": "缝线"}, {"name": "缝线"},
] ]
assert normalize_candidate_consumables_raw(raw) == ["一次性使用手术单", "缝线"] assert normalize_candidate_consumables_raw(raw) == ["一次性使用手术单", "缝线"]
assert normalize_candidate_consumables_raw([{"消耗品编号": "8036-5-22"}]) == ["8036-5-22"]
assert normalize_candidate_consumables_raw([{}]) == [] assert normalize_candidate_consumables_raw([{}]) == []
assert normalize_candidate_consumables_raw("not-a-list") == [] assert normalize_candidate_consumables_raw("not-a-list") == []
def test_effective_resolves_product_codes_via_yaml(tmp_path: Path) -> None:
yml = tmp_path / "lab.yaml"
yml.write_text(
"names:\n 0: 商品A\n 1: 商品乙\nlabel_id:\n 0: code-a\n 1: code-b\n",
encoding="utf-8",
)
got = effective_candidate_consumables(["code-b", "code-a"], labels_yaml_path=yml)
assert got == ["商品乙", "商品A"]
def test_effective_code_only_export_object(tmp_path: Path) -> None:
yml = tmp_path / "lab.yaml"
yml.write_text(
"names:\n 0: 一次性使用手术单\nlabel_id:\n 0: 14764-2-4\n",
encoding="utf-8",
)
raw = normalize_candidate_consumables_raw([{"消耗品编号": "14764-2-4"}])
assert raw == ["14764-2-4"]
got = effective_candidate_consumables(raw, labels_yaml_path=yml)
assert got == ["一次性使用手术单"]
def test_effective_preserves_non_empty_request() -> None: def test_effective_preserves_non_empty_request() -> None:
got = effective_candidate_consumables([" 纱布 ", "缝线", "纱布"]) got = effective_candidate_consumables([" 纱布 ", "缝线", "纱布"])
assert got == ["纱布", "缝线"] assert got == ["纱布", "缝线"]

View File

@@ -20,7 +20,7 @@ async def test_classify_not_started() -> None:
repo.load_final_details = AsyncMock(return_value=None) repo.load_final_details = AsyncMock(return_value=None)
pipeline = SurgeryPipeline(sessions, result_repository=repo, voice_confirmation=MagicMock()) pipeline = SurgeryPipeline(sessions, result_repository=repo, voice_confirmation=MagicMock())
code, _ = await pipeline.classify_result_unavailable("123456") code, _ = await pipeline.classify_result_unavailable("123456")
assert code == "SURGERY_NOT_STARTED" assert code == "RESULT_NOT_READY"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -29,7 +29,7 @@ async def test_classify_in_progress_no_details() -> None:
sessions.active_recording_phase = MagicMock(return_value="recording") sessions.active_recording_phase = MagicMock(return_value="recording")
pipeline = SurgeryPipeline(sessions, result_repository=MagicMock(), voice_confirmation=MagicMock()) pipeline = SurgeryPipeline(sessions, result_repository=MagicMock(), voice_confirmation=MagicMock())
code, _ = await pipeline.classify_result_unavailable("123456") code, _ = await pipeline.classify_result_unavailable("123456")
assert code == "SURGERY_IN_PROGRESS_NO_DETAILS" assert code == "RESULT_NOT_READY"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -38,7 +38,7 @@ async def test_classify_starting() -> None:
sessions.active_recording_phase = MagicMock(return_value="starting") sessions.active_recording_phase = MagicMock(return_value="starting")
pipeline = SurgeryPipeline(sessions, result_repository=MagicMock(), voice_confirmation=MagicMock()) pipeline = SurgeryPipeline(sessions, result_repository=MagicMock(), voice_confirmation=MagicMock())
code, _ = await pipeline.classify_result_unavailable("123456") code, _ = await pipeline.classify_result_unavailable("123456")
assert code == "SURGERY_STARTING" assert code == "RESULT_NOT_READY"
def test_active_recording_phase() -> None: def test_active_recording_phase() -> None:

View File

@@ -75,12 +75,8 @@
} }
const RESULT_UNAVAILABLE_LABELS = { const RESULT_UNAVAILABLE_LABELS = {
SURGERY_NOT_STARTED: "手术未开始",
SURGERY_STARTING: "手术启动中,算法尚未就绪",
SURGERY_IN_PROGRESS_NO_DETAILS: "手术进行中,尚无消耗明细",
SURGERY_ENDED_NO_CONSUMPTION: "手术已结束,无消耗明细",
SURGERY_ALREADY_RECORDING: "该手术已在录制中",
RESULT_NOT_READY: "结果尚不可查询", RESULT_NOT_READY: "结果尚不可查询",
SURGERY_ALREADY_RECORDING: "该手术已在录制中",
}; };
function formatResultUnavailable(body) { function formatResultUnavailable(body) {

View File

@@ -772,15 +772,6 @@ class VoiceMonitorEngine {
this._emitState("待机"); this._emitState("待机");
return; return;
} }
if (rstatus === "failed") {
this._emitResolveResult({ ...baseMeta, body: res });
const code = res.error_code || "";
const msg = String(res.message || "");
this._log("语音未通过(可重试)" + (code ? "[" + code + "] " : "") + msg);
this._state.failed_resolve_cid = cid;
this._emitState("请重试录音或检查麦克风");
return;
}
} }
if (st === 422 && typeof res === "object" && res && res.detail) { if (st === 422 && typeof res === "object" && res && res.detail) {
const d = res.detail; const d = res.detail;
@@ -788,7 +779,7 @@ class VoiceMonitorEngine {
const c = d.code; const c = d.code;
if (c === "VOICE_ASR_FAILED" || c === "VOICE_TEXT_EMPTY" || c === "VOICE_PARSE_FAILED") { if (c === "VOICE_ASR_FAILED" || c === "VOICE_TEXT_EMPTY" || c === "VOICE_PARSE_FAILED") {
this._emitResolveResult({ ...baseMeta, body: res }); this._emitResolveResult({ ...baseMeta, body: res });
this._log("语音未通过(可重试,旧接口[" + c + "]: " + (d.message || "")); this._log("语音未通过(可重试)[" + c + "]: " + (d.message || ""));
this._state.failed_resolve_cid = cid; this._state.failed_resolve_cid = cid;
this._emitState("请重试录音或检查麦克风"); this._emitState("请重试录音或检查麦克风");
return; return;

View File

@@ -1,5 +1,26 @@
# 手术室监控服务:客户端手术通信接口说明 # 手术室监控服务:客户端手术通信接口说明
## Changelog
对接方请按部署版本核对本节;**已发布路径与字段语义以正文为准**,本节仅记录相对上一版的客户端需改动点。
### 2026-05-25
**行为对齐(实现已与正文一致)**
| 接口 | 变更 | 客户端需改动 |
| ---- | ---- | ------------ |
| `GET /client/surgeries/{surgery_id}/result` | 无明细时的 `503` **统一**返回 `detail.code = RESULT_NOT_READY``detail.message` 仍为人可读原因) | 勿再依赖已废弃的 `SURGERY_NOT_STARTED``SURGERY_STARTING``SURGERY_IN_PROGRESS_NO_DETAILS``SURGERY_ENDED_NO_CONSUMPTION` 等细分码;判断 `503` 时用 `RESULT_NOT_READY` |
| `POST .../pending-confirmation/{confirmation_id}/resolve` | ASR/解析可重试失败(如 `VOICE_ASR_FAILED`)改回 **HTTP 422**,不再返回 `200` + `status: "failed"` | 删除对 `200`/`status=failed`/`error_code` 的处理;可重试失败按 `422` + `detail.code` 重录上传 |
| `POST /client/surgeries/start``candidate_consumables` | 新增:可仅传**产品编码**`label_id`,与 `consumable_classifier_labels.yaml` 一致) | 可传 `["14764-2-4"]``[{"消耗品编号":"14764-2-4"}]`;服务端解析为类名后参与推理,响应与其它字段不变 |
**未变更**
- 路由路径、HTTP 方法、`surgery_id` 约束、成功体字段名与 §5 各节描述一致。
- 可新增 `/internal/demo/...` 等内部接口,不影响本文档契约。
---
## 能力概览 ## 能力概览
| **能力** | **说明** | | **能力** | **说明** |
@@ -165,8 +186,8 @@ flowchart LR
- 服务端会为 `camera_ids` 中的每个摄像头建立拉流与推理任务,只有在确认开录成功(如首帧就绪)后才返回 HTTP `200` - 服务端会为 `camera_ids` 中的每个摄像头建立拉流与推理任务,只有在确认开录成功(如首帧就绪)后才返回 HTTP `200`
- `candidate_consumables` 为空时,服务端会展开为目录中的全部耗材名。 - `candidate_consumables` 为空时,服务端会展开为目录中的全部耗材名。
- 非空时,每项可为**耗材名称**或**产品编码**`label_id`);编码通过 `consumable_classifier_labels.yaml` 解析为类名。亦支持医院导出对象(见下表)。
**请求体JSON** **请求体JSON**
@@ -174,7 +195,16 @@ flowchart LR
| ----------------------- | ---------- | ------ | ----------------------------------- | | ----------------------- | ---------- | ------ | ----------------------------------- |
| `surgery_id` | `string` | 是 | 6 位数字 | | `surgery_id` | `string` | 是 | 6 位数字 |
| `camera_ids` | `string[]` | 是 | 至少 1 个;必须与运维配置的摄像头 ID 完全一致,见第 2 节 | | `camera_ids` | `string[]` | 是 | 至少 1 个;必须与运维配置的摄像头 ID 完全一致,见第 2 节 |
| `candidate_consumables` | `string[]` | 否 | 非空时仅这些名称参与自动记账与待确认;缺省或 `[]` 时使用全部候选 | | `candidate_consumables` | `string[]` 或对象数组 | 否 | 非空时仅这些名称/编码参与自动记账与待确认;缺省或 `[]` 时使用全部候选。字符串可为类名或 `label_id`;对象见下 |
**`candidate_consumables` 数组元素**
| **形式** | **示例** | **说明** |
| -------- | -------- | -------- |
| 名称字符串 | `"纱布"` | 与 yaml / 模型类名一致 |
| 编码字符串 | `"14764-2-4"` | 与 yaml 中 `label_id` 一致,服务端解析为类名 |
| 导出对象(名称) | `{"消耗品编号":"14764-2-4","名称":"一次性使用手术单"}` | 以 `名称`(或 `name`)为准 |
| 导出对象(仅编号) | `{"消耗品编号":"14764-2-4"}` | 按编号解析为类名 |
**响应体200** **响应体200**
@@ -202,6 +232,16 @@ flowchart LR
} }
``` ```
仅传产品编码时:
```
{
"surgery_id": "123456",
"camera_ids": ["or-cam-01"],
"candidate_consumables": ["14764-2-4", "8036-5-22"]
}
```
**响应示例200** **响应示例200**
``` ```
@@ -265,11 +305,11 @@ flowchart LR
**业务说明** **业务说明**
- 仅当存在至少一条消耗明细时返回 `200` - 仅当存在至少一条消耗明细时返回 `200`
- 无明细(包括已归档但零消耗)、手术未开始、未成功开录或当前尚不可查时,返回 `503` - 无明细(包括已归档但零消耗)、手术未开始、未成功开录或当前尚不可查时,返回 `503`
- 上述 `503` 场景的常见错误码为 `RESULT_NOT_READY` - 上述 `503``detail.code`**`RESULT_NOT_READY`**`detail.message` 说明具体原因(如未开始、进行中尚无明细、已结束无消耗等)
**响应体200** **响应体200**
@@ -306,7 +346,7 @@ flowchart LR
| -------- | ------------------------------ | | -------- | ------------------------------ |
| `200` | 至少有一条明细 | | `200` | 至少有一条明细 |
| `422` | `surgery_id` 路径不符合约束 | | `422` | `surgery_id` 路径不符合约束 |
| `503` | `RESULT_NOT_READY`,当前无可用明细或不可查 | | `503` | `detail.code``RESULT_NOT_READY``message` 为具体原因 |
**响应示例200** **响应示例200**
@@ -433,7 +473,7 @@ flowchart LR
| `200` | 已受理并完成解析 | | `200` | 已受理并完成解析 |
| `404` | 确认项不存在或手术未活跃;例如 `CONFIRMATION_NOT_FOUND` | | `404` | 确认项不存在或手术未活跃;例如 `CONFIRMATION_NOT_FOUND` |
| `409` | 当前确认项已处理过;例如 `CONFIRMATION_ALREADY_RESOLVED` | | `409` | 当前确认项已处理过;例如 `CONFIRMATION_ALREADY_RESOLVED` |
| `422` | 空文件、非 `.wav``VOICE_AUDIO_INVALID`、ASR/解析失败等,具体错误码见响应 | | `422` | 空文件、非 `.wav``VOICE_AUDIO_INVALID`**ASR/解析可重试失败**(如 `VOICE_ASR_FAILED``VOICE_TEXT_EMPTY``VOICE_PARSE_FAILED`)等,具体错误码见 `detail.code` |
| `503` | MinIO、百度等依赖不可用例如 `MINIO_NOT_CONFIGURED``MINIO_UPLOAD_FAILED``BAIDU_NOT_CONFIGURED` | | `503` | MinIO、百度等依赖不可用例如 `MINIO_NOT_CONFIGURED``MINIO_UPLOAD_FAILED``BAIDU_NOT_CONFIGURED` |
**cURL 示例** **cURL 示例**