Compare commits
3
Commits
120b743a39
...
1ae8603045
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ae8603045 | ||
|
|
cd99b040aa | ||
|
|
0453e90000 |
No files matched your search
@@ -24,7 +24,7 @@ class PlDispatchCalendar(Base):
|
|||||||
quantity = Column(String(16), nullable=True, comment="数量文本(吨),最多两位小数,不参与汇总")
|
quantity = Column(String(16), nullable=True, comment="数量文本(吨),最多两位小数,不参与汇总")
|
||||||
calendar_kind = Column(
|
calendar_kind = Column(
|
||||||
String(16), nullable=False, default="acid", server_default="acid",
|
String(16), nullable=False, default="acid", server_default="acid",
|
||||||
comment="日历类型:acid酸派车 alkali碱派车 other其他",
|
comment="日历类型:acid酸派车 alkali碱派车 other其他 site现场",
|
||||||
)
|
)
|
||||||
tenant_no = Column(String(64), nullable=False, comment="租户编号")
|
tenant_no = Column(String(64), nullable=False, comment="租户编号")
|
||||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ class CreateBody(BaseModel):
|
|||||||
dispatch_date: date = Field(..., alias="dispatchDate", description="派车日期,范围 1900-01-01 至 2100-12-31")
|
dispatch_date: date = Field(..., alias="dispatchDate", description="派车日期,范围 1900-01-01 至 2100-12-31")
|
||||||
vehicle_id: str = Field(..., alias="vehicleId", min_length=1, max_length=64)
|
vehicle_id: str = Field(..., alias="vehicleId", min_length=1, max_length=64)
|
||||||
product_id: str = Field(..., alias="productId", min_length=1, max_length=64)
|
product_id: str = Field(..., alias="productId", min_length=1, max_length=64)
|
||||||
origin: str = Field(..., min_length=1, max_length=255)
|
origin: str = Field(default="", max_length=255)
|
||||||
destination: str = Field(..., min_length=1, max_length=255)
|
destination: str = Field(default="", max_length=255)
|
||||||
quantity: Optional[str] = Field(default=None, max_length=16, description="数量文本,单位吨,最多两位小数;不参与汇总")
|
quantity: Optional[str] = Field(default=None, max_length=16, description="数量文本,单位吨,最多两位小数;不参与汇总")
|
||||||
calendar_kind: str = Field(default="acid", alias="calendarKind", min_length=1, max_length=16)
|
calendar_kind: str = Field(default="acid", alias="calendarKind", min_length=1, max_length=16)
|
||||||
remark: Optional[str] = Field(default=None, max_length=500)
|
remark: Optional[str] = Field(default=None, max_length=500)
|
||||||
@@ -51,8 +51,8 @@ class CreateBody(BaseModel):
|
|||||||
@field_validator("calendar_kind")
|
@field_validator("calendar_kind")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_calendar_kind(cls, value: str) -> str:
|
def validate_calendar_kind(cls, value: str) -> str:
|
||||||
if value not in ("acid", "alkali", "other"):
|
if value not in ("acid", "alkali", "other", "site"):
|
||||||
raise ValueError("日历类型须为 acid、alkali 或 other")
|
raise ValueError("日历类型须为 acid、alkali、other 或 site")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ from app.models.pl_models import PlVehicle
|
|||||||
from app.schemas.pl.dispatch_calendar import CreateBody, UpdateBody
|
from app.schemas.pl.dispatch_calendar import CreateBody, UpdateBody
|
||||||
from app.services.pl_data_scope import apply_pl_scope
|
from app.services.pl_data_scope import apply_pl_scope
|
||||||
|
|
||||||
CALENDAR_KINDS = ("acid", "alkali", "other")
|
CALENDAR_KINDS = ("acid", "alkali", "other", "site")
|
||||||
|
|
||||||
|
|
||||||
def _calendar_kind(kind: Optional[str]) -> str:
|
def _calendar_kind(kind: Optional[str]) -> str:
|
||||||
value = kind or "acid"
|
value = kind or "acid"
|
||||||
if value not in CALENDAR_KINDS:
|
if value not in CALENDAR_KINDS:
|
||||||
raise HTTPException(status_code=422, detail="日历类型须为 acid、alkali 或 other")
|
raise HTTPException(status_code=422, detail="日历类型须为 acid、alkali、other 或 site")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -172,27 +172,6 @@ def _valid_now(db, notice):
|
|||||||
return any(t.task_key == notice.task_key and t.user_id == notice.recipient_id for t in snapshot.pending)
|
return any(t.task_key == notice.task_key and t.user_id == notice.recipient_id for t in snapshot.pending)
|
||||||
|
|
||||||
|
|
||||||
def _fallback_url(notice):
|
|
||||||
base = settings.WECHAT_OA_PUBLIC_BASE_URL.rstrip("/")
|
|
||||||
if not base:
|
|
||||||
return ""
|
|
||||||
if notice.kind == "cert_daily":
|
|
||||||
return base + "/#/pl/document-reminder-module/document-reminder"
|
|
||||||
if notice.business_type == "pl_freight":
|
|
||||||
route = "/pl/freight-fee/freight-approval" if notice.kind == "pending" else "/pl/freight-fee/freight"
|
|
||||||
return base + "/#" + route
|
|
||||||
if notice.kind == "pending":
|
|
||||||
route = {
|
|
||||||
"bx_advance": "/bx/advance/approve",
|
|
||||||
"bx_reimburse": "/bx/reimburse/approve",
|
|
||||||
"pl_recharge": "/vehicle-mgmt/etc-recharge/vehicle-recharge-approval",
|
|
||||||
}.get(notice.business_type)
|
|
||||||
if route:
|
|
||||||
return base + "/#" + route
|
|
||||||
# 原生页无 Web 对等详情时回门户;关联小程序配置后优先用 page。
|
|
||||||
return base + "/#/"
|
|
||||||
|
|
||||||
|
|
||||||
def deliver_pending(db, *, client=None, limit=20):
|
def deliver_pending(db, *, client=None, limit=20):
|
||||||
if not settings.WECHAT_OA_ENABLED:
|
if not settings.WECHAT_OA_ENABLED:
|
||||||
return 0
|
return 0
|
||||||
@@ -230,7 +209,7 @@ def deliver_pending(db, *, client=None, limit=20):
|
|||||||
row.message_id = client.send(
|
row.message_id = client.send(
|
||||||
openid=binding.openid, template_id=row.template_id,
|
openid=binding.openid, template_id=row.template_id,
|
||||||
data=json.loads(row.payload), event_key=row.event_key,
|
data=json.loads(row.payload), event_key=row.event_key,
|
||||||
page=row.page, url=_fallback_url(row),
|
page=row.page,
|
||||||
)
|
)
|
||||||
row.status, row.sent_at, row.last_error = "sent", datetime.now(), None
|
row.status, row.sent_at, row.last_error = "sent", datetime.now(), None
|
||||||
count += 1
|
count += 1
|
||||||
|
|||||||
@@ -95,10 +95,10 @@ def _active_cert_counts(db: Session, user: SysUser, today: date) -> tuple[int, i
|
|||||||
def _payload(now: datetime, upcoming: int, expired: int) -> dict:
|
def _payload(now: datetime, upcoming: int, expired: int) -> dict:
|
||||||
parts = []
|
parts = []
|
||||||
if upcoming:
|
if upcoming:
|
||||||
parts.append(f"{upcoming}项即将到期")
|
parts.append(f"到期{upcoming}项")
|
||||||
if expired:
|
if expired:
|
||||||
parts.append(f"{expired}项已到期")
|
parts.append(f"已到期{expired}项")
|
||||||
title = ",".join(parts)
|
title = "证件" + ",".join(parts)
|
||||||
if len(title) > 20:
|
if len(title) > 20:
|
||||||
title = f"{upcoming + expired}项证件到期提醒"
|
title = f"{upcoming + expired}项证件到期提醒"
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ def get_snapshot(
|
|||||||
if current and (not recharge or action == "approve"):
|
if current and (not recharge or action == "approve"):
|
||||||
if current.approver_id:
|
if current.approver_id:
|
||||||
snapshot.pending = [PendingApprover(current.id, current.approver_id)]
|
snapshot.pending = [PendingApprover(current.id, current.approver_id)]
|
||||||
# 现有小程序仅详情/申请表单,无审批按钮;待办走Web审批入口。
|
# 现有小程序仅详情/申请表单,无审批按钮;待办仅提醒,不提供跳转。
|
||||||
snapshot.page = ""
|
snapshot.page = ""
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
|
|||||||
@@ -81,12 +81,10 @@ class WechatOAClient:
|
|||||||
raise WechatAPIError(-1)
|
raise WechatAPIError(-1)
|
||||||
return data["openid"]
|
return data["openid"]
|
||||||
|
|
||||||
def send(self, *, openid, template_id, data, event_key, page="", url=""):
|
def send(self, *, openid, template_id, data, event_key, page=""):
|
||||||
payload = {"touser": openid, "template_id": template_id, "data": data, "client_msg_id": event_key}
|
payload = {"touser": openid, "template_id": template_id, "data": data, "client_msg_id": event_key}
|
||||||
if page and settings.WECHAT_OA_MINI_APPID:
|
if page and settings.WECHAT_OA_MINI_APPID:
|
||||||
payload["miniprogram"] = {"appid": settings.WECHAT_OA_MINI_APPID, "pagepath": page}
|
payload["miniprogram"] = {"appid": settings.WECHAT_OA_MINI_APPID, "pagepath": page}
|
||||||
if url:
|
|
||||||
payload["url"] = url
|
|
||||||
result = self.authenticated("POST", "cgi-bin/message/template/send", sending=True, json=payload)
|
result = self.authenticated("POST", "cgi-bin/message/template/send", sending=True, json=payload)
|
||||||
if not result.get("msgid"):
|
if not result.get("msgid"):
|
||||||
raise WechatDeliveryUnknown("微信未返回消息编号,请核对发送记录")
|
raise WechatDeliveryUnknown("微信未返回消息编号,请核对发送记录")
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ def test_invalid_cross_tenant_and_deleted_relations_rejected(db, field, value):
|
|||||||
|
|
||||||
@pytest.mark.parametrize('bad', [
|
@pytest.mark.parametrize('bad', [
|
||||||
{'dispatchDate': '2026-02-30'}, {'dispatchDate': '1899-12-31'}, {'dispatchDate': '2101-01-01'},
|
{'dispatchDate': '2026-02-30'}, {'dispatchDate': '1899-12-31'}, {'dispatchDate': '2101-01-01'},
|
||||||
{'dispatchDate': 1792972800}, {'dispatchDate': '2026-10-26T00:00:00'}, {'origin': ' '}, {'destination': 'x' * 256},
|
{'dispatchDate': 1792972800}, {'dispatchDate': '2026-10-26T00:00:00'}, {'origin': 'x' * 256}, {'destination': 'x' * 256},
|
||||||
{'vehicleId': ''}, {'productId': ''}, {'remark': 'x' * 501}, {'calendarKind': 'salt'}, {'tenantNo': 't2'},
|
{'vehicleId': ''}, {'productId': ''}, {'remark': 'x' * 501}, {'calendarKind': 'salt'}, {'tenantNo': 't2'},
|
||||||
{'licensePlate': '伪造'}, {'productName': '伪造'}, {'createBy': 'other'},
|
{'licensePlate': '伪造'}, {'productName': '伪造'}, {'createBy': 'other'},
|
||||||
])
|
])
|
||||||
@@ -340,3 +340,51 @@ def test_calendar_openapi_schema_can_be_generated():
|
|||||||
for schema in ('CreateBody', 'UpdateBody'):
|
for schema in ('CreateBody', 'UpdateBody'):
|
||||||
field = document['components']['schemas'][schema]['properties']['dispatchDate']
|
field = document['components']['schemas'][schema]['properties']['dispatchDate']
|
||||||
assert field['type'] == 'string' and field['format'] == 'date'
|
assert field['type'] == 'string' and field['format'] == 'date'
|
||||||
|
|
||||||
|
|
||||||
|
def test_site_calendar_crud_isolation_and_kind_protection(db):
|
||||||
|
model, _, update, svc = modules()
|
||||||
|
existing = {kind: create(db, calendarKind=kind) for kind in ('acid', 'alkali', 'other')}
|
||||||
|
site = create(db, calendarKind='site', origin='现场', quantity='32.50')
|
||||||
|
original_id, original_created = site.id, site.create_time
|
||||||
|
page = svc.list_records(db, 't1', user(), month='2026-10', calendar_kind='site')
|
||||||
|
assert page['total'] == 1 and page['list'][0]['id'] == site.id
|
||||||
|
assert page['list'][0]['calendarKind'] == 'site'
|
||||||
|
assert svc.list_records(db, 't2', user(tenant='t2'), month='2026-10', calendar_kind='site')['total'] == 0
|
||||||
|
assert svc.list_records(db, 't1', user(), month='2026-10', calendar_kind='site', vehicle_id='v2')['total'] == 0
|
||||||
|
for kind, row in existing.items():
|
||||||
|
assert svc.list_records(db, 't1', user(), month='2026-10', calendar_kind=kind)['list'][0]['id'] == row.id
|
||||||
|
with pytest.raises(HTTPException) as moved:
|
||||||
|
svc.update_record(db, 't1', user(), site.id, update(**payload(calendarKind=kind)))
|
||||||
|
assert moved.value.status_code == 422
|
||||||
|
kept = svc.update_record(db, 't1', user(), site.id, update(**payload(calendarKind='site', destination='现场仓库')))
|
||||||
|
assert kept.id == original_id and kept.create_time == original_created
|
||||||
|
assert kept.calendar_kind == 'site' and kept.destination == '现场仓库'
|
||||||
|
assert svc.delete_record(db, 't1', user(), site.id) == 1
|
||||||
|
assert db.get(model, original_id).del_flag == 1
|
||||||
|
assert svc.list_records(db, 't1', user(), month='2026-10', calendar_kind='site')['total'] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('kind', ['acid', 'alkali', 'other', 'site'])
|
||||||
|
@pytest.mark.parametrize('addresses', [
|
||||||
|
{'origin': '', 'destination': '重庆'},
|
||||||
|
{'origin': '宜昌', 'destination': ''},
|
||||||
|
{'origin': ' ', 'destination': ' '},
|
||||||
|
])
|
||||||
|
def test_calendar_addresses_optional_on_create_and_update(db, kind, addresses):
|
||||||
|
_, schema, update, svc = modules()
|
||||||
|
created = create(db, calendarKind=kind, **addresses)
|
||||||
|
result = svc._row_to_dict(created)
|
||||||
|
assert result['origin'] == addresses['origin'].strip()
|
||||||
|
assert result['destination'] == addresses['destination'].strip()
|
||||||
|
existing = create(db, calendarKind=kind)
|
||||||
|
updated = svc.update_record(db, 't1', user(), existing.id,
|
||||||
|
update(**payload(calendarKind=kind, **addresses)))
|
||||||
|
assert updated.id == existing.id
|
||||||
|
assert updated.origin == addresses['origin'].strip()
|
||||||
|
assert updated.destination == addresses['destination'].strip()
|
||||||
|
omitted = payload(calendarKind=kind)
|
||||||
|
omitted.pop('origin')
|
||||||
|
omitted.pop('destination')
|
||||||
|
body = schema(**omitted)
|
||||||
|
assert body.origin == body.destination == ''
|
||||||
@@ -157,16 +157,23 @@ def test_explicit_transient_failure_is_bounded_retry(notices):
|
|||||||
assert db.query(Notice).one().status == 'failed'
|
assert db.query(Notice).one().status == 'failed'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('biz_type,path', [
|
@pytest.mark.parametrize('biz_type,page', [
|
||||||
('bx_advance', '/bx/advance/approve'),
|
('bx_advance', ''), ('bx_reimburse', ''), ('pl_recharge', ''),
|
||||||
('bx_reimburse', '/bx/reimburse/approve'),
|
('pl_freight', ''),
|
||||||
('pl_recharge', '/vehicle-mgmt/etc-recharge/vehicle-recharge-approval'),
|
('crm_quote', 'pages/common/mp-webview?path=quote-detail'),
|
||||||
|
('crm_contract', 'pages/common/mp-webview?path=contract-detail'),
|
||||||
])
|
])
|
||||||
def test_pending_links_to_real_approval_screen(monkeypatch, biz_type, path):
|
def test_worker_preserves_mini_page_without_web_fallback(notices, monkeypatch, biz_type, page):
|
||||||
from types import SimpleNamespace
|
svc, db, snapshot, Notice, Binding = notices
|
||||||
from app.services import wechat_approval_notices as svc
|
|
||||||
monkeypatch.setattr(svc.settings, 'WECHAT_OA_PUBLIC_BASE_URL', 'https://portal.example.test')
|
monkeypatch.setattr(svc.settings, 'WECHAT_OA_PUBLIC_BASE_URL', 'https://portal.example.test')
|
||||||
assert svc._fallback_url(SimpleNamespace(business_type=biz_type, kind='pending')) == 'https://portal.example.test/#' + path
|
snapshot.page = page
|
||||||
|
bind(db, Binding)
|
||||||
|
svc.sync_approval_notifications(db, biz_type, 'x', 't1', new_cycle=True)
|
||||||
|
db.commit()
|
||||||
|
client = FakeClient()
|
||||||
|
assert svc.deliver_pending(db, client=client) == 1
|
||||||
|
assert client.calls[0]['page'] == page
|
||||||
|
assert not client.calls[0].get('url')
|
||||||
|
|
||||||
|
|
||||||
def test_claimed_assignment_invalid_after_transfer_back(notices):
|
def test_claimed_assignment_invalid_after_transfer_back(notices):
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ def test_daily_dedup_template_and_tenant_scope(cert_notices):
|
|||||||
assert notice.cycle_id == "2026-10-26"
|
assert notice.cycle_id == "2026-10-26"
|
||||||
assert notice.template_id == svc.settings.WECHAT_OA_TEMPLATE_CERT_REMINDER
|
assert notice.template_id == svc.settings.WECHAT_OA_TEMPLATE_CERT_REMINDER
|
||||||
assert json.loads(notice.payload) == {
|
assert json.loads(notice.payload) == {
|
||||||
"thing5": {"value": "1项即将到期"},
|
"thing5": {"value": "证件到期1项"},
|
||||||
"time6": {"value": "2026-10-26 10:00:00"},
|
"time6": {"value": "2026-10-26 10:00:00"},
|
||||||
"thing3": {"value": "系统默认发出"},
|
"thing3": {"value": "系统默认发出"},
|
||||||
}
|
}
|
||||||
@@ -198,7 +198,7 @@ def test_duplicate_reminders_and_stale_remaining_days_do_not_change_daily_gate(c
|
|||||||
days_remaining=900, create_by="t1-a"))
|
days_remaining=900, create_by="t1-a"))
|
||||||
assert svc.enqueue_daily_cert_notices(db, now=now) == 1
|
assert svc.enqueue_daily_cert_notices(db, now=now) == 1
|
||||||
assert db.query(Notice).count() == 1
|
assert db.query(Notice).count() == 1
|
||||||
assert json.loads(db.query(Notice).one().payload)["thing5"]["value"] == "1项即将到期"
|
assert json.loads(db.query(Notice).one().payload)["thing5"]["value"] == "证件到期1项"
|
||||||
cert.expire_date = now.date() + timedelta(days=31)
|
cert.expire_date = now.date() + timedelta(days=31)
|
||||||
assert svc.refresh_cert_notice(db, db.query(Notice).one(), now=now) is False
|
assert svc.refresh_cert_notice(db, db.query(Notice).one(), now=now) is False
|
||||||
|
|
||||||
@@ -211,10 +211,10 @@ def test_summary_counts_expired_and_upcoming_and_refreshes_after_renewal(cert_no
|
|||||||
add_cert("hidden", creator="other", days=-1)
|
add_cert("hidden", creator="other", days=-1)
|
||||||
svc.enqueue_daily_cert_notices(db, now=now)
|
svc.enqueue_daily_cert_notices(db, now=now)
|
||||||
notice = db.query(Notice).one()
|
notice = db.query(Notice).one()
|
||||||
assert json.loads(notice.payload)["thing5"]["value"] == "1项即将到期,2项已到期"
|
assert json.loads(notice.payload)["thing5"]["value"] == "证件到期1项,已到期2项"
|
||||||
expired.expire_date = now.date() + timedelta(days=60)
|
expired.expire_date = now.date() + timedelta(days=60)
|
||||||
assert svc.refresh_cert_notice(db, notice, now=now) is True
|
assert svc.refresh_cert_notice(db, notice, now=now) is True
|
||||||
assert json.loads(notice.payload)["thing5"]["value"] == "1项即将到期,1项已到期"
|
assert json.loads(notice.payload)["thing5"]["value"] == "证件到期1项,已到期1项"
|
||||||
|
|
||||||
|
|
||||||
def test_long_summary_uses_short_total_title(cert_notices):
|
def test_long_summary_uses_short_total_title(cert_notices):
|
||||||
|
|||||||
@@ -40,11 +40,12 @@ def test_daily_worker_refreshes_real_counts_and_send_time(cert_notices, monkeypa
|
|||||||
client = Client()
|
client = Client()
|
||||||
assert deliver_pending(db, client=client) == 1
|
assert deliver_pending(db, client=client) == 1
|
||||||
assert client.calls[0]['data'] == {
|
assert client.calls[0]['data'] == {
|
||||||
'thing5': {'value': '1项即将到期,1项已到期'},
|
'thing5': {'value': '证件到期1项,已到期1项'},
|
||||||
'thing3': {'value': '系统默认发出'},
|
'thing3': {'value': '系统默认发出'},
|
||||||
'time6': {'value': '2026-10-26 10:05:00'},
|
'time6': {'value': '2026-10-26 10:05:00'},
|
||||||
}
|
}
|
||||||
assert db.query(Notice).one().status == 'sent'
|
assert db.query(Notice).one().status == 'sent'
|
||||||
|
assert not client.calls[0].get('url')
|
||||||
assert deliver_pending(db, client=client) == 0
|
assert deliver_pending(db, client=client) == 0
|
||||||
|
|
||||||
|
|
||||||
@@ -59,10 +60,13 @@ def test_completion_delivered_once_without_approval_cycle(completion_db, kind):
|
|||||||
assert row is not None
|
assert row is not None
|
||||||
recipient = row.recipient_id
|
recipient = row.recipient_id
|
||||||
expected = json.loads(row.payload)
|
expected = json.loads(row.payload)
|
||||||
|
expected_page = row.page
|
||||||
binding(db, recipient)
|
binding(db, recipient)
|
||||||
client = Client()
|
client = Client()
|
||||||
assert deliver_pending(db, client=client) == 1
|
assert deliver_pending(db, client=client) == 1
|
||||||
assert client.calls[0]['data'] == expected
|
assert client.calls[0]['data'] == expected
|
||||||
|
assert client.calls[0]['page'] == expected_page
|
||||||
|
assert bool(expected_page) == (kind != 'pl_freight')
|
||||||
assert 'amount' not in json.dumps(expected)
|
assert 'amount' not in json.dumps(expected)
|
||||||
assert db.query(WechatNotice).one().status == 'sent'
|
assert db.query(WechatNotice).one().status == 'sent'
|
||||||
assert deliver_pending(db, client=client) == 0
|
assert deliver_pending(db, client=client) == 0
|
||||||
@@ -162,11 +162,10 @@ def test_worker_dispatches_independent_events_without_approval_cycle(db, monkeyp
|
|||||||
assert worker.deliver_pending(db, client=client) == 1
|
assert worker.deliver_pending(db, client=client) == 1
|
||||||
assert checked == [row.id]
|
assert checked == [row.id]
|
||||||
assert sent[0]['openid'] == 'service-openid'
|
assert sent[0]['openid'] == 'service-openid'
|
||||||
|
assert not sent[0].get('url')
|
||||||
assert worker.deliver_pending(db, client=client) == 0
|
assert worker.deliver_pending(db, client=client) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_cert_daily_uses_existing_reminder_list(db, monkeypatch):
|
def test_cert_daily_has_no_mini_page(db):
|
||||||
from app.services.wechat_approval_notices import _fallback_url
|
|
||||||
monkeypatch.setattr(settings, 'WECHAT_OA_PUBLIC_BASE_URL', 'https://portal.example')
|
|
||||||
row = enqueue(db, kind='cert_daily', business_type='pl_cert_expiry')
|
row = enqueue(db, kind='cert_daily', business_type='pl_cert_expiry')
|
||||||
assert _fallback_url(row) == 'https://portal.example/#/pl/document-reminder-module/document-reminder'
|
assert not row.page
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- 手工派车日历增加 site(现场日历)。仅更新 COMMENT,无结构变更。
|
||||||
|
-- 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 = 'calendar_kind'
|
||||||
|
);
|
||||||
|
SET @ddl = IF(
|
||||||
|
@col_exists = 1,
|
||||||
|
'ALTER TABLE `pl_dispatch_calendar` MODIFY COLUMN `calendar_kind` varchar(16) NOT NULL DEFAULT ''acid'' COMMENT ''日历类型:acid酸派车 alkali碱派车 other其他 site现场''',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
@@ -36468,7 +36468,7 @@ CREATE TABLE IF NOT EXISTS `pl_dispatch_calendar` (
|
|||||||
`origin` varchar(255) NOT NULL COMMENT '始发地',
|
`origin` varchar(255) NOT NULL COMMENT '始发地',
|
||||||
`destination` varchar(255) NOT NULL COMMENT '目的地',
|
`destination` varchar(255) NOT NULL COMMENT '目的地',
|
||||||
`quantity` varchar(16) DEFAULT NULL COMMENT '数量文本(吨),最多两位小数,不参与汇总',
|
`quantity` varchar(16) DEFAULT NULL COMMENT '数量文本(吨),最多两位小数,不参与汇总',
|
||||||
`calendar_kind` varchar(16) NOT NULL DEFAULT 'acid' COMMENT '日历类型:acid酸派车 alkali碱派车',
|
`calendar_kind` varchar(16) NOT NULL DEFAULT 'acid' COMMENT '日历类型:acid酸派车 alkali碱派车 other其他 site现场',
|
||||||
`tenant_no` varchar(64) NOT NULL COMMENT '租户编号',
|
`tenant_no` varchar(64) NOT NULL COMMENT '租户编号',
|
||||||
`order_num` int DEFAULT NULL COMMENT '排序号',
|
`order_num` int DEFAULT NULL COMMENT '排序号',
|
||||||
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
|
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 现场日历实施计划
|
||||||
|
|
||||||
|
**Goal:** 在其他日历后增加功能相同的现场日历。
|
||||||
|
|
||||||
|
**Architecture:** 复用 ManualCalendar,以 site 作为独立类型;后端 schema/service 同步允许该类型,保留既有权限和租户过滤。
|
||||||
|
|
||||||
|
**Tech Stack:** Vue 3、TypeScript、FastAPI、SQLAlchemy、pytest、Playwright。
|
||||||
|
|
||||||
|
- [x] 在 backend/tests/test_pl_dispatch_calendar.py 添加现场日历 CRUD、租户及类型隔离、禁止跨类型编辑与软删除测试,确认旧校验拒绝 site。
|
||||||
|
- [x] 扩展 backend/app/schemas/pl/dispatch_calendar.py、backend/app/services/pl_dispatch_calendar_service.py 类型校验与错误提示。
|
||||||
|
- [x] 更新 web-admin/src/api/pl/dispatch-calendar.ts 类型和 web-admin/src/views/pl/dispatch-calendar/index.vue tab、主题与组件参数。
|
||||||
|
- [x] 更新 backend/app/models/pl_dispatch_calendar.py、docs/sql/shanxia2.0.sql 注释,增加 docs/sql/migrations/2026-10-30-dispatch-calendar-site-kind.sql。
|
||||||
|
- [x] 运行 backend/venv/bin/python -m pytest(backend 工作目录,tests/test_pl_dispatch_calendar.py 与 tests/test_pl_dispatch_calendar_migration.py)。
|
||||||
|
- [x] 运行前端相关 tsx 单元测试、目标文件 ESLint 与 pnpm build。
|
||||||
|
- [x] 运行 web-admin/scripts/test-manual-dispatch-calendar.mjs 浏览器回归,检查现场 tab、增删改、复制、失败重试与只读权限。
|
||||||
|
- [x] 复核最终 diff 与 git diff --check。
|
||||||
|
|
||||||
|
验证结果:后端 69 项通过;前端单元测试 29 项通过;Web 酸日历、Web 现场日历与 H5 酸日历浏览器回归通过;目标文件 ESLint、生产构建及 diff 空白检查通过。构建保留现有组件重名和大分包警告。未执行生产数据库迁移或部署。
|
||||||
|
|
||||||
|
## 后续:起止地选填
|
||||||
|
|
||||||
|
- [x] 四种日历分别覆盖单边空、两边空白、编辑清空与请求遗漏地址字段的回归,确认旧校验失败后修改。
|
||||||
|
- [x] 共用前端 validator 与后端 schema 取消非空限制,Web/H5 取消必填标记,保留长度校验。
|
||||||
|
- [x] 后端 81 项、前端 29 项测试通过。ESLint 检出原文件已有的 ElMessageBox 全局声明及格式问题,使用 HEAD 原文件复现;本次未改动无关代码。
|
||||||
|
- [x] Web 四种日历与 H5 酸日历浏览器回归通过,包含留空创建后编辑补填;生产构建通过,diff 空白检查通过。未部署线上。
|
||||||
|
|
||||||
|
## 数据库执行记录
|
||||||
|
|
||||||
|
经用户明确授权,已在测试库和生产库执行 `docs/sql/migrations/2026-10-30-dispatch-calendar-site-kind.sql`,并核验 `pl_dispatch_calendar.calendar_kind` 注释增加 `site现场`。两库字段类型 `varchar(16)`、非空约束、默认值 `acid`、字符集及排序规则保持不变,未修改业务数据。应用代码尚未部署。
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 派车日历增加现场日历
|
||||||
|
|
||||||
|
需求:Web `/pl/calendar-module/dispatch-calendar` 在「其他日历」之后、「实际派车信息」之前新增「现场日历」,功能与前三个手工日历一致。
|
||||||
|
|
||||||
|
复用当前 `web-admin/src/views/pl/dispatch-calendar/ManualCalendar.vue` 与 P08 页内 Tab 模式。新增 `calendarKind=site`,沿用日期导航、车辆筛选、新增、编辑、软删除、复制、汇总、打印和权限控制。记录按类型隔离,禁止编辑时跨日历移动;默认酸日历与实际派车信息保持原行为。本次范围为用户指定的 Web 页面。
|
||||||
|
|
||||||
|
后端扩展创建、更新及列表类型校验,使用原有 VARCHAR(16) 字段,无需新表或菜单。SQL 快照及独立迁移更新字段注释。租户、数据范围、维护权限与审计仍走原调用链。
|
||||||
|
|
||||||
|
验收覆盖:五个 tab 顺序、现场记录创建与编辑、其他类型及租户不可混入列表、跨类型修改拒绝、软删除、复制、失败重试及只读权限。浏览器使用模拟 API;服务层使用 SQLite 回归,生产发布独立执行。
|
||||||
|
|
||||||
|
后续调整:四个手工日历的始发地、目的地改为选填。共用表单取消必填标记和非空校验,后端允许空字符串,遗漏默认空字符串,仍限制最多 255 字符。编辑时可清空已有地址;H5 共用表单同步取消必填标记,数据库保持非 NULL 字段。
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
必选3模板 `rnPOIVknPqfgwVzKbchj6pCMCWpoDe3h3dkrFbUEsD4`:thing3 为“备用金发放/报销支付/运费发放/充值发放”,thing4 为完整业务单号,thing5 为“发放完成/支付完成”,time12 为真实处理时间,无金额字段。单号为空或超过20字符记为 failed,不截断发送。BX重复完成操作保留首次处理时间;撤销充值发放或更换关联申请时取消尚未发出的旧通知。
|
必选3模板 `rnPOIVknPqfgwVzKbchj6pCMCWpoDe3h3dkrFbUEsD4`:thing3 为“备用金发放/报销支付/运费发放/充值发放”,thing4 为完整业务单号,thing5 为“发放完成/支付完成”,time12 为真实处理时间,无金额字段。单号为空或超过20字符记为 failed,不截断发送。BX重复完成操作保留首次处理时间;撤销充值发放或更换关联申请时取消尚未发出的旧通知。
|
||||||
|
|
||||||
必选6模板 `aYzLZ_EqI8hq7RXrlf73BTAfNvzgJHeg_xWKar01Md0`:thing5 示例“3项即将到期,2项已到期”,time6 为实际发送的北京时间,thing3 **固定为“系统默认发出”**。当天到期计入“已到期”,沿用提醒列表统计口径。
|
必选6模板 `aYzLZ_EqI8hq7RXrlf73BTAfNvzgJHeg_xWKar01Md0`:thing5 示例“证件到期3项,已到期2项”(前项统计即将到期证件),仅即将到期时为“证件到期3项”,仅已到期时为“证件已到期2项”;time6 为实际发送的北京时间,thing3 **固定为“系统默认发出”**。当天到期计入“已到期”,沿用提醒列表统计口径。
|
||||||
|
|
||||||
1. 执行 `docs/sql/20261027_wechat_cert_notice_role.sql`。脚本兼容 MySQL 5.7,使用临时存储过程,需要 CREATE ROUTINE 权限;为已有租户幂等创建“证件到期通知接收人”角色,并关联证件提醒 Web 菜单及祖先目录。
|
1. 执行 `docs/sql/20261027_wechat_cert_notice_role.sql`。脚本兼容 MySQL 5.7,使用临时存储过程,需要 CREATE ROUTINE 权限;为已有租户幂等创建“证件到期通知接收人”角色,并关联证件提醒 Web 菜单及祖先目录。
|
||||||
2. 在系统角色/用户管理中给指定人员分配该角色;脚本不自动分配人员。角色名称需要与 `WECHAT_OA_CERT_NOTICE_ROLE_NAME` 一致。
|
2. 在系统角色/用户管理中给指定人员分配该角色;脚本不自动分配人员。角色名称需要与 `WECHAT_OA_CERT_NOTICE_ROLE_NAME` 一致。
|
||||||
@@ -51,7 +51,7 @@ WECHAT_OA_CERT_REMINDER_HOUR=10
|
|||||||
WECHAT_OA_CERT_REMINDER_MINUTE=0
|
WECHAT_OA_CERT_REMINDER_MINUTE=0
|
||||||
```
|
```
|
||||||
|
|
||||||
`WECHAT_OA_MINI_APPID` 必须是已关联的门户小程序,不能填服务号AppID。未配置时消息使用门户Web回退入口。小程序与服务号OpenID分别保存,不能互换。
|
`WECHAT_OA_MINI_APPID` 必须是已关联的门户小程序,不能填服务号AppID。未配置时消息仅提醒,不提供跳转;不再回退到门户Web。小程序与服务号OpenID分别保存,不能互换。
|
||||||
|
|
||||||
证件提醒开关同时受 `WECHAT_OA_ENABLED` 总开关控制;修改小时/分钟需要重启调度器。证件扫描与微信摘要使用同一调度任务,扫描仍每日执行,微信入队和发送复核均限制在周一、周三、周五。部署时如私有环境仍显式配置旧值8,须改为10并重启后端;本次未修改私有环境。通知关闭时仍执行原证件扫描;已安装队列表时,充值撤销操作仍会取消旧完成通知,避免重新启用后复活。
|
证件提醒开关同时受 `WECHAT_OA_ENABLED` 总开关控制;修改小时/分钟需要重启调度器。证件扫描与微信摘要使用同一调度任务,扫描仍每日执行,微信入队和发送复核均限制在周一、周三、周五。部署时如私有环境仍显式配置旧值8,须改为10并重启后端;本次未修改私有环境。通知关闭时仍执行原证件扫描;已安装队列表时,充值撤销操作仍会取消旧完成通知,避免重新启用后复活。
|
||||||
|
|
||||||
@@ -80,10 +80,11 @@ WECHAT_OA_CERT_REMINDER_MINUTE=0
|
|||||||
### 消息跳转
|
### 消息跳转
|
||||||
|
|
||||||
- 报价、合同:统一小程序 WebView 详情。
|
- 报价、合同:统一小程序 WebView 详情。
|
||||||
- 备用金、报销:待办跳现有Web审批列表,结果跳小程序详情。当前小程序详情没有审批按钮。
|
- 备用金、报销:待办仅提醒,不提供跳转;结果跳小程序详情。当前小程序详情没有审批按钮。
|
||||||
- 充值:待办跳Web充值审批列表,结果跳现有小程序申请详情。当前小程序表单没有审批按钮。
|
- 充值:待办仅提醒,不提供跳转;结果跳现有小程序申请详情。当前小程序表单没有审批按钮。
|
||||||
- 运费:现有仓库没有移动运费审批页,消息使用Web运费审批/结算入口;需登录门户处理。本次不扩建运费移动功能。
|
- 运费:审批待办、审批结果、发放完成均仅提醒,不提供跳转。
|
||||||
- 发放完成:备用金、报销、充值复用已有小程序详情,运费进入Web结算入口。证件汇总进入 `/#/pl/document-reminder-module/document-reminder`;按现有登录和数据权限查看。
|
- 发放完成:备用金、报销、充值复用已有小程序详情。证件汇总仅提醒,不提供跳转。
|
||||||
|
- 所有通知停止附带 Web URL,包括小程序无法打开时的门户回退;待发送队列也按新规则发送。公网基址仍用于服务号绑定授权,不应删除。已送达的历史消息链接不受此次代码变更影响。
|
||||||
- 点击消息不授予额外业务权限,依赖现有登录、菜单和单据权限。若未登录小程序,现有登录流程可能回到工作台,需要重新点击原消息。
|
- 点击消息不授予额外业务权限,依赖现有登录、菜单和单据权限。若未登录小程序,现有登录流程可能回到工作台,需要重新点击原消息。
|
||||||
|
|
||||||
## 发送记录与失败处理
|
## 发送记录与失败处理
|
||||||
|
|||||||
@@ -107,8 +107,22 @@ test('同一车辆按创建时间正序,复制时排在一起且每条换行',
|
|||||||
])
|
])
|
||||||
assert.equal(text.startsWith('2026-10-28'), true)
|
assert.equal(text.startsWith('2026-10-28'), true)
|
||||||
})
|
})
|
||||||
test('表单拒绝空白、无效日期,保留手写起止地', () => {
|
test('表单允许起止地留空,仍拒绝无效日期和超长地址', () => {
|
||||||
assert.equal(validateManualDraft({ ...row('1'), origin: ' ' }), '请填写始发地')
|
for (const calendarKind of ['acid', 'alkali', 'other', 'site'] as const) {
|
||||||
|
for (const addresses of [
|
||||||
|
{ origin: '', destination: '云栋' },
|
||||||
|
{ origin: '山峡', destination: '' },
|
||||||
|
{ origin: ' ', destination: ' ' }
|
||||||
|
]) {
|
||||||
|
assert.equal(validateManualDraft({ ...row('1'), calendarKind, ...addresses }), '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const field of ['origin', 'destination']) {
|
||||||
|
assert.equal(
|
||||||
|
validateManualDraft({ ...row('1'), [field]: '地'.repeat(256) }),
|
||||||
|
'始发地和目的地最多255个字符'
|
||||||
|
)
|
||||||
|
}
|
||||||
assert.equal(
|
assert.equal(
|
||||||
validateManualDraft({ ...row('1'), dispatchDate: '2026-02-30' }),
|
validateManualDraft({ ...row('1'), dispatchDate: '2026-02-30' }),
|
||||||
'请选择有效的派车日期'
|
'请选择有效的派车日期'
|
||||||
@@ -118,7 +132,9 @@ test('表单拒绝空白、无效日期,保留手写起止地', () => {
|
|||||||
test('数量是可空文本,复制逐条展示,日汇总仍只计车次', () => {
|
test('数量是可空文本,复制逐条展示,日汇总仍只计车次', () => {
|
||||||
const tons = { ...row('1'), quantity: '32.50' }
|
const tons = { ...row('1'), quantity: '32.50' }
|
||||||
const blank = { ...row('2'), quantity: ' ' }
|
const blank = { ...row('2'), quantity: ' ' }
|
||||||
const day = manualCalendarDays([tons, blank], '2026-10').find((item) => item.date === '2026-10-26')!
|
const day = manualCalendarDays([tons, blank], '2026-10').find(
|
||||||
|
(item) => item.date === '2026-10-26'
|
||||||
|
)!
|
||||||
assert.deepEqual(day.products[0], { id: 'p1', name: '液碱', count: 2 })
|
assert.deepEqual(day.products[0], { id: 'p1', name: '液碱', count: 2 })
|
||||||
const text = manualCopyText('2026-10-26', [tons, blank])
|
const text = manualCopyText('2026-10-26', [tons, blank])
|
||||||
assert.match(text, /液碱 32\.50吨 山峡 → 云栋/)
|
assert.match(text, /液碱 32\.50吨 山峡 → 云栋/)
|
||||||
@@ -130,8 +146,5 @@ test('数量是可空文本,复制逐条展示,日汇总仍只计车次', ()
|
|||||||
validateManualDraft({ ...row('1'), quantity: '12.345' }),
|
validateManualDraft({ ...row('1'), quantity: '12.345' }),
|
||||||
'数量须为数字,最多两位小数'
|
'数量须为数字,最多两位小数'
|
||||||
)
|
)
|
||||||
assert.equal(
|
assert.equal(validateManualDraft({ ...row('1'), quantity: '1吨' }), '数量须为数字,最多两位小数')
|
||||||
validateManualDraft({ ...row('1'), quantity: '1吨' }),
|
|
||||||
'数量须为数字,最多两位小数'
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
@@ -2,7 +2,13 @@ import assert from 'node:assert/strict'
|
|||||||
import { chromium } from 'playwright'
|
import { chromium } from 'playwright'
|
||||||
const browser = await chromium.launch({ headless: true, channel: 'chrome' })
|
const browser = await chromium.launch({ headless: true, channel: 'chrome' })
|
||||||
try {
|
try {
|
||||||
for (const mobile of [false, true]) {
|
for (const { mobile, kind, title } of [
|
||||||
|
{ mobile: false, kind: 'acid', title: '酸派车日历' },
|
||||||
|
{ mobile: false, kind: 'alkali', title: '碱派车日历' },
|
||||||
|
{ mobile: false, kind: 'other', title: '其他日历' },
|
||||||
|
{ mobile: false, kind: 'site', title: '现场日历' },
|
||||||
|
{ mobile: true, kind: 'acid', title: '酸派车日历' }
|
||||||
|
]) {
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
permissions: ['clipboard-read', 'clipboard-write'],
|
permissions: ['clipboard-read', 'clipboard-write'],
|
||||||
viewport: mobile ? { width: 375, height: 812 } : { width: 1440, height: 1000 }
|
viewport: mobile ? { width: 375, height: 812 } : { width: 1440, height: 1000 }
|
||||||
@@ -132,6 +138,13 @@ try {
|
|||||||
console.log('PAGE', await page.locator('body').innerText(), errors)
|
console.log('PAGE', await page.locator('body').innerText(), errors)
|
||||||
throw e
|
throw e
|
||||||
})
|
})
|
||||||
|
if (!mobile) {
|
||||||
|
assert.deepEqual(
|
||||||
|
await page.locator('.dispatch-calendar-tabs [role="tab"]').allTextContents(),
|
||||||
|
['酸派车日历', '碱派车日历', '其他日历', '现场日历', '实际派车信息']
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await page.getByRole('tab', { name: title, exact: true }).click()
|
||||||
if (mobile) {
|
if (mobile) {
|
||||||
await page.getByLabel('查看月份', { exact: true }).click()
|
await page.getByLabel('查看月份', { exact: true }).click()
|
||||||
const popup = page.locator('.mp-calendar-date-picker:visible')
|
const popup = page.locator('.mp-calendar-date-picker:visible')
|
||||||
@@ -175,6 +188,15 @@ try {
|
|||||||
await editor.getByRole('combobox', { name: '产品名称', exact: true }).click()
|
await editor.getByRole('combobox', { name: '产品名称', exact: true }).click()
|
||||||
await page.getByRole('option', { name: '液碱', exact: true }).click()
|
await page.getByRole('option', { name: '液碱', exact: true }).click()
|
||||||
}
|
}
|
||||||
|
// 起止地留空也应保存成功,编辑后仍可补填。
|
||||||
|
await editor.getByRole('button', { name: '保存', exact: true }).click()
|
||||||
|
await editor.waitFor({ state: 'hidden' })
|
||||||
|
assert.equal(rows.length, 1)
|
||||||
|
assert.equal(rows[0].origin, '')
|
||||||
|
assert.equal(rows[0].destination, '')
|
||||||
|
assert.equal(rows[0].calendarKind, kind)
|
||||||
|
await page.locator('[data-testid="manual-record"]').first().click()
|
||||||
|
await page.getByRole('button', { name: '编辑', exact: true }).click()
|
||||||
await editor.getByPlaceholder('请输入始发地').fill('山峡')
|
await editor.getByPlaceholder('请输入始发地').fill('山峡')
|
||||||
await editor.getByPlaceholder('请输入目的地').fill('云栋')
|
await editor.getByPlaceholder('请输入目的地').fill('云栋')
|
||||||
failSave = true
|
failSave = true
|
||||||
@@ -184,10 +206,11 @@ try {
|
|||||||
await editor.getByRole('button', { name: '保存', exact: true }).click()
|
await editor.getByRole('button', { name: '保存', exact: true }).click()
|
||||||
await editor.waitFor({ state: 'hidden' })
|
await editor.waitFor({ state: 'hidden' })
|
||||||
assert.equal(rows.length, 1)
|
assert.equal(rows.length, 1)
|
||||||
|
assert.equal(rows[0].calendarKind, kind)
|
||||||
const card = page.locator('[data-testid="manual-record"]').first()
|
const card = page.locator('[data-testid="manual-record"]').first()
|
||||||
await card.waitFor()
|
await card.waitFor()
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: `/tmp/manual-calendar-${mobile ? 'mobile' : 'web'}.png`,
|
path: `/tmp/manual-calendar-${mobile ? 'mobile' : 'web'}-${kind}.png`,
|
||||||
fullPage: false
|
fullPage: false
|
||||||
})
|
})
|
||||||
assert.match(await card.innerText(), /山峡 → 云栋/)
|
assert.match(await card.innerText(), /山峡 → 云栋/)
|
||||||
@@ -197,7 +220,7 @@ try {
|
|||||||
.click()
|
.click()
|
||||||
assert.match(
|
assert.match(
|
||||||
await page.evaluate(() => navigator.clipboard.readText()),
|
await page.evaluate(() => navigator.clipboard.readText()),
|
||||||
/皖H12345.*液碱.*山峡 → 云栋/
|
/皖H12345.*液碱.*山峡 → 云栋/s
|
||||||
)
|
)
|
||||||
|
|
||||||
await card.click()
|
await card.click()
|
||||||
@@ -213,7 +236,7 @@ try {
|
|||||||
assert.equal(rows.length, 0)
|
assert.equal(rows.length, 0)
|
||||||
failLoad = true
|
failLoad = true
|
||||||
await page.getByRole('tab', { name: '实际派车信息', exact: true }).click()
|
await page.getByRole('tab', { name: '实际派车信息', exact: true }).click()
|
||||||
await page.getByRole('tab', { name: '酸派车日历', exact: true }).click()
|
await page.getByRole('tab', { name: title, exact: true }).click()
|
||||||
await page.getByText('派车日历加载失败,请重试', { exact: false }).first().waitFor()
|
await page.getByText('派车日历加载失败,请重试', { exact: false }).first().waitFor()
|
||||||
await page.getByRole('button', { name: '重试', exact: true }).click()
|
await page.getByRole('button', { name: '重试', exact: true }).click()
|
||||||
await page
|
await page
|
||||||
@@ -240,10 +263,10 @@ try {
|
|||||||
origin: '山峡',
|
origin: '山峡',
|
||||||
destination: '云栋',
|
destination: '云栋',
|
||||||
remark: '',
|
remark: '',
|
||||||
calendarKind: 'acid'
|
calendarKind: kind
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
await page.getByRole('tab', { name: '酸派车日历', exact: true }).click()
|
await page.getByRole('tab', { name: title, exact: true }).click()
|
||||||
await page.locator('[data-testid="manual-record"]').first().waitFor()
|
await page.locator('[data-testid="manual-record"]').first().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('[data-testid="manual-record"]').first().click()
|
await page.locator('[data-testid="manual-record"]').first().click()
|
||||||
@@ -251,7 +274,7 @@ try {
|
|||||||
assert.equal(await page.getByRole('button', { name: '删除', exact: true }).count(), 0)
|
assert.equal(await page.getByRole('button', { name: '删除', exact: true }).count(), 0)
|
||||||
assert.deepEqual(errors, [])
|
assert.deepEqual(errors, [])
|
||||||
await context.close()
|
await context.close()
|
||||||
console.log(mobile ? 'mobile CRUD passed' : 'web CRUD passed')
|
console.log(`${mobile ? 'mobile' : 'web'} ${kind} CRUD passed`)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close()
|
await browser.close()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import request, { pickBxData } from '@/utils/http/bx'
|
import request, { pickBxData } from '@/utils/http/bx'
|
||||||
|
|
||||||
export type DispatchCalendarKind = 'acid' | 'alkali' | 'other'
|
export type DispatchCalendarKind = 'acid' | 'alkali' | 'other' | 'site'
|
||||||
|
|
||||||
export interface DispatchCalendarBody {
|
export interface DispatchCalendarBody {
|
||||||
dispatchDate: string
|
dispatchDate: string
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { describe, it } from 'node:test'
|
||||||
|
import { countContractPdfPages } from './contract-pdf-pages'
|
||||||
|
|
||||||
|
function pixels(width: number, height: number, marks: number[]) {
|
||||||
|
const data = new Uint8ClampedArray(width * height * 4).fill(255)
|
||||||
|
for (const row of marks) data[(row * width + 1) * 4] = 0
|
||||||
|
return { width, height, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('countContractPdfPages', () => {
|
||||||
|
it('ignores trailing white padding that crosses onto a second page', () => {
|
||||||
|
assert.equal(countContractPdfPages(pixels(210, 330, [20, 295])), 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps even a single visible pixel on a second page', () => {
|
||||||
|
assert.equal(countContractPdfPages(pixels(210, 330, [20, 297])), 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not add a page when content ends exactly at the page boundary', () => {
|
||||||
|
assert.equal(countContractPdfPages(pixels(210, 330, [296])), 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes only trailing blank pages from a multipage contract', () => {
|
||||||
|
assert.equal(countContractPdfPages(pixels(210, 900, [20, 580])), 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps one page for an empty document', () => {
|
||||||
|
assert.equal(countContractPdfPages(pixels(210, 330, [])), 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps faint content while ignoring fully transparent pixels', () => {
|
||||||
|
const image = pixels(210, 330, [20, 310])
|
||||||
|
const offset = (310 * image.width + 1) * 4
|
||||||
|
image.data[offset] = 254
|
||||||
|
assert.equal(countContractPdfPages(image), 2)
|
||||||
|
image.data[offset + 3] = 0
|
||||||
|
assert.equal(countContractPdfPages(image), 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/** 按不含水印的截图计算页数,仅移除没有任何可见像素的尾页。 */
|
||||||
|
export function countContractPdfPages(image: Pick<ImageData, 'width' | 'height' | 'data'>): number {
|
||||||
|
const { width, height, data } = image
|
||||||
|
const pageHeight = (width * 297) / 210
|
||||||
|
for (let y = height - 1; y >= 0; y -= 1) {
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
const offset = (y * width + x) * 4
|
||||||
|
// 不使用深色阈值,保留浅色文字、细表格线和印章。
|
||||||
|
if (
|
||||||
|
data[offset + 3] > 0 &&
|
||||||
|
(data[offset] < 255 || data[offset + 1] < 255 || data[offset + 2] < 255)
|
||||||
|
) {
|
||||||
|
return Math.max(1, Math.ceil((y + 1) / pageHeight))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
@@ -1059,6 +1059,7 @@ import jsPDF from 'jspdf';
|
|||||||
import { fitRichTextTablesToMaxWidth } from '@/utils/tinymce-table-excel-resize';
|
import { fitRichTextTablesToMaxWidth } from '@/utils/tinymce-table-excel-resize';
|
||||||
import { applySavedProductTableLayout, stampSavedProductTableLayout } from '@/utils/crm/contract-table-layout';
|
import { applySavedProductTableLayout, stampSavedProductTableLayout } from '@/utils/crm/contract-table-layout';
|
||||||
import { appendContractPageWatermarks, contractWatermarkPrintCss } from '@/utils/crm/contract-watermark';
|
import { appendContractPageWatermarks, contractWatermarkPrintCss } from '@/utils/crm/contract-watermark';
|
||||||
|
import { countContractPdfPages } from '@/utils/crm/contract-pdf-pages';
|
||||||
import {
|
import {
|
||||||
downloadCrmAttachment,
|
downloadCrmAttachment,
|
||||||
getCrmAttachmentCategoryLabel,
|
getCrmAttachmentCategoryLabel,
|
||||||
@@ -3205,14 +3206,25 @@ const handleGeneratePdfFromDialog = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 使用html2canvas转换为canvas(PNG 无损,避免 JPEG 压糊文字)
|
// 2. 使用html2canvas转换为canvas(PNG 无损,避免 JPEG 压糊文字)
|
||||||
const canvas = await html2canvas(tempDiv, {
|
const canvasOptions = {
|
||||||
scale: getContractPdfCanvasScale(),
|
scale: getContractPdfCanvasScale(),
|
||||||
useCORS: true,
|
useCORS: true,
|
||||||
allowTaint: true,
|
allowTaint: true,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
width: 794,
|
width: 794,
|
||||||
windowWidth: 794
|
windowWidth: 794
|
||||||
|
};
|
||||||
|
// 先排除装饰水印判断实际内容页数,模板底部 padding 不应生成空白尾页。
|
||||||
|
let canvas = await html2canvas(tempDiv, {
|
||||||
|
...canvasOptions,
|
||||||
|
ignoreElements: (element) => element.hasAttribute('data-contract-watermark')
|
||||||
});
|
});
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) throw new Error('无法读取合同截图');
|
||||||
|
const pageCount = countContractPdfPages(context.getImageData(0, 0, canvas.width, canvas.height));
|
||||||
|
if (isSXSupplier) {
|
||||||
|
canvas = await html2canvas(tempDiv, canvasOptions);
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 移除临时容器
|
// 3. 移除临时容器
|
||||||
document.body.removeChild(tempDiv);
|
document.body.removeChild(tempDiv);
|
||||||
@@ -3226,30 +3238,16 @@ const handleGeneratePdfFromDialog = async () => {
|
|||||||
const pdf = new jsPDF({
|
const pdf = new jsPDF({
|
||||||
orientation: 'portrait',
|
orientation: 'portrait',
|
||||||
unit: 'mm',
|
unit: 'mm',
|
||||||
format: 'a4'
|
format: 'a4',
|
||||||
|
compress: true // 无损压缩整页 PNG,避免原始像素直接写入导致 PDF 过大
|
||||||
});
|
});
|
||||||
|
|
||||||
const imgData = canvas.toDataURL('image/png');
|
const imgData = canvas.toDataURL('image/png');
|
||||||
|
|
||||||
// 6. 如果内容高度小于等于一页,直接添加
|
// 6. 只输出有正文的页面,保留原截图比例和分页位置。
|
||||||
if (imgHeight <= pageHeight) {
|
for (let page = 0; page < pageCount; page += 1) {
|
||||||
pdf.addImage(imgData, 'PNG', 0, 0, imgWidth, imgHeight);
|
if (page > 0) pdf.addPage();
|
||||||
} else {
|
pdf.addImage(imgData, 'PNG', 0, -page * pageHeight, imgWidth, imgHeight);
|
||||||
// 内容超过一页,需要分页
|
|
||||||
let heightLeft = imgHeight;
|
|
||||||
let position = 0;
|
|
||||||
|
|
||||||
// 添加第一页
|
|
||||||
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
|
||||||
heightLeft -= pageHeight;
|
|
||||||
|
|
||||||
// 添加后续页面
|
|
||||||
while (heightLeft > 0) {
|
|
||||||
position = -(imgHeight - heightLeft);
|
|
||||||
pdf.addPage();
|
|
||||||
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
|
||||||
heightLeft -= pageHeight;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. 下载PDF
|
// 7. 下载PDF
|
||||||
|
|||||||
@@ -898,6 +898,7 @@ import yzImg from '@/assets/images/yz.png';
|
|||||||
import ydImg from '@/assets/images/yd.png';
|
import ydImg from '@/assets/images/yd.png';
|
||||||
import html2canvas from 'html2canvas';
|
import html2canvas from 'html2canvas';
|
||||||
import jsPDF from 'jspdf';
|
import jsPDF from 'jspdf';
|
||||||
|
import { countContractPdfPages as countQuotePdfPages } from '@/utils/crm/contract-pdf-pages';
|
||||||
import { fitRichTextTablesToMaxWidth } from '@/utils/tinymce-table-excel-resize';
|
import { fitRichTextTablesToMaxWidth } from '@/utils/tinymce-table-excel-resize';
|
||||||
|
|
||||||
import QuoteFormDialog from './components/QuoteFormDialog.vue'
|
import QuoteFormDialog from './components/QuoteFormDialog.vue'
|
||||||
@@ -3062,6 +3063,7 @@ const handleGeneratePdf = async () => {
|
|||||||
// 如果是SX编码,添加水印背景层
|
// 如果是SX编码,添加水印背景层
|
||||||
if (showBackground) {
|
if (showBackground) {
|
||||||
const watermarkBg = document.createElement('div');
|
const watermarkBg = document.createElement('div');
|
||||||
|
watermarkBg.setAttribute('data-quote-watermark', '1');
|
||||||
watermarkBg.style.cssText = `
|
watermarkBg.style.cssText = `
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -3103,15 +3105,25 @@ const handleGeneratePdf = async () => {
|
|||||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||||
});
|
});
|
||||||
|
|
||||||
// 使用html2canvas转换为canvas
|
// 先排除水印判断实际内容页数,避免底部留白生成空白尾页。
|
||||||
const canvas = await html2canvas(tempDiv, {
|
const canvasOptions = {
|
||||||
scale: 2, // 提高清晰度
|
scale: 2, // 提高清晰度
|
||||||
useCORS: true,
|
useCORS: true,
|
||||||
allowTaint: true,
|
allowTaint: true,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
width: 794,
|
width: 794,
|
||||||
windowWidth: 794
|
windowWidth: 794
|
||||||
|
};
|
||||||
|
let canvas = await html2canvas(tempDiv, {
|
||||||
|
...canvasOptions,
|
||||||
|
ignoreElements: (element) => element.hasAttribute('data-quote-watermark')
|
||||||
});
|
});
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) throw new Error('无法读取报价单截图');
|
||||||
|
const pageCount = countQuotePdfPages(context.getImageData(0, 0, canvas.width, canvas.height));
|
||||||
|
if (showBackground) {
|
||||||
|
canvas = await html2canvas(tempDiv, canvasOptions);
|
||||||
|
}
|
||||||
|
|
||||||
// 移除临时容器
|
// 移除临时容器
|
||||||
document.body.removeChild(tempDiv);
|
document.body.removeChild(tempDiv);
|
||||||
@@ -3125,30 +3137,16 @@ const handleGeneratePdf = async () => {
|
|||||||
const pdf = new jsPDF({
|
const pdf = new jsPDF({
|
||||||
orientation: 'portrait',
|
orientation: 'portrait',
|
||||||
unit: 'mm',
|
unit: 'mm',
|
||||||
format: 'a4'
|
format: 'a4',
|
||||||
|
compress: true // 无损压缩整页 PNG,保留文字与表格清晰度
|
||||||
});
|
});
|
||||||
|
|
||||||
const imgData = canvas.toDataURL('image/jpeg', 1.0);
|
const imgData = canvas.toDataURL('image/png');
|
||||||
|
|
||||||
// 如果内容高度小于等于一页,直接添加
|
// 只输出有实际内容的页面,保留原截图比例和分页位置。
|
||||||
if (imgHeight <= pageHeight) {
|
for (let page = 0; page < pageCount; page += 1) {
|
||||||
pdf.addImage(imgData, 'JPEG', 0, 0, imgWidth, imgHeight);
|
if (page > 0) pdf.addPage();
|
||||||
} else {
|
pdf.addImage(imgData, 'PNG', 0, -page * pageHeight, imgWidth, imgHeight);
|
||||||
// 内容超过一页,需要分页
|
|
||||||
let heightLeft = imgHeight;
|
|
||||||
let position = 0;
|
|
||||||
|
|
||||||
// 添加第一页
|
|
||||||
pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight);
|
|
||||||
heightLeft -= pageHeight;
|
|
||||||
|
|
||||||
// 添加后续页面
|
|
||||||
while (heightLeft > 0) {
|
|
||||||
position = -(imgHeight - heightLeft);
|
|
||||||
pdf.addPage();
|
|
||||||
pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight);
|
|
||||||
heightLeft -= pageHeight;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载PDF
|
// 下载PDF
|
||||||
|
|||||||
@@ -128,14 +128,12 @@
|
|||||||
<van-field
|
<van-field
|
||||||
v-model="draft.origin"
|
v-model="draft.origin"
|
||||||
label="始发地"
|
label="始发地"
|
||||||
required
|
|
||||||
maxlength="255"
|
maxlength="255"
|
||||||
placeholder="请输入始发地"
|
placeholder="请输入始发地"
|
||||||
/>
|
/>
|
||||||
<van-field
|
<van-field
|
||||||
v-model="draft.destination"
|
v-model="draft.destination"
|
||||||
label="目的地"
|
label="目的地"
|
||||||
required
|
|
||||||
maxlength="255"
|
maxlength="255"
|
||||||
placeholder="请输入目的地"
|
placeholder="请输入目的地"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -179,10 +179,10 @@
|
|||||||
><template #append>吨</template></el-input
|
><template #append>吨</template></el-input
|
||||||
></el-form-item
|
></el-form-item
|
||||||
>
|
>
|
||||||
<el-form-item label="始发地" required
|
<el-form-item label="始发地"
|
||||||
><el-input v-model="draft.origin" maxlength="255" placeholder="请输入始发地"
|
><el-input v-model="draft.origin" maxlength="255" placeholder="请输入始发地"
|
||||||
/></el-form-item>
|
/></el-form-item>
|
||||||
<el-form-item label="目的地" required
|
<el-form-item label="目的地"
|
||||||
><el-input v-model="draft.destination" maxlength="255" placeholder="请输入目的地"
|
><el-input v-model="draft.destination" maxlength="255" placeholder="请输入目的地"
|
||||||
/></el-form-item>
|
/></el-form-item>
|
||||||
<el-form-item label="备注" class="form-col-span-2"
|
<el-form-item label="备注" class="form-col-span-2"
|
||||||
|
|||||||
@@ -3,18 +3,21 @@
|
|||||||
<div
|
<div
|
||||||
class="dispatch-calendar-tabs"
|
class="dispatch-calendar-tabs"
|
||||||
:class="{
|
:class="{
|
||||||
'manual-calendar-theme': active === 'acid' || active === 'alkali' || active === 'other'
|
'manual-calendar-theme':
|
||||||
|
active === 'acid' || active === 'alkali' || active === 'other' || active === 'site'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<el-tabs v-model="active">
|
<el-tabs v-model="active">
|
||||||
<el-tab-pane label="酸派车日历" name="acid" />
|
<el-tab-pane label="酸派车日历" name="acid" />
|
||||||
<el-tab-pane label="碱派车日历" name="alkali" />
|
<el-tab-pane label="碱派车日历" name="alkali" />
|
||||||
<el-tab-pane label="其他日历" name="other" />
|
<el-tab-pane label="其他日历" name="other" />
|
||||||
|
<el-tab-pane label="现场日历" name="site" />
|
||||||
<el-tab-pane label="实际派车信息" name="actual" />
|
<el-tab-pane label="实际派车信息" name="actual" />
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
<ManualCalendar v-if="active === 'acid'" calendar-kind="acid" title="酸派车日历" />
|
<ManualCalendar v-if="active === 'acid'" calendar-kind="acid" title="酸派车日历" />
|
||||||
<ManualCalendar v-else-if="active === 'alkali'" calendar-kind="alkali" title="碱派车日历" />
|
<ManualCalendar v-else-if="active === 'alkali'" calendar-kind="alkali" title="碱派车日历" />
|
||||||
<ManualCalendar v-else-if="active === 'other'" calendar-kind="other" title="其他日历" />
|
<ManualCalendar v-else-if="active === 'other'" calendar-kind="other" title="其他日历" />
|
||||||
|
<ManualCalendar v-else-if="active === 'site'" calendar-kind="site" title="现场日历" />
|
||||||
<ActualCalendar v-else />
|
<ActualCalendar v-else />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -118,8 +118,6 @@ export function validateManualDraft(body: DispatchCalendarBody): string {
|
|||||||
return '请选择有效的派车日期'
|
return '请选择有效的派车日期'
|
||||||
if (!body.vehicleId) return '请选择车辆'
|
if (!body.vehicleId) return '请选择车辆'
|
||||||
if (!body.productId) return '请选择产品'
|
if (!body.productId) return '请选择产品'
|
||||||
if (!body.origin.trim()) return '请填写始发地'
|
|
||||||
if (!body.destination.trim()) return '请填写目的地'
|
|
||||||
if (body.origin.trim().length > 255 || body.destination.trim().length > 255)
|
if (body.origin.trim().length > 255 || body.destination.trim().length > 255)
|
||||||
return '始发地和目的地最多255个字符'
|
return '始发地和目的地最多255个字符'
|
||||||
if (manualQuantityText(body.quantity) && !QUANTITY_TEXT.test(manualQuantityText(body.quantity)))
|
if (manualQuantityText(body.quantity) && !QUANTITY_TEXT.test(manualQuantityText(body.quantity)))
|
||||||
|
|||||||
Reference in new issue
Block a user