|
|
@@ -0,0 +1,807 @@
|
|
|
+# 小程序 ETC加油充值申请 Implementation Plan
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** 小程序「运输管理」下新增「ETC加油充值申请」,司机/押运员按车辆人员归属查看与申请,复用现有充值申请 API(`mineVehicles` 作用域)。
|
|
|
+
|
|
|
+**Architecture:** 后端在 `list/get/create/update/submit/cancel` 与车辆列表增加 `mineVehicles`;解析 `user → pl_personnel.sys_user_id → pl_vehicle_personnel → license_plate`。`mineVehicles=true` 时用车牌集过滤,**跳过** `apply_pl_scope`/`assert_pl_row_in_scope`(否则「本人数据范围」会挡住他人代申请)。写操作另校验申请人=当前用户。小程序页面骨架对齐加油登记。
|
|
|
+
|
|
|
+**Tech Stack:** FastAPI + SQLAlchemy;uni-app Vue3 小程序;MySQL 菜单 SQL;pytest(SQLite 内存库,对齐现有 `test_pl_vehicle_recharge_workflow.py`)。
|
|
|
+
|
|
|
+**Spec:** `docs/superpowers/specs/2026-09-27-mini-pl-vehicle-recharge-apply-design.md`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## File map
|
|
|
+
|
|
|
+| 文件 | 职责 |
|
|
|
+|------|------|
|
|
|
+| `backend/app/services/pl_vehicle_mine_vehicles.py` | **新建**:本人车辆车牌集解析(可复用 `resolve_personnel_for_user`) |
|
|
|
+| `backend/app/services/pl_vehicle_recharge_service.py` | `mine_vehicles` 接入 list/get/create/update/submit/cancel |
|
|
|
+| `backend/app/api/pl/vehicle_recharge.py` | 解析 `mineVehicles` Query/参数 |
|
|
|
+| `backend/app/api/pl/base_data.py` | `GET /vehicle` 支持 `mineVehicles` |
|
|
|
+| `backend/tests/test_pl_vehicle_recharge_mine_vehicles.py` | **新建**:过滤与写校验测试 |
|
|
|
+| `docs/sql/migrations/2026-09-27-mini-pl-vehicle-recharge-apply-menus.sql` | 菜单 + 司机/押运员授权 |
|
|
|
+| `miniprogram/api/pl.js` | `vehicleRechargeApplyApi`;车辆 options 传 `mineVehicles` |
|
|
|
+| `miniprogram/api/dbOptions.js` | `fetchVehicleOptions` 支持 `mineVehicles` |
|
|
|
+| `miniprogram/config/api.js` / `workbench-menus.js` / `pages.json` | 路由与菜单 |
|
|
|
+| `miniprogram/pages/pl/vehicle-recharge-apply/list.vue` | 列表 |
|
|
|
+| `miniprogram/pages/pl/vehicle-recharge-apply/form.vue` | 表单/详情 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 1: 后端 — 本人车辆车牌集 helper + 失败测试
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `backend/app/services/pl_vehicle_mine_vehicles.py`
|
|
|
+- Create: `backend/tests/test_pl_vehicle_recharge_mine_vehicles.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 写失败测试(车牌集解析)**
|
|
|
+
|
|
|
+```python
|
|
|
+"""mineVehicles:车辆人员归属过滤与写校验。"""
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+from datetime import date, datetime
|
|
|
+from types import SimpleNamespace
|
|
|
+
|
|
|
+import pytest
|
|
|
+from sqlalchemy import create_engine
|
|
|
+from sqlalchemy.orm import sessionmaker
|
|
|
+
|
|
|
+from app.models.pl_models import (
|
|
|
+ PlPersonnel,
|
|
|
+ PlVehicle,
|
|
|
+ PlVehiclePersonnel,
|
|
|
+ PlVehicleRechargeApply,
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+def _make_session():
|
|
|
+ engine = create_engine("sqlite://")
|
|
|
+ for table in (
|
|
|
+ PlVehicle.__table__,
|
|
|
+ PlPersonnel.__table__,
|
|
|
+ PlVehiclePersonnel.__table__,
|
|
|
+ PlVehicleRechargeApply.__table__,
|
|
|
+ ):
|
|
|
+ table.create(engine)
|
|
|
+ return sessionmaker(bind=engine)()
|
|
|
+
|
|
|
+
|
|
|
+def _user(uid: str = "u-driver") -> SimpleNamespace:
|
|
|
+ return SimpleNamespace(id=uid, name="司机甲", dept_id="d1", role="driver", tenant_no="t1")
|
|
|
+
|
|
|
+
|
|
|
+def _seed_person_vehicle(db, *, user_id="u-driver", plate="鄂A11111", vehicle_id="veh-a"):
|
|
|
+ db.add(
|
|
|
+ PlVehicle(
|
|
|
+ id=vehicle_id,
|
|
|
+ vehicle_type="self",
|
|
|
+ license_plate=plate,
|
|
|
+ tenant_no="t1",
|
|
|
+ create_dept_id="d1",
|
|
|
+ del_flag=0,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ db.add(
|
|
|
+ PlPersonnel(
|
|
|
+ id="p-1",
|
|
|
+ personnel_name="司机甲",
|
|
|
+ personnel_type="driver",
|
|
|
+ personnel_affiliation="self",
|
|
|
+ status="1",
|
|
|
+ sys_user_id=user_id,
|
|
|
+ tenant_no="t1",
|
|
|
+ create_dept_id="d1",
|
|
|
+ del_flag=0,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ db.add(
|
|
|
+ PlVehiclePersonnel(
|
|
|
+ id="vp-1",
|
|
|
+ vehicle_id=vehicle_id,
|
|
|
+ personnel_id="p-1",
|
|
|
+ role="driver",
|
|
|
+ is_default=1,
|
|
|
+ tenant_no="t1",
|
|
|
+ create_dept_id="d1",
|
|
|
+ del_flag=0,
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def test_license_plates_for_user_personnel():
|
|
|
+ from app.services.pl_vehicle_mine_vehicles import license_plates_for_user_personnel
|
|
|
+
|
|
|
+ db = _make_session()
|
|
|
+ _seed_person_vehicle(db)
|
|
|
+ db.add(
|
|
|
+ PlVehicle(
|
|
|
+ id="veh-b",
|
|
|
+ vehicle_type="self",
|
|
|
+ license_plate="鄂A22222",
|
|
|
+ tenant_no="t1",
|
|
|
+ create_dept_id="d1",
|
|
|
+ del_flag=0,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ db.commit()
|
|
|
+ plates = license_plates_for_user_personnel(db, "t1", "u-driver")
|
|
|
+ assert plates == {"鄂A11111"}
|
|
|
+
|
|
|
+
|
|
|
+def test_license_plates_empty_when_no_personnel():
|
|
|
+ from app.services.pl_vehicle_mine_vehicles import license_plates_for_user_personnel
|
|
|
+
|
|
|
+ db = _make_session()
|
|
|
+ db.commit()
|
|
|
+ assert license_plates_for_user_personnel(db, "t1", "u-nobody") == set()
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 运行确认失败**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_pl_vehicle_recharge_mine_vehicles.py::test_license_plates_for_user_personnel -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL(模块/函数不存在)
|
|
|
+
|
|
|
+- [ ] **Step 3: 实现 helper**
|
|
|
+
|
|
|
+```python
|
|
|
+# backend/app/services/pl_vehicle_mine_vehicles.py
|
|
|
+"""当前用户关联驾押人员所挂靠车辆的车牌集合。"""
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+from typing import Optional, Set
|
|
|
+
|
|
|
+from sqlalchemy.orm import Session
|
|
|
+
|
|
|
+from app.models.pl_models import PlPersonnel, PlVehicle, PlVehiclePersonnel
|
|
|
+from app.services.pl_driver_plan_service import resolve_personnel_for_user
|
|
|
+
|
|
|
+
|
|
|
+def license_plates_for_user_personnel(
|
|
|
+ db: Session, tenant_no: str, user_id: str
|
|
|
+) -> Set[str]:
|
|
|
+ personnel = resolve_personnel_for_user(db, tenant_no=tenant_no, user_id=user_id)
|
|
|
+ if not personnel:
|
|
|
+ return set()
|
|
|
+ rows = (
|
|
|
+ db.query(PlVehicle.license_plate)
|
|
|
+ .join(
|
|
|
+ PlVehiclePersonnel,
|
|
|
+ PlVehiclePersonnel.vehicle_id == PlVehicle.id,
|
|
|
+ )
|
|
|
+ .filter(
|
|
|
+ PlVehiclePersonnel.personnel_id == personnel.id,
|
|
|
+ PlVehiclePersonnel.tenant_no == tenant_no,
|
|
|
+ PlVehiclePersonnel.del_flag == 0,
|
|
|
+ PlVehicle.tenant_no == tenant_no,
|
|
|
+ PlVehicle.del_flag == 0,
|
|
|
+ )
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ return {str(r[0]).strip() for r in rows if r and r[0] and str(r[0]).strip()}
|
|
|
+
|
|
|
+
|
|
|
+def resolve_personnel_id_for_user(
|
|
|
+ db: Session, tenant_no: str, user_id: str
|
|
|
+) -> Optional[str]:
|
|
|
+ p = resolve_personnel_for_user(db, tenant_no=tenant_no, user_id=user_id)
|
|
|
+ return p.id if p else None
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 再跑测试通过**
|
|
|
+
|
|
|
+```bash
|
|
|
+pytest tests/test_pl_vehicle_recharge_mine_vehicles.py::test_license_plates_for_user_personnel tests/test_pl_vehicle_recharge_mine_vehicles.py::test_license_plates_empty_when_no_personnel -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/services/pl_vehicle_mine_vehicles.py backend/tests/test_pl_vehicle_recharge_mine_vehicles.py
|
|
|
+git commit -m "feat(pl): add helper for user personnel vehicle plates"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2: 后端 — list_applies / get_apply 的 mine_vehicles
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/app/services/pl_vehicle_recharge_service.py`
|
|
|
+- Modify: `backend/tests/test_pl_vehicle_recharge_mine_vehicles.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 追加失败测试**
|
|
|
+
|
|
|
+在测试文件中增加(并补齐必要 seed / monkeypatch,对齐 `test_pl_vehicle_recharge_workflow.py` 的 `_patch_scope`):
|
|
|
+
|
|
|
+```python
|
|
|
+def _seed_apply(db, *, apply_id, plate, applicant_id, status="draft"):
|
|
|
+ db.add(
|
|
|
+ PlVehicleRechargeApply(
|
|
|
+ id=apply_id,
|
|
|
+ apply_no=f"SXSQ{apply_id}",
|
|
|
+ license_plate=plate,
|
|
|
+ recharge_type="etc",
|
|
|
+ apply_date=date.today(),
|
|
|
+ applicant_id=applicant_id,
|
|
|
+ status=status,
|
|
|
+ tenant_no="t1",
|
|
|
+ create_by=applicant_id,
|
|
|
+ create_dept_id="d1",
|
|
|
+ create_time=datetime.now(),
|
|
|
+ update_time=datetime.now(),
|
|
|
+ del_flag=0,
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def test_list_applies_mine_vehicles_includes_others_on_my_plate(monkeypatch):
|
|
|
+ from app.services import pl_vehicle_recharge_service as svc
|
|
|
+
|
|
|
+ db = _make_session()
|
|
|
+ _seed_person_vehicle(db)
|
|
|
+ _seed_apply(db, apply_id="a1", plate="鄂A11111", applicant_id="u-office")
|
|
|
+ _seed_apply(db, apply_id="a2", plate="鄂A99999", applicant_id="u-office")
|
|
|
+ db.commit()
|
|
|
+ monkeypatch.setattr(svc, "apply_pl_scope", lambda query, *_a, **_k: query)
|
|
|
+ monkeypatch.setattr(svc, "assert_pl_row_in_scope", lambda *_a, **_k: None)
|
|
|
+ monkeypatch.setattr(svc, "_resolve_user_name", lambda *_a, **_k: "X")
|
|
|
+ monkeypatch.setattr(svc, "apply_to_read", lambda row, db, **kw: SimpleNamespace(
|
|
|
+ id=row.id, licensePlate=row.license_plate, applicantId=row.applicant_id
|
|
|
+ ))
|
|
|
+
|
|
|
+ items, total = svc.list_applies(
|
|
|
+ db, "t1", _user(), mine_vehicles=True, page=1, page_size=20
|
|
|
+ )
|
|
|
+ assert total == 1
|
|
|
+ assert items[0].id == "a1"
|
|
|
+
|
|
|
+
|
|
|
+def test_get_apply_mine_vehicles_allows_others_on_my_plate(monkeypatch):
|
|
|
+ from app.services import pl_vehicle_recharge_service as svc
|
|
|
+ from fastapi import HTTPException
|
|
|
+
|
|
|
+ db = _make_session()
|
|
|
+ _seed_person_vehicle(db)
|
|
|
+ _seed_apply(db, apply_id="a1", plate="鄂A11111", applicant_id="u-office")
|
|
|
+ db.commit()
|
|
|
+ monkeypatch.setattr(svc, "assert_pl_row_in_scope", lambda *_a, **_k: (_ for _ in ()).throw(
|
|
|
+ AssertionError("should not use data scope when mine_vehicles")
|
|
|
+ ))
|
|
|
+ monkeypatch.setattr(svc, "_resolve_user_name", lambda *_a, **_k: "X")
|
|
|
+
|
|
|
+ # 实现后:应成功返回,且不应调用 assert_pl_row_in_scope
|
|
|
+ # 先期望当前签名不接受 mine_vehicles → TypeError / 或 403
|
|
|
+ with pytest.raises((TypeError, HTTPException)):
|
|
|
+ svc.get_apply(db, "t1", _user(), "a1", mine_vehicles=True)
|
|
|
+```
|
|
|
+
|
|
|
+(实现后把 `get` 测试改为断言成功返回 `a1`,并删掉「应抛错」分支。)
|
|
|
+
|
|
|
+- [ ] **Step 2: 跑测试确认失败**
|
|
|
+
|
|
|
+```bash
|
|
|
+pytest tests/test_pl_vehicle_recharge_mine_vehicles.py::test_list_applies_mine_vehicles_includes_others_on_my_plate -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL(`mine_vehicles` 未定义)
|
|
|
+
|
|
|
+- [ ] **Step 3: 改 `list_applies` / `get_apply` / `_get_apply_row`**
|
|
|
+
|
|
|
+在 `list_applies` 签名增加 `mine_vehicles: bool = False`。在 `apply_pl_scope` **之前**:
|
|
|
+
|
|
|
+```python
|
|
|
+if mine_vehicles:
|
|
|
+ plates = license_plates_for_user_personnel(db, tenant_no, user.id)
|
|
|
+ if not plates:
|
|
|
+ return [], 0
|
|
|
+ query = query.filter(PlVehicleRechargeApply.license_plate.in_(list(plates)))
|
|
|
+ # 跳过 apply_pl_scope:本人数据范围会挡住他人代申请
|
|
|
+ total = query.count()
|
|
|
+ ...
|
|
|
+ return [...], total
|
|
|
+
|
|
|
+query = apply_pl_scope(...)
|
|
|
+```
|
|
|
+
|
|
|
+`_get_apply_row` 增加可选 `mine_vehicles: bool = False`:
|
|
|
+
|
|
|
+```python
|
|
|
+if mine_vehicles:
|
|
|
+ plates = license_plates_for_user_personnel(db, tenant_no, user.id)
|
|
|
+ if (row.license_plate or "").strip() not in plates:
|
|
|
+ raise HTTPException(status_code=403, detail="无权查看该申请")
|
|
|
+ return row # 不走 assert_pl_row_in_scope
|
|
|
+assert_pl_row_in_scope(...)
|
|
|
+```
|
|
|
+
|
|
|
+`get_apply(..., mine_vehicles: bool = False)` 传入 `_get_apply_row`。
|
|
|
+
|
|
|
+- [ ] **Step 4: 修正 get 测试为成功断言并跑通**
|
|
|
+
|
|
|
+```bash
|
|
|
+pytest tests/test_pl_vehicle_recharge_mine_vehicles.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS(本文件全部)
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/services/pl_vehicle_recharge_service.py backend/tests/test_pl_vehicle_recharge_mine_vehicles.py
|
|
|
+git commit -m "feat(pl): filter recharge applies by mineVehicles plates"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 3: 后端 — create / update / submit / cancel 写校验
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/app/services/pl_vehicle_recharge_service.py`
|
|
|
+- Modify: `backend/tests/test_pl_vehicle_recharge_mine_vehicles.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 写失败测试**
|
|
|
+
|
|
|
+```python
|
|
|
+def test_create_apply_mine_vehicles_rejects_foreign_plate(monkeypatch):
|
|
|
+ from app.services import pl_vehicle_recharge_service as svc
|
|
|
+ from app.schemas.pl.vehicle_recharge import VehicleRechargeApplyCreate
|
|
|
+ from fastapi import HTTPException
|
|
|
+
|
|
|
+ db = _make_session()
|
|
|
+ _seed_person_vehicle(db)
|
|
|
+ db.commit()
|
|
|
+ monkeypatch.setattr(svc, "apply_pl_scope", lambda query, *_a, **_k: query)
|
|
|
+ monkeypatch.setattr(svc, "assert_pl_row_in_scope", lambda *_a, **_k: None)
|
|
|
+ monkeypatch.setattr(svc, "_resolve_user_name", lambda *_a, **_k: "X")
|
|
|
+ monkeypatch.setattr(svc, "_resolve_applicant_id", lambda *_a, **_k: "u-driver")
|
|
|
+ monkeypatch.setattr(svc, "_assert_vehicle_exists", lambda *_a, **_k: None)
|
|
|
+ monkeypatch.setattr(svc, "generate_apply_no", lambda *_a, **_k: "SXSQTEST1")
|
|
|
+
|
|
|
+ payload = VehicleRechargeApplyCreate(
|
|
|
+ licensePlate="鄂A99999",
|
|
|
+ rechargeType="etc",
|
|
|
+ applyDate=date.today(),
|
|
|
+ )
|
|
|
+ with pytest.raises(HTTPException) as ei:
|
|
|
+ svc.create_apply(db, "t1", _user(), payload, mine_vehicles=True)
|
|
|
+ assert ei.value.status_code in (400, 403)
|
|
|
+
|
|
|
+
|
|
|
+def test_update_cancel_submit_require_own_applicant(monkeypatch):
|
|
|
+ from app.services import pl_vehicle_recharge_service as svc
|
|
|
+ from fastapi import HTTPException
|
|
|
+
|
|
|
+ db = _make_session()
|
|
|
+ _seed_person_vehicle(db)
|
|
|
+ _seed_apply(db, apply_id="a1", plate="鄂A11111", applicant_id="u-office", status="draft")
|
|
|
+ db.commit()
|
|
|
+ monkeypatch.setattr(svc, "assert_pl_row_in_scope", lambda *_a, **_k: None)
|
|
|
+ monkeypatch.setattr(svc, "_resolve_user_name", lambda *_a, **_k: "X")
|
|
|
+
|
|
|
+ with pytest.raises(HTTPException) as ei:
|
|
|
+ svc.cancel_apply(db, "t1", _user(), "a1", mine_vehicles=True)
|
|
|
+ assert ei.value.status_code == 403
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 跑测确认失败**
|
|
|
+
|
|
|
+```bash
|
|
|
+pytest tests/test_pl_vehicle_recharge_mine_vehicles.py::test_create_apply_mine_vehicles_rejects_foreign_plate -v
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 实现写校验**
|
|
|
+
|
|
|
+在 service 中:
|
|
|
+
|
|
|
+```python
|
|
|
+def _assert_mine_vehicle_plate(db, tenant_no, user, plate: str) -> None:
|
|
|
+ plates = license_plates_for_user_personnel(db, tenant_no, user.id)
|
|
|
+ if (plate or "").strip() not in plates:
|
|
|
+ raise HTTPException(status_code=403, detail="只能选择本人车辆")
|
|
|
+
|
|
|
+
|
|
|
+def _assert_own_apply_for_write(row, user) -> None:
|
|
|
+ if (row.applicant_id or "").strip() != (user.id or "").strip():
|
|
|
+ raise HTTPException(status_code=403, detail="只能操作本人申请")
|
|
|
+```
|
|
|
+
|
|
|
+- `create_apply(..., mine_vehicles=False)`:若 true → `_assert_mine_vehicle_plate`;`applicant_id` 强制 `user.id`(可在调用 `_resolve_applicant_id` 前覆盖 payload 或传 `force_self`)
|
|
|
+- `update_apply`:`mine_vehicles` 时先 `_get_apply_row(..., mine_vehicles=True)`,再 `_assert_own_apply_for_write`,再校验车牌
|
|
|
+- `submit_apply` / `cancel_apply`:同上(读用车牌集,写要求本人申请人)
|
|
|
+
|
|
|
+注意:`create` 时 Web 路径不变;仅 `mine_vehicles=True` 强制申请人。
|
|
|
+
|
|
|
+- [ ] **Step 4: 全量本文件测试 + 工作流回归**
|
|
|
+
|
|
|
+```bash
|
|
|
+pytest tests/test_pl_vehicle_recharge_mine_vehicles.py tests/test_pl_vehicle_recharge_workflow.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/services/pl_vehicle_recharge_service.py backend/tests/test_pl_vehicle_recharge_mine_vehicles.py
|
|
|
+git commit -m "feat(pl): enforce mineVehicles write rules on recharge apply"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 4: 后端 — API 层接入 `mineVehicles`
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/app/api/pl/vehicle_recharge.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: list / get / create / update / submit / cancel 增加 Query**
|
|
|
+
|
|
|
+模式(与 `vehicle_fuel.py` 的 `mineOnly` 类似):
|
|
|
+
|
|
|
+```python
|
|
|
+mine_vehicles: Optional[bool] = Query(default=None, alias="mineVehicles"),
|
|
|
+...
|
|
|
+mine_vehicles=_as_bool(_param_get(params, "mineVehicles", "mine_vehicles")),
|
|
|
+```
|
|
|
+
|
|
|
+对 POST/PUT:允许 Query `mineVehicles=true`(小程序 `db.post(..., { params: { mineVehicles: true } })`)。
|
|
|
+
|
|
|
+辅助:
|
|
|
+
|
|
|
+```python
|
|
|
+def _as_bool(v) -> bool:
|
|
|
+ if v is True or v is False:
|
|
|
+ return v
|
|
|
+ if v is None or v == "":
|
|
|
+ return False
|
|
|
+ return str(v).strip().lower() in ("1", "true", "yes", "y")
|
|
|
+```
|
|
|
+
|
|
|
+(若文件内已有类似函数则复用。)
|
|
|
+
|
|
|
+把解析结果传入 `svc.list_applies(..., mine_vehicles=...)` 等。
|
|
|
+
|
|
|
+- [ ] **Step 2: 手动或用 TestClient 冒烟(可选)**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_pl_vehicle_recharge_mine_vehicles.py tests/test_pl_vehicle_recharge_workflow.py -v
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/api/pl/vehicle_recharge.py
|
|
|
+git commit -m "feat(api): accept mineVehicles on vehicle-recharge-apply endpoints"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 5: 后端 — 车辆列表 `mineVehicles`
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/app/api/pl/base_data.py`(`list_vehicles`)
|
|
|
+- Optional test in `test_pl_vehicle_recharge_mine_vehicles.py` 或轻量单测车牌过滤 SQL
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 `list_vehicles` 解析 `mineVehicles`**
|
|
|
+
|
|
|
+在现有 `query` 构建后、分页前:
|
|
|
+
|
|
|
+```python
|
|
|
+if _as_bool(_param_get(params, "mineVehicles", "mine_vehicles")):
|
|
|
+ from app.services.pl_vehicle_mine_vehicles import license_plates_for_user_personnel
|
|
|
+ plates = license_plates_for_user_personnel(db, tenant_no, user.id)
|
|
|
+ if not plates:
|
|
|
+ return VehicleListResponse(items=[], total=0, page=page, pageSize=page_size)
|
|
|
+ query = query.filter(PlVehicle.license_plate.in_(list(plates)))
|
|
|
+```
|
|
|
+
|
|
|
+注意:`_merge_query` 需纳入 `mineVehicles`;若函数内无 `_as_bool`,内联或从公共处引入。
|
|
|
+
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/api/pl/base_data.py
|
|
|
+git commit -m "feat(api): filter vehicle options by mineVehicles personnel"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 6: SQL — 小程序菜单与角色授权
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `docs/sql/migrations/2026-09-27-mini-pl-vehicle-recharge-apply-menus.sql`
|
|
|
+
|
|
|
+- [ ] **Step 1: 写幂等迁移**(对齐 driver-plan 计划中的 SQL 风格)
|
|
|
+
|
|
|
+要点:
|
|
|
+
|
|
|
+1. 幂等创建角色 `司机` / `押运员`(`NOT EXISTS role_name`)
|
|
|
+2. 幂等创建/复用 `MINI_PL_ROOT`
|
|
|
+3. 插入菜单:
|
|
|
+
|
|
|
+```sql
|
|
|
+-- id: MINI_PL_VEHICLE_RECHARGE_APPLY
|
|
|
+-- menu_name: ETC加油充值申请
|
|
|
+-- parent_id: MINI_PL_ROOT
|
|
|
+-- path: vehicle-recharge-apply
|
|
|
+-- perms: mini:pl:vehicleRechargeApply:list
|
|
|
+-- order_num: 2
|
|
|
+```
|
|
|
+
|
|
|
+4. `sys_role_menu`:对 `role_name IN ('司机','押运员')` 绑定 `1`、`MINI_PL_ROOT`、`MINI_PL_VEHICLE_RECHARGE_APPLY`(`NOT EXISTS` 防重)
|
|
|
+
|
|
|
+完整列名复制仓库内已有 migration 或 `docs/superpowers/plans/2026-09-13-mini-pl-driver-plan.md` Task 7 片段。
|
|
|
+
|
|
|
+- [ ] **Step 2: 在测试库执行该 SQL;生产执行前确认角色名**
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add docs/sql/migrations/2026-09-27-mini-pl-vehicle-recharge-apply-menus.sql
|
|
|
+git commit -m "docs(sql): add mini ETC recharge apply menu for driver/escort"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 7: 小程序 — API / 路由 / 菜单 / pages.json
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `miniprogram/api/pl.js`
|
|
|
+- Modify: `miniprogram/api/dbOptions.js`
|
|
|
+- Modify: `miniprogram/config/api.js`
|
|
|
+- Modify: `miniprogram/config/workbench-menus.js`
|
|
|
+- Modify: `miniprogram/pages.json`
|
|
|
+
|
|
|
+- [ ] **Step 1: `dbOptions.js`**
|
|
|
+
|
|
|
+```js
|
|
|
+export async function fetchVehicleOptions(q, opts = {}) {
|
|
|
+ const params = {
|
|
|
+ ...(q?.trim() ? { q: q.trim() } : {}),
|
|
|
+ ...(opts.vehicleType ? { vehicleType: opts.vehicleType } : {}),
|
|
|
+ ...(opts.mineVehicles ? { mineVehicles: true } : {}),
|
|
|
+ page: 1,
|
|
|
+ pageSize: 100
|
|
|
+ }
|
|
|
+ // 其余保持现有 db.get / request 逻辑
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: `pl.js` 增加 API**
|
|
|
+
|
|
|
+```js
|
|
|
+export const vehicleRechargeApplyApi = {
|
|
|
+ list: async (params = {}) => {
|
|
|
+ const data = await db.get('/transport/vehicle-recharge-apply/', {
|
|
|
+ mineVehicles: true,
|
|
|
+ ...params
|
|
|
+ })
|
|
|
+ return unwrapList(data)
|
|
|
+ },
|
|
|
+ get: async (id) =>
|
|
|
+ unwrapEntity(
|
|
|
+ await db.get(`/transport/vehicle-recharge-apply/${id}`, {
|
|
|
+ mineVehicles: true
|
|
|
+ })
|
|
|
+ ),
|
|
|
+ create: async (payload) =>
|
|
|
+ unwrapEntity(
|
|
|
+ await db.post('/transport/vehicle-recharge-apply/', payload, {
|
|
|
+ params: { mineVehicles: true }
|
|
|
+ })
|
|
|
+ ),
|
|
|
+ update: async (id, payload) =>
|
|
|
+ unwrapEntity(
|
|
|
+ await db.put(`/transport/vehicle-recharge-apply/${id}`, payload, {
|
|
|
+ params: { mineVehicles: true }
|
|
|
+ })
|
|
|
+ ),
|
|
|
+ submit: (id) =>
|
|
|
+ db.post(
|
|
|
+ `/transport/vehicle-recharge-apply/${id}/submit`,
|
|
|
+ {},
|
|
|
+ { params: { mineVehicles: true } }
|
|
|
+ ),
|
|
|
+ cancel: (id) =>
|
|
|
+ db.post(
|
|
|
+ `/transport/vehicle-recharge-apply/${id}/cancel`,
|
|
|
+ {},
|
|
|
+ { params: { mineVehicles: true } }
|
|
|
+ )
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+确认 `db.get` 第二参为 query;`unwrapEntity` 识别 `applyNo` 字段(可扩展现有判断:`data.applyNo == null`)。
|
|
|
+
|
|
|
+- [ ] **Step 3: 配置**
|
|
|
+
|
|
|
+`config/api.js`:
|
|
|
+
|
|
|
+```js
|
|
|
+plVehicleRechargeApply: '/pages/pl/vehicle-recharge-apply/list',
|
|
|
+plVehicleRechargeApplyForm: '/pages/pl/vehicle-recharge-apply/form',
|
|
|
+// WORKBENCH_ROUTE_MAP:
|
|
|
+'pl-vehicle-recharge-apply': PAGE_PATHS.plVehicleRechargeApply,
|
|
|
+```
|
|
|
+
|
|
|
+`workbench-menus.js` 运输管理 menus 在加油登记后插入:
|
|
|
+
|
|
|
+```js
|
|
|
+{
|
|
|
+ name: 'ETC加油充值申请',
|
|
|
+ enabled: true,
|
|
|
+ code: 'pl-vehicle-recharge-apply',
|
|
|
+ icon: '⛽', // 或选更贴切图标,与现有风格一致即可
|
|
|
+ perms: 'mini:pl:vehicleRechargeApply:list'
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+`pages.json` 的 `pages/pl` 分包增加:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "path": "vehicle-recharge-apply/list",
|
|
|
+ "style": { "navigationStyle": "custom", "navigationBarTitleText": "ETC加油充值申请" }
|
|
|
+},
|
|
|
+{
|
|
|
+ "path": "vehicle-recharge-apply/form",
|
|
|
+ "style": { "navigationStyle": "custom", "navigationBarTitleText": "ETC加油充值申请" }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add miniprogram/api/pl.js miniprogram/api/dbOptions.js miniprogram/config/api.js miniprogram/config/workbench-menus.js miniprogram/pages.json
|
|
|
+git commit -m "feat(mini): wire ETC recharge apply menu routes and API"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 8: 小程序 — 列表页
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `miniprogram/pages/pl/vehicle-recharge-apply/list.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 以 `fuel-register/list.vue` 为模板复制后改写**
|
|
|
+
|
|
|
+关键差异:
|
|
|
+
|
|
|
+- 标题:`ETC加油充值申请`
|
|
|
+- API:`vehicleRechargeApplyApi.list`
|
|
|
+- Tab:
|
|
|
+
|
|
|
+```js
|
|
|
+const tabs = [
|
|
|
+ { label: '全部', value: '' },
|
|
|
+ { label: '草稿', value: 'draft' },
|
|
|
+ { label: '审批中', value: 'in_approval' },
|
|
|
+ { label: '已驳回', value: 'rejected' },
|
|
|
+ { label: '已发放', value: 'disbursed' },
|
|
|
+ { label: '已作废', value: 'cancelled' }
|
|
|
+]
|
|
|
+```
|
|
|
+
|
|
|
+- 卡片 KV:申请单号、充值类型(etc→ETC / fuel→加油)、申请日期、申请人、期初公里数、备注
|
|
|
+- 金额区可改为展示充值类型文案或申请日期主信息(勿抄加油升数)
|
|
|
+- 操作:`isOwn = item.applicantId === userStore.userInfo?.userId`(或项目内实际用户 id 字段,对齐加油登记/userStore);`draft|rejected` 且本人 → 编辑/提交/作废;否则查看
|
|
|
+- 路由:`PAGE_PATHS.plVehicleRechargeApplyForm`
|
|
|
+
|
|
|
+从 `userStore` 取当前用户 id 的方式与 `fuel-register/form.vue` 保持一致。
|
|
|
+
|
|
|
+- [ ] **Step 2: 本地打开小程序工作台点入列表,确认空态/请求带 `mineVehicles=true`**
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add miniprogram/pages/pl/vehicle-recharge-apply/list.vue
|
|
|
+git commit -m "feat(mini): add ETC recharge apply list page"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 9: 小程序 — 表单/详情页
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `miniprogram/pages/pl/vehicle-recharge-apply/form.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 以 `fuel-register/form.vue` 为骨架实现**
|
|
|
+
|
|
|
+**可编辑字段:** 申请人(disabled 当前用户名)、车牌 sheet(`fetchVehicleOptions('', { mineVehicles: true })`;仅 1 辆自动选中)、充值类型分段(ETC/加油)、申请日期、期初公里数、备注、附件(folder `pl/vehicle-recharge/apply`,对齐加油登记上传写法)。
|
|
|
+
|
|
|
+**只读块(`get` 返回后):**
|
|
|
+
|
|
|
+- 审批:渲染 `approvalFlow` / `approvalHistory`(节点名、状态、意见、审批人、时间);可简化为列表卡片,不必 1:1 复刻 Web Tabs
|
|
|
+- 发放:若 `record` 存在,展示订单号/日期/金额/卡号/渠道/发放人/备注/附件;否则「暂无」
|
|
|
+
|
|
|
+**底栏:**
|
|
|
+
|
|
|
+- 可编辑:`保存` → create/update;`保存并提交` → 保存后 `submit`
|
|
|
+- 本人草稿/驳回:作废按钮 → `cancel`
|
|
|
+- `readonly`:他人单或非可编辑状态
|
|
|
+
|
|
|
+**权限判定:**
|
|
|
+
|
|
|
+```js
|
|
|
+const isOwn = computed(() => !editId.value || detail.value?.applicantId === currentUserId)
|
|
|
+const canEdit = computed(() => {
|
|
|
+ if (!isOwn.value && editId.value) return false
|
|
|
+ if (!editId.value) return true
|
|
|
+ return ['draft', 'rejected'].includes(detail.value?.status)
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+打开他人单:`readonly=true`,仍加载审批与发放。
|
|
|
+
|
|
|
+- [ ] **Step 2: 手工验收**
|
|
|
+
|
|
|
+按 spec §8:新建草稿 → 提交 → Web 审批可见;驳回再提;他人单只读;无车辆提示。
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add miniprogram/pages/pl/vehicle-recharge-apply/form.vue
|
|
|
+git commit -m "feat(mini): add ETC recharge apply form and detail"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 10: 收尾 — 图谱与回归
|
|
|
+
|
|
|
+**Files:** 无强制新文件
|
|
|
+
|
|
|
+- [ ] **Step 1: 后端回归**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_pl_vehicle_recharge_mine_vehicles.py tests/test_pl_vehicle_recharge_workflow.py tests/test_vehicle_recharge_types.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS
|
|
|
+
|
|
|
+- [ ] **Step 2: 更新知识图谱**
|
|
|
+
|
|
|
+```bash
|
|
|
+./scripts/update-graph.sh backend
|
|
|
+# 若脚本支持 frontend/miniprogram 则按需;否则
|
|
|
+cd /Users/wuzilong/work/00code/shanxia2.0 && graphify update .
|
|
|
+```
|
|
|
+
|
|
|
+(以仓库 `AGENTS.md` / `scripts/update-graph.sh` 实际参数为准。)
|
|
|
+
|
|
|
+- [ ] **Step 3: 最终 commit(若有图谱变更)**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/graphify-out miniprogram/graphify-out graphify-out 2>/dev/null || true
|
|
|
+git commit -m "chore: update graphify after mini recharge apply"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Spec coverage checklist
|
|
|
+
|
|
|
+| Spec 项 | Task |
|
|
|
+|---------|------|
|
|
|
+| D1 列表按车辆人员 | Task 2 |
|
|
|
+| D2 ETC/加油 | Task 9 |
|
|
|
+| D3 闭环与他人只读 | Task 3, 8, 9 |
|
|
|
+| D4 详情审批+发放 | Task 9 |
|
|
|
+| D5 mineVehicles API | Task 2–5 |
|
|
|
+| D6 申请人锁定 | Task 3, 9 |
|
|
|
+| D7 车牌下拉 | Task 5, 7, 9 |
|
|
|
+| D8 司机/押运员菜单 | Task 6, 7 |
|
|
|
+| 跳过 data scope(他人代申请可见) | Task 2(显式) |
|
|
|
+| UI 对齐加油登记 | Task 8, 9 |
|
|
|
+| Web 不传参数不变 | Task 2–4(默认 false)+ workflow 回归 |
|
|
|
+
|
|
|
+## Placeholder / consistency self-check
|
|
|
+
|
|
|
+- 参数名统一:`mineVehicles`(API)/ `mine_vehicles`(Python)
|
|
|
+- 函数名统一:`license_plates_for_user_personnel`
|
|
|
+- 菜单 id / perms:`MINI_PL_VEHICLE_RECHARGE_APPLY` / `mini:pl:vehicleRechargeApply:list`
|
|
|
+- 无 TBD;TDD 步骤含命令与期望
|