feat: 为现场日历增加单据类型并同步派车记录,减少重复录入。

按起止地关键词生成现场出入库计划,同步来源修改和删除,保留历史记录不补录。调整卡片及复制文本的单据类型格式,并记录测试库和生产库迁移结果。
This commit is contained in:
wuzilong committed 2026-10-30 14:04:24 +08:00
1 parent 1ae8603045
commit fcbf8a9b65
17 files changed
+732 -42

No files matched your search

+6 -1
View File
@@ -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
+38 -1
View File
@@ -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;
+5 -1
View File
@@ -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)