Переглянути джерело

接口参数修改+回调测试案例

wangxi 3 тижнів тому
батько
коміт
7f4040aab5
6 змінених файлів з 387 додано та 29 видалено
  1. 2 0
      .env.example
  2. 6 2
      README.md
  3. 133 0
      scripts/test_api_callback.py
  4. 124 1
      scripts/tests/test_api.py
  5. 82 0
      scripts/tests/test_api_callback.py
  6. 40 26
      src/http_api.py

+ 2 - 0
.env.example

@@ -25,6 +25,8 @@ DEEPSEEK_MODEL=deepseek-v4-flash
  PROPOSA_WORK_DIR=/data/model/shenqin_data
 # HTTP API JSON 模式下参考投标文件相对路径的全局目录。
  REFERENCE_DIR=/data/shenqin/
+# HTTP API JSON 模式下招标文件、采购需求和澄清公告相对路径的统一目录。
+ TPC_DIR=/data/shenqin/
 # 单个上传文件的大小上限,单位 MB。
  PROPOSA_API_MAX_UPLOAD_MB=512
 # Step1-6 后台子进程超时,单位秒;28800 为 8 小时。

+ 6 - 2
README.md

@@ -78,6 +78,7 @@ DEEPSEEK_API_KEY=你的密钥
 | `PROPOSA_API_PORT` | 否 | `8000` | HTTP API 监听端口。 |
 | `PROPOSA_WORK_DIR` | 否 | `output/api_jobs` | HTTP API 和 test_step1-6 默认工作目录;每个请求的内容放在 `PROPOSA_WORK_DIR/<request_id>/` 下,中间文件在 `work/`,最终 DOCX 在请求目录根。 |
 | `REFERENCE_DIR` | 否 | `/data/shenqin/` | JSON 模式下参考投标文件相对路径的全局目录。 |
+| `TPC_DIR` | 否 | 无 | JSON 模式下招标文件、采购需求和澄清公告相对路径的拼接前缀。 |
 | `PROPOSA_API_MAX_UPLOAD_MB` | 否 | `512` | 单个上传文件的大小上限,单位 MB。 |
 | `PROPOSA_API_TIMEOUT_SECONDS` | 否 | `28800` | Step1-6 后台子进程超时秒数,默认 8 小时。 |
 | `PROPOSA_API_CONCURRENCY` | 否 | `1` | 同时执行的后台生成任务数;大型 DOCX 场景建议保持 1。 |
@@ -169,6 +170,7 @@ curl.exe -X POST "http://127.0.0.1:8000/api/v1/final-review" `
     "txbId": "投续表的DMS-ID",
     "TENDER_FILE": "E:/CODE/proposa-ai/test_data/171-上海群众艺术馆/上海市群众艺术馆物业管理服务采购项目招标文件.pdf",
     "PROCUREMENT_FILE": "E:/CODE/proposa-ai/test_data/171-上海群众艺术馆/采购需求.docx",
+    "TPC_IS_ABSOLUTE": true,
     "REFERENCE_BID": "E:/CODE/proposa-ai/test_data/171-上海群众艺术馆/参考投标文件/物业管理费项目投标文件.docx",
     "REFERENCE_IS_ABSOLUTE": true,
     "CLA_FILE": "",
@@ -186,8 +188,9 @@ curl.exe -X POST "http://127.0.0.1:8000/api/v1/final-review" `
 | `PROCUREMENT_FILE` | 是 | 采购需求;multipart 上传 DOCX,JSON 传服务端本地路径。 |
 | `REFERENCE_BID` | 是 | 参考投标文件;multipart 上传 DOCX,JSON 传服务端本地路径。 |
 | `CLA_FILE` | 否 | 澄清公告文件;当前仅接收并记录,不参与生成。 |
+| `TPC_IS_ABSOLUTE` | JSON 是 | 仅 JSON 路径模式生效,必须传布尔值;`false` 时 TENDER_FILE、PROCUREMENT_FILE、非空 CLA_FILE 与 `TPC_DIR` 拼接,`true` 时原样读取。接口不会根据路径文本自行判断。 |
 | `CLEAN_INTERMEDIATE` | 否 | 默认 `true`;设为 `false` 时保留中间文件并返回 `intermediateDir`。 |
-| `REFERENCE_IS_ABSOLUTE` | 否 | 仅 JSON 路径模式生效;`false` 时相对 `REFERENCE_BID` 会与 `REFERENCE_DIR` 拼接。 |
+| `REFERENCE_IS_ABSOLUTE` | JSON 是 | 仅 JSON 路径模式生效,必须传布尔值;`false` 时 `REFERENCE_BID` 与 `REFERENCE_DIR` 拼接,`true` 时原样读取。接口不会根据路径文本自行判断。 |
 
 ### REFERENCE_BID 路径处理
 
@@ -196,7 +199,8 @@ JSON 模式下:
 - `REFERENCE_BID` 为绝对路径时直接使用。
 - `REFERENCE_BID` 为相对路径时,设置 `"REFERENCE_IS_ABSOLUTE": false`,接口按
   `REFERENCE_DIR + REFERENCE_BID` 拼接为绝对路径,再复制到隔离任务目录。
-- 不传 `REFERENCE_IS_ABSOLUTE` 时,使用 `os.path.isabs` 自动判断。
+- 接口只按 `REFERENCE_IS_ABSOLUTE` 的布尔值处理,不根据路径是否以 `/` 开头自行判断;
+  相对模式下会先去掉文件值开头的路径分隔符再与配置目录拼接。
 
 multipart 模式下 `REFERENCE_BID` 是上传文件本身,`REFERENCE_IS_ABSOLUTE` 仅兼容接收,
 不参与路径拼接。

+ 133 - 0
scripts/test_api_callback.py

@@ -0,0 +1,133 @@
+"""向对接系统发送一条与 Proposa API 当前格式一致的测试回调。
+
+直接运行只预览 JSON;确认内容后增加 ``--send`` 才会真正发送。
+最常修改的参数集中在下方“可编辑配置”区域。
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from dataclasses import dataclass
+from urllib.error import HTTPError, URLError
+from urllib.request import Request, urlopen
+
+
+# ============================ 可编辑配置 ============================
+CALLBACK_URL = "http://121.43.55.7:10026/shenqin/tender/callback"
+CALLBACK_STATUS = "completed"  # 可改为 "failed"
+TXB_ID = "4c9e748b-331f-468b-a735-d6748bc50cfd"
+REQUEST_ID = "test_callback_request_001"
+RESULT_PATH = "output/api_jobs/test_callback_request_001/测试招标文件.docx"
+FINAL_REVIEW_URL = (
+    "http://127.0.0.1:8000/api/v1/jobs/test_callback_request_001/file"
+)
+ERROR_MESSAGE = "测试失败:Step1-6 处理进程异常退出(退出码 7)"
+TIMEOUT_SECONDS = 30
+
+# 文件名、大小和 SHA-256 均为测试数据,可按需直接修改。
+FILES = {
+    "TENDER_FILE": {
+        "filename": "测试招标文件.pdf",
+        "size": 123456,
+        "sha256": "1" * 64,
+    },
+    "PROCUREMENT_FILE": {
+        "filename": "测试采购需求.docx",
+        "size": 234567,
+        "sha256": "2" * 64,
+    },
+    "REFERENCE_BID": {
+        "filename": "测试参考投书.docx",
+        "size": 345678,
+        "sha256": "3" * 64,
+    },
+}
+# ===================================================================
+
+
+@dataclass(frozen=True)
+class CallbackResponse:
+    status: int
+    body: str
+
+
+def build_callback_payload(status: str = CALLBACK_STATUS) -> dict:
+    """按 src/http_api.py 当前成功/失败回调字段生成测试载荷。"""
+    if status not in {"completed", "failed"}:
+        raise ValueError("status 只能是 completed 或 failed")
+
+    payload = {
+        "request_id": REQUEST_ID,
+        "txbId": TXB_ID,
+        "status": status,
+        "resultPath": RESULT_PATH,
+        "files": FILES,
+    }
+    if status == "completed":
+        payload["final_review_url"] = FINAL_REVIEW_URL
+    else:
+        payload["resultPath"] = f"error: {ERROR_MESSAGE}"
+        payload["error"] = ERROR_MESSAGE
+    return payload
+
+
+def send_callback(url: str, payload: dict) -> CallbackResponse:
+    """使用与正式 API 相同的 POST JSON 编码发送,并返回接收端响应。"""
+    body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+    request = Request(
+        url,
+        data=body,
+        headers={"Content-Type": "application/json; charset=utf-8"},
+        method="POST",
+    )
+    try:
+        with urlopen(request, timeout=TIMEOUT_SECONDS) as response:
+            response_body = response.read().decode("utf-8", errors="replace")
+            return CallbackResponse(status=response.status, body=response_body)
+    except HTTPError as exc:
+        response_body = exc.read().decode("utf-8", errors="replace")
+        return CallbackResponse(status=exc.code, body=response_body)
+    except URLError as exc:
+        raise RuntimeError(f"无法连接回调地址: {exc.reason}") from exc
+
+
+def main() -> int:
+    if hasattr(sys.stdout, "reconfigure"):
+        sys.stdout.reconfigure(encoding="utf-8")
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--send", action="store_true", help="实际发送;不传时只预览")
+    parser.add_argument("--url", default=CALLBACK_URL, help="临时覆盖回调 URL")
+    parser.add_argument(
+        "--status",
+        choices=("completed", "failed"),
+        default=CALLBACK_STATUS,
+        help="生成成功或失败回调",
+    )
+    args = parser.parse_args()
+    payload = build_callback_payload(args.status)
+
+    print(f"回调 URL: {args.url}")
+    print(json.dumps(payload, ensure_ascii=False, indent=2))
+    if not args.send:
+        print("\n当前仅预览,确认后增加 --send 才会发送。")
+        return 0
+
+    response = send_callback(args.url, payload)
+    print(f"\nHTTP 状态: {response.status}")
+    print(f"响应内容: {response.body or '<空>'}")
+    business_ok = True
+    try:
+        response_json = json.loads(response.body)
+    except (json.JSONDecodeError, TypeError):
+        response_json = None
+    if isinstance(response_json, dict) and "code" in response_json:
+        business_code = response_json["code"]
+        business_ok = str(business_code) in {"0", "200"}
+        print(f"业务码: {business_code}({'成功' if business_ok else '失败'})")
+    return 0 if 200 <= response.status < 300 and business_ok else 1
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 124 - 1
scripts/tests/test_api.py

@@ -313,7 +313,9 @@ class FinalReviewApiTests(unittest.TestCase):
                 "txbId": 1,
                 "TENDER_FILE": str(tender),
                 "PROCUREMENT_FILE": str(procurement),
+                "TPC_IS_ABSOLUTE": True,
                 "REFERENCE_BID": str(reference),
+                "REFERENCE_IS_ABSOLUTE": True,
                 "CLA_FILE": "",
             }
             with patch.object(http_api, "API_WORK_ROOT", root / "jobs"), patch.object(
@@ -354,7 +356,8 @@ class FinalReviewApiTests(unittest.TestCase):
                 "txbId": 1,
                 "TENDER_FILE": str(tender),
                 "PROCUREMENT_FILE": str(procurement),
-                "REFERENCE_BID": "参考投书.docx",
+                "TPC_IS_ABSOLUTE": True,
+                "REFERENCE_BID": "/参考投书.docx",
                 "REFERENCE_IS_ABSOLUTE": False,
                 "CLA_FILE": "",
             }
@@ -375,6 +378,97 @@ class FinalReviewApiTests(unittest.TestCase):
             worker.assert_not_called()
             shutil.rmtree(root / "jobs", ignore_errors=True)
 
+    def test_json_relative_tpc_files_join_tpc_dir(self):
+        with tempfile.TemporaryDirectory() as temp_dir:
+            root = Path(temp_dir)
+            tpc_dir = root / "tpc_root"
+            tpc_dir.mkdir()
+            tender = tpc_dir / "招标文件.pdf"
+            procurement = tpc_dir / "采购需求.docx"
+            clarification = tpc_dir / "澄清公告.docx"
+            reference = root / "参考投书.docx"
+            tender.write_bytes(_pdf_bytes())
+            procurement.write_bytes(_docx_bytes("procurement"))
+            clarification.write_bytes(_docx_bytes("clarification"))
+            reference.write_bytes(_docx_bytes("reference"))
+            executor = RecordingExecutor()
+            payload = {
+                "CALLBACK_URL": "http://127.0.0.1:9999/callback",
+                "txbId": 1,
+                "TENDER_FILE": f"/{tender.name}",
+                "PROCUREMENT_FILE": f"/{procurement.name}",
+                "REFERENCE_BID": str(reference),
+                "REFERENCE_IS_ABSOLUTE": True,
+                "CLA_FILE": f"/{clarification.name}",
+                "TPC_IS_ABSOLUTE": False,
+            }
+            with patch.object(http_api, "TPC_DIR", str(tpc_dir)), patch.object(
+                http_api, "API_WORK_ROOT", root / "jobs"
+            ), patch.object(http_api, "_JOB_EXECUTOR", executor), patch.object(
+                http_api, "_run_pipeline_worker"
+            ) as worker:
+                response = TestClient(http_api.app).post(
+                    "/api/v1/final-review",
+                    json=payload,
+                )
+                function, args = executor.submitted[0]
+                job = args[0]
+
+            self.assertEqual(response.status_code, 202)
+            self.assertEqual(job["files"]["TENDER_FILE"]["filename"], tender.name)
+            self.assertEqual(
+                job["files"]["PROCUREMENT_FILE"]["filename"], procurement.name
+            )
+            self.assertEqual(
+                job["files"]["CLA_FILE"]["filename"], clarification.name
+            )
+            worker.assert_not_called()
+            shutil.rmtree(root / "jobs", ignore_errors=True)
+
+    def test_invalid_tpc_is_absolute_value_is_rejected(self):
+        with patch.object(http_api, "_JOB_EXECUTOR") as executor:
+            response = TestClient(http_api.app).post(
+                "/api/v1/final-review",
+                json={
+                    "CALLBACK_URL": "http://127.0.0.1:9999/callback",
+                    "txbId": "bad-tpc",
+                    "TENDER_FILE": "tender.pdf",
+                    "PROCUREMENT_FILE": "procurement.docx",
+                    "REFERENCE_BID": "reference.docx",
+                    "TPC_IS_ABSOLUTE": "false",
+                    "REFERENCE_IS_ABSOLUTE": True,
+                },
+            )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(
+            response.json()["detail"],
+            "TPC_IS_ABSOLUTE 必须是布尔值 true/false",
+        )
+        executor.submit.assert_not_called()
+
+    def test_relative_tpc_files_require_tpc_dir(self):
+        with patch.object(http_api, "TPC_DIR", ""), patch.object(
+            http_api, "_JOB_EXECUTOR"
+        ) as executor:
+            response = TestClient(http_api.app).post(
+                "/api/v1/final-review",
+                json={
+                    "CALLBACK_URL": "http://127.0.0.1:9999/callback",
+                    "txbId": "missing-tpc-dir",
+                    "TENDER_FILE": "tender.pdf",
+                    "PROCUREMENT_FILE": "procurement.docx",
+                    "REFERENCE_BID": "reference.docx",
+                    "TPC_IS_ABSOLUTE": False,
+                    "REFERENCE_IS_ABSOLUTE": True,
+                },
+            )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(
+            response.json()["detail"],
+            "TENDER_FILE 为相对路径,但未配置 TPC_DIR",
+        )
+        executor.submit.assert_not_called()
+
     def test_invalid_reference_is_absolute_value_is_rejected(self):
         with patch.object(http_api, "_JOB_EXECUTOR") as executor:
             response = TestClient(http_api.app).post(
@@ -384,6 +478,7 @@ class FinalReviewApiTests(unittest.TestCase):
                     "txbId": "bad-ref",
                     "TENDER_FILE": "tender.pdf",
                     "PROCUREMENT_FILE": "procurement.docx",
+                    "TPC_IS_ABSOLUTE": True,
                     "REFERENCE_BID": "reference.docx",
                     "REFERENCE_IS_ABSOLUTE": "maybe",
                 },
@@ -391,6 +486,34 @@ class FinalReviewApiTests(unittest.TestCase):
         self.assertEqual(response.status_code, 400)
         executor.submit.assert_not_called()
 
+    def test_missing_absolute_flags_are_rejected(self):
+        base_payload = {
+            "CALLBACK_URL": "http://127.0.0.1:9999/callback",
+            "txbId": "missing-flags",
+            "TENDER_FILE": "tender.pdf",
+            "PROCUREMENT_FILE": "procurement.docx",
+            "REFERENCE_BID": "reference.docx",
+        }
+        with patch.object(http_api, "_JOB_EXECUTOR") as executor:
+            missing_tpc = TestClient(http_api.app).post(
+                "/api/v1/final-review", json=base_payload
+            )
+            missing_reference = TestClient(http_api.app).post(
+                "/api/v1/final-review",
+                json={**base_payload, "TPC_IS_ABSOLUTE": True},
+            )
+        self.assertEqual(missing_tpc.status_code, 400)
+        self.assertEqual(
+            missing_tpc.json()["detail"],
+            "TPC_IS_ABSOLUTE 必须是布尔值 true/false",
+        )
+        self.assertEqual(missing_reference.status_code, 400)
+        self.assertEqual(
+            missing_reference.json()["detail"],
+            "REFERENCE_IS_ABSOLUTE 必须是布尔值 true/false",
+        )
+        executor.submit.assert_not_called()
+
     def test_multipart_accepts_reference_is_absolute_without_path_join(self):
         with tempfile.TemporaryDirectory() as temp_dir, patch.object(
             http_api, "API_WORK_ROOT", Path(temp_dir)

+ 82 - 0
scripts/tests/test_api_callback.py

@@ -0,0 +1,82 @@
+import json
+import sys
+import unittest
+from io import StringIO
+from unittest.mock import patch
+
+from scripts import test_api_callback
+
+
+class ApiCallbackScriptTests(unittest.TestCase):
+    def test_completed_payload_matches_current_api_contract(self):
+        payload = test_api_callback.build_callback_payload("completed")
+
+        self.assertEqual(payload["status"], "completed")
+        self.assertIn("final_review_url", payload)
+        self.assertNotIn("error", payload)
+        self.assertEqual(
+            set(payload),
+            {
+                "request_id",
+                "txbId",
+                "status",
+                "resultPath",
+                "files",
+                "final_review_url",
+            },
+        )
+
+    def test_failed_payload_matches_current_api_contract(self):
+        payload = test_api_callback.build_callback_payload("failed")
+
+        self.assertEqual(payload["status"], "failed")
+        self.assertTrue(payload["resultPath"].startswith("error: "))
+        self.assertIn("error", payload)
+        self.assertNotIn("final_review_url", payload)
+
+    def test_send_callback_posts_utf8_json_and_returns_response(self):
+        class FakeResponse:
+            status = 200
+
+            def __enter__(self):
+                return self
+
+            def __exit__(self, *_args):
+                return False
+
+            def read(self):
+                return '{"code":0,"message":"成功"}'.encode("utf-8")
+
+        payload = test_api_callback.build_callback_payload("completed")
+        with patch.object(
+            test_api_callback, "urlopen", return_value=FakeResponse()
+        ) as mocked_urlopen:
+            response = test_api_callback.send_callback(
+                test_api_callback.CALLBACK_URL, payload
+            )
+
+        request = mocked_urlopen.call_args.args[0]
+        self.assertEqual(request.method, "POST")
+        self.assertEqual(
+            request.headers["Content-type"], "application/json; charset=utf-8"
+        )
+        self.assertEqual(json.loads(request.data.decode("utf-8")), payload)
+        self.assertEqual(response.status, 200)
+        self.assertIn("成功", response.body)
+
+    def test_main_treats_http_200_with_business_400_as_failure(self):
+        response = test_api_callback.CallbackResponse(
+            status=200,
+            body='{"code":400,"message":"txbId不存在"}',
+        )
+        with patch.object(sys, "argv", ["test_api_callback.py", "--send"]), patch.object(
+            test_api_callback, "send_callback", return_value=response
+        ), patch("sys.stdout", new_callable=StringIO) as output:
+            exit_code = test_api_callback.main()
+
+        self.assertEqual(exit_code, 1)
+        self.assertIn("业务码: 400(失败)", output.getvalue())
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 40 - 26
src/http_api.py

@@ -38,6 +38,7 @@ if not API_WORK_ROOT.is_absolute():
 else:
     API_WORK_ROOT = API_WORK_ROOT.resolve()
 REFERENCE_DIR = os.getenv("REFERENCE_DIR", "").strip()
+TPC_DIR = os.getenv("TPC_DIR", "").strip()
 
 MAX_UPLOAD_BYTES = int(os.getenv("PROPOSA_API_MAX_UPLOAD_MB", "512")) * 1024 * 1024
 PIPELINE_TIMEOUT_SECONDS = int(
@@ -196,28 +197,18 @@ def _parse_clean_intermediate(value) -> bool:
     )
 
 
-def _parse_reference_is_absolute(value) -> bool | None:
-    """解析 REFERENCE_IS_ABSOLUTE;None 表示自动按 os.path.isabs 判断。"""
-    if value is None:
-        return None
+def _parse_path_is_absolute(value, field_name: str) -> bool:
+    """严格解析 JSON 路径模式开关,不根据路径文本自行判断。"""
     if isinstance(value, bool):
         return value
-    if isinstance(value, str):
-        normalized = value.strip().lower()
-        if normalized in {"false", "0", "no", "off"}:
-            return False
-        if normalized in {"true", "1", "yes", "on"}:
-            return True
     raise HTTPException(
         status_code=400,
-        detail="REFERENCE_IS_ABSOLUTE 必须是 true/false",
+        detail=f"{field_name} 必须是布尔值 true/false",
     )
 
 
-def _resolve_reference_path(reference_value: str, is_absolute: bool | None) -> str:
-    """按 REFERENCE_IS_ABSOLUTE 或 os.path.isabs 判断,必要时拼接 REFERENCE_DIR。"""
-    if is_absolute is None:
-        is_absolute = os.path.isabs(reference_value)
+def _resolve_reference_path(reference_value: str, is_absolute: bool) -> str:
+    """严格按 REFERENCE_IS_ABSOLUTE 决定是否拼接 REFERENCE_DIR。"""
     if is_absolute:
         return reference_value
     if not REFERENCE_DIR:
@@ -225,7 +216,19 @@ def _resolve_reference_path(reference_value: str, is_absolute: bool | None) -> s
             status_code=400,
             detail="REFERENCE_BID 为相对路径,但未配置 REFERENCE_DIR",
         )
-    return os.path.join(REFERENCE_DIR, reference_value)
+    return os.path.join(REFERENCE_DIR, reference_value.lstrip("/\\"))
+
+
+def _resolve_tpc_path(path_value: str, is_absolute: bool, field_name: str) -> str:
+    """TPC 相对路径与统一根目录拼接;绝对路径模式原样返回。"""
+    if is_absolute:
+        return path_value
+    if not TPC_DIR:
+        raise HTTPException(
+            status_code=400,
+            detail=f"{field_name} 为相对路径,但未配置 TPC_DIR",
+        )
+    return os.path.join(TPC_DIR, path_value.lstrip("/\\"))
 
 
 def _save_local_path(source: str | Path, target: Path, field_name: str) -> dict:
@@ -585,9 +588,6 @@ async def generate_final_review(
         procurement_value = _required_upload(form, "PROCUREMENT_FILE")
         reference_value = _required_upload(form, "REFERENCE_BID")
         reference_source = reference_value
-        reference_is_absolute = _parse_reference_is_absolute(
-            form.get("REFERENCE_IS_ABSOLUTE")
-        )
         cla_value = form.get("CLA_FILE")
         clean_intermediate = _parse_clean_intermediate(
             form.get("CLEAN_INTERMEDIATE")
@@ -610,19 +610,33 @@ async def generate_final_review(
         txb_id = _normalize_txb_id(_required_json_value(payload, "txbId"))
         tender_value = _required_json_value(payload, "TENDER_FILE")
         procurement_value = _required_json_value(payload, "PROCUREMENT_FILE")
+        tpc_is_absolute = _parse_path_is_absolute(
+            payload.get("TPC_IS_ABSOLUTE"), "TPC_IS_ABSOLUTE"
+        )
+        tender_source = _resolve_tpc_path(
+            tender_value, tpc_is_absolute, "TENDER_FILE"
+        )
+        procurement_source = _resolve_tpc_path(
+            procurement_value, tpc_is_absolute, "PROCUREMENT_FILE"
+        )
         reference_value = _required_json_value(payload, "REFERENCE_BID")
-        reference_is_absolute = _parse_reference_is_absolute(
-            payload.get("REFERENCE_IS_ABSOLUTE")
+        reference_is_absolute = _parse_path_is_absolute(
+            payload.get("REFERENCE_IS_ABSOLUTE"), "REFERENCE_IS_ABSOLUTE"
         )
         reference_source = _resolve_reference_path(
             reference_value, reference_is_absolute
         )
         cla_value = _optional_json_value(payload, "CLA_FILE")
+        cla_source = (
+            _resolve_tpc_path(cla_value, tpc_is_absolute, "CLA_FILE")
+            if cla_value
+            else ""
+        )
         clean_intermediate = _parse_clean_intermediate(
             payload.get("CLEAN_INTERMEDIATE")
         )
-        tender_suffix = Path(tender_value).suffix.lower()
-        procurement_suffix = Path(procurement_value).suffix.lower()
+        tender_suffix = Path(tender_source).suffix.lower()
+        procurement_suffix = Path(procurement_source).suffix.lower()
         reference_suffix = Path(reference_source).suffix.lower()
         _require_suffix_from_string(tender_suffix, "TENDER_FILE", ".pdf")
         _require_suffix_from_string(
@@ -656,10 +670,10 @@ async def generate_final_review(
         else:
             files = {
                 "TENDER_FILE": _save_local_path(
-                    tender_value, tender_path, "TENDER_FILE"
+                    tender_source, tender_path, "TENDER_FILE"
                 ),
                 "PROCUREMENT_FILE": _save_local_path(
-                    procurement_value, procurement_path, "PROCUREMENT_FILE"
+                    procurement_source, procurement_path, "PROCUREMENT_FILE"
                 ),
                 "REFERENCE_BID": _save_local_path(
                     reference_source, reference_path, "REFERENCE_BID"
@@ -676,7 +690,7 @@ async def generate_final_review(
                     cla_value, clarification_path, "CLA_FILE"
                 )
             else:
-                clarification_path_value = Path(cla_value)
+                clarification_path_value = Path(cla_source)
                 clarification_path = (
                     job_dir / f"clarification{clarification_path_value.suffix.lower()}"
                 )