From 142b8fdf486272787279b568f462e85b70393edb Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Sat, 31 Oct 2026 16:46:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(pl):=20=E5=AE=8C=E5=96=84=E5=8F=8C?= =?UTF-8?q?=E7=AB=AF=E7=8E=B0=E5=9C=BA=E6=97=A5=E5=8E=86=E5=B9=B6=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=BE=85=E6=B4=BE=E8=BD=A6=E5=92=8C=E7=9B=B4=E9=94=80?= =?UTF-8?q?=E8=AE=A1=E5=88=92=EF=BC=8C=E6=BB=A1=E8=B6=B3=E8=BD=A6=E8=BE=86?= =?UTF-8?q?=E6=9C=AA=E5=AE=9A=E5=8F=8A=E7=8E=B0=E5=9C=BA=E6=9F=A5=E7=9C=8B?= =?UTF-8?q?=E9=9C=80=E6=B1=82=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/__init__.py | 2 + backend/app/api/pl/site_dispatch_calendar.py | 49 +++++ backend/app/main.py | 2 + .../services/pl_dispatch_calendar_service.py | 14 +- .../scripts/refresh_dispatch_calendar_site.py | 129 +++++++++++++ backend/tests/test_pl_dispatch_calendar.py | 35 ++++ .../test_refresh_dispatch_calendar_site.py | 119 ++++++++++++ backend/tests/test_site_dispatch_calendar.py | 135 +++++++++++++ .../tests/test_site_dispatch_calendar_menu.py | 100 ++++++++++ ...10-30-mini-site-dispatch-calendar-menu.sql | 34 ++++ .../plans/2026-10-30-mobile-site-calendar.md | 11 ++ .../plans/2026-10-30-refresh-calendar-site.md | 20 ++ .../2026-10-30-site-calendar-readonly.md | 18 ++ .../2026-10-31-dispatch-calendar-pending.md | 23 +++ .../2026-10-30-mobile-site-calendar-design.md | 11 ++ ...026-10-30-site-calendar-readonly-design.md | 9 + ...-10-31-dispatch-calendar-pending-design.md | 9 + miniprogram/config/api.js | 1 + miniprogram/config/workbench-menus.js | 9 +- .../scripts/manual-dispatch-calendar.test.ts | 75 +++++++- .../scripts/test-manual-dispatch-calendar.mjs | 78 ++++++-- .../test-site-dispatch-calendar-readonly.mjs | 182 ++++++++++++++++++ web-admin/src/api/pl/dispatch-calendar.ts | 4 + web-admin/src/router/routes/staticRoutes.ts | 6 + .../m/pl/dispatch-calendar/ManualCalendar.vue | 68 ++++++- .../views/m/pl/dispatch-calendar/index.vue | 25 ++- .../m/pl/site-dispatch-calendar/index.vue | 7 + .../pl/dispatch-calendar/ManualCalendar.vue | 5 +- .../pl/dispatch-calendar/ManualDayContent.vue | 12 +- .../pl/dispatch-calendar/manual-model.ts | 20 ++ .../pl/dispatch-calendar/useManualCalendar.ts | 28 ++- 31 files changed, 1180 insertions(+), 60 deletions(-) create mode 100644 backend/app/api/pl/site_dispatch_calendar.py create mode 100644 backend/scripts/refresh_dispatch_calendar_site.py create mode 100644 backend/tests/test_refresh_dispatch_calendar_site.py create mode 100644 backend/tests/test_site_dispatch_calendar.py create mode 100644 backend/tests/test_site_dispatch_calendar_menu.py create mode 100644 docs/sql/migrations/2026-10-30-mini-site-dispatch-calendar-menu.sql create mode 100644 docs/superpowers/plans/2026-10-30-mobile-site-calendar.md create mode 100644 docs/superpowers/plans/2026-10-30-refresh-calendar-site.md create mode 100644 docs/superpowers/plans/2026-10-30-site-calendar-readonly.md create mode 100644 docs/superpowers/plans/2026-10-31-dispatch-calendar-pending.md create mode 100644 docs/superpowers/specs/2026-10-30-mobile-site-calendar-design.md create mode 100644 docs/superpowers/specs/2026-10-30-site-calendar-readonly-design.md create mode 100644 docs/superpowers/specs/2026-10-31-dispatch-calendar-pending-design.md create mode 100644 web-admin/scripts/test-site-dispatch-calendar-readonly.mjs create mode 100644 web-admin/src/views/m/pl/site-dispatch-calendar/index.vue diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 35376c8dd..b6c845100 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -12,6 +12,7 @@ from app.api.mini_auth import router as mini_auth_router from app.api.doclib import router as doclib_router from app.api.pl.vehicle_fuel_card import router as vehicle_fuel_card_router from app.api.pl.dispatch_calendar import router as dispatch_calendar_router +from app.api.pl.site_dispatch_calendar import router as site_dispatch_calendar_router from app.api.wechat_notices import router as wechat_notices_router __all__ = [ @@ -29,4 +30,5 @@ __all__ = [ "doclib_router", "vehicle_fuel_card_router", "dispatch_calendar_router", + "site_dispatch_calendar_router", ] diff --git a/backend/app/api/pl/site_dispatch_calendar.py b/backend/app/api/pl/site_dispatch_calendar.py new file mode 100644 index 000000000..b3eca6de6 --- /dev/null +++ b/backend/app/api/pl/site_dispatch_calendar.py @@ -0,0 +1,49 @@ +"""现场人员独立只读入口;固定现场日历,不授予调度维护和主数据访问权限。""" +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.core import menu_guard +from app.core.database import get_db +from app.core.db_auth import DbUserContext, get_db_current_user +from app.core.tenant_context import get_tenant_no +from app.services import pl_dispatch_calendar_service as svc +from app.services.web_auth_roles import iter_user_menu_perm_codes + + +def require_site_calendar_permission( + current_user: dict = Depends(menu_guard.require_admin_or_super_admin), + db: Session = Depends(get_db), +): + if current_user.get("role") != menu_guard.SUPER_ADMIN_ROLE: + tenant = get_tenant_no(db, current_user) + codes = iter_user_menu_perm_codes(db, current_user.get("user_id"), tenant) + if "mini:pl:site-dispatch-calendar:list" not in codes: + raise HTTPException(status_code=403, detail="无现场派车日历访问权限") + return current_user + + +router = APIRouter( + prefix="/transport/site-dispatch-calendar", tags=["现场派车日历(只读)"], + dependencies=[Depends(require_site_calendar_permission)], +) + + +@router.get("") +def list_records(month: str = Query(...), vehicle_id: Optional[str] = Query(None, alias="vehicleId"), + page: int = Query(1, ge=1), page_size: int = Query(500, alias="pageSize", ge=1, le=500), + db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user)): + tenant = get_tenant_no(db, user.to_tenant_user_dict()) + return svc.list_records(db, tenant, user, month=month, vehicle_id=vehicle_id, + calendar_kind="site", page=page, page_size=page_size) + + +@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()) + 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) diff --git a/backend/app/main.py b/backend/app/main.py index a2b4a0ded..e4a979a71 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -33,6 +33,7 @@ from app.api import ( doclib_router, vehicle_fuel_card_router, dispatch_calendar_router, + site_dispatch_calendar_router, wechat_notices_router, ) from app.api.pl import pl_router @@ -183,6 +184,7 @@ app.include_router(db_router, prefix=settings.API_V1_PREFIX) app.include_router(pl_router, prefix=settings.API_V1_PREFIX) app.include_router(vehicle_fuel_card_router, prefix=settings.API_V1_PREFIX) app.include_router(dispatch_calendar_router, prefix=settings.API_V1_PREFIX) +app.include_router(site_dispatch_calendar_router, prefix=settings.API_V1_PREFIX) app.include_router(wechat_notices_router, prefix=settings.API_V1_PREFIX) app.include_router(duty_router, prefix=settings.API_V1_PREFIX) app.include_router(monitor_router, prefix=settings.API_V1_PREFIX) diff --git a/backend/app/services/pl_dispatch_calendar_service.py b/backend/app/services/pl_dispatch_calendar_service.py index 2d2cb82a9..6d850cf0f 100644 --- a/backend/app/services/pl_dispatch_calendar_service.py +++ b/backend/app/services/pl_dispatch_calendar_service.py @@ -17,6 +17,8 @@ from app.schemas.pl.dispatch_calendar import CreateBody, UpdateBody from app.services.pl_data_scope import apply_pl_scope CALENDAR_KINDS = ("acid", "alkali", "other", "site") +# 日历专用占位值,不对应车辆档案;与两端共享 hook 保持一致。 +PENDING_VEHICLE_ID = "__pending_vehicle__" def _calendar_kind(kind: Optional[str]) -> str: @@ -90,9 +92,13 @@ def get_record(db: Session, tenant_no: str, user: DbUserContext, record_id: str, def _resolve_snapshots(db: Session, tenant_no: str, user: DbUserContext, body: CreateBody) -> dict: - vehicle = _query(db, tenant_no, user, PlVehicle).filter(PlVehicle.id == body.vehicle_id).first() - if vehicle is None: - raise HTTPException(status_code=422, detail="车辆不存在、已删除或无权访问,请重新选择") + if body.vehicle_id == PENDING_VEHICLE_ID: + license_plate = "待派车" + 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 product = ( _query(db, tenant_no, user, CrmProduct) .filter(CrmProduct.id == body.product_id, CrmProduct.status == "on") @@ -100,7 +106,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": vehicle.license_plate, "product_name": product.product_name} + return {"license_plate": license_plate, "product_name": product.product_name} def _site_document_type(origin: str, destination: str) -> Optional[str]: diff --git a/backend/scripts/refresh_dispatch_calendar_site.py b/backend/scripts/refresh_dispatch_calendar_site.py new file mode 100644 index 000000000..96abffe4e --- /dev/null +++ b/backend/scripts/refresh_dispatch_calendar_site.py @@ -0,0 +1,129 @@ +"""按租户和派车日期补派历史记录,并刷新已有自动现场记录。 + +这是运维脚本,使用显式租户范围和专用审计身份;不提供 HTTP 入口。 +默认只读预览,--apply 才写入。历史记录仅在命中现有派发规则时启用后续同步。 +在 backend 目录执行,例如: + venv/bin/python scripts/refresh_dispatch_calendar_site.py --env-file .env \ + --tenant shanxia2.0 --start-date 2026-10-28 --end-date 2026-10-30 +增加 --apply 正式执行;生产配置使用 --env-file .env.production。 +""" +import argparse +import json +import sys +from datetime import date +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from dotenv import dotenv_values +from sqlalchemy import create_engine, or_ +from sqlalchemy.orm import Session + +from app.core.db_auth import DbUserContext +from app.models.pl_dispatch_calendar import PlDispatchCalendar as Record +from app.services import pl_dispatch_calendar_service as svc + +SOURCE_KINDS = ('acid', 'alkali', 'other') +COPY_FIELDS = ('dispatch_date', 'vehicle_id', 'license_plate', 'product_id', 'product_name', + 'origin', 'destination', 'quantity', 'remark') + + +def refresh(db: Session, *, tenant: str, start: date, end: date, + operator: str, apply: bool = False) -> dict: + """一次事务内补派;不改手工现场行,不按日期/车牌猜测关联。""" + if not tenant.strip() or not operator.strip() or len(operator) > 64 or start > end: + raise ValueError('须提供租户、有效审计身份及正确日期范围') + if not date(1900, 1, 1) <= start <= end <= date(2100, 12, 31): + raise ValueError('派车日期须在 1900-01-01 至 2100-12-31 范围内') + user = DbUserContext(operator, '现场日历补派脚本', '', 'super_admin', tenant) + result = {'tenant': tenant, 'startDate': start.isoformat(), 'endDate': end.isoformat(), + 'apply': apply, 'counts': {key: 0 for key in + ('created', 'updated', 'restored', 'removed', 'unchanged', 'ignored', 'enabled')}, + 'records': []} + try: + query = db.query(Record).filter( + Record.tenant_no == tenant, Record.calendar_kind.in_(SOURCE_KINDS), + Record.dispatch_date >= start, Record.dispatch_date <= end, + or_(Record.del_flag == 0, Record.site_sync_enabled == 1), + ).order_by(Record.id) + if apply: + query = query.populate_existing().with_for_update() + sources = query.all() + for source in sources: + target_query = db.query(Record).filter( + Record.tenant_no == tenant, Record.source_record_id == source.id, + ) + if apply: + target_query = target_query.populate_existing().with_for_update() + target = target_query.one_or_none() + if target is not None and target.calendar_kind != 'site': + raise ValueError('来源关联到了非现场日历,停止刷新:' + source.id) + document_type = None if source.del_flag == 1 else svc._site_document_type( + source.origin, source.destination) + enable = not source.site_sync_enabled and document_type is not None + if not document_type: + action = 'removed' if target is not None and target.del_flag == 0 else 'ignored' + elif target is None: + action = 'created' + elif target.del_flag == 1: + action = 'restored' + elif target.document_type != document_type or any( + getattr(target, field) != getattr(source, field) for field in COPY_FIELDS): + action = 'updated' + else: + action = 'unchanged' + changes_target = action in ('created', 'updated', 'restored', 'removed') + # 预览与正式执行一致:异常关联须人工核对,不能静默越过同步开关。 + if changes_target and not (source.site_sync_enabled or enable): + raise ValueError('自动关联存在但来源未启用同步,停止刷新:' + source.id) + result['counts'][action] += 1 + result['counts']['enabled'] += int(enable) + if apply: + if enable: + source.site_sync_enabled = 1 + source.update_by = operator + if changes_target: + svc._sync_site_record(db, tenant, user, source) + db.flush() + result['records'].append({'sourceId': source.id, 'action': action, + 'enabled': bool(enable)}) + if apply: + db.commit() + else: + db.rollback() + return result + except Exception: + db.rollback() + raise + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--env-file', required=True, help='明确指定 .env 或 .env.production') + parser.add_argument('--tenant', required=True) + parser.add_argument('--start-date', type=date.fromisoformat, required=True) + parser.add_argument('--end-date', type=date.fromisoformat, required=True) + parser.add_argument('--operator', default='calendar-site-refresh', help='写入 update_by 的运维审计身份') + parser.add_argument('--apply', action='store_true', help='提交变更,默认只读预览') + args = parser.parse_args() + # 直接读取指定文件,避免进程环境 DATABASE_URL 覆盖所选数据库。 + url = dotenv_values(args.env_file).get('DATABASE_URL') + if not url: + parser.error('指定配置文件没有 DATABASE_URL') + engine = create_engine(url, pool_pre_ping=True, connect_args={ + 'connect_timeout': 10, 'read_timeout': 60, 'write_timeout': 60}) + try: + with Session(engine, autoflush=False, expire_on_commit=False) as db: + if engine.dialect.name == 'mysql': + from sqlalchemy import text + db.execute(text('SET SESSION innodb_lock_wait_timeout = 15')) + result = refresh(db, tenant=args.tenant, start=args.start_date, end=args.end_date, + operator=args.operator, apply=args.apply) + print(json.dumps({'database': engine.url.database, 'host': engine.url.host, **result}, + ensure_ascii=False, indent=2)) + finally: + engine.dispose() + + +if __name__ == '__main__': + main() diff --git a/backend/tests/test_pl_dispatch_calendar.py b/backend/tests/test_pl_dispatch_calendar.py index a5d14661c..7de28a5c1 100644 --- a/backend/tests/test_pl_dispatch_calendar.py +++ b/backend/tests/test_pl_dispatch_calendar.py @@ -425,3 +425,38 @@ def test_site_document_type_optional_and_length_limit(db): assert not svc._row_to_dict(create(db, calendarKind='site'))['documentType'] with pytest.raises(ValidationError): Create(**payload(calendarKind='site', documentType='字' * 51)) + + +@pytest.mark.parametrize('kind', ['acid', 'alkali', 'other', 'site']) +def test_pending_vehicle_roundtrip_and_site_sync(db, kind): + model, _, Update, svc = modules() + pending = '__pending_vehicle__' + row = create(db, vehicleId=pending, calendarKind=kind, origin='山峡') + identity = (row.id, row.create_by, row.create_time) + assert svc._row_to_dict(row)['licensePlate'] == '待派车' + assert svc.list_records(db, 't1', user(), month='2026-10', + calendar_kind=kind, vehicle_id=pending)['total'] == 1 + assert db.get(PlVehicle, pending) is None + target = db.query(model).filter(model.source_record_id == row.id).first() + target_identity = (target.id, target.create_time) if target else None + for vehicle_id, plate in [('v1', '鄂E12345'), (pending, '待派车')]: + row = svc.update_record(db, 't1', user(), row.id, Update(**payload( + vehicleId=vehicle_id, calendarKind=kind, origin='山峡'))) + assert (row.id, row.create_by, row.create_time) == identity + assert row.vehicle_id == vehicle_id and row.license_plate == plate + if target: + db.refresh(target) + assert (target.id, target.create_time) == target_identity + assert target.vehicle_id == vehicle_id and target.license_plate == plate + assert svc.list_records(db, 't2', user(tenant='t2'), month='2026-10', + calendar_kind=kind)['total'] == 0 + + +@pytest.mark.parametrize('product_id', ['p3', 'p4', 'missing']) +def test_pending_vehicle_still_validates_product(db, product_id): + model, _, _, _ = modules() + with pytest.raises(HTTPException) as exc: + create(db, vehicleId='__pending_vehicle__', productId=product_id) + assert exc.value.status_code == 422 + assert '产品' in exc.value.detail + assert db.query(model).count() == 0 diff --git a/backend/tests/test_refresh_dispatch_calendar_site.py b/backend/tests/test_refresh_dispatch_calendar_site.py new file mode 100644 index 000000000..cabbf144e --- /dev/null +++ b/backend/tests/test_refresh_dispatch_calendar_site.py @@ -0,0 +1,119 @@ +"""历史派车补派脚本:预览只读、差量、幂等及事务。""" +from datetime import date + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from app.models.pl_dispatch_calendar import PlDispatchCalendar as Record +from scripts.refresh_dispatch_calendar_site import refresh + + +@pytest.fixture +def db(): + engine = create_engine('sqlite://') + Record.__table__.create(engine) + with Session(engine, autoflush=False, expire_on_commit=False) as session: + yield session + engine.dispose() + + +def source(db, id='s1', **values): + data = dict(id=id, tenant_no='t1', calendar_kind='acid', dispatch_date=date(2026, 10, 28), + vehicle_id='v1', license_plate='鄂E12345', product_id='p1', product_name='盐酸', + origin='山峡', destination='武汉', create_by='u1', create_dept_id='d1', + site_sync_enabled=0) + data.update(values) + row = Record(**data) + db.add(row) + db.commit() + return row + + +def run(db, apply=False): + return refresh(db, tenant='t1', start=date(2026, 10, 28), end=date(2026, 10, 30), + operator='calendar-site-refresh', apply=apply) + + +@pytest.mark.parametrize('kind', ['acid', 'alkali', 'other']) +def test_preview_backfill_and_repeat_preserve_identity(db, kind): + row = source(db, calendar_kind=kind) + assert run(db)['counts']['created'] == 1 + assert db.query(Record).count() == 1 and row.site_sync_enabled == 0 + assert run(db, True)['counts']['created'] == 1 + target = db.query(Record).filter(Record.source_record_id == row.id).one() + identity = (target.id, target.create_time, target.create_by, target.update_time) + assert row.site_sync_enabled == 1 and target.document_type == '出库计划' + assert run(db, True)['counts']['unchanged'] == 1 + assert (target.id, target.create_time, target.create_by, target.update_time) == identity + assert db.query(Record).count() == 2 + + +def test_scope_and_matching(db): + source(db, 'before', dispatch_date=date(2026, 10, 27)) + source(db, 'after', dispatch_date=date(2026, 10, 31)) + source(db, 'foreign', tenant_no='t2') + source(db, 'manual', calendar_kind='site') + source(db, 'deleted', del_flag=1) + source(db, 'nomatch', origin='武汉', destination='合肥') + source(db, 'inbound', destination='云栋', origin='合肥', dispatch_date=date(2026, 10, 29)) + source(db, 'both', destination='云栋', dispatch_date=date(2026, 10, 30)) + result = run(db, True) + targets = db.query(Record).filter(Record.source_record_id.isnot(None)).all() + assert {r.source_record_id: r.document_type for r in targets} == {'inbound': '入库计划', 'both': '出入库计划'} + assert result['counts']['created'] == 2 + assert db.get(Record, 'nomatch').site_sync_enabled == 0 + assert db.get(Record, 'manual').origin == '山峡' + + +def test_update_soft_delete_restore_and_enable_history_with_existing_target(db): + row = source(db) + run(db, True) + target = db.query(Record).filter(Record.source_record_id == row.id).one() + identity = (target.id, target.create_time) + row.destination = '云栋' + row.quantity = '32.50' + db.commit() + assert run(db, True)['counts']['updated'] == 1 + assert target.document_type == '出入库计划' and target.quantity == '32.50' + row.del_flag = 1 + db.commit() + assert run(db, True)['counts']['removed'] == 1 + assert target.del_flag == 1 + row.del_flag = 0 + row.site_sync_enabled = 0 + db.commit() + assert run(db, True)['counts']['restored'] == 1 + assert row.site_sync_enabled == 1 and target.del_flag == 0 + assert (target.id, target.create_time) == identity + + +def test_failure_rolls_back_all_changes(db, monkeypatch): + import scripts.refresh_dispatch_calendar_site as script + row = source(db) + original = script.svc._sync_site_record + def fail(*args): + original(*args) + args[0].flush() + raise RuntimeError('forced failure') + monkeypatch.setattr(script.svc, '_sync_site_record', fail) + with pytest.raises(RuntimeError): + run(db, True) + assert db.query(Record).count() == 1 + assert db.get(Record, row.id).site_sync_enabled == 0 + + +def test_reversed_date_range_is_rejected(db): + with pytest.raises(ValueError): + refresh(db, tenant='t1', start=date(2026, 10, 30), end=date(2026, 10, 28), + operator='calendar-site-refresh', apply=True) + + +def test_preview_rejects_disabled_source_with_stale_automatic_target(db): + row = source(db) + run(db, True) + row.site_sync_enabled = 0 + row.origin = '武汉' + db.commit() + with pytest.raises(ValueError, match='未启用同步'): + run(db) diff --git a/backend/tests/test_site_dispatch_calendar.py b/backend/tests/test_site_dispatch_calendar.py new file mode 100644 index 000000000..dcc3c5fa3 --- /dev/null +++ b/backend/tests/test_site_dispatch_calendar.py @@ -0,0 +1,135 @@ +"""独立现场只读入口:真实菜单多角色并集、数据范围及 HTTP 越权回归。""" +from datetime import date +from importlib.util import find_spec + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +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 + +PATH = '/api/transport/site-dispatch-calendar' +ORIGINAL = '/api/transport/dispatch-calendar' +PERMISSION = 'mini:pl:site-dispatch-calendar:list' + + +@pytest.fixture +def setup(): + assert find_spec('app.api.pl.site_dispatch_calendar') is not None, '独立现场只读 API 尚未实现' + 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): + model.__table__.create(engine) + factory = sessionmaker(bind=engine) + with factory() as db: + db.add_all([ + 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'), + SysUserRole(id='ur2', user_id='u1', role_id='r2', tenant_no='t1'), + SysMenu(id='site-menu', menu_name='现场派车日历', parent_id='0', menu_type='C', + perms=PERMISSION, tenant_no='t1'), + ]) + for rid, kind, tenant, owner, deleted, vehicle in [ + ('site-own', 'site', 't1', 'u1', 0, 'v1'), + ('site-other', 'site', 't1', 'u2', 0, 'v2'), + ('site-t2', 'site', 't2', 'u1', 0, 'v1'), + ('site-deleted', 'site', 't1', 'u1', 1, 'v1'), + ('acid', 'acid', 't1', 'u1', 0, 'v1'), + ('alkali', 'alkali', 't1', 'u1', 0, 'v1'), + ('other', 'other', 't1', 'u1', 0, 'v1'), + ]: + db.add(PlDispatchCalendar(id=rid, calendar_kind=kind, tenant_no=tenant, create_by=owner, + del_flag=deleted, dispatch_date=date(2026, 10, 30), vehicle_id=vehicle, + license_plate='鄂E12345', product_id='p1', product_name='液碱', origin='山峡', destination='重庆')) + db.commit() + def database(): + with factory() as db: + yield db + app = FastAPI() + app.include_router(router, prefix='/api') + app.include_router(original_router, prefix='/api') + app.dependency_overrides[get_db] = database + with TestClient(app) as client: + yield app, client, factory + engine.dispose() + + +def login(app, **kwargs): + app.dependency_overrides[get_current_user] = lambda: { + 'role': 'admin', 'user_id': 'u1', 'tenant_no': 't1', 'region_id': 'd1', **kwargs, + } + + +def grant(factory): + with factory() as db: + db.add(SysRoleMenu(id='grant', role_id='r2', menu_id='site-menu', tenant_no='t1')) + db.commit() + + +def test_auth_permission_second_role_and_tenant(setup): + app, client, factory = setup + assert client.get(PATH, params={'month': '2026-10'}).status_code == 401 + login(app) + assert client.get(PATH, params={'month': '2026-10'}).status_code == 403 + grant(factory) + response = client.get(PATH, params={'month': '2026-10'}) + assert response.status_code == 200, response.text + assert [r['id'] for r in response.json()['list']] == ['site-own'] + login(app, tenant_no='t2') + assert client.get(PATH, params={'month': '2026-10'}).status_code == 403 + login(app, role='super_admin') + assert client.get(PATH, params={'month': '2026-10'}).json()['total'] == 2 + + +def test_fixed_kind_tenant_scope_and_details(setup): + app, client, factory = setup + login(app) + grant(factory) + response = client.get(PATH, params={'month': '2026-10', 'calendarKind': 'acid', 'tenantNo': 't2', + 'tenant_no': 't2', 'vehicleId': 'v1'}) + assert response.status_code == 200 + assert [r['id'] for r in response.json()['list']] == ['site-own'] + assert client.get(PATH + '/site-own').json()['calendarKind'] == 'site' + 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 + with factory() as db: + db.get(SysRole, 'r1').data_scope = '1' + db.commit() + # 菜单授权来自第二角色,全部数据范围来自第一角色,均按并集生效。 + 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-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() + assert page1['total'] == page2['total'] == 2 + assert page1['list'][0]['id'] != page2['list'][0]['id'] + + +def test_new_permission_does_not_allow_write_original_or_master_options(setup): + app, client, factory = setup + login(app) + grant(factory) + for method, path in [('post', PATH), ('put', PATH + '/site-own'), ('delete', PATH + '/site-own')]: + assert getattr(client, method)(path).status_code == 405 + for method, path in [('get', ORIGINAL), ('get', ORIGINAL + '/acid'), ('get', ORIGINAL + '/vehicles'), + ('get', ORIGINAL + '/products'), ('post', ORIGINAL), + ('put', ORIGINAL + '/site-own'), ('delete', ORIGINAL + '/site-own')]: + assert getattr(client, method)(path).status_code == 403 + + +@pytest.mark.parametrize('params', [{}, {'month': '2026-13'}, {'month': '2026-10', 'page': 0}, + {'month': '2026-10', 'pageSize': 501}]) +def test_query_validation(setup, params): + app, client, factory = setup + login(app) + grant(factory) + assert client.get(PATH, params=params).status_code == 422 diff --git a/backend/tests/test_site_dispatch_calendar_menu.py b/backend/tests/test_site_dispatch_calendar_menu.py new file mode 100644 index 000000000..ac32e634a --- /dev/null +++ b/backend/tests/test_site_dispatch_calendar_menu.py @@ -0,0 +1,100 @@ +"""现场派车日历菜单迁移:仅现场角色可获得只读入口,保留管理员撤权。""" +from pathlib import Path +import re +import sqlite3 +from uuid import uuid4 + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +MIGRATION = ROOT / 'docs/sql/migrations/2026-10-30-mini-site-dispatch-calendar-menu.sql' +PERM = 'mini:pl:site-dispatch-calendar:list' + + +def database(): + db = sqlite3.connect(':memory:') + db.create_function('UUID', 0, lambda: str(uuid4())) + db.create_function('NOW', 0, lambda: '2026-10-30 12:00:00') + common = "tenant_no TEXT DEFAULT 'shanxia2.0', order_num INT, remark TEXT, create_by TEXT, create_dept_id TEXT, create_time TEXT, update_time TEXT, del_flag INT DEFAULT 0" + db.execute(f"CREATE TABLE sys_menu (id TEXT PRIMARY KEY, menu_name TEXT, parent_id TEXT DEFAULT '0', menu_type TEXT, path TEXT, component TEXT, perms TEXT, is_frame INT, visible TEXT DEFAULT '1', status TEXT DEFAULT '1', {common})") + db.execute(f"CREATE TABLE sys_role (id TEXT PRIMARY KEY, role_name TEXT, status TEXT DEFAULT '1', {common})") + db.execute(f"CREATE TABLE sys_role_menu (id TEXT PRIMARY KEY, role_id TEXT, menu_id TEXT, {common})") + db.execute("INSERT INTO sys_menu (id,path) VALUES ('mini-root','/mini')") + db.execute("INSERT INTO sys_menu (id,parent_id,path) VALUES ('MINI_PL_ROOT','mini-root','/mini/pl')") + for role_id, name, tenant, status, deleted in [ + ('site', '现场人员', 'shanxia2.0', '1', 0), + ('site2', '现场人员', 'shanxia2.0', '1', 0), + ('driver', '司机', 'shanxia2.0', '1', 0), + ('admin', '管理员', 'shanxia2.0', '1', 0), + ('similar', '现场人员管理', 'shanxia2.0', '1', 0), + ('foreign', '现场人员', 'other', '1', 0), + ('disabled', '现场人员', 'shanxia2.0', '0', 0), + ('deleted', '现场人员', 'shanxia2.0', '1', 1), + ]: + db.execute('INSERT INTO sys_role (id,role_name,tenant_no,status,del_flag) VALUES (?,?,?,?,?)', + (role_id, name, tenant, status, deleted)) + return db + + +def migrate(db): + assert MIGRATION.exists(), '现场派车日历迁移尚未创建' + for sql in re.findall(r'INSERT INTO sys_(?:menu|role_menu)\s*\(.*?;', MIGRATION.read_text(), re.S): + db.execute(sql) + + +def test_only_active_exact_site_roles_get_leaf_and_ancestors_once(): + with database() as db: + migrate(db) + migrate(db) + leaf = db.execute('SELECT id FROM sys_menu WHERE perms=?', (PERM,)).fetchone()[0] + assert set(db.execute('SELECT role_id,menu_id FROM sys_role_menu')) == { + (role, menu) for role in ['site', 'site2'] for menu in [leaf, 'MINI_PL_ROOT', 'mini-root'] + } + assert db.execute('SELECT COUNT(*) FROM sys_role_menu').fetchone()[0] == 6 + assert list(db.execute('SELECT perms FROM sys_menu WHERE perms IS NOT NULL')) == [(PERM,)] + + +@pytest.mark.parametrize('part', ['leaf', 'MINI_PL_ROOT', 'mini-root']) +def test_deleted_grants_are_never_restored(part): + with database() as db: + migrate(db) + if part == 'leaf': + part = db.execute('SELECT id FROM sys_menu WHERE perms=?', (PERM,)).fetchone()[0] + db.execute('UPDATE sys_role_menu SET del_flag=1 WHERE role_id=? AND menu_id=?', ('site', part)) + migrate(db) + assert list(db.execute('SELECT del_flag FROM sys_role_menu WHERE role_id=? AND menu_id=?', ('site', part))) == [(1,)] + + +@pytest.mark.parametrize('field,value', [('del_flag', 1), ('status', '0'), ('visible', '0')]) +@pytest.mark.parametrize('part', ['leaf', 'MINI_PL_ROOT', 'mini-root']) +def test_inactive_menus_do_not_create_new_grants(field, value, part): + with database() as db: + migrate(db) + if part == 'leaf': + part = db.execute('SELECT id FROM sys_menu WHERE perms=?', (PERM,)).fetchone()[0] + db.execute(f'UPDATE sys_menu SET {field}=? WHERE id=?', (value, part)) + db.execute("INSERT INTO sys_role (id,role_name) VALUES ('new-site','现场人员')") + migrate(db) + assert db.execute("SELECT COUNT(*) FROM sys_role_menu WHERE role_id='new-site'").fetchone()[0] == 0 + assert db.execute('SELECT COUNT(*) FROM sys_menu WHERE perms=?', (PERM,)).fetchone()[0] == 1 + + +@pytest.mark.parametrize('change', [ + "UPDATE sys_menu SET path='/web' WHERE id='mini-root'", + "UPDATE sys_menu SET parent_id='not-root' WHERE id='mini-root'", + "UPDATE sys_menu SET tenant_no='other'", + "UPDATE sys_menu SET del_flag=1 WHERE id='MINI_PL_ROOT'", +]) +def test_wrong_tree_or_tenant_does_not_create_menu(change): + with database() as db: + db.execute(change) + migrate(db) + assert db.execute('SELECT COUNT(*) FROM sys_menu WHERE perms=?', (PERM,)).fetchone()[0] == 0 + assert db.execute('SELECT COUNT(*) FROM sys_role_menu').fetchone()[0] == 0 + + +def test_mini_entry_uses_dedicated_permission_and_h5_path(): + menu = (ROOT / 'miniprogram/config/workbench-menus.js').read_text() + route = (ROOT / 'miniprogram/config/api.js').read_text() + assert re.search(r"name: '现场派车日历'.*?code: 'pl-site-dispatch-calendar-webview'.*?perms: '" + PERM + "'", menu, re.S) + assert "'pl-site-dispatch-calendar-webview': PAGE_PATHS.mpWebview + '?path=' + encodeURIComponent('/m/pl/site-dispatch-calendar')" in route diff --git a/docs/sql/migrations/2026-10-30-mini-site-dispatch-calendar-menu.sql b/docs/sql/migrations/2026-10-30-mini-site-dispatch-calendar-menu.sql new file mode 100644 index 000000000..565bec39e --- /dev/null +++ b/docs/sql/migrations/2026-10-30-mini-site-dispatch-calendar-menu.sql @@ -0,0 +1,34 @@ +-- 小程序运输管理 -> 现场派车日历。MySQL 5.7;只读入口,与原派车日历权限独立。 +-- 仅为 shanxia2.0 有效「现场人员」角色补齐入口及祖先,不恢复软删除菜单或授权。 +SET NAMES utf8mb4; +START TRANSACTION; + +INSERT INTO sys_menu ( + id,menu_name,parent_id,menu_type,path,component,perms,is_frame,visible,status, + tenant_no,order_num,remark,create_by,create_dept_id,create_time,update_time,del_flag +) +SELECT '523ab46a-a4b9-4fbd-bc53-b913e548e639','现场派车日历',p.id,'C','mp-webview',NULL, + 'mini:pl:site-dispatch-calendar:list',1,'1','1',p.tenant_no,8,'小程序 H5 现场派车日历只读入口','system','',NOW(),NOW(),0 +FROM sys_menu p +JOIN sys_menu root ON root.id=p.parent_id AND root.tenant_no=p.tenant_no +WHERE p.id='MINI_PL_ROOT' AND p.path='/mini/pl' AND p.del_flag=0 AND p.tenant_no='shanxia2.0' + AND p.status='1' AND p.visible='1' + AND root.parent_id='0' AND root.path='/mini' AND root.del_flag=0 AND root.status='1' AND root.visible='1' + AND NOT EXISTS (SELECT 1 FROM sys_menu existing WHERE existing.id='523ab46a-a4b9-4fbd-bc53-b913e548e639' + OR (existing.tenant_no=p.tenant_no AND existing.perms='mini:pl:site-dispatch-calendar:list')); + +INSERT INTO sys_role_menu ( + id,role_id,menu_id,tenant_no,order_num,remark,create_by,create_dept_id,create_time,update_time,del_flag +) +SELECT UUID(),r.id,target.id,r.tenant_no,0,'现场派车日历:现场人员只读入口及祖先','system','',NOW(),NOW(),0 +FROM sys_role r +JOIN sys_menu leaf ON leaf.perms='mini:pl:site-dispatch-calendar:list' AND leaf.tenant_no=r.tenant_no + AND leaf.del_flag=0 AND leaf.status='1' AND leaf.visible='1' +JOIN sys_menu p ON p.id=leaf.parent_id AND p.tenant_no=r.tenant_no AND p.del_flag=0 AND p.status='1' AND p.visible='1' +JOIN sys_menu root ON root.id=p.parent_id AND root.tenant_no=r.tenant_no AND root.del_flag=0 AND root.status='1' AND root.visible='1' +JOIN sys_menu target ON target.id IN (leaf.id,p.id,root.id) AND target.tenant_no=r.tenant_no +WHERE r.tenant_no='shanxia2.0' AND r.role_name='现场人员' AND r.del_flag=0 AND r.status='1' + AND p.id='MINI_PL_ROOT' AND p.path='/mini/pl' AND root.parent_id='0' AND root.path='/mini' + AND NOT EXISTS (SELECT 1 FROM sys_role_menu existing + WHERE existing.role_id=r.id AND existing.menu_id=target.id AND existing.tenant_no=r.tenant_no); +COMMIT; diff --git a/docs/superpowers/plans/2026-10-30-mobile-site-calendar.md b/docs/superpowers/plans/2026-10-30-mobile-site-calendar.md new file mode 100644 index 000000000..d4931a3db --- /dev/null +++ b/docs/superpowers/plans/2026-10-30-mobile-site-calendar.md @@ -0,0 +1,11 @@ +# 小程序现场日历实施计划 + +- [x] 在已有浏览器回归中加入移动 site 场景,验证旧页面缺少 tab。 +- [x] 修改移动 H5 index.vue 增加 tab 和对应 ManualCalendar;同目录 ManualCalendar.vue 补单据类型编辑/详情与自动记录保护。 +- [x] 执行浏览器功能回归、相关模型单元测试、目标 ESLint 与前端构建;核对窄屏截图。 + +参考:现有 `/m/pl/dispatch-calendar` 页面与 Vant 底部弹层,遵守三墙小程序视觉规范。小程序原入口保持有效,发布需要更新 Web/H5 资源。 + +验证结果:旧页面回归因不存在现场日历 tab 失败;补齐后移动 site/acid 与 Web acid/alkali/other/site 六组浏览器回归全部通过(模拟 API)。相关模型测试 13 项通过,目标 ESLint、pnpm build、git diff --check 通过。截图核查 375px,自动验证 320/375/414px 无横滚。构建有既有组件重名和大分包告警;未进行微信真机验证,未发布线上。 + +后续卡片/复制调整:共享卡片隐藏所有单据类型;现场复制按产品汇总车次,每条文本含车牌、产品、数量及类型,符合用户 2026-11-01 样例。先验证旧格式三项测试失败,再实现;18 项相关单元测试、6 组 Web/H5 浏览器回归、目标 ESLint、生产构建及 diff 检查通过。截图确认移动卡片不显示类型;表单详情保留类型。未部署线上。 diff --git a/docs/superpowers/plans/2026-10-30-refresh-calendar-site.md b/docs/superpowers/plans/2026-10-30-refresh-calendar-site.md new file mode 100644 index 000000000..d85547155 --- /dev/null +++ b/docs/superpowers/plans/2026-10-30-refresh-calendar-site.md @@ -0,0 +1,20 @@ +# 三天历史派车补派现场日历 + +用户确认范围:按派车日期 2026-10-28 至 2026-10-30(含两端),在测试与生产执行。 + +实现:`backend/scripts/refresh_dispatch_calendar_site.py` 为运维脚本,显式指定环境文件、租户、日期范围;默认只读预览,`--apply` 才提交。复用当前服务的山峡/云栋出入库匹配与差量同步。仅命中规则的历史记录开启后续同步;已有自动记录按来源 ID 更新或恢复,失去匹配或来源软删除则软删除自动记录。手工现场、其他租户及范围外来源不变。 + +一次执行在一个事务内锁定来源和关联记录;失败整体回滚;记录专用 `calendar-site-refresh` 审计身份。无差异不触碰目标时间。脚本仅供具有数据库访问权限的运维人员使用,不增加 HTTP 入口。 + +- [x] 编写预览、三类来源、边界日期、跨租户、手工现场、幂等、更新/软删除/恢复、失败回滚测试并先验证缺失实现。 +- [x] 实现脚本,运行日历及同步回归:106 项通过。 +- [x] 两库预览,核对租户和影响数量。 +- [x] 两库正式执行,重复预览及独立查询验证。 + + +执行结果(2026-10-30):租户 `shanxia2.0`,测试库新增 0、已有同步不变 1、不匹配跳过 5;生产库新增 15、启用历史来源同步 15、不匹配跳过 1。逐条独立核对复制字段和单据类型、唯一来源关联;执行前后快照比较确认其他原记录未改变。再次预览两库新增/更新/恢复/删除/启用数量全部为 0。 + +代码复核补充:异常的“关联存在但来源未启用且已失去匹配”在只读预览时即报错,正式执行也整体回滚,相关测试已覆盖。本次实际数据未出现此异常。 + + +追加执行:用户授权仅生产库,派车日期 2026-10-31 至 2026-11-05(含两端),租户 shanxia2.0。新增现场记录 6 条并启用对应来源后续同步,4 条不匹配跳过,无更新/恢复/删除。独立逐条核对字段、单据类型及唯一来源关联,其他原记录保持不变;重复预览无待变更项。未连接或刷新测试库。 diff --git a/docs/superpowers/plans/2026-10-30-site-calendar-readonly.md b/docs/superpowers/plans/2026-10-30-site-calendar-readonly.md new file mode 100644 index 000000000..704c1b651 --- /dev/null +++ b/docs/superpowers/plans/2026-10-30-site-calendar-readonly.md @@ -0,0 +1,18 @@ +# 现场派车日历只读入口实施计划 + +- [x] 新增独立 GET 路由及权限回归,两处注册 API,固定 site 类型与租户/数据范围。 +- [x] 新增小程序入口与仅授权现场人员角色的菜单迁移,验证幂等和祖先授权。 +- [x] 复用移动日历页面增加固定只读模式及专用 API,增加静态 H5 路由。 +- [x] 浏览器验证无 tab/新增/编辑/删除,正常筛选、详情、复制,管理员访问也只读。 +- [x] 跑相关后端/前端测试、构建与变更检查;记录发布条件和未验证项。 + + +验收记录:后端/菜单/原日历相关测试 153 passed,前端相关模型 15 passed,专用只读页面两种权限模式(包括全部权限)浏览器回归通过,原 Web/H5 六组日历回归通过。核查日期切换、车辆筛选、复制、手工/自动详情、无 tab、无维护按钮、无维护主数据请求,320/375/414px 无横滚。目标 ESLint、pnpm build、git diff --check 通过;构建仍有既有组件重名和大分包告警。跨任务复核未发现权限契约问题。 + +只读查询确认测试库与生产库均已有有效现场人员角色,数据范围为全部(1)。没有执行本次菜单 SQL或部署。SQL测试使用内存SQLite验证INSERT授权语义,未做MySQL实库执行;未做微信真机验证。 + +发布顺序:部署后端只读接口和Web/H5 → 执行 `docs/sql/migrations/2026-10-30-mini-site-dispatch-calendar-menu.sql` → 按现有流程发布小程序入口配置并验证现场人员登录。菜单迁移只授权现场人员及必要小程序祖先,不改变已有其他业务授权;超级管理员沿用体系默认全权限,但本页面仍固定只读。 + +## 菜单 SQL 实际执行 + +2026-10-30,经用户明确授权,已在测试库及生产库执行菜单迁移。每库新增「现场派车日历」菜单 1 条、现场人员角色授权 1 条;小程序根及运输管理祖先授权原已存在且有效。核验新菜单仅授权现场人员角色,权限为 `mini:pl:site-dispatch-calendar:list`。再次执行两库新增菜单/授权均为 0,确认幂等。本次仅完成菜单数据库变更,未执行应用部署。 diff --git a/docs/superpowers/plans/2026-10-31-dispatch-calendar-pending.md b/docs/superpowers/plans/2026-10-31-dispatch-calendar-pending.md new file mode 100644 index 000000000..774e2919a --- /dev/null +++ b/docs/superpowers/plans/2026-10-31-dispatch-calendar-pending.md @@ -0,0 +1,23 @@ +# 派车日历待派车实施计划 + +**Goal:** 两端车牌号首位可选待派车并可保存、回显、后续换车。 + +**Architecture:** 共享前端 hook 添加固定占位选项;后端快照解析识别保留值,复用既有 CRUD 与现场同步。 + +**Tech Stack:** Vue 3、Element Plus、Vant、FastAPI、SQLAlchemy、pytest、Playwright。 + +- [x] 在 backend/tests/test_pl_dispatch_calendar.py 添加待派车新增、双向换车、同步 ID 保留及非法产品拒绝回归,先运行失败验证。 +- [x] 在 backend/app/services/pl_dispatch_calendar_service.py 识别 `__pending_vehicle__` 并写入“待派车”快照,其余车辆保持原校验。 +- [x] 在 web-admin/src/views/pl/dispatch-calendar/useManualCalendar.ts 加载车辆后前置占位选项,两端直接复用。 +- [x] 扩展 web-admin/scripts/test-manual-dispatch-calendar.mjs,检查首位选择、保存及回显,再切换真实车辆继续既有回归。 +- [x] 运行后端日历和现场同步 pytest、前端模型测试、浏览器测试以及目标 ESLint,记录实际结果。 + +不提交工作区其他已有改动,不执行生产操作。 + +## 验证结果 + +- 后端在 backend 目录执行 `venv/bin/python -m pytest tests/test_pl_dispatch_calendar.py tests/test_pl_dispatch_calendar_site_sync.py -q`:105 passed;有既有依赖弃用及 LibreSSL 警告。 +- 前端 `pnpm exec tsx --test scripts/manual-dispatch-calendar.test.ts`:11 passed。 +- `node scripts/test-manual-dispatch-calendar.mjs`:6 组通过(Web 酸、碱、其他、现场;移动 H5 酸、现场)。覆盖待派车首项、新建保存、编辑回显、换车及既有 CRUD/权限流程。测试中的 HTTP 500 为脚本主动模拟的失败恢复场景。 +- 目标文件 ESLint 和 `git diff --check` 通过。 +- 未执行微信真机测试、全量构建或生产发布。浏览器测试使用模拟 API,后端逻辑由独立 pytest 覆盖。 diff --git a/docs/superpowers/specs/2026-10-30-mobile-site-calendar-design.md b/docs/superpowers/specs/2026-10-30-mobile-site-calendar-design.md new file mode 100644 index 000000000..6d01bbc33 --- /dev/null +++ b/docs/superpowers/specs/2026-10-30-mobile-site-calendar-design.md @@ -0,0 +1,11 @@ +# 小程序现场日历 + +小程序现有派车日历入口通过 `miniprogram/config/api.js` 打开 `/m/pl/dispatch-calendar`。移动 H5 缺少现场日历 tab,本次复用既有入口和后端,不新增菜单、数据库字段或原生页面。 + +在其他日历后增加现场日历,复用手工日历筛选、增删改、查看、汇总与复制。现场表单支持单据类型预设选择和自定义输入;手机保持单列 Vant 表单。卡片和复制沿用共用实现,复制保留产品、数量和类型。自动关联记录显示来源维护提示,隐藏编辑删除并在操作函数中防护。 + +验证:移动现场 CRUD、单据类型往返、自定义、复制、自动记录只读、无维护权限,以及 320/375/414 像素宽度;原 Web 与移动酸日历回归。微信真机验证须明确与浏览器模拟验证区分。 + +## 后续用户调整:卡片与复制文本 + +现场卡片(单车单趟、多趟)不再显示单据类型,表单和详情保留。Web 与小程序使用共用组件统一生效。现场复制按产品 ID 分组,组标题为「产品名N车」,每条来源记录计一车;组内按创建时间升序、ID 次序输出「 ·车牌 产品名 数量吨(单据类型)」。顶部为「日期 派车计划:」并空一行;起止地与备注不进入现场复制文本。数量和类型为空时省略相应文本。产品组沿用日卡产品统计的首次出现次序,其他日历复制格式保持不变。 diff --git a/docs/superpowers/specs/2026-10-30-site-calendar-readonly-design.md b/docs/superpowers/specs/2026-10-30-site-calendar-readonly-design.md new file mode 100644 index 000000000..80545093f --- /dev/null +++ b/docs/superpowers/specs/2026-10-30-site-calendar-readonly-design.md @@ -0,0 +1,9 @@ +# 小程序现场派车日历独立只读入口 + +用户批准采用 H5 嵌套模式。新增小程序菜单「现场派车日历」,打开 `/m/pl/site-dispatch-calendar`,只展示现场日历,无日历 tab。保留日期定位、车辆筛选、详情及按产品复制。页面固定只读,即使登录者同时拥有管理权限也不显示维护入口或加载维护选项。 + +新权限 `mini:pl:site-dispatch-calendar:list` 的迁移仅授予当前租户有效「现场人员」角色及必要小程序祖先菜单,不授予原日历和维护权限。遵循现有超级管理员权限惯例、角色权限并集和数据范围;不改变已有角色授权。 + +新接口 `/transport/site-dispatch-calendar` 只提供 GET,服务端固定 site 类型,执行租户与数据范围过滤。专用权限不能用于原日历接口。前端只读模式只请求专用列表接口;车辆筛选从当前月可见记录提取,不请求车辆/产品主数据。 + +前端复用已有小程序日历布局及品牌色。无需复制业务数据。发布涉及后端、Web/H5、小程序入口配置和菜单迁移;本次开发不自动执行生产发布或数据库授权变更。 diff --git a/docs/superpowers/specs/2026-10-31-dispatch-calendar-pending-design.md b/docs/superpowers/specs/2026-10-31-dispatch-calendar-pending-design.md new file mode 100644 index 000000000..efb80e4c4 --- /dev/null +++ b/docs/superpowers/specs/2026-10-31-dispatch-calendar-pending-design.md @@ -0,0 +1,9 @@ +# 派车日历待派车选项 + +Web 与小程序 webview 共用 useManualCalendar,在车牌号选项首位加入“待派车”。未选择时仍为空并保持必填校验,用户明确选择后才保存占位值。 + +采用日历专用保留值 `__pending_vehicle__`,车牌快照由后端写为“待派车”。不创建虚假车辆档案,不改数据库结构;相比添加真实车辆档案不会污染车辆管理,相比改为空值不需要修改必填字段和分组协议。真实车辆继续校验租户、数据权限和软删除;产品校验不变。 + +新增、编辑、查看、按车辆筛选、复制以及既有现场同步均保持同一语义。允许待派车与真实车辆双向切换,更新保留来源及同步记录 ID。适用现有所有可维护日历,现场只读入口保持只读。 + +沿用现有页面和控件,不新增布局或颜色。验证后端新增、切换、快照、同步及隔离,并使用现有双端浏览器脚本验证首项、保存和编辑回显。 diff --git a/miniprogram/config/api.js b/miniprogram/config/api.js index 75c8d5b59..e11a9a03c 100644 --- a/miniprogram/config/api.js +++ b/miniprogram/config/api.js @@ -117,6 +117,7 @@ export const WORKBENCH_ROUTE_MAP = { 'pl-driver-plan': PAGE_PATHS.plDriverPlan, 'pl-plan-webview': PAGE_PATHS.mpWebview + '?path=' + encodeURIComponent('/m/pl/plan'), 'pl-dispatch-calendar-webview': PAGE_PATHS.mpWebview + '?path=' + encodeURIComponent('/m/pl/dispatch-calendar'), + 'pl-site-dispatch-calendar-webview': PAGE_PATHS.mpWebview + '?path=' + encodeURIComponent('/m/pl/site-dispatch-calendar'), 'pl-plan-cert-webview': PAGE_PATHS.mpWebview + '?path=' + encodeURIComponent('/m/pl/plan-cert'), 'crm-product-webview': PAGE_PATHS.mpWebview + '?path=' + encodeURIComponent('/m/crm/product'), 'crm-customer-webview': PAGE_PATHS.mpWebview + '?path=' + encodeURIComponent('/m/crm/customer'), diff --git a/miniprogram/config/workbench-menus.js b/miniprogram/config/workbench-menus.js index d9b5dd8c1..5d727ce06 100644 --- a/miniprogram/config/workbench-menus.js +++ b/miniprogram/config/workbench-menus.js @@ -89,7 +89,7 @@ export const WORKBENCH_MODULES = [ { key: 'pl', title: '运输管理', - desc: '加油登记 · ETC/油卡充值申请 · 现场运输计划单 · 运输计划 · 派车日历 · 危化品证上传 · 司机运输计划单', + desc: '加油登记 · ETC/油卡充值申请 · 现场运输计划单 · 运输计划 · 派车日历 · 现场派车日历 · 危化品证上传 · 司机运输计划单', iconText: '运输', visible: true, menus: [ @@ -128,6 +128,13 @@ export const WORKBENCH_MODULES = [ icon: '📅', perms: 'mini:pl:dispatch-calendar:list' }, + { + name: '现场派车日历', + enabled: true, + code: 'pl-site-dispatch-calendar-webview', + icon: '📅', + perms: 'mini:pl:site-dispatch-calendar:list' + }, { name: '危化品证上传', enabled: true, diff --git a/web-admin/scripts/manual-dispatch-calendar.test.ts b/web-admin/scripts/manual-dispatch-calendar.test.ts index 612fea532..a47494579 100644 --- a/web-admin/scripts/manual-dispatch-calendar.test.ts +++ b/web-admin/scripts/manual-dispatch-calendar.test.ts @@ -162,10 +162,11 @@ test('现场复制隐藏起止地和备注,单据类型放在每条记录末 assert.deepEqual( manualCopyText('2026-10-26', [record, { ...record, id: 'site-2', quantity: '' }]).split('\n'), [ - '2026-10-26 派车计划', - ' ·皖H12345', - ` 液碱 32.50吨(${documentType})`, - ` 液碱(${documentType})` + '2026-10-26 派车计划:', + '', + '液碱2车', + ` ·皖H12345 液碱 32.50吨(${documentType})`, + ` ·皖H12345 液碱(${documentType})` ] ) assert.equal( @@ -181,7 +182,10 @@ test('现场复制隐藏起止地和备注,单据类型放在每条记录末 documentType, remark: '内部备注' } - assert.equal(manualCopyText('2026-10-26', [record]), '2026-10-26 派车计划\n ·皖H12345\n 液碱') + assert.equal( + manualCopyText('2026-10-26', [record]), + '2026-10-26 派车计划:\n\n液碱1车\n ·皖H12345 液碱' + ) } }) @@ -192,3 +196,64 @@ test('单据类型选填且最多50个字符', () => { /单据类型.*50/ ) }) + +test('现场按产品分组统计,交错产品集中展示且符合指定复制样例', () => { + const site = ( + id: string, + productId: string, + productName: string, + licensePlate: string, + quantity: string + ) => ({ + ...row(id, '2026-11-01', licensePlate), + calendarKind: 'site' as const, + productId, + productName, + licensePlate, + quantity, + documentType: '出库计划', + remark: '内部备注' + }) + const records = [ + site('1', 'p98', '98% 工业硫酸', '皖H10291', '32'), + site('2', 'pure', '精制硫酸', '皖H57837', '32'), + site('4', 'p50', '50% 工业硫酸', '皖HA3690', '28'), + site('3', 'pure', '精制硫酸', '皖HA3190', '32') + ] + assert.equal( + manualCopyText('2026-11-01', records), + [ + '2026-11-01 派车计划:', + '', + '98% 工业硫酸1车', + ' ·皖H10291 98% 工业硫酸 32吨(出库计划)', + '精制硫酸2车', + ' ·皖H57837 精制硫酸 32吨(出库计划)', + ' ·皖HA3190 精制硫酸 32吨(出库计划)', + '50% 工业硫酸1车', + ' ·皖HA3690 50% 工业硫酸 28吨(出库计划)' + ].join('\n') + ) + assert.deepEqual( + records.map((r) => r.id), + ['1', '2', '4', '3'] + ) +}) + +test('现场同车重复记录逐条计车,同名不同产品ID保持独立且组内按创建顺序', () => { + const first = { ...row('1'), calendarKind: 'site' as const, createTime: '2026-10-26 01:00:00' } + const later = { ...first, id: '2', createTime: '2026-10-26 02:00:00', quantity: '20' } + const different = { ...first, id: '3', productId: 'p2' } + assert.equal( + manualCopyText('2026-10-26', [later, different, first]), + [ + '2026-10-26 派车计划:', + '', + '液碱2车', + ' ·皖H12345 液碱', + ' ·皖H12345 液碱 20吨', + '液碱1车', + ' ·皖H12345 液碱' + ].join('\n') + ) +}) diff --git a/web-admin/scripts/test-manual-dispatch-calendar.mjs b/web-admin/scripts/test-manual-dispatch-calendar.mjs index fd0cb18a6..9186d94e4 100644 --- a/web-admin/scripts/test-manual-dispatch-calendar.mjs +++ b/web-admin/scripts/test-manual-dispatch-calendar.mjs @@ -3,6 +3,7 @@ import { chromium } from 'playwright' const browser = await chromium.launch({ headless: true, channel: 'chrome' }) try { for (const { mobile, kind, title } of [ + { mobile: true, kind: 'site', title: '现场日历' }, { mobile: false, kind: 'acid', title: '酸派车日历' }, { mobile: false, kind: 'alkali', title: '碱派车日历' }, { mobile: false, kind: 'other', title: '其他日历' }, @@ -101,7 +102,12 @@ try { } const body = req.postDataJSON(), id = req.method() === 'PUT' ? path.split('/').pop() : String(writes) - const row = { ...body, id, licensePlate: '皖H12345', productName: '液碱' } + const row = { + ...body, + id, + licensePlate: body.vehicleId === '__pending_vehicle__' ? '待派车' : '皖H12345', + productName: '液碱' + } rows = rows.filter((r) => r.id !== id).concat(row) return route.fulfill({ json: row }) } @@ -173,8 +179,12 @@ try { const editor = page.locator('[data-testid="manual-editor"]:visible') if (mobile) { await editor.getByPlaceholder('选择车辆').click() + assert.equal( + await page.locator('.mp-manual-picker-list button').first().innerText(), + '待派车' + ) await page - .getByRole('button', { name: '皖H12345', exact: true }) + .getByRole('button', { name: '待派车', exact: true }) .click({ timeout: 8000 }) .catch(async (e) => { console.log('PICKER', await page.locator('.mp-manual-sheet:visible').allTextContents()) @@ -185,14 +195,21 @@ try { await page.getByRole('button', { name: '液碱', exact: true }).click() } else { await editor.getByRole('combobox', { name: '车牌号', exact: true }).click() - await page.getByRole('option', { name: '皖H12345', exact: true }).click() + assert.equal( + await page.locator('.el-select-dropdown:visible [role=option]').first().innerText(), + '待派车' + ) + await page.getByRole('option', { name: '待派车', exact: true }).click() await editor.getByRole('combobox', { name: '产品名称', exact: true }).click() await page.getByRole('option', { name: '液碱', exact: true }).click() } - if (kind === 'site') { + if (kind === 'site' && mobile) { + await editor.getByLabel('选择单据类型', { exact: true }).click() + await page.getByRole('button', { name: '直销计划', exact: true }).click() + } else if (kind === 'site') { await editor.getByRole('combobox', { name: '单据类型', exact: true }).click() await page.getByRole('option', { name: '出库计划', exact: true }).waitFor() - await page.getByRole('option', { name: '入库计划', exact: true }).click() + await page.getByRole('option', { name: '直销计划', exact: true }).click() } else { assert.equal(await editor.getByRole('combobox', { name: '单据类型', exact: true }).count(), 0) } @@ -200,16 +217,33 @@ try { await editor.getByRole('button', { name: '保存', exact: true }).click() await editor.waitFor({ state: 'hidden' }) assert.equal(rows.length, 1) + assert.equal(rows[0].vehicleId, '__pending_vehicle__') + assert.equal(rows[0].licensePlate, '待派车') assert.equal(rows[0].origin, '') assert.equal(rows[0].destination, '') assert.equal(rows[0].calendarKind, kind) - if (kind === 'site') assert.equal(rows[0].documentType, '入库计划') + if (kind === 'site') assert.equal(rows[0].documentType, '直销计划') await page.locator('[data-testid="manual-record"]').first().click() if (kind === 'site') - await page.locator('.el-dialog:visible').getByText('入库计划', { exact: true }).waitFor() + await page + .locator(mobile ? '.mp-manual-detail' : '.el-dialog:visible') + .getByText('直销计划', { exact: true }) + .waitFor() await page.getByRole('button', { name: '编辑', exact: true }).click() - if (kind === 'site') { - assert.match(await editor.innerText(), /入库计划/) + if (mobile) { + assert.equal(await editor.getByPlaceholder('选择车辆').inputValue(), '待派车') + await editor.getByPlaceholder('选择车辆').click() + await page.getByRole('button', { name: '皖H12345', exact: true }).click() + } else { + assert.match(await editor.innerText(), /待派车/) + await editor.getByRole('combobox', { name: '车牌号', exact: true }).click() + await page.getByRole('option', { name: '皖H12345', exact: true }).click() + } + if (kind === 'site' && mobile) { + assert.equal(await editor.getByPlaceholder('选择或输入单据类型').inputValue(), '直销计划') + await editor.getByPlaceholder('选择或输入单据类型').fill('现场调拨') + } else if (kind === 'site') { + assert.match(await editor.innerText(), /直销计划/) await editor.getByRole('combobox', { name: '单据类型', exact: true }).fill('现场调拨') await page.getByRole('option', { name: '现场调拨', exact: true }).click() } @@ -232,8 +266,9 @@ try { assert.match(await card.innerText(), /山峡 → 云栋/) if (kind === 'site') { assert.equal(rows[0].documentType, '现场调拨') - assert.equal(await card.locator('.route').innerText(), '山峡 → 云栋(现场调拨)') + assert.equal(await card.locator('.route').innerText(), '山峡 → 云栋') assert.equal(await card.locator('.el-tag').count(), 0) + assert.doesNotMatch(await card.innerText(), /现场调拨/) } await page .locator('[data-date="2026-10-26"]') @@ -241,7 +276,7 @@ try { .click() const copiedText = await page.evaluate(() => navigator.clipboard.readText()) if (kind === 'site') { - assert.equal(copiedText, '2026-10-26 派车计划\n ·皖H12345\n 液碱(现场调拨)') + assert.equal(copiedText, '2026-10-26 派车计划:\n\n液碱1车\n ·皖H12345 液碱(现场调拨)') } else { assert.match(copiedText, /皖H12345.*液碱.*山峡 → 云栋/s) } @@ -252,10 +287,7 @@ try { await editor.getByRole('button', { name: '保存', exact: true }).click() await editor.waitFor({ state: 'hidden' }) if (kind === 'site') assert.equal(rows[0].documentType, '现场调拨') - await page - .getByText(kind === 'site' ? '山峡 → 安庆(现场调拨)' : '山峡 → 安庆', { exact: true }) - .first() - .waitFor() + await page.getByText('山峡 → 安庆', { exact: true }).first().waitFor() await page.locator('[data-testid="manual-record"]').first().click() await page.getByRole('button', { name: '删除', exact: true }).click() await page.getByRole('button', { name: mobile ? '确认删除' : '确定', exact: true }).click() @@ -305,14 +337,24 @@ try { }) await page.getByRole('tab', { name: title, exact: true }).click() await page.locator('.pair-trip').nth(1).waitFor() - assert.match(await page.locator('.pair-trip').nth(0).innerText(), /山峡 → 云栋(出入库计划)/) - assert.match(await page.locator('.pair-trip').nth(1).innerText(), /山峡 → 云栋(入库计划)/) + assert.match(await page.locator('.pair-trip').nth(0).innerText(), /山峡 → 云栋/) + assert.match(await page.locator('.pair-trip').nth(1).innerText(), /山峡 → 云栋/) assert.equal(await page.locator('.manual-day-content .el-tag').count(), 0) + assert.doesNotMatch( + await page.locator('.manual-day-content').innerText(), + /出入库计划|入库计划/ + ) await page.locator('[data-testid="manual-record"]').first().click() await page.getByText('自动同步,请在来源派车日历修改或删除', { exact: true }).waitFor() assert.equal(await page.getByRole('button', { name: '编辑', exact: true }).count(), 0) assert.equal(await page.getByRole('button', { name: '删除', exact: true }).count(), 0) - await page.locator('.el-dialog:visible .el-dialog__headerbtn').click() + await page + .locator( + mobile + ? '.van-popup:visible .van-popup__close-icon' + : '.el-dialog:visible .el-dialog__headerbtn' + ) + .click() await page.getByRole('tab', { name: '实际派车信息', exact: true }).click() allowManage = false delete rows[0].sourceRecordId diff --git a/web-admin/scripts/test-site-dispatch-calendar-readonly.mjs b/web-admin/scripts/test-site-dispatch-calendar-readonly.mjs new file mode 100644 index 000000000..84ddfbe24 --- /dev/null +++ b/web-admin/scripts/test-site-dispatch-calendar-readonly.mjs @@ -0,0 +1,182 @@ +import assert from 'node:assert/strict' +import { chromium } from 'playwright' +const browser = await chromium.launch({ headless: true, channel: 'chrome' }) +try { + for (const allPermissions of [false, true]) { + const context = await browser.newContext({ + permissions: ['clipboard-read', 'clipboard-write'], + viewport: { width: 375, height: 812 } + }) + const page = await context.newPage() + // 固定起始日期但保持时钟前进;冻结 Date.now 会触发 Vue 嵌套点击事件的时间戳保护。 + await page.clock.install({ time: new Date('2026-10-26T04:00:00Z') }) + page.setDefaultTimeout(20000) + page.on('console', (msg) => { + if (msg.type() === 'error') console.log('CONSOLE', msg.text()) + }) + page.on('requestfailed', (r) => console.log('FAILED', r.url(), r.failure())) + const errors = [] + page.on('pageerror', (e) => errors.push(e.message)) + const rows = [ + { + id: 'site1', + dispatchDate: '2026-10-26', + calendarKind: 'site', + vehicleId: 'v1', + licensePlate: '皖H12345', + productId: 'p1', + productName: '液碱', + origin: '山峡', + destination: '武汉', + quantity: '32', + documentType: '出库计划', + remark: '' + }, + { + id: 'site2', + dispatchDate: '2026-10-26', + calendarKind: 'site', + sourceRecordId: 'source2', + vehicleId: 'v2', + licensePlate: '皖H54321', + productId: 'p1', + productName: '液碱', + origin: '武汉', + destination: '云栋', + quantity: '28', + documentType: '入库计划', + remark: '' + } + ] + const forbiddenRequests = [] + let lastMonth = '' + await context.addInitScript(() => { + localStorage.setItem( + 'sys-v3.0.2-user', + JSON.stringify({ + isLogin: true, + accessToken: 'fixture', + info: { + id: 'fixture', + userId: 'fixture', + userName: '测试', + role: 'admin', + roles: ['admin'] + } + }) + ) + localStorage.setItem('sys-version', '3.0.2') + }) + await context.route('**/api/**', async (route) => { + const req = route.request(), + url = new URL(req.url()), + path = url.pathname.slice(4) + if (!url.pathname.startsWith('/api/')) return route.continue() + const user = { + id: 'fixture', + userId: 'fixture', + userName: '测试', + role: 'admin', + roles: ['admin'] + } + if (path === '/web/auth/mp-webview-exchange') + return route.fulfill({ + json: { + code: 200, + data: { token: 'fixture', refreshToken: 'refresh', expiresIn: 3600, userInfo: user } + } + }) + if (path === '/web/auth/current-user') + return route.fulfill({ json: { code: 200, data: user } }) + if (path === '/web/auth/menu-codes') + return route.fulfill({ + json: { + code: 200, + data: { + all: allPermissions, + menuCodes: ['mini:pl:site-dispatch-calendar:list'] + } + } + }) + if (path === '/transport/site-dispatch-calendar' && req.method() === 'GET') { + lastMonth = url.searchParams.get('month') + assert.equal(url.searchParams.has('calendarKind'), false) + const list = rows.filter((row) => row.dispatchDate.startsWith(lastMonth)) + return route.fulfill({ json: { list, total: list.length } }) + } + if (path.startsWith('/transport/')) { + forbiddenRequests.push(req.method() + ' ' + path) + return route.fulfill({ status: 403, json: { detail: 'forbidden' } }) + } + return route.fulfill({ + json: { + code: 200, + data: path.includes('favorites') || path.includes('upgrade-logs') ? [] : {} + } + }) + }) + await page.goto('http://localhost:3006/#/m/pl/site-dispatch-calendar?mpTicket=readonly-test') + await page.getByText('现场派车日历', { exact: true }).waitFor() + await page.locator('[data-testid="manual-record"]').nth(1).waitFor() + assert.equal(await page.getByRole('tab').count(), 0) + assert.equal(await page.getByRole('button', { name: /新增/ }).count(), 0) + assert.equal(await page.locator('[data-testid="manual-editor"]').count(), 0) + for (let index = 0; index < 2; index++) { + await page.locator('[data-testid="manual-record"]').nth(index).click() + await page.getByText('派车计划详情', { exact: true }).waitFor() + assert.equal(await page.getByRole('button', { name: '编辑', exact: true }).count(), 0) + assert.equal(await page.getByRole('button', { name: '删除', exact: true }).count(), 0) + await page.locator('.van-popup:visible .van-popup__close-icon').click() + } + await page + .locator('[data-date="2026-10-26"]') + .getByRole('button', { name: '复制', exact: true }) + .click() + assert.equal( + await page.evaluate(() => navigator.clipboard.readText()), + '2026-10-26 派车计划:\n\n液碱2车\n ·皖H12345 液碱 32吨(出库计划)\n ·皖H54321 液碱 28吨(入库计划)' + ) + await page.getByLabel('车辆筛选', { exact: true }).click() + let vehiclePicker = page.locator('.mp-calendar-vehicle-picker:visible') + await vehiclePicker.getByText('皖H54321', { exact: true }).click() + await vehiclePicker.getByRole('button', { name: '确认', exact: true }).click() + await page + .locator('[data-testid="manual-record"]') + .filter({ hasText: '皖H12345' }) + .waitFor({ state: 'hidden' }) + assert.equal(await page.locator('[data-testid="manual-record"]').count(), 1) + await page + .locator('[data-date="2026-10-26"]') + .getByRole('button', { name: '复制', exact: true }) + .click() + assert.equal( + await page.evaluate(() => navigator.clipboard.readText()), + '2026-10-26 派车计划:\n\n液碱1车\n ·皖H54321 液碱 28吨(入库计划)' + ) + await page.getByLabel('车辆筛选', { exact: true }).click() + vehiclePicker = page.locator('.mp-calendar-vehicle-picker:visible') + await vehiclePicker.getByText('全部车辆', { exact: true }).click() + await vehiclePicker.getByRole('button', { name: '确认', exact: true }).click() + await page.getByLabel('查看月份', { exact: true }).click() + const popup = page.locator('.mp-calendar-date-picker:visible') + await popup.locator('.van-picker-column').nth(1).getByText('09', { exact: true }).click() + await popup.getByRole('button', { name: '确认', exact: true }).click() + await page.getByText('本月暂无现场派车计划', { exact: true }).waitFor() + assert.equal(lastMonth, '2026-09') + await page.getByRole('button', { name: '今天', exact: true }).click() + await page.locator('[data-testid="manual-record"]').nth(1).waitFor() + for (const width of [320, 375, 414]) { + await page.setViewportSize({ width, height: 812 }) + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)) + } + await page.screenshot({ path: `/tmp/site-readonly-${allPermissions ? 'admin' : 'staff'}.png` }) + assert.deepEqual(forbiddenRequests, []) + assert.deepEqual(errors, []) + console.log( + `readonly site ${allPermissions ? 'all permissions' : 'site permission only'} passed` + ) + await context.close() + } +} finally { + await browser.close() +} diff --git a/web-admin/src/api/pl/dispatch-calendar.ts b/web-admin/src/api/pl/dispatch-calendar.ts index 8d70c2bb6..1c22084d4 100644 --- a/web-admin/src/api/pl/dispatch-calendar.ts +++ b/web-admin/src/api/pl/dispatch-calendar.ts @@ -40,6 +40,10 @@ export interface CalendarPageQuery { } const base = '/transport/dispatch-calendar' export const dispatchCalendarApi = { + siteList: (params: CalendarPageQuery & { month: string; vehicleId?: string }) => + request + .get('/transport/site-dispatch-calendar', { params }) + .then(pickBxData>), list: ( params: CalendarPageQuery & { month: string diff --git a/web-admin/src/router/routes/staticRoutes.ts b/web-admin/src/router/routes/staticRoutes.ts index 0ba582664..5beffa9d7 100644 --- a/web-admin/src/router/routes/staticRoutes.ts +++ b/web-admin/src/router/routes/staticRoutes.ts @@ -129,6 +129,12 @@ export const staticRoutes: AppRouteRecordRaw[] = [ component: () => import('@views/public/duty/index.vue'), meta: { title: '值班抢班', isHideTab: true, isPublicMobile: true } }, + { + path: '/m/pl/site-dispatch-calendar', + name: 'MpSiteDispatchCalendar', + component: () => import('@views/m/pl/site-dispatch-calendar/index.vue'), + meta: { title: '现场派车日历', isHideTab: true, isMpWebview: true } + }, { path: '/m/pl/dispatch-calendar', name: 'MpDispatchCalendar', diff --git a/web-admin/src/views/m/pl/dispatch-calendar/ManualCalendar.vue b/web-admin/src/views/m/pl/dispatch-calendar/ManualCalendar.vue index c3ac986fa..768094fcb 100644 --- a/web-admin/src/views/m/pl/dispatch-calendar/ManualCalendar.vue +++ b/web-admin/src/views/m/pl/dispatch-calendar/ManualCalendar.vue @@ -25,9 +25,9 @@ >
正在加载{{ title }}
+ + + +

派车计划详情

+ + (), - { calendarKind: 'acid', title: '酸派车日历' } + defineProps<{ calendarKind?: DispatchCalendarKind; title?: string; siteReadOnly?: boolean }>(), + { calendarKind: 'acid', title: '酸派车日历', siteReadOnly: false } ) const { month, @@ -309,7 +348,11 @@ remove, copyDay, locate - } = useManualCalendar({ success: showSuccessToast, error: showFailToast }, props.calendarKind) + } = useManualCalendar( + { success: showSuccessToast, error: showFailToast }, + props.calendarKind, + props.siteReadOnly + ) const filterHeader = ref() const { height: filterHeight } = useElementSize(filterHeader) const viewRow = ref() @@ -319,6 +362,11 @@ if (!value) viewRow.value = undefined } }) + const documentTypeOpen = ref(false) + function selectDocumentType(action: { name: string }) { + if (saving.value || sessionChanged.value) return + draft.documentType = action.name + } const picker = ref<'vehicle' | 'product'>('vehicle') const pickerOpen = ref(false) const pickerSelectedId = computed(() => @@ -370,11 +418,12 @@ void scrollSelected() } function editViewed() { - if (viewRow.value) openEdit(viewRow.value) + if (!viewRow.value || viewRow.value.sourceRecordId) return + openEdit(viewRow.value) viewRow.value = undefined } async function deleteViewed() { - if (!viewRow.value) return + if (!viewRow.value || viewRow.value.sourceRecordId) return const row = viewRow.value try { await showConfirmDialog({ @@ -392,6 +441,7 @@ if (!value) void scrollSelected() }) watch(sessionChanged, () => { + documentTypeOpen.value = false pickerOpen.value = false dateOpen.value = false viewRow.value = undefined diff --git a/web-admin/src/views/m/pl/dispatch-calendar/index.vue b/web-admin/src/views/m/pl/dispatch-calendar/index.vue index e3da5ef5a..428916341 100644 --- a/web-admin/src/views/m/pl/dispatch-calendar/index.vue +++ b/web-admin/src/views/m/pl/dispatch-calendar/index.vue @@ -2,18 +2,36 @@
- - + + +
@@ -26,6 +44,7 @@ import ManualCalendar from './ManualCalendar.vue' import ActualCalendar from './ActualCalendar.vue' defineOptions({ name: 'MpDispatchCalendar' }) + withDefaults(defineProps<{ siteReadOnly?: boolean }>(), { siteReadOnly: false }) const header = ref() const { height: headerHeight } = useElementSize(header) const ready = ref(false) diff --git a/web-admin/src/views/m/pl/site-dispatch-calendar/index.vue b/web-admin/src/views/m/pl/site-dispatch-calendar/index.vue new file mode 100644 index 000000000..f29295478 --- /dev/null +++ b/web-admin/src/views/m/pl/site-dispatch-calendar/index.vue @@ -0,0 +1,7 @@ + + diff --git a/web-admin/src/views/pl/dispatch-calendar/ManualCalendar.vue b/web-admin/src/views/pl/dispatch-calendar/ManualCalendar.vue index 02c509dc7..f215e74f7 100644 --- a/web-admin/src/views/pl/dispatch-calendar/ManualCalendar.vue +++ b/web-admin/src/views/pl/dispatch-calendar/ManualCalendar.vue @@ -181,7 +181,7 @@ - + + - 1车 - {{ group.rows[0].origin }} → {{ group.rows[0].destination - }} + {{ group.rows[0].origin }} → {{ group.rows[0].destination }} {{ group.rows[0].remark }}
@@ -63,10 +58,7 @@ >{{ ' ' + manualQuantityLabel(row.quantity) }} - · {{ row.origin }} → {{ row.destination - }} {{ row.remark }} diff --git a/web-admin/src/views/pl/dispatch-calendar/manual-model.ts b/web-admin/src/views/pl/dispatch-calendar/manual-model.ts index 1c365a3bb..2094439a9 100644 --- a/web-admin/src/views/pl/dispatch-calendar/manual-model.ts +++ b/web-admin/src/views/pl/dispatch-calendar/manual-model.ts @@ -99,6 +99,26 @@ function manualTripText(row: DispatchCalendarRecord) { } export function manualCopyText(date: string, rows: DispatchCalendarRecord[]) { + if (rows.length && rows.every((row) => row.calendarKind === 'site')) { + // 与日卡汇总一致:按产品 ID 分组,每条计划计一车,同车多趟不合并。 + const products = new Map() + for (const row of rows) { + if (!products.has(row.productId)) { + products.set(row.productId, { name: row.productName, rows: [] }) + } + products.get(row.productId)!.rows.push(row) + } + return [ + `${date} 派车计划:`, + '', + ...[...products.values()].flatMap((group) => [ + `${group.name}${group.rows.length}车`, + ...group.rows + .sort(byCreateOrder) + .map((row) => ` ·${row.licensePlate} ${manualTripText(row)}`) + ]) + ].join('\n') + } return [ `${date} 派车计划`, ...groupByVehicle(rows).flatMap((group) => [ diff --git a/web-admin/src/views/pl/dispatch-calendar/useManualCalendar.ts b/web-admin/src/views/pl/dispatch-calendar/useManualCalendar.ts index 895b8d403..67f6fb9c7 100644 --- a/web-admin/src/views/pl/dispatch-calendar/useManualCalendar.ts +++ b/web-admin/src/views/pl/dispatch-calendar/useManualCalendar.ts @@ -17,12 +17,19 @@ import { } from './manual-model' import { copyTextToClipboard } from '@/utils/clipboard' +// 日历专用占位值,与后端 PENDING_VEHICLE_ID 保持一致,不创建车辆档案。 +const PENDING_VEHICLE: CalendarVehicleOption = { + id: '__pending_vehicle__', + licensePlate: '待派车' +} + export function useManualCalendar( feedback: { success: (message: string) => void; error: (message: string) => void } = { success: (message) => ElMessage.success(message), error: (message) => ElMessage.error(message) }, - calendarKind: DispatchCalendarKind = 'acid' + calendarKind: DispatchCalendarKind = 'acid', + siteReadOnly = false ) { const user = useUserStore() const sessionKey = () => JSON.stringify([user.accessToken, user.getUserInfo]) @@ -77,7 +84,10 @@ export function useManualCalendar( records.value = [] try { const data = await loadCalendarPages( - (query) => dispatchCalendarApi.list({ ...query, month: month.value, calendarKind }), + (query) => + siteReadOnly + ? dispatchCalendarApi.siteList({ ...query, month: month.value }) + : dispatchCalendarApi.list({ ...query, month: month.value, calendarKind }), current ) if (data && current()) records.value = data @@ -88,7 +98,7 @@ export function useManualCalendar( } } async function loadOptions() { - if (!live() || optionsLoading.value) return + if (siteReadOnly || !live() || optionsLoading.value) return optionsLoading.value = true optionsError.value = '' try { @@ -97,7 +107,7 @@ export function useManualCalendar( loadCalendarPages(dispatchCalendarApi.products, live) ]) if (live()) { - vehicles.value = v || [] + vehicles.value = [PENDING_VEHICLE, ...(v || [])] products.value = p || [] } } catch { @@ -107,14 +117,14 @@ export function useManualCalendar( } } function openCreate(date = selectedDate.value || `${month.value}-01`) { - if (!canManage.value || !live() || saving.value) return + if (siteReadOnly || !canManage.value || !live() || saving.value) return editingId.value = '' Object.assign(draft, blank(date)) formError.value = '' editorOpen.value = true } function openEdit(row: DispatchCalendarRecord) { - if (!canManage.value || !live() || saving.value || row.sourceRecordId) return + if (siteReadOnly || !canManage.value || !live() || saving.value || row.sourceRecordId) return editingId.value = row.id Object.assign(draft, { dispatchDate: row.dispatchDate, @@ -131,7 +141,7 @@ export function useManualCalendar( editorOpen.value = true } async function save() { - if (!live() || !canManage.value || saving.value) return + if (siteReadOnly || !live() || !canManage.value || saving.value) return formError.value = validateManualDraft(draft) if (formError.value) return saving.value = true @@ -160,7 +170,8 @@ export function useManualCalendar( } } async function remove(row: DispatchCalendarRecord) { - if (!live() || !canManage.value || saving.value || row.sourceRecordId) return false + if (siteReadOnly || !live() || !canManage.value || saving.value || row.sourceRecordId) + return false saving.value = true try { await dispatchCalendarApi.delete(row.id) @@ -212,6 +223,7 @@ export function useManualCalendar( ) onMounted(() => { void load() + if (siteReadOnly) return void loadOptions() void fetchGetMenuCodes() .then((perm) => {