From fcbf8a9b65390643b573725d64f3c980dbdf3303 Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Fri, 30 Oct 2026 14:04:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=BA=E7=8E=B0=E5=9C=BA=E6=97=A5?= =?UTF-8?q?=E5=8E=86=E5=A2=9E=E5=8A=A0=E5=8D=95=E6=8D=AE=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=B9=B6=E5=90=8C=E6=AD=A5=E6=B4=BE=E8=BD=A6=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=EF=BC=8C=E5=87=8F=E5=B0=91=E9=87=8D=E5=A4=8D=E5=BD=95=E5=85=A5?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按起止地关键词生成现场出入库计划,同步来源修改和删除,保留历史记录不补录。调整卡片及复制文本的单据类型格式,并记录测试库和生产库迁移结果。 --- backend/app/models/pl_dispatch_calendar.py | 7 +- backend/app/schemas/pl/dispatch_calendar.py | 1 + .../services/pl_dispatch_calendar_service.py | 134 +++++++-- backend/tests/test_pl_dispatch_calendar.py | 39 ++- .../test_pl_dispatch_calendar_site_sync.py | 265 ++++++++++++++++++ ...-10-30-dispatch-calendar-document-type.sql | 19 ++ ...2026-10-30-dispatch-calendar-site-sync.sql | 41 +++ docs/sql/shanxia2.0.sql | 6 +- .../2026-10-30-dispatch-calendar-site-sync.md | 58 ++++ ...0-30-dispatch-calendar-site-sync-design.md | 28 ++ .../scripts/manual-dispatch-calendar.test.ts | 44 +++ .../scripts/test-manual-dispatch-calendar.mjs | 64 ++++- web-admin/src/api/pl/dispatch-calendar.ts | 2 + .../pl/dispatch-calendar/ManualCalendar.vue | 35 ++- .../pl/dispatch-calendar/ManualDayContent.vue | 12 +- .../pl/dispatch-calendar/manual-model.ts | 12 +- .../pl/dispatch-calendar/useManualCalendar.ts | 7 +- 17 files changed, 732 insertions(+), 42 deletions(-) create mode 100644 backend/tests/test_pl_dispatch_calendar_site_sync.py create mode 100644 docs/sql/migrations/2026-10-30-dispatch-calendar-document-type.sql create mode 100644 docs/sql/migrations/2026-10-30-dispatch-calendar-site-sync.sql create mode 100644 docs/superpowers/plans/2026-10-30-dispatch-calendar-site-sync.md create mode 100644 docs/superpowers/specs/2026-10-30-dispatch-calendar-site-sync-design.md diff --git a/backend/app/models/pl_dispatch_calendar.py b/backend/app/models/pl_dispatch_calendar.py index 421c74092..c63888fc1 100644 --- a/backend/app/models/pl_dispatch_calendar.py +++ b/backend/app/models/pl_dispatch_calendar.py @@ -1,7 +1,7 @@ """独立手工派车记录,与运输计划无写入联动。""" from datetime import datetime -from sqlalchemy import Column, Date, DateTime, Index, Integer, SmallInteger, String +from sqlalchemy import Column, Date, DateTime, Index, Integer, SmallInteger, String, UniqueConstraint from app.core.database import Base @@ -11,6 +11,7 @@ class PlDispatchCalendar(Base): __table_args__ = ( Index("idx_dispatch_calendar_month", "tenant_no", "del_flag", "dispatch_date"), Index("idx_dispatch_calendar_vehicle", "tenant_no", "vehicle_id", "dispatch_date"), + UniqueConstraint("tenant_no", "source_record_id", name="uk_dispatch_calendar_source"), ) id = Column(String(64), primary_key=True, comment="主键UUID") @@ -26,6 +27,10 @@ class PlDispatchCalendar(Base): String(16), nullable=False, default="acid", server_default="acid", comment="日历类型:acid酸派车 alkali碱派车 other其他 site现场", ) + document_type = Column(String(50), nullable=True, comment="单据类型(现场日历,支持自定义)") + site_sync_enabled = Column(SmallInteger, nullable=False, default=0, server_default="0", + comment="是否启用现场同步:仅新建来源置1,历史为0") + source_record_id = Column(String(64), nullable=True, comment="自动现场记录的来源派车ID,手工记录为空") tenant_no = Column(String(64), nullable=False, comment="租户编号") order_num = Column(Integer, nullable=True, comment="排序号") remark = Column(String(500), nullable=True, comment="备注") diff --git a/backend/app/schemas/pl/dispatch_calendar.py b/backend/app/schemas/pl/dispatch_calendar.py index dc16043be..728b53e01 100644 --- a/backend/app/schemas/pl/dispatch_calendar.py +++ b/backend/app/schemas/pl/dispatch_calendar.py @@ -16,6 +16,7 @@ class CreateBody(BaseModel): destination: str = Field(default="", max_length=255) quantity: Optional[str] = Field(default=None, max_length=16, description="数量文本,单位吨,最多两位小数;不参与汇总") calendar_kind: str = Field(default="acid", alias="calendarKind", min_length=1, max_length=16) + document_type: Optional[str] = Field(default=None, alias="documentType", max_length=50) remark: Optional[str] = Field(default=None, max_length=500) @field_validator("dispatch_date", mode="before") diff --git a/backend/app/services/pl_dispatch_calendar_service.py b/backend/app/services/pl_dispatch_calendar_service.py index 6b32e71a8..2d2cb82a9 100644 --- a/backend/app/services/pl_dispatch_calendar_service.py +++ b/backend/app/services/pl_dispatch_calendar_service.py @@ -1,9 +1,11 @@ """手工派车 CRUD:租户、角色数据范围和关联主数据校验。""" import re from datetime import date +from functools import wraps from typing import Optional from fastapi import HTTPException +from sqlalchemy.exc import OperationalError from sqlalchemy.orm import Session from app.core.db_auth import DbUserContext @@ -36,6 +38,8 @@ def _row_to_dict(row: PlDispatchCalendar) -> dict: "destination": row.destination, "quantity": row.quantity, "calendarKind": row.calendar_kind or "acid", + "documentType": row.document_type, + "sourceRecordId": row.source_record_id, "remark": row.remark, "createTime": row.create_time, "updateTime": row.update_time, @@ -73,8 +77,13 @@ def list_records(db: Session, tenant_no: str, user: DbUserContext, *, month: str return {"list": [_row_to_dict(row) for row in rows], "total": total} -def get_record(db: Session, tenant_no: str, user: DbUserContext, record_id: str) -> PlDispatchCalendar: - row = _query(db, tenant_no, user).filter(PlDispatchCalendar.id == record_id).first() +def get_record(db: Session, tenant_no: str, user: DbUserContext, record_id: str, + *, for_update: bool = False) -> PlDispatchCalendar: + query = _query(db, tenant_no, user).filter(PlDispatchCalendar.id == record_id) + if for_update: + # 来源行锁串行化同一来源的更新/删除;刷新会话内缓存,避免并发覆盖关联状态。 + query = query.populate_existing().with_for_update() + row = query.first() if row is None: raise HTTPException(status_code=404, detail="派车记录不存在或无权访问") return row @@ -94,38 +103,127 @@ def _resolve_snapshots(db: Session, tenant_no: str, user: DbUserContext, body: C return {"license_plate": vehicle.license_plate, "product_name": product.product_name} +def _site_document_type(origin: str, destination: str) -> Optional[str]: + outbound = any(word in (origin or "") for word in ("山峡", "云栋")) + inbound = any(word in (destination or "") for word in ("山峡", "云栋")) + if outbound and inbound: + return "出入库计划" + if outbound: + return "出库计划" + if inbound: + return "入库计划" + return None + + +def _sync_site_record(db: Session, tenant_no: str, user: DbUserContext, + source: PlDispatchCalendar) -> None: + """在来源事务和行锁内维护唯一现场记录;不为历史来源补录。""" + if not source.site_sync_enabled or source.calendar_kind not in ("acid", "alkali", "other"): + return + # 包含已软删除关联行,以便重新命中时恢复原 ID 和创建信息。 + query = db.query(PlDispatchCalendar).filter( + PlDispatchCalendar.tenant_no == tenant_no, + PlDispatchCalendar.source_record_id == source.id, + PlDispatchCalendar.calendar_kind == "site", + ) + target = apply_pl_scope(query, PlDispatchCalendar, user, db, tenant_no).populate_existing().with_for_update().first() + document_type = None if source.del_flag == 1 else _site_document_type(source.origin, source.destination) + if document_type is None: + if target is not None and target.del_flag != 1: + target.del_flag = 1 + target.update_by = user.id + return + if target is None: + target = PlDispatchCalendar( + id=generate_uuid(), tenant_no=tenant_no, source_record_id=source.id, + calendar_kind="site", site_sync_enabled=0, create_by=source.create_by, + create_dept_id=source.create_dept_id, + ) + db.add(target) + for field in ("dispatch_date", "vehicle_id", "license_plate", "product_id", "product_name", + "origin", "destination", "quantity", "remark"): + setattr(target, field, getattr(source, field)) + target.document_type = document_type + target.del_flag = 0 + target.update_by = user.id + + +def _ensure_manual_record(row: PlDispatchCalendar) -> None: + if row.source_record_id: + raise HTTPException(status_code=409, detail="此现场记录由派车日历自动同步,请在来源派车日历修改或删除") + + +def _retry_deadlock(action): + """MySQL 间隙锁竞争可能选择当前事务为死锁牺牲者,回滚后完整重试。""" + @wraps(action) + def run(db: Session, *args, **kwargs): + for attempt in range(3): + try: + return action(db, *args, **kwargs) + except OperationalError as exc: + db.rollback() + code = exc.orig.args[0] if getattr(exc.orig, "args", ()) else None + if code != 1213 or attempt == 2: + raise + return run + + +@_retry_deadlock def create_record(db: Session, tenant_no: str, user: DbUserContext, body: CreateBody) -> PlDispatchCalendar: snapshots = _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, ) - db.add(row) - db.commit() - db.refresh(row) + try: + db.add(row) + db.flush() + _sync_site_record(db, tenant_no, user, row) + db.commit() + except Exception: + db.rollback() + raise + # ORM 默认值已在 flush/commit 时赋值;提交后不再查询,避免重试已提交的写入。 return row +@_retry_deadlock def update_record(db: Session, tenant_no: str, user: DbUserContext, record_id: str, body: UpdateBody) -> PlDispatchCalendar: - row = get_record(db, tenant_no, user, record_id) - 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)} - for key, value in values.items(): - setattr(row, key, value) - row.update_by = user.id - db.commit() - db.refresh(row) + try: + row = get_record(db, tenant_no, user, record_id, for_update=True) + _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)} + # 兼容未提交新字段的旧客户端;显式空值仍可清空。 + 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 + _sync_site_record(db, tenant_no, user, row) + db.commit() + except Exception: + db.rollback() + raise return row +@_retry_deadlock def delete_record(db: Session, tenant_no: str, user: DbUserContext, record_id: str) -> int: - row = get_record(db, tenant_no, user, record_id) - row.del_flag = 1 - row.update_by = user.id - db.commit() + try: + row = get_record(db, tenant_no, user, record_id, for_update=True) + _ensure_manual_record(row) + row.del_flag = 1 + row.update_by = user.id + _sync_site_record(db, tenant_no, user, row) + db.commit() + except Exception: + db.rollback() + raise return 1 diff --git a/backend/tests/test_pl_dispatch_calendar.py b/backend/tests/test_pl_dispatch_calendar.py index 020facfb8..a5d14661c 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', 'remark', 'createTime', 'updateTime'} + assert set(result) == {'id', 'dispatchDate', 'vehicleId', '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 @@ -168,6 +168,7 @@ def test_invalid_cross_tenant_and_deleted_relations_rejected(db, field, value): {'dispatchDate': '2026-02-30'}, {'dispatchDate': '1899-12-31'}, {'dispatchDate': '2101-01-01'}, {'dispatchDate': 1792972800}, {'dispatchDate': '2026-10-26T00:00:00'}, {'origin': 'x' * 256}, {'destination': 'x' * 256}, {'vehicleId': ''}, {'productId': ''}, {'remark': 'x' * 501}, {'calendarKind': 'salt'}, {'tenantNo': 't2'}, + {'sourceRecordId': '伪造'}, {'siteSyncEnabled': 1}, {'source_record_id': '伪造'}, {'site_sync_enabled': 1}, {'licensePlate': '伪造'}, {'productName': '伪造'}, {'createBy': 'other'}, ]) def test_schema_rejects_bad_fields_and_client_snapshots(bad): @@ -239,6 +240,19 @@ def test_http_read_only_manage_permissions_both_clients_and_routes(db, monkeypat assert other.status_code == 200 and other.json()['calendarKind'] == 'other' assert client.get(path, params={'month': '2026-10', 'calendarKind': 'other'}).json()['total'] == 1 assert client.put(f"{path}/{other.json()['id']}", json=payload(calendarKind='alkali')).status_code == 422 + for kind in ('acid', 'alkali', 'other'): + source = client.post(path, json=payload(calendarKind=kind, origin='山峡')).json() + sites = client.get(path, params={'month': '2026-10', 'calendarKind': 'site'}).json()['list'] + assert len(sites) == 1 and sites[0]['sourceRecordId'] == source['id'] + assert sites[0]['documentType'] == '出库计划' + site_path = f"{path}/{sites[0]['id']}" + assert client.put(site_path, json=payload(calendarKind='site')).status_code == 409 + assert client.delete(site_path).status_code == 409 + assert client.put(f"{path}/{source['id']}", json=payload( + calendarKind=kind, origin='山峡', destination='云栋')).status_code == 200 + assert client.get(site_path).json()['documentType'] == '出入库计划' + assert client.delete(f"{path}/{source['id']}").status_code == 200 + assert client.get(site_path).status_code == 404 engine.dispose() @@ -388,3 +402,26 @@ def test_calendar_addresses_optional_on_create_and_update(db, kind, addresses): omitted.pop('destination') body = schema(**omitted) assert body.origin == body.destination == '' + + +@pytest.mark.parametrize('document_type', ['入库计划', '出库计划', '客户现场调拨']) +def test_site_document_type_round_trip_and_omitted_update_preserves_value(db, document_type): + _, _, Update, svc = modules() + row = create(db, calendarKind='site', documentType=' ' + document_type + ' ') + original = (row.id, row.create_by, row.create_time) + assert svc._row_to_dict(row)['documentType'] == document_type + assert svc.list_records(db, 't1', user(), month='2026-10', calendar_kind='site')['list'][0]['documentType'] == document_type + row = svc.update_record(db, 't1', user(), row.id, Update(**payload(calendarKind='site', remark='只改备注'))) + assert row.document_type == document_type + assert (row.id, row.create_by, row.create_time) == original + row = svc.update_record(db, 't1', user(), row.id, Update(**payload(calendarKind='site', documentType='自定义类型'))) + assert row.document_type == '自定义类型' + row = svc.update_record(db, 't1', user(), row.id, Update(**payload(calendarKind='site', documentType=''))) + assert not row.document_type + + +def test_site_document_type_optional_and_length_limit(db): + _, Create, _, svc = modules() + assert not svc._row_to_dict(create(db, calendarKind='site'))['documentType'] + with pytest.raises(ValidationError): + Create(**payload(calendarKind='site', documentType='字' * 51)) diff --git a/backend/tests/test_pl_dispatch_calendar_site_sync.py b/backend/tests/test_pl_dispatch_calendar_site_sync.py new file mode 100644 index 000000000..55d2ce449 --- /dev/null +++ b/backend/tests/test_pl_dispatch_calendar_site_sync.py @@ -0,0 +1,265 @@ +"""来源派车与现场日历的匹配、关联生命周期和事务回归。""" +import pytest +from fastapi import HTTPException +from sqlalchemy import event +from sqlalchemy.exc import IntegrityError, OperationalError + +from test_pl_dispatch_calendar import db, create, modules, payload, user + + +@pytest.fixture(autouse=True) +def production_session_options(db): + db.autoflush = False + db.expire_on_commit = False + + +def site_rows(db, tenant='t1', caller=None, month='2026-10'): + return modules()[3].list_records(db, tenant, caller or user(tenant=tenant), + month=month, calendar_kind='site')['list'] + + +@pytest.mark.parametrize('kind', ['acid', 'alkali', 'other']) +@pytest.mark.parametrize('origin,destination,expected', [ + ('山峡化工仓库', '武汉', '出库计划'), + ('云栋厂区', '武汉', '出库计划'), + ('武汉', '山峡化工仓库', '入库计划'), + ('武汉', '云栋厂区', '入库计划'), + ('山峡仓库', '云栋厂区', '出入库计划'), + ('云栋一库', '云栋二库', '出入库计划'), + ('武汉', '安庆', None), + ('', '', None), +]) +def test_new_source_match_matrix(db, kind, origin, destination, expected): + source = create(db, calendarKind=kind, origin=origin, destination=destination, + quantity='32.50', remark='保持备注') + rows = site_rows(db) + assert len(rows) == (1 if expected else 0) + if expected: + target = rows[0] + assert target['id'] != source.id + assert target['sourceRecordId'] == source.id + assert target['documentType'] == expected + original = modules()[3]._row_to_dict(source) + for key in ('dispatchDate', 'vehicleId', 'licensePlate', 'productId', 'productName', + 'origin', 'destination', 'quantity', 'remark'): + assert target[key] == original[key] + + +def test_source_update_preserves_identity_and_soft_delete_lifecycle(db): + model, _, Update, svc = modules() + source = create(db, origin='山峡') + assert len(site_rows(db)) == 1 + target = db.get(model, site_rows(db)[0]['id']) + identity = (target.id, target.create_by, target.create_dept_id, target.create_time) + # 不变保存以及更换月份、主数据和类型,均为原地更新。 + for _ in range(2): + svc.update_record(db, 't1', user(), source.id, Update(**payload(origin='山峡'))) + assert len(site_rows(db)) == 1 + svc.update_record(db, 't1', user('u2'), source.id, Update(**payload( + dispatchDate='2026-11-01', vehicleId='v2', productId='p2', + origin='武汉', destination='云栋', quantity='20.00', remark='改备注'))) + assert site_rows(db) == [] + changed = site_rows(db, month='2026-11')[0] + assert changed['id'] == target.id and changed['documentType'] == '入库计划' + assert changed['licensePlate'] == '鄂E67890' and changed['productName'] == '盐酸' + assert changed['quantity'] == '20.00' and changed['remark'] == '改备注' + assert (target.id, target.create_by, target.create_dept_id, target.create_time) == identity + assert target.update_by == 'u2' + svc.update_record(db, 't1', user(), source.id, Update(**payload())) + assert site_rows(db) == [] and target.del_flag == 1 + svc.update_record(db, 't1', user(), source.id, + Update(**payload(origin='山峡', destination='云栋'))) + assert site_rows(db)[0]['id'] == target.id + assert target.del_flag == 0 and target.document_type == '出入库计划' + assert (target.id, target.create_by, target.create_dept_id, target.create_time) == identity + svc.delete_record(db, 't1', user(), source.id) + assert source.del_flag == target.del_flag == 1 + assert site_rows(db) == [] + assert db.query(model).count() == 2 + + +def test_new_nonmatching_source_can_match_later_but_history_never_backfills(db): + model, _, Update, svc = modules() + source = create(db) + assert site_rows(db) == [] + svc.update_record(db, 't1', user(), source.id, Update(**payload(destination='云栋'))) + assert len(site_rows(db)) == 1 + # 模拟迁移前已有记录,数据库新字段使用默认值;不依赖派车日期判断历史。 + historical = model(id='legacy', tenant_no='t1', calendar_kind='acid', + dispatch_date=source.dispatch_date, vehicle_id='v1', license_plate='鄂E12345', + product_id='p1', product_name='液碱', origin='山峡', destination='云栋', + create_by='u1', create_dept_id='d1') + db.add(historical) + db.commit() + svc.update_record(db, 't1', user(), historical.id, Update(**payload(origin='山峡', destination='云栋'))) + assert len(site_rows(db)) == 1 + assert all(row['sourceRecordId'] != historical.id for row in site_rows(db)) + svc.delete_record(db, 't1', user(), historical.id) + assert len(site_rows(db)) == 1 + + +def test_manual_site_is_independent_and_generated_site_is_read_only(db): + model, _, Update, svc = modules() + manual = create(db, calendarKind='site', origin='山峡', destination='云栋', documentType='自定义') + assert len(site_rows(db)) == 1 + create(db, origin='山峡') + assert len(site_rows(db)) == 2 + generated = next(row for row in site_rows(db) if row['id'] != manual.id) + for action in [lambda: svc.update_record(db, 't1', user(), generated['id'], + Update(**payload(calendarKind='site'))), + lambda: svc.delete_record(db, 't1', user(), generated['id'])]: + with pytest.raises(HTTPException) as exc: + action() + assert exc.value.status_code == 409 and '来源' in exc.value.detail + svc.update_record(db, 't1', user(), manual.id, Update(**payload(calendarKind='site', documentType='出库计划'))) + svc.delete_record(db, 't1', user(), manual.id) + assert len(site_rows(db)) == 1 and db.get(model, generated['id']).del_flag == 0 + + +def test_generated_record_inherits_scope_and_cannot_cross_tenant(db, monkeypatch): + _, Create, Update, svc = modules() + own = create(db, origin='山峡') + other = create(db, caller=user('u2'), origin='山峡') + foreign = svc.create_record(db, 't2', user(tenant='t2'), Create(**payload( + vehicleId='v3', productId='p3', origin='山峡'))) + assert len(site_rows(db)) == 2 + assert site_rows(db, tenant='t2')[0]['sourceRecordId'] == foreign.id + import app.core.data_scope as scope + monkeypatch.setattr(scope, '_resolve_user_role_scopes', lambda *a: [{'data_scope': '4', 'details': []}]) + limited = user(role='admin') + assert [r['sourceRecordId'] for r in site_rows(db, caller=limited)] == [own.id] + for source in (other, foreign): + for action in [lambda: svc.update_record(db, 't1', limited, source.id, Update(**payload())), + lambda: svc.delete_record(db, 't1', limited, source.id)]: + with pytest.raises(HTTPException) as exc: + action() + assert exc.value.status_code == 404 + svc.update_record(db, 't1', limited, own.id, Update(**payload(origin='山峡', destination='云栋'))) + assert site_rows(db, caller=limited)[0]['documentType'] == '出入库计划' + svc.delete_record(db, 't1', limited, own.id) + assert len(site_rows(db)) == 1 and len(site_rows(db, tenant='t2')) == 1 + + +@pytest.mark.parametrize('operation', ['create', 'update', 'delete']) +def test_source_and_site_rollback_together(db, operation): + model, _, Update, svc = modules() + source = None + if operation != 'create': + source = create(db, origin='山峡') + assert len(site_rows(db)) == 1 + def reject_site(mapper, connection, target): + if target.calendar_kind == 'site': + raise RuntimeError('模拟现场写入失败') + hook = 'before_insert' if operation == 'create' else 'before_update' + event.listen(model, hook, reject_site) + try: + with pytest.raises(RuntimeError, match='模拟现场写入失败'): + if operation == 'create': + create(db, origin='山峡') + elif operation == 'update': + svc.update_record(db, 't1', user(), source.id, Update(**payload(origin='云栋'))) + else: + svc.delete_record(db, 't1', user(), source.id) + finally: + event.remove(model, hook, reject_site) + # 服务已回滚,会话可直接继续查询。 + if source is None: + assert db.query(model).count() == 0 + else: + assert source.origin == '山峡' and source.del_flag == 0 + assert len(site_rows(db)) == 1 and site_rows(db)[0]['origin'] == '山峡' + + +def test_database_unique_source_including_soft_deleted_rows(db): + model, _, Update, svc = modules() + source = create(db, origin='山峡') + assert len(site_rows(db)) == 1 + target = db.get(model, site_rows(db)[0]['id']) + svc.update_record(db, 't1', user(), source.id, Update(**payload())) + assert target.del_flag == 1 + db.add(model(id='duplicate', tenant_no='t1', source_record_id=source.id, + calendar_kind='site', dispatch_date=source.dispatch_date, + vehicle_id='v1', license_plate='鄂E12345', product_id='p1', product_name='液碱', + origin='山峡', destination='武汉', create_dept_id='d1')) + with pytest.raises(IntegrityError): + db.commit() + db.rollback() + + +def test_identical_source_trips_remain_independent_of_manual_site(db): + _, _, _, svc = modules() + manual = create(db, calendarKind='site', origin='山峡') + first = create(db, origin='山峡') + second = create(db, origin='山峡') + assert len(site_rows(db)) == 3 + assert {row['sourceRecordId'] for row in site_rows(db)} == {None, first.id, second.id} + svc.delete_record(db, 't1', user(), first.id) + assert len(site_rows(db)) == 2 + assert {row['sourceRecordId'] for row in site_rows(db)} == {None, second.id} + assert svc.get_record(db, 't1', user(), manual.id).del_flag == 0 + + +@pytest.mark.parametrize('operation', ['create', 'update', 'delete']) +def test_mysql_deadlock_retries_entire_transaction(db, operation): + model, _, Update, svc = modules() + source = create(db, origin='山峡') if operation != 'create' else None + attempts = [] + def fail_once(mapper, connection, target): + if target.calendar_kind == 'site': + attempts.append(target.id) + if len(attempts) == 1: + raise OperationalError('test', {}, Exception(1213, 'Deadlock found')) + hook = 'before_insert' if operation == 'create' else 'before_update' + event.listen(model, hook, fail_once) + try: + if operation == 'create': + source = create(db, origin='山峡') + elif operation == 'update': + svc.update_record(db, 't1', user(), source.id, Update(**payload(origin='云栋'))) + else: + svc.delete_record(db, 't1', user(), source.id) + finally: + event.remove(model, hook, fail_once) + assert len(attempts) == 2 + assert db.query(model).count() == 2 + if operation == 'delete': + assert source.del_flag == 1 and site_rows(db) == [] + else: + assert len(site_rows(db)) == 1 + assert site_rows(db)[0]['sourceRecordId'] == source.id + assert site_rows(db)[0]['origin'] == ('云栋' if operation == 'update' else '山峡') + + +@pytest.mark.parametrize('error_code,expected_attempts', [(1213, 3), (1205, 1)]) +def test_database_retry_is_bounded_and_only_for_deadlocks(db, error_code, expected_attempts): + model, _, _, _ = modules() + attempts = [] + def reject_site(mapper, connection, target): + if target.calendar_kind == 'site': + attempts.append(target.id) + raise OperationalError('test', {}, Exception(error_code, 'database failure')) + event.listen(model, 'before_insert', reject_site) + try: + with pytest.raises(OperationalError): + create(db, origin='山峡') + finally: + event.remove(model, 'before_insert', reject_site) + assert len(attempts) == expected_attempts + assert db.query(model).count() == 0 + + +def test_post_commit_refresh_failure_does_not_replay_a_committed_create(db, monkeypatch): + model, _, _, _ = modules() + refresh = db.refresh + calls = [] + def fail_first_refresh(*args, **kwargs): + calls.append(1) + if len(calls) == 1: + raise OperationalError('test', {}, Exception(1213, 'post-commit read failed')) + return refresh(*args, **kwargs) + monkeypatch.setattr(db, 'refresh', fail_first_refresh) + try: + create(db, origin='山峡') + except OperationalError: + pass + assert db.query(model).count() == 2 diff --git a/docs/sql/migrations/2026-10-30-dispatch-calendar-document-type.sql b/docs/sql/migrations/2026-10-30-dispatch-calendar-document-type.sql new file mode 100644 index 000000000..5886ce4f7 --- /dev/null +++ b/docs/sql/migrations/2026-10-30-dispatch-calendar-document-type.sql @@ -0,0 +1,19 @@ +-- 现场日历新增选填单据类型,历史记录保留 NULL。 +-- 先完成手工派车日历建表,再执行本迁移,最后部署后端和 Web。 +-- MySQL 5.7 / utf8mb4,可重复执行。 +SET NAMES utf8mb4; + +SET @col_exists = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'pl_dispatch_calendar' + AND COLUMN_NAME = 'document_type' +); +SET @ddl = IF( + @col_exists = 0, + 'ALTER TABLE `pl_dispatch_calendar` ADD COLUMN `document_type` varchar(50) DEFAULT NULL COMMENT ''单据类型(现场日历,支持自定义)'' AFTER `calendar_kind`', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/docs/sql/migrations/2026-10-30-dispatch-calendar-site-sync.sql b/docs/sql/migrations/2026-10-30-dispatch-calendar-site-sync.sql new file mode 100644 index 000000000..537f33e1b --- /dev/null +++ b/docs/sql/migrations/2026-10-30-dispatch-calendar-site-sync.sql @@ -0,0 +1,41 @@ +-- 派车日历自动同步现场日历,MySQL 5.7 / utf8mb4,可重复执行。 +-- 前置:2026-10-30-dispatch-calendar-document-type.sql。先执行迁移再部署后端和 Web。 +-- 只改变结构:历史来源默认 site_sync_enabled=0,后续编辑也不补录。 +-- 上线后后端新建来源显式设置为1;手工现场记录仍为0。 +SET NAMES utf8mb4; + +SET @col_exists = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pl_dispatch_calendar' + AND COLUMN_NAME = 'site_sync_enabled' +); +SET @ddl = IF(@col_exists = 0, + 'ALTER TABLE `pl_dispatch_calendar` ADD COLUMN `site_sync_enabled` smallint NOT NULL DEFAULT 0 COMMENT ''是否启用现场同步:仅新建来源置1,历史为0'' AFTER `document_type`', + 'SELECT 1'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @col_exists = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pl_dispatch_calendar' + AND COLUMN_NAME = 'source_record_id' +); +SET @ddl = IF(@col_exists = 0, + 'ALTER TABLE `pl_dispatch_calendar` ADD COLUMN `source_record_id` varchar(64) DEFAULT NULL COMMENT ''自动现场记录的来源派车ID,手工记录为空'' AFTER `site_sync_enabled`', + 'SELECT 1'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @idx_exists = ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pl_dispatch_calendar' + AND INDEX_NAME = 'uk_dispatch_calendar_source' +); +SET @ddl = IF(@idx_exists = 0, + 'ALTER TABLE `pl_dispatch_calendar` ADD UNIQUE KEY `uk_dispatch_calendar_source` (`tenant_no`, `source_record_id`)', + 'SELECT 1'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/docs/sql/shanxia2.0.sql b/docs/sql/shanxia2.0.sql index 600f03169..1e101f255 100644 --- a/docs/sql/shanxia2.0.sql +++ b/docs/sql/shanxia2.0.sql @@ -36469,6 +36469,9 @@ CREATE TABLE IF NOT EXISTS `pl_dispatch_calendar` ( `destination` varchar(255) NOT NULL COMMENT '目的地', `quantity` varchar(16) DEFAULT NULL COMMENT '数量文本(吨),最多两位小数,不参与汇总', `calendar_kind` varchar(16) NOT NULL DEFAULT 'acid' COMMENT '日历类型:acid酸派车 alkali碱派车 other其他 site现场', + `document_type` varchar(50) DEFAULT NULL COMMENT '单据类型(现场日历,支持自定义)', + `site_sync_enabled` smallint NOT NULL DEFAULT 0 COMMENT '是否启用现场同步:仅新建来源置1,历史为0', + `source_record_id` varchar(64) DEFAULT NULL COMMENT '自动现场记录的来源派车ID,手工记录为空', `tenant_no` varchar(64) NOT NULL COMMENT '租户编号', `order_num` int DEFAULT NULL COMMENT '排序号', `remark` varchar(500) DEFAULT NULL COMMENT '备注', @@ -36480,7 +36483,8 @@ CREATE TABLE IF NOT EXISTS `pl_dispatch_calendar` ( `del_flag` smallint NOT NULL DEFAULT 0 COMMENT '删除标记', PRIMARY KEY (`id`), KEY `idx_dispatch_calendar_month` (`tenant_no`,`del_flag`,`dispatch_date`), - KEY `idx_dispatch_calendar_vehicle` (`tenant_no`,`vehicle_id`,`dispatch_date`) + KEY `idx_dispatch_calendar_vehicle` (`tenant_no`,`vehicle_id`,`dispatch_date`), + UNIQUE KEY `uk_dispatch_calendar_source` (`tenant_no`,`source_record_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='手工派车日历'; -- 维护与查看分权;仅向已有日历入口且具有运输计划维护能力的角色授予维护按钮。 diff --git a/docs/superpowers/plans/2026-10-30-dispatch-calendar-site-sync.md b/docs/superpowers/plans/2026-10-30-dispatch-calendar-site-sync.md new file mode 100644 index 000000000..6b9cd9c62 --- /dev/null +++ b/docs/superpowers/plans/2026-10-30-dispatch-calendar-site-sync.md @@ -0,0 +1,58 @@ +# 派车日历自动同步现场日历 Implementation Plan + +> 使用 executing-plans 逐项执行;需求已确认,在当前已有单据类型改动上继续,不另行拆分工作区。 + +**Goal:** 新建酸、碱、其他派车记录按起止地生成并维护一条现场记录,历史记录不补录。 + +**Architecture:** 来源开关区分历史;唯一来源关联防重复;后端行锁与同事务保存;前端自动记录只读。 + +**Tech Stack:** Python 3.9、SQLAlchemy、Pydantic v2、MySQL 5.7、Vue 3。 + +## Task 1:先建立回归 + +- [x] 新建 `backend/tests/test_pl_dispatch_calendar_site_sync.py`,复用现有 SQLite fixture,通过公开 CRUD 验证匹配矩阵、生命周期、历史不补录、隔离、关联保护、唯一约束与失败回滚。 +- [x] 在 backend 执行 `ENV_FILE=/dev/null venv/bin/python -m pytest tests/test_pl_dispatch_calendar_site_sync.py -q`,确认自动生成断言失败。 + +## Task 2:模型、事务和迁移 + +- [x] 修改 `backend/app/models/pl_dispatch_calendar.py`,增加 `site_sync_enabled` smallint 默认 0、`source_record_id` varchar(64) 可空、租户来源唯一索引。 +- [x] 修改 `backend/app/services/pl_dispatch_calendar_service.py`:`get_record(..., for_update=True)` 锁定来源;创建时启用新来源;同步助手按四种匹配结果原地维护现场行;更新、删除拒绝自动现场行;每次 CRUD 只提交一次且失败 rollback。 +- [x] `sourceRecordId` 仅输出,不加入可写 schema。补充现有字段集合测试。 +- [x] 新建 `docs/sql/migrations/2026-10-30-dispatch-calendar-site-sync.sql` 并同步 `docs/sql/shanxia2.0.sql`;使用 information_schema 和 PREPARE 条件执行 ALTER,禁止历史数据 UPDATE/INSERT。 +- [x] 执行两个后端日历测试模块,确认通过。 + +## Task 3:界面保护与验证 + +- [x] 在 `web-admin/scripts/test-manual-dispatch-calendar.mjs` 加入自动现场 fixture,验证有维护权限时详情仍无编辑/删除且显示来源提示;先运行确认失败。 +- [x] 修改 `web-admin/src/api/pl/dispatch-calendar.ts` 的只读 Record 类型,增加 `sourceRecordId?: string | null`。 +- [x] 修改 `web-admin/src/views/pl/dispatch-calendar/ManualCalendar.vue`,详情提示来源维护,隐藏关联行编辑删除;共享 `useManualCalendar.ts` 防止直接打开编辑和删除。 +- [x] 执行已有浏览器脚本、前端模型测试、受影响文件 ESLint、`pnpm build`,完成代码审查。数据库迁移另按后续用户授权执行,见下方记录。 + +- [x] 按后续要求调整现场卡片:单据类型以普通文字追加在路线末尾的中文括号中;复制文本隐藏起止地和备注,类型追加在产品、数量末尾。单条和同车多条浏览器回归通过。 + + +## 验证结果 + +- 后端两个日历模块:98 项通过,包含事务故障注入、1213 有限重试及禁止提交后重放。 +- 前端日历模型:13 项通过;ESLint、Stylelint 通过。 +- 浏览器:Web 酸/碱/其他/现场和 H5 酸共 5 个场景通过,现场自动行的详情只读提示通过。 +- Web `pnpm build` 通过(41.95 秒),保留已有组件命名冲突和产物大小提示。 +- 代码审查已完成,死锁重试和提交后刷新边界问题已修复并回归。 +- SQL 迁移已按用户授权在测试库和生产库执行并核验(MySQL 5.7.36),详见下方执行记录。后端业务回归使用 SQLite;真实 MySQL 并发仍未实测。 +- 浏览器测试时钟改为固定起点后正常前进,避免冻结 Date.now 导致 Vue 嵌套点击时间戳过滤;业务页面未为测试增加特殊逻辑。 + +## 数据库执行记录(2026-10-30 北京时间 13:54) + +用户明确授权在测试数据库及生产数据库执行两个 SQL,按测试后生产顺序完成。连接分别来自 `backend/.env` 和 `backend/.env.production`,未修改环境配置。 + +| 环境 | 数据库 | 执行前记录数 | 执行后记录数 | 原有字段及记录 | 历史同步开关 | +| --- | --- | --- | --- | --- | --- | +| 测试 | shanxia2.0 | 7 | 7 | 逐行比对未变 | 全部为 0 | +| 生产 | shanxia2.0 | 31 | 31 | 逐行比对未变 | 全部为 0 | + +两库均成功新增 `document_type`、`site_sync_enabled`、`source_record_id`,并核验唯一索引 `uk_dispatch_calendar_source (tenant_no, source_record_id)`。未补录历史数据,未部署应用或重启服务。 + +执行脚本 SHA-256: + +- `2026-10-30-dispatch-calendar-document-type.sql`:`7bba5867852a45d43bded641ca29e0c4a6b654159c911352fc4440deeda8456b` +- `2026-10-30-dispatch-calendar-site-sync.sql`:`0a6bd205d7a93253b0bae119b1ba7cbcf56c31fb3f2380a03bcdd94fa6fb5f04` diff --git a/docs/superpowers/specs/2026-10-30-dispatch-calendar-site-sync-design.md b/docs/superpowers/specs/2026-10-30-dispatch-calendar-site-sync-design.md new file mode 100644 index 000000000..70edfe205 --- /dev/null +++ b/docs/superpowers/specs/2026-10-30-dispatch-calendar-site-sync-design.md @@ -0,0 +1,28 @@ +# 派车日历自动同步现场日历 + +用户已确认按起止地匹配同步,历史记录不补录,开始实施。 + +## 规则与边界 + +- 仅酸、碱、其他日历作为来源;始发地含“山峡”或“云栋”为出库,目的地含任一关键词为入库,两端命中为一条“出入库计划”,均不命中不生成。 +- 同步日期、车辆及车牌快照、产品及名称快照、起止地、数量和备注,不联动实际运输单或库存。 +- 来源更新时原地更新关联现场记录;失去匹配或来源删除时软删除;重新匹配时恢复原关联行,保持 ID 和创建信息。 +- 上线前记录不补录,即使后续编辑也不启用同步。上线后新建的来源即使初始不匹配,也支持后续修改命中。 +- 手工现场记录独立维护;自动现场记录只读,提示在来源派车日历维护,后端同时拒绝直接修改/删除。 +- 现场卡片将类型以普通文字放在路线末尾,如“山峡 → 芜湖(出库计划)”,不使用 Tag;复制文本保留车牌、产品和数量,隐藏起止地和备注,类型放在每条产品行末尾的中文括号中。 + +## 实现选择 + +采用后端同步事务,所有客户端统一生效。前端分步创建会产生部分成功;异步任务会延迟并增加重试成本,本需求无需引入。 + +`pl_dispatch_calendar` 增加内部 `site_sync_enabled`(默认 0,服务创建新来源显式设 1),以及只读 `source_record_id`(手工行 NULL)。唯一索引 `(tenant_no, source_record_id)` 限制每个来源最多一条关联行,包括软删除行。迁移只变更结构,不更新业务记录。 + +更新和删除先按租户、用户数据范围锁定来源行,再同步关联行并一次提交;任何失败回滚。关联行继承来源创建人和部门以保持数据范围一致。接口只读输出 `sourceRecordId`,输入禁止伪造关联和开关。 + +MySQL 死锁错误 1213 回滚后最多尝试三次完整事务,其他错误直接抛出。重试范围不含提交后的查询,避免重放已提交的新增操作。 + +## 验收 + +覆盖三个来源日历、两种关键词、四种匹配情况、重复保存、变更月份/车辆/产品、取消匹配/重新命中、来源删除、手工现场不递归、历史不补录、隔离和事务失败回滚。Web 现场详情显示维护提示并隐藏编辑删除;原手工 CRUD 保持不变。 + +上线顺序:单据类型迁移 → 同步关联迁移 → 后端和 Web 发布。无需 env 配置及小程序包变更。 diff --git a/web-admin/scripts/manual-dispatch-calendar.test.ts b/web-admin/scripts/manual-dispatch-calendar.test.ts index 18642411f..612fea532 100644 --- a/web-admin/scripts/manual-dispatch-calendar.test.ts +++ b/web-admin/scripts/manual-dispatch-calendar.test.ts @@ -148,3 +148,47 @@ test('数量是可空文本,复制逐条展示,日汇总仍只计车次', () ) assert.equal(validateManualDraft({ ...row('1'), quantity: '1吨' }), '数量须为数字,最多两位小数') }) + +test('现场复制隐藏起止地和备注,单据类型放在每条记录末尾的中文括号中', () => { + for (const documentType of ['入库计划', '出库计划', '出入库计划', '现场调拨']) { + const record = { + ...row('site'), + calendarKind: 'site' as const, + documentType, + quantity: '32.50', + remark: '内部备注' + } + assert.equal(validateManualDraft(record), '') + assert.deepEqual( + manualCopyText('2026-10-26', [record, { ...record, id: 'site-2', quantity: '' }]).split('\n'), + [ + '2026-10-26 派车计划', + ' ·皖H12345', + ` 液碱 32.50吨(${documentType})`, + ` 液碱(${documentType})` + ] + ) + assert.equal( + manualCalendarDays([record], '2026-10').find((d) => d.date === record.dispatchDate)?.rows + .length, + 1 + ) + } + for (const documentType of [null, undefined, '', ' ']) { + const record = { + ...row('site'), + calendarKind: 'site' as const, + documentType, + remark: '内部备注' + } + assert.equal(manualCopyText('2026-10-26', [record]), '2026-10-26 派车计划\n ·皖H12345\n 液碱') + } +}) + +test('单据类型选填且最多50个字符', () => { + assert.equal(validateManualDraft({ ...row('site'), calendarKind: 'site', documentType: '' }), '') + assert.match( + validateManualDraft({ ...row('site'), calendarKind: 'site', documentType: '字'.repeat(51) }), + /单据类型.*50/ + ) +}) diff --git a/web-admin/scripts/test-manual-dispatch-calendar.mjs b/web-admin/scripts/test-manual-dispatch-calendar.mjs index a792c6a0a..fd0cb18a6 100644 --- a/web-admin/scripts/test-manual-dispatch-calendar.mjs +++ b/web-admin/scripts/test-manual-dispatch-calendar.mjs @@ -14,7 +14,8 @@ try { viewport: mobile ? { width: 375, height: 812 } : { width: 1440, height: 1000 } }) const page = await context.newPage() - await page.clock.setFixedTime(new Date('2026-10-26T04:00:00Z')) + // 固定起始日期但保持时钟前进;冻结 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()) @@ -188,6 +189,13 @@ try { await editor.getByRole('combobox', { name: '产品名称', exact: true }).click() await page.getByRole('option', { name: '液碱', exact: true }).click() } + 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() + } else { + assert.equal(await editor.getByRole('combobox', { name: '单据类型', exact: true }).count(), 0) + } // 起止地留空也应保存成功,编辑后仍可补填。 await editor.getByRole('button', { name: '保存', exact: true }).click() await editor.waitFor({ state: 'hidden' }) @@ -195,8 +203,16 @@ try { assert.equal(rows[0].origin, '') assert.equal(rows[0].destination, '') assert.equal(rows[0].calendarKind, kind) + 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.getByRole('button', { name: '编辑', exact: true }).click() + if (kind === 'site') { + assert.match(await editor.innerText(), /入库计划/) + await editor.getByRole('combobox', { name: '单据类型', exact: true }).fill('现场调拨') + await page.getByRole('option', { name: '现场调拨', exact: true }).click() + } await editor.getByPlaceholder('请输入始发地').fill('山峡') await editor.getByPlaceholder('请输入目的地').fill('云栋') failSave = true @@ -214,21 +230,32 @@ try { fullPage: false }) 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('.el-tag').count(), 0) + } await page .locator('[data-date="2026-10-26"]') .getByRole('button', { name: mobile ? '复制' : '复制2026-10-26派车计划', exact: true }) .click() - assert.match( - await page.evaluate(() => navigator.clipboard.readText()), - /皖H12345.*液碱.*山峡 → 云栋/s - ) + const copiedText = await page.evaluate(() => navigator.clipboard.readText()) + if (kind === 'site') { + assert.equal(copiedText, '2026-10-26 派车计划\n ·皖H12345\n 液碱(现场调拨)') + } else { + assert.match(copiedText, /皖H12345.*液碱.*山峡 → 云栋/s) + } await card.click() await page.getByRole('button', { name: '编辑', exact: true }).click() await editor.getByPlaceholder('请输入目的地').fill('安庆') await editor.getByRole('button', { name: '保存', exact: true }).click() await editor.waitFor({ state: 'hidden' }) - await page.getByText('山峡 → 安庆', { exact: true }).first().waitFor() + if (kind === 'site') assert.equal(rows[0].documentType, '现场调拨') + await page + .getByText(kind === 'site' ? '山峡 → 安庆(现场调拨)' : '山峡 → 安庆', { 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() @@ -266,6 +293,31 @@ try { calendarKind: kind } ] + if (kind === 'site') { + allowManage = true + rows[0].sourceRecordId = 'source-fixture' + rows[0].documentType = '出入库计划' + rows.push({ + ...rows[0], + id: 'second-site', + sourceRecordId: 'second-source', + documentType: '入库计划' + }) + 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.equal(await page.locator('.manual-day-content .el-tag').count(), 0) + 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.getByRole('tab', { name: '实际派车信息', exact: true }).click() + allowManage = false + delete rows[0].sourceRecordId + rows.pop() + } await page.getByRole('tab', { name: title, exact: true }).click() await page.locator('[data-testid="manual-record"]').first().waitFor() assert.equal(await page.getByRole('button', { name: '新增派车计划', exact: true }).count(), 0) diff --git a/web-admin/src/api/pl/dispatch-calendar.ts b/web-admin/src/api/pl/dispatch-calendar.ts index b866b6cb6..8d70c2bb6 100644 --- a/web-admin/src/api/pl/dispatch-calendar.ts +++ b/web-admin/src/api/pl/dispatch-calendar.ts @@ -10,10 +10,12 @@ export interface DispatchCalendarBody { destination: string quantity: string | null calendarKind: DispatchCalendarKind + documentType?: string | null remark: string } export interface DispatchCalendarRecord extends DispatchCalendarBody { id: string + sourceRecordId?: string | null licensePlate: string productName: string createTime: string | null diff --git a/web-admin/src/views/pl/dispatch-calendar/ManualCalendar.vue b/web-admin/src/views/pl/dispatch-calendar/ManualCalendar.vue index d101383b6..02c509dc7 100644 --- a/web-admin/src/views/pl/dispatch-calendar/ManualCalendar.vue +++ b/web-admin/src/views/pl/dispatch-calendar/ManualCalendar.vue @@ -171,11 +171,7 @@ :label="row.productName" /> @@ -185,6 +181,21 @@ + + + + + +