feat: 为现场日历增加单据类型并同步派车记录,减少重复录入。
按起止地关键词生成现场出入库计划,同步来源修改和删除,保留历史记录不补录。调整卡片及复制文本的单据类型格式,并记录测试库和生产库迁移结果。
This commit is contained in:
1 parent
1ae8603045
commit
fcbf8a9b65
17 files changed
+732
-42
No files matched your search
@@ -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="备注")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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='手工派车日历';
|
||||
|
||||
-- 维护与查看分权;仅向已有日历入口且具有运输计划维护能力的角色授予维护按钮。
|
||||
|
||||
@@ -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`
|
||||
@@ -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 配置及小程序包变更。
|
||||
@@ -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/
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -171,11 +171,7 @@
|
||||
:label="row.productName" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item label="数量"
|
||||
><el-input
|
||||
v-model="draft.quantity"
|
||||
maxlength="15"
|
||||
placeholder="选填"
|
||||
aria-label="数量"
|
||||
><el-input v-model="draft.quantity" maxlength="15" placeholder="选填" aria-label="数量"
|
||||
><template #append>吨</template></el-input
|
||||
></el-form-item
|
||||
>
|
||||
@@ -185,6 +181,21 @@
|
||||
<el-form-item label="目的地"
|
||||
><el-input v-model="draft.destination" maxlength="255" placeholder="请输入目的地"
|
||||
/></el-form-item>
|
||||
<el-form-item v-if="calendarKind === 'site'" label="单据类型" class="form-col-span-2">
|
||||
<el-select
|
||||
v-model="draft.documentType"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
popper-class="manual-calendar-popper"
|
||||
placeholder="选择或输入单据类型"
|
||||
aria-label="单据类型"
|
||||
>
|
||||
<el-option label="入库计划" value="入库计划" />
|
||||
<el-option label="出库计划" value="出库计划" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="form-col-span-2"
|
||||
><el-input
|
||||
v-model="draft.remark"
|
||||
@@ -222,7 +233,7 @@
|
||||
width="720px"
|
||||
>
|
||||
<template #hero-actions
|
||||
><template v-if="canManage && viewRow"
|
||||
><template v-if="canManage && viewRow && !viewRow.sourceRecordId"
|
||||
><el-button :disabled="saving" @click="editViewed"
|
||||
><el-icon><Edit /></el-icon>编辑</el-button
|
||||
><el-button type="danger" :disabled="saving" @click="deleteViewed"
|
||||
@@ -302,6 +313,18 @@
|
||||
fields: viewRow.value
|
||||
? [
|
||||
{ label: '派车日期', value: viewRow.value.dispatchDate },
|
||||
...(viewRow.value.sourceRecordId
|
||||
? [{ label: '维护方式', value: '自动同步,请在来源派车日历修改或删除' }]
|
||||
: []),
|
||||
...(viewRow.value.calendarKind === 'site'
|
||||
? [
|
||||
{
|
||||
label: '单据类型',
|
||||
value: viewRow.value.documentType,
|
||||
tagType: 'primary' as const
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{ label: '车牌号', value: viewRow.value.licensePlate },
|
||||
{ label: '产品名称', value: viewRow.value.productName },
|
||||
{ label: '始发地', value: viewRow.value.origin },
|
||||
|
||||
@@ -35,7 +35,12 @@
|
||||
}}</span>
|
||||
1车</span
|
||||
>
|
||||
<span class="route">{{ group.rows[0].origin }} → {{ group.rows[0].destination }}</span>
|
||||
<span class="route"
|
||||
>{{ group.rows[0].origin }} → {{ group.rows[0].destination
|
||||
}}<template v-if="group.rows[0].calendarKind === 'site' && group.rows[0].documentType"
|
||||
>({{ group.rows[0].documentType }})</template
|
||||
></span
|
||||
>
|
||||
<span v-if="group.rows[0].remark" class="remark">{{ group.rows[0].remark }}</span>
|
||||
</component>
|
||||
<div v-else class="pair">
|
||||
@@ -58,7 +63,10 @@
|
||||
><span v-if="manualQuantityLabel(row.quantity)" class="quantity">{{
|
||||
' ' + manualQuantityLabel(row.quantity)
|
||||
}}</span>
|
||||
· {{ row.origin }} → {{ row.destination }}</span
|
||||
· {{ row.origin }} → {{ row.destination
|
||||
}}<template v-if="row.calendarKind === 'site' && row.documentType"
|
||||
>({{ row.documentType }})</template
|
||||
></span
|
||||
>
|
||||
<span v-if="row.remark" class="remark">{{ row.remark }}</span>
|
||||
</component>
|
||||
|
||||
@@ -37,10 +37,7 @@ function byCreateOrder(a: DispatchCalendarRecord, b: DispatchCalendarRecord) {
|
||||
}
|
||||
|
||||
function groupByVehicle(rows: DispatchCalendarRecord[]) {
|
||||
const vehicles = new Map<
|
||||
string,
|
||||
{ id: string; plate: string; rows: DispatchCalendarRecord[] }
|
||||
>()
|
||||
const vehicles = new Map<string, { id: string; plate: string; rows: DispatchCalendarRecord[] }>()
|
||||
for (const row of rows) {
|
||||
if (!vehicles.has(row.vehicleId))
|
||||
vehicles.set(row.vehicleId, { id: row.vehicleId, plate: row.licensePlate, rows: [] })
|
||||
@@ -91,9 +88,13 @@ export function manualQuantityLabel(quantity?: string | null) {
|
||||
}
|
||||
|
||||
function manualTripText(row: DispatchCalendarRecord) {
|
||||
const remark = row.remark ? `(${row.remark.replace(/\s+/g, ' ')})` : ''
|
||||
const quantity = manualQuantityLabel(row.quantity)
|
||||
const amount = quantity ? ` ${quantity}` : ''
|
||||
if (row.calendarKind === 'site') {
|
||||
const documentType = (row.documentType || '').trim().replace(/\s+/g, ' ')
|
||||
return `${row.productName}${amount}${documentType ? `(${documentType})` : ''}`
|
||||
}
|
||||
const remark = row.remark ? `(${row.remark.replace(/\s+/g, ' ')})` : ''
|
||||
return `${row.productName}${amount} ${row.origin} → ${row.destination}${remark}`
|
||||
}
|
||||
|
||||
@@ -123,5 +124,6 @@ export function validateManualDraft(body: DispatchCalendarBody): string {
|
||||
if (manualQuantityText(body.quantity) && !QUANTITY_TEXT.test(manualQuantityText(body.quantity)))
|
||||
return '数量须为数字,最多两位小数'
|
||||
if (body.remark.length > 500) return '备注最多500个字符'
|
||||
if ((body.documentType || '').trim().length > 50) return '单据类型最多50个字符'
|
||||
return ''
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export function useManualCalendar(
|
||||
destination: '',
|
||||
quantity: '',
|
||||
calendarKind,
|
||||
documentType: '',
|
||||
remark: ''
|
||||
})
|
||||
const draft = reactive<DispatchCalendarBody>(blank(selectedDate.value))
|
||||
@@ -113,7 +114,7 @@ export function useManualCalendar(
|
||||
editorOpen.value = true
|
||||
}
|
||||
function openEdit(row: DispatchCalendarRecord) {
|
||||
if (!canManage.value || !live() || saving.value) return
|
||||
if (!canManage.value || !live() || saving.value || row.sourceRecordId) return
|
||||
editingId.value = row.id
|
||||
Object.assign(draft, {
|
||||
dispatchDate: row.dispatchDate,
|
||||
@@ -123,6 +124,7 @@ export function useManualCalendar(
|
||||
destination: row.destination,
|
||||
quantity: row.quantity || '',
|
||||
calendarKind,
|
||||
documentType: row.documentType || '',
|
||||
remark: row.remark || ''
|
||||
})
|
||||
formError.value = ''
|
||||
@@ -139,6 +141,7 @@ export function useManualCalendar(
|
||||
origin: draft.origin.trim(),
|
||||
destination: draft.destination.trim(),
|
||||
quantity: (draft.quantity || '').trim(),
|
||||
documentType: (draft.documentType || '').trim(),
|
||||
remark: draft.remark.trim()
|
||||
}
|
||||
try {
|
||||
@@ -157,7 +160,7 @@ export function useManualCalendar(
|
||||
}
|
||||
}
|
||||
async function remove(row: DispatchCalendarRecord) {
|
||||
if (!live() || !canManage.value || saving.value) return false
|
||||
if (!live() || !canManage.value || saving.value || row.sourceRecordId) return false
|
||||
saving.value = true
|
||||
try {
|
||||
await dispatchCalendarApi.delete(row.id)
|
||||
|
||||
Reference in new issue
Block a user