diff --git a/app/config.py b/app/config.py index 717f8c6..8002eae 100644 --- a/app/config.py +++ b/app/config.py @@ -273,7 +273,7 @@ class Settings(BaseSettings): video_result_doctor_id: str = "vision" #: 为 true 时,每次单帧分类得到 top1 等结果会打一条 INFO 日志(联调用;高流量时建议关)。 video_log_inference_results: bool = False - #: 为 true 时,将时间窗级识别写入文本日志(`start_surgery` 时按手术截断/初始化;每行 tab:item_id、item_name、qty、doctor_id、timestamp;停录后追加汇总块 item_id、item_name、qty)。 + #: 为 true 时,将时间窗级识别写入文本日志(`start_surgery` 时按手术截断/初始化;每行 tab:item_id、item_name、qty、doctor_id、timestamp;停录后按内存明细与查结果 API 同口径追加汇总块 item_id、item_name、qty)。 consumption_tsv_log_enabled: bool = True #: 路径模板,须含 `{surgery_id}`(每例手术独立文件)。不含占位时自动在扩展名前追加 `_`。 consumption_tsv_log_path: str = "logs/consumption_{surgery_id}.txt" diff --git a/app/resources/consumable_classifier.pt b/app/resources/consumable_classifier.pt index 161804d..f2f26fe 100644 Binary files a/app/resources/consumable_classifier.pt and b/app/resources/consumable_classifier.pt differ diff --git a/app/resources/consumable_classifier_labels.yaml b/app/resources/consumable_classifier_labels.yaml index 5ddf359..21cc880 100644 --- a/app/resources/consumable_classifier_labels.yaml +++ b/app/resources/consumable_classifier_labels.yaml @@ -1,93 +1,87 @@ -# 与训练 data.yaml 类名一一对应;多规格时 label_id 以 / 连接;业务唯一来源。 -# 耗材分类器 consumable_classifier.pt 的 YOLO-cls 类别名应与训练 data.yaml 一致; -# 业务 label_id 与 names 下标一一对应;推理时以权重内嵌 names 为准。 +# 与训练 data.yaml 类名一致;label_id 来自 refs/商品信息表.xlsx(商品名称→产品编码;同名多规格以 / 连接)。 +# 推理时以权重内嵌 names 为准;本文件供业务 label_id 与开录空 candidate 全量类名。 names: - 0: 医用纱布敷料 - 1: 一次性使用无菌注射器带针 - 2: 一次性使用手术单 - 3: 一次性使用肛门管 - 4: 医用缝合针 - 5: 非吸收性外科缝线(蚕丝线) - 6: 一次性中性电极板 - 7: 一次性使用胃管 - 8: 一次性使用输卵管导管 - 9: 医用脱脂棉纱布块 - 10: 导管固定器 - 11: 可吸收性外科缝线 - 12: 一次性使用医疗卫生用品 - 13: 一次性医用灭菌棉签 - 14: 一次性使用静脉输液针 - 15: 非吸收性外科缝线 - 16: 一次性使用冲洗袋 - 17: 医用凡士林敷料 - 18: 一次性使用无菌敷贴 - 19: 自粘性薄膜敷料 - 20: 密闭式防针刺伤型静脉留置针 + 0: MCuⅡ功能性宫内节育器 + 1: 一次性中性电极板 + 2: 一次性使用乳胶导尿管 + 3: 一次性使用冲洗袋 + 4: 一次性使用医疗卫生用品 + 5: 一次性使用单极手术电极 + 6: 一次性使用导尿管 + 7: 一次性使用手术单 + 8: 一次性使用无菌敷贴 + 9: 一次性使用无菌气管插管Tracheal Tube + 10: 一次性使用无菌注射器带针 + 11: 一次性使用无菌采样拭子 + 12: 一次性使用气管插管 + 13: 一次性使用灭菌橡胶外科手套 + 14: 一次性使用牙垫 + 15: 一次性使用精密过滤输液器 带针 + 16: 一次性使用肛门管 + 17: 一次性使用胃管 + 18: 一次性使用血液透析管路 + 19: 一次性使用输卵管导管 + 20: 一次性使用雾化器 21: 一次性使用静脉留置针 - 22: 一次性使用手术单(一次性医用垫单) - 23: 一次性使用无菌采样拭子 - 24: 一次性使用手术衣 - 25: 一次性使用气管插管 - 26: 一次性使用牙垫 - 27: 一次性使用血液透析管路 - 28: 血液净化装置的体外循环血路 - 29: 一次性使用精密过滤输液器 带针 - 30: 结扎夹Ligating Clips - 31: 一次性使用麻醉面罩 - 32: 一次性内窥镜护套 - 33: 一次性使用导尿管 - 34: 一次性使用灭菌橡胶外科手套 - 35: 一次性无菌喉罩 - 36: 一次性使用无菌气管插管Tracheal Tube - 37: 负压引流器 - 38: 一次性使用单极手术电极 - 39: 一次性使用乳胶导尿管 - 40: 气管切开插管 - 41: 一次性使用雾化器 - 42: MCuⅡ功能性宫内节育器 -# 与 names 的整数下标一一对应;多规格同名则为 id1/id2/... 。 + 22: 一次性使用静脉输液针 + 23: 一次性使用麻醉面罩 + 24: 一次性内窥镜护套 + 25: 一次性医用灭菌棉签 + 26: 一次性无菌喉罩 + 27: 医用凡士林敷料 + 28: 医用纱布敷料 + 29: 医用缝合针 + 30: 医用脱脂棉纱布块 + 31: 可吸收性外科缝线 + 32: 密闭式防针刺伤型静脉留置针 + 33: 导管固定器 + 34: 气管切开插管 + 35: 结扎夹Ligating Clips + 36: 自粘性薄膜敷料 + 37: 血液净化装置的体外循环血路 + 38: 负压引流器 + 39: 非吸收性外科缝线 + 40: 非吸收性外科缝线(蚕丝线) label_id: - 0: 19246-3-14 - 1: 1531-3-2/1531-3-1/1174-42-4/1174-42-1 - 2: 14764-2-4 - 3: 1518-22-4 - 4: 583039/11207-1-64 - 5: 654032 - 6: 4787-2-55 - 7: 1518-34-17 - 8: 1380-15-1 - 9: 8028-4-39 - 10: 1441340 - 11: 11765-1-101/1330-49-185 - 12: 2241272 - 13: 2237844/10183-1-29 - 14: 129-5-30 - 15: 4142-1-46 - 16: 1644-37-3 - 17: 10870-25-16 - 18: 215-93-1 - 19: 1819-4-1 - 20: 1281-39-3 - 21: 12591-1-184 - 22: 21504-1-1 - 23: 15026-1-1 - 24: 8386-24-1 - 25: 21444-1-2 - 26: 975961 - 27: 14730-10-10 - 28: 739-2-1 - 29: 2295950 - 30: 14780-2-12 - 31: 2003984 - 32: 521-31-1 - 33: 735592/14556-4-18 - 34: 10362-1-4 - 35: 7386-61-46 - 36: 14780-3-5 - 37: 1518-20-8 - 38: 4805-2-50 - 39: 7386-10-89 - 40: 10869-30-7 - 41: 5019-4-43 - 42: 740-2-14 -nc: 43 + 0: "740-2-14" + 1: "4787-2-55" + 2: "7386-10-89" + 3: "1644-37-3" + 4: "2241272" + 5: "4805-2-50" + 6: "735592/14556-4-18" + 7: "14764-2-4" + 8: "215-93-1" + 9: "14780-3-5" + 10: "1531-3-2/1531-3-1/1174-42-4/1174-42-1" + 11: "15026-1-1" + 12: "21444-1-2" + 13: "10362-1-4" + 14: "975961" + 15: "2295950" + 16: "1518-22-4" + 17: "1518-34-17" + 18: "14730-10-10" + 19: "1380-15-1" + 20: "5019-4-43" + 21: "12591-1-184" + 22: "129-5-30" + 23: "2003984" + 24: "521-31-1" + 25: "2237844/10183-1-29" + 26: "7386-61-46" + 27: "10870-25-16" + 28: "19246-3-14" + 29: "583039/11207-1-64" + 30: "8028-4-39" + 31: "11765-1-101/1330-49-185" + 32: "1281-39-3" + 33: "1441340" + 34: "10869-30-7" + 35: "14780-2-12" + 36: "1819-4-1" + 37: "739-2-1" + 38: "1518-20-8" + 39: "4142-1-46" + 40: "654032" +nc: 41 diff --git a/app/services/consumable_vision_algorithm.py b/app/services/consumable_vision_algorithm.py index af77bb6..39a79aa 100644 --- a/app/services/consumable_vision_algorithm.py +++ b/app/services/consumable_vision_algorithm.py @@ -5,8 +5,9 @@ from __future__ import annotations -from typing import Any +import functools import os +from typing import Any import sys from collections import Counter from dataclasses import dataclass @@ -126,6 +127,38 @@ def load_name_to_label_id_from_yaml(path: Path) -> dict[str, str]: return out +def load_index_to_label_id_from_yaml(path: Path) -> dict[int, str]: + """与 ``label_id`` 段:类索引 -> 业务 id 字符串;类名与 YAML 略有不一致时仍可落盘到正确 id。""" + try: + raw = path.read_text(encoding="utf-8") + except OSError: + return {} + try: + data: Any = yaml.safe_load(raw) + except yaml.YAMLError: + return {} + if not isinstance(data, dict): + return {} + label_raw = data.get("label_id") + if not isinstance(label_raw, dict): + return {} + out: dict[int, str] = {} + for k, v in label_raw.items(): + try: + i = int(k) + except (TypeError, ValueError): + continue + if v is None or (isinstance(v, str) and not str(v).strip()): + continue + out[i] = str(v).strip() + return out + + +@functools.lru_cache(maxsize=8) +def _cached_index_to_label_id(path_resolved: str, mtime_ns: int) -> dict[int, str]: + return load_index_to_label_id_from_yaml(Path(path_resolved)) + + def list_sorted_class_names_from_yaml(path: Path) -> list[str]: """自 ``names`` 段按类索引升序取类名字符串(与训练/权重一致)。""" try: @@ -218,7 +251,11 @@ def _probs_data_to_numpy1d(raw) -> np.ndarray: def cls_top3_from_result( - cls: YOLO, r, name_to_code: dict[str, str] + cls: YOLO, + r, + name_to_code: dict[str, str], + *, + index_to_label_id: dict[int, str] | None = None, ) -> ClsTop3 | None: pr = r[0].probs if pr is None: @@ -244,6 +281,7 @@ def cls_top3_from_result( n2 = n3 = "" c2 = c3 = 0.0 + i2 = i3 = -1 if len(t5i) > 1: i2 = int(t5i[1]) n2 = str(cls.names.get(i2, "")).strip() @@ -253,12 +291,19 @@ def cls_top3_from_result( n3 = str(cls.names.get(i3, "")).strip() c3 = _conf_for_idx(i3) - def _pid(label: str) -> str: + idx_extras = index_to_label_id or {} + + def _pid(label: str, class_idx: int) -> str: lb = (label or "").strip() if not lb: return "" norm = _norm_product_name(lb) - return (name_to_code.get(norm) or name_to_code.get(lb) or "").strip() + c = (name_to_code.get(norm) or name_to_code.get(lb) or "").strip() + if c: + return c + if class_idx >= 0 and class_idx in idx_extras: + return idx_extras[class_idx] + return "" return ClsTop3( t1_name=n1, @@ -267,9 +312,9 @@ def cls_top3_from_result( t2_conf=c2, t3_name=n3, t3_conf=c3, - t1_pid=_pid(n1), - t2_pid=_pid(n2), - t3_pid=_pid(n3), + t1_pid=_pid(n1, t1i), + t2_pid=_pid(n2, i2), + t3_pid=_pid(n3, i3), ) @@ -475,12 +520,31 @@ class ConsumableVisionAlgorithmService: except Exception as exc: raise PredictionError(f"耗材分类推理失败: {exc}") from exc - snap = cls_top3_from_result(cls_model, r, name_to_code) + yp = Path(self._s.consumable_classifier_labels_yaml_path).expanduser() + if yp.is_file(): + st = yp.stat() + index_to_label_id = _cached_index_to_label_id( + str(yp.resolve()), st.st_mtime_ns + ) + else: + index_to_label_id = {} + + snap = cls_top3_from_result( + cls_model, + r, + name_to_code, + index_to_label_id=index_to_label_id, + ) if snap is None: return None if snap.t1_conf < self._s.consumable_min_cls_confidence: return None pname = snap.t1_name - if not pname or pname not in whitelist: + if not pname: return None - return snap + pnorm = _norm_product_name(pname) + if pnorm in whitelist or pname in whitelist: + return snap + if (snap.t1_pid or "").strip(): + return snap + return None diff --git a/app/services/consumption_tsv_log.py b/app/services/consumption_tsv_log.py index 2905a80..f6a64f7 100644 --- a/app/services/consumption_tsv_log.py +++ b/app/services/consumption_tsv_log.py @@ -1,6 +1,6 @@ -"""每例手术一个文本文件(制表符列):`start_surgery` 时截断并写表头,每次时间窗识别**追加**一行(仅 item_id, item_name, qty, doctor_id, timestamp)。终端 Markdown 时间戳为可读形式;落盘时间戳为 ISO 区间便于程序解析。 +"""每例手术一个文本文件(制表符列):`start_surgery` 时截断并写表头,每次时间窗识别**追加**一行(仅 item_id, item_name, qty, doctor_id, timestamp)。待确认行首列为 ``pending:{confirmation_id}``,语音落锤后**整行替换**为与客户端一致的最终真值,不再重复追加。终端 Markdown 时间戳为可读形式;落盘时间戳为 ISO 区间便于程序解析。 -手术结束时再追加一节汇总行:item_id, item_name, qty(无其它列)。 +手术结束时再追加一节汇总行:item_id, item_name, qty(无其它列);与 HTTP ``summary`` 同算法,由内存 ``details`` 经 ``build_consumption_summary`` 得到,非录制过程中按窗累计。 时间戳:在拉流起点记录 `time.time()`,与 `time.monotonic()` 时间窗对齐。直播 RTSP 经 OpenCV 一般无可靠绝对时码,以本机接收时刻为准。 """ @@ -353,25 +353,15 @@ def append_consumption_pending_window( ) -def append_consumption_voice_resolution_line( +def _build_voice_resolved_tsv_data_line( *, - surgery_id: str, name_to_code: dict[str, str], chosen_label: str, doctor_id: str, wall_epoch: float, - tsv_enabled: bool | None = None, -) -> None: - """语音确认后追加一行最终耗材(医生 ID 多为 voice);top2/3 空列。 - - 待确认流程下,时间窗仅记「待确认」,此处写入医生选定后的正式记录。 - """ - en = settings.consumption_tsv_log_enabled if tsv_enabled is None else tsv_enabled - if not en: - return +) -> str: + """与客户端一致的最终行(top2/3 空列,timestamp 为单点)。""" lb = (chosen_label or "").strip() - if not lb: - return norm = _norm_product_name(lb) p = ( name_to_code.get(norm) or name_to_code.get(lb) or "" @@ -387,7 +377,7 @@ def append_consumption_voice_resolution_line( t2_pid="", t3_pid="", ) - line = build_tsv_line( + return build_tsv_line( name_to_code=name_to_code, best=snap, doctor_id=doctor_id, @@ -395,6 +385,98 @@ def append_consumption_voice_resolution_line( wall_start_epoch=wall_epoch, wall_end_epoch=wall_epoch, ) + + +def _data_line_starts_with_pending_id(line: str, confirmation_id: str) -> bool: + s = (line or "").rstrip("\r\n") + if not s or s.startswith("item_id\t"): + return False + want = f"pending:{(confirmation_id or '').strip()}" + first = s.split("\t", 1)[0] + return first == _encode_cell(want) or first == want + + +def replace_pending_line_with_voice_resolution( + *, + surgery_id: str, + confirmation_id: str, + name_to_code: dict[str, str], + chosen_label: str, + doctor_id: str, + wall_epoch: float, + tsv_enabled: bool | None = None, +) -> None: + """将同一 ``confirmation_id`` 下先前追加的「待确认」行整行改为最终真值,不再多追加一行。 + + 未找到 ``pending:{confirmation_id}`` 时回退为追加(兼容旧文件或行缺失)。读改写全程持 + :data:`_lock`,避免与并发的 append 交错。 + """ + en = settings.consumption_tsv_log_enabled if tsv_enabled is None else tsv_enabled + if not en: + return + if not (chosen_label or "").strip() or not (confirmation_id or "").strip(): + return + new_line = _build_voice_resolved_tsv_data_line( + name_to_code=name_to_code, + chosen_label=chosen_label, + doctor_id=doctor_id, + wall_epoch=wall_epoch, + ) + path = resolved_consumption_log_path(surgery_id) + with _lock: + if not path.is_file(): + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as f: + f.write(HEADER) + f.write(new_line) + logger.warning( + "consumption TSV 无文件,无法替换 pending 行,已写表头+最终行: {}", + path, + ) + return + text = path.read_text(encoding="utf-8") + lines = text.splitlines(keepends=True) + replaced = False + out: list[str] = [] + for line in lines: + if ( + not replaced + and _data_line_starts_with_pending_id(line, confirmation_id) + ): + out.append(new_line) + replaced = True + else: + out.append(line) + if not replaced: + logger.warning( + "未在 TSV 中找到 pending 行,回退为追加: surgery={} confirm={}", + surgery_id, + confirmation_id, + ) + with path.open("a", encoding="utf-8") as f: + f.write(new_line) + return + path.write_text("".join(out), encoding="utf-8") + + +def append_consumption_voice_resolution_line( + *, + surgery_id: str, + name_to_code: dict[str, str], + chosen_label: str, + doctor_id: str, + wall_epoch: float, + tsv_enabled: bool | None = None, +) -> None: + """已弃用:请使用 ``replace_pending_line_with_voice_resolution`` 并传 ``confirmation_id``。""" + if not (chosen_label or "").strip(): + return + line = _build_voice_resolved_tsv_data_line( + name_to_code=name_to_code, + chosen_label=chosen_label, + doctor_id=doctor_id, + wall_epoch=wall_epoch, + ) append_consumption_tsv_line(surgery_id, line) @@ -467,19 +549,9 @@ class ConsumptionTsvWriter: camera_id: str, wall_start_epoch: float, wall_end_epoch: float, - running_totals: dict[str, tuple[str, int]] | None = None, ) -> None: if not self._s.consumption_tsv_log_enabled and not self._s.consumption_log_markdown_terminal: return - _tsv_id, totals_key = resolve_consumption_ids( - best.t1_name, best.t1_pid, name_to_code - ) - iname = (best.t1_name or "").strip() - if running_totals is not None: - if totals_key not in running_totals: - running_totals[totals_key] = (iname, 0) - prev_name, q = running_totals[totals_key] - running_totals[totals_key] = (prev_name, q + 1) if self._s.consumption_tsv_log_enabled: line = build_tsv_line( name_to_code=name_to_code, @@ -549,19 +621,9 @@ def append_consumption_window( camera_id: str, wall_start_epoch: float, wall_end_epoch: float, - running_totals: dict[str, tuple[str, int]] | None = None, ) -> None: if not settings.consumption_tsv_log_enabled and not settings.consumption_log_markdown_terminal: return - _tsv_id, totals_key = resolve_consumption_ids( - best.t1_name, best.t1_pid, name_to_code - ) - iname = (best.t1_name or "").strip() - if running_totals is not None: - if totals_key not in running_totals: - running_totals[totals_key] = (iname, 0) - prev_name, q = running_totals[totals_key] - running_totals[totals_key] = (prev_name, q + 1) if settings.consumption_tsv_log_enabled: line = build_tsv_line( name_to_code=name_to_code, diff --git a/app/services/video/classification_handler.py b/app/services/video/classification_handler.py index 0d507ba..5c64a56 100644 --- a/app/services/video/classification_handler.py +++ b/app/services/video/classification_handler.py @@ -7,7 +7,7 @@ - 置信度 ≥ 自动阈值但 Top1 不在候选内 → 视 voice_confirmation_enabled 入 pending。 - 中等置信度 → 入 pending(若有可展示候选项)。 -需医生确认时:消耗 TSV / 内存明细记「待确认」(不写模型 top1 商品名);语音确认后再落最终耗材并更新汇总。 +需医生确认时:消耗 TSV / 内存明细记「待确认」(不写模型 top1 商品名);语音确认后**替换**该条 TSV 为最终耗材。停录时 TSV/终端汇总与查结果 API 同口径,由 details 经 ``build_consumption_summary`` 生成。 """ from __future__ import annotations @@ -85,7 +85,6 @@ class VisionClassificationHandler: camera_id=camera_id, wall_start_epoch=ready.wall_lo, wall_end_epoch=ready.wall_hi, - running_totals=state.consumption_log_totals, ) async def handle( @@ -99,7 +98,8 @@ class VisionClassificationHandler: ) -> None: conf = cls_res.confidence label = (cls_res.label or "").strip() - item_id = resolve_consumption_item_id(label, "", state.name_to_code) + t1_pid = (ready.best.t1_pid if ready is not None else "") + item_id = resolve_consumption_item_id(label, t1_pid, state.name_to_code) voice_floor = self._s.video_voice_confirm_min_confidence if conf < voice_floor: return diff --git a/app/services/video/session_manager.py b/app/services/video/session_manager.py index be2ca67..ef5240d 100644 --- a/app/services/video/session_manager.py +++ b/app/services/video/session_manager.py @@ -29,6 +29,7 @@ from app.services.video.session_registry import ( ) from app.services.video.stream_worker import CameraStreamWorker, redact_rtsp_url from app.services.video.types import VideoBackendKind +from app.schemas import SurgeryConsumptionDetail, build_consumption_summary from app.services.consumption_tsv_log import ( append_consumption_log_summary, init_consumption_log_file, @@ -214,11 +215,21 @@ class CameraSessionManager: if isinstance(res, BaseException): logger.warning("surgery task finished with error: {}", res) - totals = dict(run.state.consumption_log_totals) + details = list(run.state.details) + detail_rows = [ + SurgeryConsumptionDetail( + item_id=r.item_id, + item_name=r.item_name, + qty=r.qty, + doctor_id=r.doctor_id, + timestamp=r.timestamp, + ) + for r in details + ] + summ = build_consumption_summary(detail_rows) + totals = {s.item_id: (s.item_name, s.total_quantity) for s in summ} append_consumption_log_summary(surgery_id, totals) print_consumption_summary_markdown(totals) - - details = list(run.state.details) await self._archive.persist_or_archive(surgery_id, details) # ------------------------------------------------------------------ diff --git a/app/services/video/session_registry.py b/app/services/video/session_registry.py index c68e4da..4bf207f 100644 --- a/app/services/video/session_registry.py +++ b/app/services/video/session_registry.py @@ -22,8 +22,7 @@ from app.services.consumable_vision_algorithm import ( _norm_product_name, ) from app.services.consumption_tsv_log import ( - append_consumption_voice_resolution_line, - resolve_consumption_ids, + replace_pending_line_with_voice_resolution, resolve_consumption_item_id, ) from app.services.voice_confirm import build_prompt_text @@ -74,8 +73,6 @@ class SurgerySessionState: last_asr_text: str | None = None #: 最近一次语音确认错误说明(ASR/解析失败等)。 last_voice_error: str | None = None - #: 视觉时间窗落盘用量累计,供停录时写汇总(item_id -> 首次名称, 次数)。 - consumption_log_totals: dict[str, tuple[str, int]] = field(default_factory=dict) @dataclass @@ -281,6 +278,7 @@ class SurgerySessionRegistry: self._finalize_voice_confirmed_consumption_log( state=st, surgery_id=surgery_id, + confirmation_id=confirmation_id, chosen_label=label, ) try: @@ -295,20 +293,16 @@ class SurgerySessionRegistry: *, state: SurgerySessionState, surgery_id: str, + confirmation_id: str, chosen_label: str, ) -> None: - """待确认流程在语音落锤后:汇总 +1 最终耗材,并追加 TSV 正式行。""" + """待确认流程在语音落锤后:将 TSV 中原 pending 行替换为最终真值。停录汇总与 HTTP 一致,由 details 经 ``build_consumption_summary`` 得到。""" cl = (chosen_label or "").strip() if not cl: return - _, key_chosen = resolve_consumption_ids(cl, "", state.name_to_code) - tot = state.consumption_log_totals - if key_chosen not in tot: - tot[key_chosen] = (cl, 0) - nm, q = tot[key_chosen] - tot[key_chosen] = (nm, q + 1) - append_consumption_voice_resolution_line( + replace_pending_line_with_voice_resolution( surgery_id=surgery_id, + confirmation_id=confirmation_id, name_to_code=state.name_to_code, chosen_label=cl, doctor_id=self._s.video_voice_confirm_doctor_id, diff --git a/tests/test_consumption_tsv_log.py b/tests/test_consumption_tsv_log.py index 2f73c09..0558ae1 100644 --- a/tests/test_consumption_tsv_log.py +++ b/tests/test_consumption_tsv_log.py @@ -13,6 +13,7 @@ from app.services.consumption_tsv_log import ( build_consumption_markdown, build_tsv_line, init_consumption_log_file, + replace_pending_line_with_voice_resolution, resolve_consumption_item_id, short_camera_label, ) @@ -84,6 +85,43 @@ def test_header_columns() -> None: ] +def test_replace_pending_line_with_voice_resolution_rewrites_one_row( + tmp_path: object, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """语音确认后应替换 pending 行,而不是再多一行。""" + monkeypatch.setattr(settings, "consumption_tsv_log_enabled", True) + monkeypatch.setattr(settings, "consumption_log_timezone", "UTC") + monkeypatch.setattr( + settings, + "consumption_tsv_log_path", + str(tmp_path / "{surgery_id}.txt"), + ) + init_consumption_log_file("SURG01") + pending = ( + "pending:abc-123\t待确认\t1\tvision\t" + "cam01@2024-01-01T00:00:00.000+00:00" + f"{_RANGE_SEP}2024-01-01T00:00:45.000+00:00\tx\t0.1\ty\t0.2\n" + ) + append_consumption_tsv_line("SURG01", pending) + replace_pending_line_with_voice_resolution( + surgery_id="SURG01", + confirmation_id="abc-123", + name_to_code={"纱布": "G1"}, + chosen_label="纱布", + doctor_id="voice", + wall_epoch=1704067200.0, + ) + text = (tmp_path / "SURG01.txt").read_text(encoding="utf-8") + assert "待确认" not in text + assert "pending:abc-123" not in text + assert "纱布" in text + assert "G1" in text + # HEADER + 恰好一行数据 + data_lines = [ln for ln in text.splitlines() if ln and not ln.startswith("item_id\t")] + assert len(data_lines) == 1 + + def test_per_surgery_file_init_and_append( tmp_path, monkeypatch: pytest.MonkeyPatch,