diff --git a/backend/app/api/pl/public_transport.py b/backend/app/api/pl/public_transport.py index 0fa5da43e..2a449efb3 100644 --- a/backend/app/api/pl/public_transport.py +++ b/backend/app/api/pl/public_transport.py @@ -46,6 +46,7 @@ from app.schemas.pl.transport import ( ) from app.services.pl_dibang_bridge_service import plan_has_load_stop from app.services.pl_feedback_brand_qty import ( + driver_feedback_enforces_brand, persist_transport_product_feedback, require_brand_qtys_if_needed, ) @@ -796,7 +797,7 @@ def save_public_driver_stop_feedback( required_brand_id=plan_product.brand_id if plan_product else None, actual_qty=actual_qty, rows=brand_qtys, - enforce=True, + enforce=driver_feedback_enforces_brand(brand_qtys), ) except ValueError as exc: return error_response(400, str(exc)) diff --git a/backend/app/services/pl_driver_plan_service.py b/backend/app/services/pl_driver_plan_service.py index f1721db13..44495df5e 100644 --- a/backend/app/services/pl_driver_plan_service.py +++ b/backend/app/services/pl_driver_plan_service.py @@ -31,6 +31,7 @@ from app.schemas.pl.transport import ( PublicTransportPageRead, ) from app.services.pl_feedback_brand_qty import ( + driver_feedback_enforces_brand, persist_transport_product_feedback, require_brand_qtys_if_needed, ) @@ -552,7 +553,7 @@ def save_driver_stop_feedback_for_plan( required_brand_id=plan_product.brand_id if plan_product else None, actual_qty=actual_qty, rows=brand_qtys, - enforce=True, + enforce=driver_feedback_enforces_brand(brand_qtys), ) except ValueError as exc: return False, str(exc) diff --git a/backend/app/services/pl_feedback_brand_qty.py b/backend/app/services/pl_feedback_brand_qty.py index 55136ad8b..e8b43135b 100644 --- a/backend/app/services/pl_feedback_brand_qty.py +++ b/backend/app/services/pl_feedback_brand_qty.py @@ -20,6 +20,16 @@ def format_brand_qty_summary(rows: list[dict]) -> Optional[str]: return " / ".join(parts) if parts else None +def driver_feedback_enforces_brand(rows: Optional[list]) -> bool: + """司机回执未填品牌数量时不拦截单据保存;已填写则继续校验。""" + for row in rows or []: + if not isinstance(row, dict): + continue + if Decimal(str(row.get("qty") or 0)) > 0: + return True + return False + + def require_brand_qtys_if_needed( *, brand_mode: Optional[str], diff --git a/backend/tests/test_pl_feedback_brand_qty.py b/backend/tests/test_pl_feedback_brand_qty.py index f6e2d0303..8a3c9c1cc 100644 --- a/backend/tests/test_pl_feedback_brand_qty.py +++ b/backend/tests/test_pl_feedback_brand_qty.py @@ -2,7 +2,11 @@ from decimal import Decimal import pytest -from app.services.pl_feedback_brand_qty import format_brand_qty_summary, require_brand_qtys_if_needed +from app.services.pl_feedback_brand_qty import ( + driver_feedback_enforces_brand, + format_brand_qty_summary, + require_brand_qtys_if_needed, +) def test_format_brand_qty_summary(): @@ -41,6 +45,29 @@ def test_require_skips_when_not_enforced(): ) +def test_driver_feedback_missing_brand_qty_does_not_block_save(): + rows = [] + require_brand_qtys_if_needed( + brand_mode="required", + required_brand_id="b1", + actual_qty=Decimal("0"), + rows=rows, + enforce=driver_feedback_enforces_brand(rows), + ) + + +def test_driver_feedback_still_checks_brand_qty_when_submitted(): + rows = [{"brandId": "b1", "qty": Decimal("3")}] + with pytest.raises(ValueError, match="必须等于"): + require_brand_qtys_if_needed( + brand_mode="required", + required_brand_id="b1", + actual_qty=Decimal("10"), + rows=rows, + enforce=driver_feedback_enforces_brand(rows), + ) + + def test_require_enforces_when_completed(): with pytest.raises(ValueError, match="至少一个品牌数量"): require_brand_qtys_if_needed( diff --git a/miniprogram/utils/__tests__/portalEnvelope.test.mjs b/miniprogram/utils/__tests__/portalEnvelope.test.mjs new file mode 100644 index 000000000..0507ca985 --- /dev/null +++ b/miniprogram/utils/__tests__/portalEnvelope.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { portalBusinessErrorMessage } from '../portalEnvelope.js' + +test('portal business failure keeps the server message', () => { + assert.equal( + portalBusinessErrorMessage({ + code: 400, + message: '有品牌产品须填写至少一个品牌数量', + data: null + }), + '有品牌产品须填写至少一个品牌数量' + ) +}) + +test('portal success and raw payloads are not errors', () => { + assert.equal( + portalBusinessErrorMessage({ code: 200, message: 'success', data: { success: true } }), + null + ) + assert.equal(portalBusinessErrorMessage({ code: 0, msg: 'ok' }), null) + assert.equal(portalBusinessErrorMessage({ id: 'plan-1', planNo: 'PO1' }), null) + assert.equal(portalBusinessErrorMessage({ code: 'ABC', name: '地磅单据' }), null) + assert.equal(portalBusinessErrorMessage(null), null) +}) + +test('portal failure without a message still fails', () => { + assert.equal(portalBusinessErrorMessage({ code: '400', data: null }), '请求失败') +}) diff --git a/miniprogram/utils/dbRequest.js b/miniprogram/utils/dbRequest.js index f848079a6..e2f91cd5a 100644 --- a/miniprogram/utils/dbRequest.js +++ b/miniprogram/utils/dbRequest.js @@ -1,8 +1,9 @@ /** - * 地磅模块 HTTP(裸 JSON,非门户 code/data 信封) + * 地磅与运输小程序 HTTP。裸 JSON 原样返回;门户信封在 HTTP 200 下若 code 不是 200/0,按业务失败处理。 * 对齐 mobile-h5/src/utils/http/db.ts */ import { TOKEN_KEY, REFRESH_TOKEN_KEY, USER_INFO_KEY, resolveApiBase } from '@/config/api' +import { portalBusinessErrorMessage } from '@/utils/portalEnvelope' const DB_COLLECTION_ROOT = /^\/(plans|inventory|weighing)$/ @@ -83,6 +84,14 @@ export function dbRequest(options = {}) { return } if (status >= 200 && status < 300) { + const businessError = portalBusinessErrorMessage(data) + if (businessError) { + if (showError) { + uni.showToast({ title: businessError.slice(0, 40), icon: 'none' }) + } + reject(new Error(businessError)) + return + } resolve(data) return } diff --git a/miniprogram/utils/portalEnvelope.js b/miniprogram/utils/portalEnvelope.js new file mode 100644 index 000000000..206bb3393 --- /dev/null +++ b/miniprogram/utils/portalEnvelope.js @@ -0,0 +1,15 @@ +/** + * 门户信封 { code, message, data } 的业务失败文案。 + * HTTP 200 仍可能携带 code=400;code 缺失或非数字时视为裸 JSON。 + * @param {unknown} data + * @returns {string | null} + */ +export function portalBusinessErrorMessage(data) { + if (!data || typeof data !== 'object' || Array.isArray(data)) return null + if (!Object.prototype.hasOwnProperty.call(data, 'code')) return null + const code = Number(data.code) + if (!Number.isFinite(code) || code === 200 || code === 0) return null + const message = typeof data.message === 'string' ? data.message.trim() : '' + const msg = typeof data.msg === 'string' ? data.msg.trim() : '' + return message || msg || '请求失败' +}