diff --git a/backend/app/api/pl/dispatch_calendar.py b/backend/app/api/pl/dispatch_calendar.py index 43a883bc4..4e71b24e6 100644 --- a/backend/app/api/pl/dispatch_calendar.py +++ b/backend/app/api/pl/dispatch_calendar.py @@ -64,21 +64,24 @@ def product_options(q: Optional[str] = None, page: int = Query(1, ge=1), def create_record(body: CreateBody, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user)): tenant = get_tenant_no(db, user.to_tenant_user_dict()) - return svc._row_to_dict(svc.create_record(db, tenant, user, body)) + row = svc.create_record(db, tenant, user, body) + return svc._row_to_dict(row) @router.get("/{record_id}") def get_record(record_id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user)): tenant = get_tenant_no(db, user.to_tenant_user_dict()) - return svc._row_to_dict(svc.get_record(db, tenant, user, record_id)) + row = svc.get_record(db, tenant, user, record_id) + return svc.serialize_records(db, tenant, user, [row])[0] @router.put("/{record_id}", dependencies=[Depends(require_manage)]) def update_record(record_id: str, body: UpdateBody, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user)): tenant = get_tenant_no(db, user.to_tenant_user_dict()) - return svc._row_to_dict(svc.update_record(db, tenant, user, record_id, body)) + row = svc.update_record(db, tenant, user, record_id, body) + return svc._row_to_dict(row) @router.delete("/{record_id}", dependencies=[Depends(require_manage)]) diff --git a/backend/app/api/pl/site_dispatch_calendar.py b/backend/app/api/pl/site_dispatch_calendar.py index b3eca6de6..d06c305c0 100644 --- a/backend/app/api/pl/site_dispatch_calendar.py +++ b/backend/app/api/pl/site_dispatch_calendar.py @@ -46,4 +46,4 @@ def get_record(record_id: str, db: Session = Depends(get_db), row = svc.get_record(db, tenant, user, record_id) if row.calendar_kind != "site": raise HTTPException(status_code=404, detail="派车记录不存在或无权访问") - return svc._row_to_dict(row) + return svc.serialize_records(db, tenant, user, [row])[0] diff --git a/backend/app/services/pl_dispatch_calendar_service.py b/backend/app/services/pl_dispatch_calendar_service.py index 6d850cf0f..968063b18 100644 --- a/backend/app/services/pl_dispatch_calendar_service.py +++ b/backend/app/services/pl_dispatch_calendar_service.py @@ -28,11 +28,14 @@ def _calendar_kind(kind: Optional[str]) -> str: return value -def _row_to_dict(row: PlDispatchCalendar) -> dict: +def _row_to_dict(row: PlDispatchCalendar, vehicle_type: Optional[str] = None) -> dict: + if vehicle_type is None: + vehicle_type = getattr(row, "_response_vehicle_type", "unknown") return { "id": row.id, "dispatchDate": row.dispatch_date, "vehicleId": row.vehicle_id, + "vehicleType": vehicle_type, "licensePlate": row.license_plate, "productId": row.product_id, "productName": row.product_name, @@ -53,6 +56,23 @@ def _query(db: Session, tenant_no: str, user: DbUserContext, model=PlDispatchCal return apply_pl_scope(query, model, user, db, tenant_no) +def serialize_records(db: Session, tenant_no: str, user: DbUserContext, rows: list[PlDispatchCalendar]) -> list[dict]: + """仅批量读取当前页可见车辆类型;历史失效关联保留记录并回退中性色。""" + vehicle_ids = {row.vehicle_id for row in rows if row.vehicle_id != PENDING_VEHICLE_ID} + vehicle_types = {} + if vehicle_ids: + vehicles = _query(db, tenant_no, user, PlVehicle).filter(PlVehicle.id.in_(vehicle_ids)).all() + vehicle_types = { + vehicle.id: vehicle.vehicle_type if vehicle.vehicle_type in ("self", "carrier") else "unknown" + for vehicle in vehicles + } + return [ + _row_to_dict(row, "pending" if row.vehicle_id == PENDING_VEHICLE_ID + else vehicle_types.get(row.vehicle_id, "unknown")) + for row in rows + ] + + def _month_bounds(month: str): if not re.fullmatch(r"[0-9]{4}-[0-9]{2}", month or ""): raise HTTPException(status_code=422, detail="月份须为 YYYY-MM") @@ -76,7 +96,7 @@ def list_records(db: Session, tenant_no: str, user: DbUserContext, *, month: str rows = query.order_by(PlDispatchCalendar.create_time.desc(), PlDispatchCalendar.id.desc()).offset( (page - 1) * page_size ).limit(page_size).all() - return {"list": [_row_to_dict(row) for row in rows], "total": total} + return {"list": serialize_records(db, tenant_no, user, rows), "total": total} def get_record(db: Session, tenant_no: str, user: DbUserContext, record_id: str, @@ -91,14 +111,16 @@ def get_record(db: Session, tenant_no: str, user: DbUserContext, record_id: str, return row -def _resolve_snapshots(db: Session, tenant_no: str, user: DbUserContext, body: CreateBody) -> dict: +def _resolve_snapshots(db: Session, tenant_no: str, user: DbUserContext, body: CreateBody) -> tuple[dict, str]: if body.vehicle_id == PENDING_VEHICLE_ID: license_plate = "待派车" + vehicle_type = "pending" else: vehicle = _query(db, tenant_no, user, PlVehicle).filter(PlVehicle.id == body.vehicle_id).first() if vehicle is None: raise HTTPException(status_code=422, detail="车辆不存在、已删除或无权访问,请重新选择") license_plate = vehicle.license_plate + vehicle_type = vehicle.vehicle_type if vehicle.vehicle_type in ("self", "carrier") else "unknown" product = ( _query(db, tenant_no, user, CrmProduct) .filter(CrmProduct.id == body.product_id, CrmProduct.status == "on") @@ -106,7 +128,7 @@ def _resolve_snapshots(db: Session, tenant_no: str, user: DbUserContext, body: C ) if product is None: raise HTTPException(status_code=422, detail="产品不存在、已下架、已删除或无权访问,请重新选择") - return {"license_plate": license_plate, "product_name": product.product_name} + return {"license_plate": license_plate, "product_name": product.product_name}, vehicle_type def _site_document_type(origin: str, destination: str) -> Optional[str]: @@ -176,13 +198,15 @@ def _retry_deadlock(action): @_retry_deadlock def create_record(db: Session, tenant_no: str, user: DbUserContext, body: CreateBody) -> PlDispatchCalendar: - snapshots = _resolve_snapshots(db, tenant_no, user, body) + snapshots, vehicle_type = _resolve_snapshots(db, tenant_no, user, body) row = PlDispatchCalendar( id=generate_uuid(), tenant_no=tenant_no, create_by=user.id, create_dept_id=user.dept_id or "", update_by=user.id, site_sync_enabled=1 if body.calendar_kind in ("acid", "alkali", "other") else 0, **body.model_dump(), **snapshots, ) + # 仅供保存响应使用的非持久化属性,复用提交前已校验的车辆类型。 + row._response_vehicle_type = vehicle_type try: db.add(row) db.flush() @@ -203,13 +227,15 @@ def update_record(db: Session, tenant_no: str, user: DbUserContext, record_id: s _ensure_manual_record(row) if body.calendar_kind != (row.calendar_kind or "acid"): raise HTTPException(status_code=422, detail="不能把派车计划改到另一本日历") - values = {**body.model_dump(), **_resolve_snapshots(db, tenant_no, user, body)} + snapshots, vehicle_type = _resolve_snapshots(db, tenant_no, user, body) + values = {**body.model_dump(), **snapshots} # 兼容未提交新字段的旧客户端;显式空值仍可清空。 if "document_type" not in body.model_fields_set: values.pop("document_type", None) for key, value in values.items(): setattr(row, key, value) row.update_by = user.id + row._response_vehicle_type = vehicle_type _sync_site_record(db, tenant_no, user, row) db.commit() except Exception: diff --git a/backend/tests/test_pl_dispatch_calendar.py b/backend/tests/test_pl_dispatch_calendar.py index 7de28a5c1..cca6fd82f 100644 --- a/backend/tests/test_pl_dispatch_calendar.py +++ b/backend/tests/test_pl_dispatch_calendar.py @@ -73,7 +73,7 @@ def test_crud_duplicate_trips_snapshots_and_soft_delete(db): assert result['licensePlate'] == '鄂E67890' and result['productName'] == '盐酸' assert result['origin'] == '武汉' assert result['calendarKind'] == 'acid' - assert set(result) == {'id', 'dispatchDate', 'vehicleId', 'licensePlate', 'productId', 'productName', 'origin', 'destination', 'quantity', 'calendarKind', 'documentType', 'sourceRecordId', 'remark', 'createTime', 'updateTime'} + assert set(result) == {'id', 'dispatchDate', 'vehicleId', 'vehicleType', 'licensePlate', 'productId', 'productName', 'origin', 'destination', 'quantity', 'calendarKind', 'documentType', 'sourceRecordId', 'remark', 'createTime', 'updateTime'} assert svc.delete_record(db, 't1', user(), first.id) == 1 assert db.get(model, first.id).del_flag == 1 assert svc.list_records(db, 't1', user(), month='2026-10')['total'] == 1 @@ -460,3 +460,107 @@ def test_pending_vehicle_still_validates_product(db, product_id): assert exc.value.status_code == 422 assert '产品' in exc.value.detail assert db.query(model).count() == 0 + + +@pytest.mark.parametrize('kind', ['acid', 'alkali', 'other', 'site']) +def test_vehicle_type_classification_is_scoped_and_current(db, monkeypatch, kind): + model, _, _, svc = modules() + db.add(PlVehicle(id='legacy', license_plate='待派车', vehicle_type='legacy', + tenant_no='t1', create_by='u1')) + ids = ['v1', 'v2', '__pending_vehicle__', 'v3', 'v4', 'missing', 'legacy'] + for vehicle_id in ids: + db.add(model(id='type-' + vehicle_id, dispatch_date=date(2026, 10, 26), + vehicle_id=vehicle_id, license_plate='待派车', product_id='p1', + product_name='液碱', origin='', destination='', calendar_kind=kind, + tenant_no='t1', create_by='u1')) + db.commit() + def types(caller): + return {r['vehicleId']: r.get('vehicleType') for r in + svc.list_records(db, 't1', caller, month='2026-10', calendar_kind=kind)['list']} + expected = dict(zip(ids, ['self', 'carrier', 'pending', 'unknown', 'unknown', 'unknown', 'unknown'])) + assert types(user()) == expected + import app.core.data_scope as scope + monkeypatch.setattr(scope, '_resolve_user_role_scopes', lambda *a: [{'data_scope': '4', 'details': []}]) + assert types(user(role='admin')) == {**expected, 'v2': 'unknown'} + db.get(PlVehicle, 'v1').vehicle_type = 'carrier' + db.commit() + assert types(user())['v1'] == 'carrier' + + +def test_vehicle_types_use_one_query_for_current_page(db): + from sqlalchemy import event + model, _, _, svc = modules() + for i in range(25): + db.add(model(id=f'batch-{i:02}', dispatch_date=date(2026, 10, 26), + vehicle_id='v1' if i < 20 else 'v2', license_plate='车牌', + product_id='p1', product_name='液碱', origin='', destination='', + tenant_no='t1', create_by='u1', create_time=datetime(2026, 10, 26, 0, i))) + db.commit() + statements = [] + def capture(conn, cursor, statement, parameters, context, executemany): + if 'FROM pl_vehicle' in statement: + statements.append((statement, parameters)) + event.listen(db.get_bind(), 'before_cursor_execute', capture) + try: + page = svc.list_records(db, 't1', user(), month='2026-10', page_size=5) + assert [r.get('vehicleType') for r in page['list']] == ['carrier'] * 5 + assert len(statements) == 1 + assert 'v2' in statements[0][1] and 'v1' not in statements[0][1] + statements.clear() + assert svc.list_records(db, 't1', user(), month='2026-09')['list'] == [] + assert not statements + finally: + event.remove(db.get_bind(), 'before_cursor_execute', capture) + + +def test_single_record_routes_return_vehicle_type(db): + from app.api.pl import dispatch_calendar as api, site_dispatch_calendar as site_api + _, Create, Update, _ = modules() + created = api.create_record(Create(**payload()), db, user()) + assert created.get('vehicleType') == 'self' + assert api.get_record(created['id'], db, user()).get('vehicleType') == 'self' + updated = api.update_record(created['id'], Update(**payload(vehicleId='v2')), db, user()) + assert updated.get('vehicleType') == 'carrier' + pending = api.create_record(Create(**payload(vehicleId='__pending_vehicle__', calendarKind='site')), db, user()) + assert pending.get('vehicleType') == 'pending' + assert site_api.get_record(pending['id'], db, user()).get('vehicleType') == 'pending' + with pytest.raises(ValidationError): + Create(**payload(vehicleType='self')) + + +@pytest.mark.parametrize('action', ['create', 'update']) +@pytest.mark.parametrize('vehicle_id,expected', [ + ('v1', 'self'), ('v2', 'carrier'), ('__pending_vehicle__', 'pending'), ('legacy', 'unknown'), +]) +def test_save_response_never_queries_after_commit(db, action, vehicle_id, expected): + from sqlalchemy import event + from app.api.pl import dispatch_calendar as api + _, Create, Update, _ = modules() + db.add(PlVehicle(id='legacy', license_plate='旧车辆', vehicle_type='legacy', + tenant_no='t1', create_by='u1')) + db.commit() + # 与应用 SessionLocal 一致;提交后失联也必须返回已成功保存的数据。 + with sessionmaker(bind=db.get_bind(), expire_on_commit=False)() as session: + existing = api.create_record(Create(**payload()), session, user()) if action == 'update' else None + committed = False + + def after_commit(session): + nonlocal committed + committed = True + + def reject_after_commit(conn, cursor, statement, parameters, context, executemany): + assert not committed, '保存成功后不得再查询数据库生成响应' + + event.listen(session, 'after_commit', after_commit) + event.listen(db.get_bind(), 'before_cursor_execute', reject_after_commit) + try: + body = payload(vehicleId=vehicle_id, origin='山峡') + result = (api.create_record(Create(**body), session, user()) if action == 'create' + else api.update_record(existing['id'], Update(**body), session, user())) + assert committed + assert result['vehicleType'] == expected + assert result['vehicleId'] == vehicle_id + assert result['id'] + finally: + event.remove(session, 'after_commit', after_commit) + event.remove(db.get_bind(), 'before_cursor_execute', reject_after_commit) diff --git a/backend/tests/test_site_dispatch_calendar.py b/backend/tests/test_site_dispatch_calendar.py index dcc3c5fa3..a2b01a371 100644 --- a/backend/tests/test_site_dispatch_calendar.py +++ b/backend/tests/test_site_dispatch_calendar.py @@ -13,6 +13,7 @@ from app.core.auth import get_current_user from app.core.database import get_db from app.models.models import SysMenu, SysRole, SysRoleMenu, SysUserRole, SysRoleDataScopeDetail from app.models.pl_dispatch_calendar import PlDispatchCalendar +from app.models.pl_models import PlVehicle PATH = '/api/transport/site-dispatch-calendar' ORIGINAL = '/api/transport/dispatch-calendar' @@ -25,11 +26,13 @@ def setup(): from app.api.pl.site_dispatch_calendar import router from app.api.pl.dispatch_calendar import router as original_router engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool) - for model in (PlDispatchCalendar, SysMenu, SysRole, SysRoleMenu, SysUserRole, SysRoleDataScopeDetail): + for model in (PlDispatchCalendar, PlVehicle, SysMenu, SysRole, SysRoleMenu, SysUserRole, SysRoleDataScopeDetail): model.__table__.create(engine) factory = sessionmaker(bind=engine) with factory() as db: db.add_all([ + PlVehicle(id='v1', vehicle_type='self', license_plate='鄂E12345', tenant_no='t1', create_by='u1'), + PlVehicle(id='v2', vehicle_type='carrier', license_plate='鄂E67890', tenant_no='t1', create_by='u2'), SysRole(id='r1', role_name='基础角色', tenant_no='t1', data_scope='4'), SysRole(id='r2', role_name='现场人员', tenant_no='t1', data_scope='4'), SysUserRole(id='ur1', user_id='u1', role_id='r1', tenant_no='t1'), @@ -97,7 +100,9 @@ def test_fixed_kind_tenant_scope_and_details(setup): 'tenant_no': 't2', 'vehicleId': 'v1'}) assert response.status_code == 200 assert [r['id'] for r in response.json()['list']] == ['site-own'] + assert response.json()['list'][0]['vehicleType'] == 'self' assert client.get(PATH + '/site-own').json()['calendarKind'] == 'site' + assert client.get(PATH + '/site-own').json()['vehicleType'] == 'self' for rid in ('acid', 'alkali', 'other', 'site-t2', 'site-other', 'site-deleted', 'missing', 'vehicles', 'products'): assert client.get(PATH + '/' + rid).status_code == 404 assert client.get(PATH, params={'month': '2026-10', 'vehicleId': 'v2'}).json()['total'] == 0 @@ -107,6 +112,7 @@ def test_fixed_kind_tenant_scope_and_details(setup): # 菜单授权来自第二角色,全部数据范围来自第一角色,均按并集生效。 assert client.get(PATH, params={'month': '2026-10'}).json()['total'] == 2 assert client.get(PATH + '/site-other').status_code == 200 + assert client.get(PATH + '/site-other').json()['vehicleType'] == 'carrier' assert client.get(PATH + '/site-t2').status_code == 404 page1 = client.get(PATH, params={'month': '2026-10', 'pageSize': 1, 'page': 1}).json() page2 = client.get(PATH, params={'month': '2026-10', 'pageSize': 1, 'page': 2}).json() diff --git a/docs/superpowers/plans/2026-11-01-dispatch-calendar-vehicle-colors.md b/docs/superpowers/plans/2026-11-01-dispatch-calendar-vehicle-colors.md new file mode 100644 index 000000000..6537a2949 --- /dev/null +++ b/docs/superpowers/plans/2026-11-01-dispatch-calendar-vehicle-colors.md @@ -0,0 +1,42 @@ +# 派车日历车辆配色 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. + +**Goal:** 实现工单 #5 已确认的两端日卡车辆类型配色。 + +**Architecture:** 后端批量解析只读车辆类型,前端共用日卡按该类型着色。保留租户和数据范围、现有写入及日历交互;不新增数据库列。 + +**Tech Stack:** FastAPI / SQLAlchemy / Vue 3 / TypeScript / SCSS。 + +## Task 1:类型返回与共用日卡 + +修改 `backend/app/services/pl_dispatch_calendar_service.py`、`backend/app/api/pl/dispatch_calendar.py`、`backend/app/api/pl/site_dispatch_calendar.py`、`web-admin/src/api/pl/dispatch-calendar.ts`、`web-admin/src/views/pl/dispatch-calendar/manual-model.ts`、`ManualDayContent.vue`;测试沿用 `backend/tests/test_pl_dispatch_calendar.py` 和 `web-admin/scripts/manual-dispatch-calendar.test.ts`。 + +- [x] 先添加 SQLite 回归:自有/三方/待派车列表返回 `vehicleType`,无权限、删除和跨租户车辆返回 unknown;断言分页多行只执行一次车辆查询。运行并确认新增用例因缺字段失败。 +- [x] `_row_to_dict(row, vehicle_type=None)` 返回规范枚举;保存时复用提交前类型,列表/详情通过批量序列化显式传类型。使用 `_query(db, tenant_no, user, PlVehicle).filter(PlVehicle.id.in_(ids))` 解析当前页 ID。列表与详情复用批量解析;创建、编辑响应使用非持久化属性,提交后不查询。 +- [x] 前端增加可选只读 `vehicleType?: 'self' | 'carrier' | 'pending' | 'unknown'`。模型函数先判断 `vehicleId === '__pending_vehicle__'`,再白名单校验车辆类型,其他为 unknown。 +- [x] 单车卡及多趟分组绑定 `vehicle-${类型}`;背景使用设计中的四组色值,移动自有车辆覆盖为 `#e9f9f2`。保留产品汇总配色。 +- [x] 运行后端日历三个专项文件与前端日历模型测试;规格审查通过,代码质量审查另见下方交付记录。 + +## Task 2:验证与交付记录 + +- [x] 浏览器渲染实际组件,用合成数据检查 Web、375px、414px 的三类单车/多趟卡、未知类型与点击行为,保存本地截图。 +- [x] 执行目标文件 ESLint、Stylelint 和 `pnpm build:typecheck`,记录基线问题,不改无关代码。 +- [x] 复核工单最新正文与评论、运行 `git diff --check`,记录最终验证及限制;仅在本地任务分支保存实现,不推送、合并或关闭工单。 + + +## 2026-11-01 验证与交付记录 + +- 用户已明确确认 Figma 18:2 配色并要求实施。分支 `codex/gitea-issue-5`,基线 `cb0344498abc4c796bc12ee20f25e9097a4076f1`。 +- 工单正文/全部评论在交付前重新读取,正文未变,0评论,无图片,远端仍 open。 +- 后端:使用主工作目录现有 Python 3.9 venv,工作目录为本任务 backend;`DATABASE_URL` 指向本机不可连接的测试地址,测试fixture仅使用SQLite。运行 `python -m pytest tests/test_pl_dispatch_calendar.py tests/test_site_dispatch_calendar.py tests/test_pl_dispatch_calendar_site_sync.py -q`:126 passed,12条既有依赖警告。 +- 前端:`pnpm exec tsx --test scripts/manual-dispatch-calendar.test.ts scripts/mobile-dispatch-calendar.test.ts scripts/pl-dispatch-calendar.test.ts scripts/calendar-copy.test.ts`:38 passed。 +- 对本次四个前端修改文件执行 ESLint;对 ManualDayContent.vue 执行 Stylelint,均通过。Prettier目标检查通过,`git diff --check`通过。 +- `pnpm build` 最终通过(39.22秒),存在既有组件重名与产物体积提示。 +- `pnpm build:typecheck` 未通过:首次隔离目录缺自动导入声明;生成声明后单独执行 `pnpm exec vue-tsc --noEmit`,基线与修改后均505条错误,输出1351行逐行一致,本次无新增。不能称全量类型检查通过。 +- pnpm复用同仓库现有node_modules软链,命令附 `pnpm_config_verify_deps_before_run=false`,防止新版pnpm自动重装共享依赖;未安装/更新依赖。 +- 浏览器:Vite在内存编译实际 ManualDayContent.vue,以合成数据在Chrome验证Web 1440px、移动375px/414px × 单车/多趟,共6组通过。核对三种类型及未知回退背景、选择事件、移动触达高度>=44、无横向溢出;实际截图已查看,与已批准配色一致。 +- 本地证据位于 `.local/agent/issue5/`:`verify-colors.mjs`、六张截图、`backend-final.log`、`vite-final.log`、`typecheck-baseline.log`、`typecheck-final.log`;这些临时证据被Git忽略。 +- 保存回归修正:先以8组commit后禁SQL用例确认6失败/2通过,再复用提交前车辆类型,修正后全部通过。列表仍实时查询类型,保存不新增提交后数据库依赖。 +- 独立规格审查和最终代码质量审查均通过,无剩余阻塞;审查者独立执行git diff --check通过。 +- 验证限制:未连接真实业务库,未执行微信真机或完整业务环境端到端验收。未修改数据库schema、未推送、未合并、未关闭工单。 diff --git a/docs/superpowers/specs/2026-11-01-dispatch-calendar-vehicle-colors-design.md b/docs/superpowers/specs/2026-11-01-dispatch-calendar-vehicle-colors-design.md new file mode 100644 index 000000000..8c99b4e50 --- /dev/null +++ b/docs/superpowers/specs/2026-11-01-dispatch-calendar-vehicle-colors-design.md @@ -0,0 +1,24 @@ +# 工单 #5:派车日历车辆类型配色 + +来源:https://gitea.aichengyan.com/cykj/shanxia2.0/issues/5 。2026-11-01 已重新读取正文及全部评论(0 条,无图片)。 +用户已确认按 Figma v1 实施:https://www.figma.com/design/ImDcikQnCX5GiJQLwUHwDC/shanxia2.0?node-id=18-2 。 + +## 范围与视觉依据 + +复用 P08/P27 现有派车日历和 `web-admin/src/views/pl/dispatch-calendar/ManualDayContent.vue`;小程序入口实际使用移动 H5,复用同一日卡组件。酸、碱、其他、现场手工日历沿用现有结构、汇总、复制、打印和点击行为;实际派车信息页不在本次手工日卡范围内。 + +- 自有车辆:Web 沿用日卡浅绿 `#e8f3ec`,移动端采用三墙浅绿 `#e9f9f2`。 +- 三方车辆:沿用日历浅蓝 `#e7f0f8`。 +- 待派车:采用 Fresh Mint 浅红 `#fee2e2`。 +- 同车多趟的整个分组采用相同车辆类型背景;产品汇总保留产品配色。 +- 无法识别类型的历史车辆使用中性 `#f1f5f9`,不推断为三方车辆。 + +## 数据与实现 + +后端从当前租户、未删除且满足数据范围的车辆档案批量读取 `vehicle_type`,作为只读 `vehicleType` 返回。取值 `self` / `carrier` / `pending` / `unknown`;待派车以 `__pending_vehicle__` 专用 ID 判断,不依赖显示车牌名称。列表分页不增加逐行查询,详情和保存响应保持同一字段语义。不增加数据库字段,不改变写入、同步或权限逻辑。 + +前端只读字段不进入保存表单。日卡及同车多趟分组按车辆类型选择背景,不再按产品名选择卡片背景。旧响应缺字段时保留待派车 ID 识别,其余中性回退。 + +## 验收 + +覆盖三种类型、未知/已删除/跨租户/无权限车辆、同车多趟、待派车优先级;检查移动端 375/414px 和 Web 日卡的背景、内容及点击。后端用 SQLite 隔离测试,禁止连接业务库。执行受影响前端测试、检查和构建;独立审查后记录实际结果。未授权推送、合并或关单。 diff --git a/web-admin/scripts/manual-dispatch-calendar.test.ts b/web-admin/scripts/manual-dispatch-calendar.test.ts index a47494579..118824d85 100644 --- a/web-admin/scripts/manual-dispatch-calendar.test.ts +++ b/web-admin/scripts/manual-dispatch-calendar.test.ts @@ -4,6 +4,7 @@ import { loadCalendarPages, manualCalendarDays, manualCopyText, + manualVehicleType, validateManualDraft } from '../src/views/pl/dispatch-calendar/manual-model' @@ -257,3 +258,20 @@ test('现场同车重复记录逐条计车,同名不同产品ID保持独立且 ].join('\n') ) }) + +test('车辆分类以待派车ID优先,其余仅接受类型白名单,不从车牌或产品推断', () => { + for (const vehicleType of ['self', 'carrier', 'pending', 'unknown'] as const) { + assert.equal(manualVehicleType({ ...row('1'), vehicleType }), vehicleType) + assert.equal( + manualVehicleType({ ...row('1'), vehicleId: '__pending_vehicle__', vehicleType }), + 'pending' + ) + } + for (const vehicleType of [undefined, null, '', 'legacy', 'SELF']) { + assert.equal(manualVehicleType({ vehicleId: 'v1', vehicleType }), 'unknown') + } + const misleadingPlate = { ...row('1'), licensePlate: '待派车' } + const otherProduct = { ...row('1'), productName: '盐酸' } + assert.equal(manualVehicleType(misleadingPlate), 'unknown') + assert.equal(manualVehicleType(otherProduct), 'unknown') +}) diff --git a/web-admin/src/api/pl/dispatch-calendar.ts b/web-admin/src/api/pl/dispatch-calendar.ts index 1c22084d4..98430f461 100644 --- a/web-admin/src/api/pl/dispatch-calendar.ts +++ b/web-admin/src/api/pl/dispatch-calendar.ts @@ -2,6 +2,8 @@ import request, { pickBxData } from '@/utils/http/bx' export type DispatchCalendarKind = 'acid' | 'alkali' | 'other' | 'site' +export type DispatchCalendarVehicleType = 'self' | 'carrier' | 'pending' | 'unknown' + export interface DispatchCalendarBody { dispatchDate: string vehicleId: string @@ -16,6 +18,7 @@ export interface DispatchCalendarBody { export interface DispatchCalendarRecord extends DispatchCalendarBody { id: string sourceRecordId?: string | null + readonly vehicleType?: DispatchCalendarVehicleType licensePlate: string productName: string createTime: string | null diff --git a/web-admin/src/views/pl/dispatch-calendar/ManualDayContent.vue b/web-admin/src/views/pl/dispatch-calendar/ManualDayContent.vue index 2991b1060..407d69c3a 100644 --- a/web-admin/src/views/pl/dispatch-calendar/ManualDayContent.vue +++ b/web-admin/src/views/pl/dispatch-calendar/ManualDayContent.vue @@ -23,7 +23,7 @@ v-if="group.rows.length === 1" type="button" class="item" - :class="`tone-${productTone(group.rows[0].productName)}`" + :class="`vehicle-${manualVehicleType(group.rows[0])}`" data-testid="manual-record" @click="emit('select', group.rows[0])" > @@ -38,7 +38,7 @@ {{ group.rows[0].origin }} → {{ group.rows[0].destination }} {{ group.rows[0].remark }} -