diff --git a/backend/app/models/pl_dispatch_calendar.py b/backend/app/models/pl_dispatch_calendar.py index 8eeac9a51..421c74092 100644 --- a/backend/app/models/pl_dispatch_calendar.py +++ b/backend/app/models/pl_dispatch_calendar.py @@ -24,7 +24,7 @@ class PlDispatchCalendar(Base): quantity = Column(String(16), nullable=True, comment="数量文本(吨),最多两位小数,不参与汇总") calendar_kind = Column( 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="租户编号") order_num = Column(Integer, nullable=True, comment="排序号") diff --git a/backend/app/schemas/pl/dispatch_calendar.py b/backend/app/schemas/pl/dispatch_calendar.py index 08b212ef7..dc16043be 100644 --- a/backend/app/schemas/pl/dispatch_calendar.py +++ b/backend/app/schemas/pl/dispatch_calendar.py @@ -12,8 +12,8 @@ class CreateBody(BaseModel): 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) product_id: str = Field(..., alias="productId", min_length=1, max_length=64) - origin: str = Field(..., min_length=1, max_length=255) - destination: str = Field(..., min_length=1, max_length=255) + origin: str = Field(default="", max_length=255) + 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) remark: Optional[str] = Field(default=None, max_length=500) @@ -51,8 +51,8 @@ class CreateBody(BaseModel): @field_validator("calendar_kind") @classmethod def validate_calendar_kind(cls, value: str) -> str: - if value not in ("acid", "alkali", "other"): - raise ValueError("日历类型须为 acid、alkali 或 other") + if value not in ("acid", "alkali", "other", "site"): + raise ValueError("日历类型须为 acid、alkali、other 或 site") return value diff --git a/backend/app/services/pl_dispatch_calendar_service.py b/backend/app/services/pl_dispatch_calendar_service.py index 4a3f493b3..6b32e71a8 100644 --- a/backend/app/services/pl_dispatch_calendar_service.py +++ b/backend/app/services/pl_dispatch_calendar_service.py @@ -14,13 +14,13 @@ from app.models.pl_models import PlVehicle from app.schemas.pl.dispatch_calendar import CreateBody, UpdateBody 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: value = kind or "acid" 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 diff --git a/backend/tests/test_pl_dispatch_calendar.py b/backend/tests/test_pl_dispatch_calendar.py index 5b7d69f48..020facfb8 100644 --- a/backend/tests/test_pl_dispatch_calendar.py +++ b/backend/tests/test_pl_dispatch_calendar.py @@ -166,7 +166,7 @@ def test_invalid_cross_tenant_and_deleted_relations_rejected(db, field, value): @pytest.mark.parametrize('bad', [ {'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'}, {'licensePlate': '伪造'}, {'productName': '伪造'}, {'createBy': 'other'}, ]) @@ -340,3 +340,51 @@ def test_calendar_openapi_schema_can_be_generated(): for schema in ('CreateBody', 'UpdateBody'): field = document['components']['schemas'][schema]['properties']['dispatchDate'] 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 == '' diff --git a/docs/sql/migrations/2026-10-30-dispatch-calendar-site-kind.sql b/docs/sql/migrations/2026-10-30-dispatch-calendar-site-kind.sql new file mode 100644 index 000000000..21a1c5dbb --- /dev/null +++ b/docs/sql/migrations/2026-10-30-dispatch-calendar-site-kind.sql @@ -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; diff --git a/docs/sql/shanxia2.0.sql b/docs/sql/shanxia2.0.sql index 709071ba0..600f03169 100644 --- a/docs/sql/shanxia2.0.sql +++ b/docs/sql/shanxia2.0.sql @@ -36468,7 +36468,7 @@ CREATE TABLE IF NOT EXISTS `pl_dispatch_calendar` ( `origin` varchar(255) NOT NULL COMMENT '始发地', `destination` varchar(255) NOT 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 '租户编号', `order_num` int DEFAULT NULL COMMENT '排序号', `remark` varchar(500) DEFAULT NULL COMMENT '备注', diff --git a/docs/superpowers/plans/2026-10-30-site-dispatch-calendar.md b/docs/superpowers/plans/2026-10-30-site-dispatch-calendar.md new file mode 100644 index 000000000..688322688 --- /dev/null +++ b/docs/superpowers/plans/2026-10-30-site-dispatch-calendar.md @@ -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`、字符集及排序规则保持不变,未修改业务数据。应用代码尚未部署。 diff --git a/docs/superpowers/specs/2026-10-30-site-dispatch-calendar-design.md b/docs/superpowers/specs/2026-10-30-site-dispatch-calendar-design.md new file mode 100644 index 000000000..ba64e0ccb --- /dev/null +++ b/docs/superpowers/specs/2026-10-30-site-dispatch-calendar-design.md @@ -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 字段。 diff --git a/web-admin/scripts/manual-dispatch-calendar.test.ts b/web-admin/scripts/manual-dispatch-calendar.test.ts index def6f9e2c..18642411f 100644 --- a/web-admin/scripts/manual-dispatch-calendar.test.ts +++ b/web-admin/scripts/manual-dispatch-calendar.test.ts @@ -107,8 +107,22 @@ test('同一车辆按创建时间正序,复制时排在一起且每条换行', ]) assert.equal(text.startsWith('2026-10-28'), true) }) -test('表单拒绝空白、无效日期,保留手写起止地', () => { - assert.equal(validateManualDraft({ ...row('1'), origin: ' ' }), '请填写始发地') +test('表单允许起止地留空,仍拒绝无效日期和超长地址', () => { + 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( validateManualDraft({ ...row('1'), dispatchDate: '2026-02-30' }), '请选择有效的派车日期' @@ -118,7 +132,9 @@ test('表单拒绝空白、无效日期,保留手写起止地', () => { test('数量是可空文本,复制逐条展示,日汇总仍只计车次', () => { const tons = { ...row('1'), quantity: '32.50' } 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 }) const text = manualCopyText('2026-10-26', [tons, blank]) assert.match(text, /液碱 32\.50吨 山峡 → 云栋/) @@ -130,8 +146,5 @@ test('数量是可空文本,复制逐条展示,日汇总仍只计车次', () validateManualDraft({ ...row('1'), quantity: '12.345' }), '数量须为数字,最多两位小数' ) - assert.equal( - validateManualDraft({ ...row('1'), quantity: '1吨' }), - '数量须为数字,最多两位小数' - ) + assert.equal(validateManualDraft({ ...row('1'), quantity: '1吨' }), '数量须为数字,最多两位小数') }) diff --git a/web-admin/scripts/test-manual-dispatch-calendar.mjs b/web-admin/scripts/test-manual-dispatch-calendar.mjs index 35646a336..a792c6a0a 100644 --- a/web-admin/scripts/test-manual-dispatch-calendar.mjs +++ b/web-admin/scripts/test-manual-dispatch-calendar.mjs @@ -2,7 +2,13 @@ import assert from 'node:assert/strict' import { chromium } from 'playwright' const browser = await chromium.launch({ headless: true, channel: 'chrome' }) 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({ permissions: ['clipboard-read', 'clipboard-write'], viewport: mobile ? { width: 375, height: 812 } : { width: 1440, height: 1000 } @@ -132,6 +138,13 @@ try { console.log('PAGE', await page.locator('body').innerText(), errors) 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) { await page.getByLabel('查看月份', { exact: true }).click() const popup = page.locator('.mp-calendar-date-picker:visible') @@ -175,6 +188,15 @@ try { await editor.getByRole('combobox', { 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('云栋') failSave = true @@ -184,10 +206,11 @@ try { await editor.getByRole('button', { name: '保存', exact: true }).click() await editor.waitFor({ state: 'hidden' }) assert.equal(rows.length, 1) + assert.equal(rows[0].calendarKind, kind) const card = page.locator('[data-testid="manual-record"]').first() await card.waitFor() await page.screenshot({ - path: `/tmp/manual-calendar-${mobile ? 'mobile' : 'web'}.png`, + path: `/tmp/manual-calendar-${mobile ? 'mobile' : 'web'}-${kind}.png`, fullPage: false }) assert.match(await card.innerText(), /山峡 → 云栋/) @@ -197,7 +220,7 @@ try { .click() assert.match( await page.evaluate(() => navigator.clipboard.readText()), - /皖H12345.*液碱.*山峡 → 云栋/ + /皖H12345.*液碱.*山峡 → 云栋/s ) await card.click() @@ -213,7 +236,7 @@ try { assert.equal(rows.length, 0) failLoad = true 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.getByRole('button', { name: '重试', exact: true }).click() await page @@ -240,10 +263,10 @@ try { origin: '山峡', destination: '云栋', 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() assert.equal(await page.getByRole('button', { name: '新增派车计划', exact: true }).count(), 0) 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.deepEqual(errors, []) await context.close() - console.log(mobile ? 'mobile CRUD passed' : 'web CRUD passed') + console.log(`${mobile ? 'mobile' : 'web'} ${kind} CRUD passed`) } } finally { await browser.close() diff --git a/web-admin/src/api/pl/dispatch-calendar.ts b/web-admin/src/api/pl/dispatch-calendar.ts index 7b42958a0..b866b6cb6 100644 --- a/web-admin/src/api/pl/dispatch-calendar.ts +++ b/web-admin/src/api/pl/dispatch-calendar.ts @@ -1,6 +1,6 @@ import request, { pickBxData } from '@/utils/http/bx' -export type DispatchCalendarKind = 'acid' | 'alkali' | 'other' +export type DispatchCalendarKind = 'acid' | 'alkali' | 'other' | 'site' export interface DispatchCalendarBody { dispatchDate: string diff --git a/web-admin/src/views/m/pl/dispatch-calendar/ManualCalendar.vue b/web-admin/src/views/m/pl/dispatch-calendar/ManualCalendar.vue index 9e6b19f28..c3ac986fa 100644 --- a/web-admin/src/views/m/pl/dispatch-calendar/ManualCalendar.vue +++ b/web-admin/src/views/m/pl/dispatch-calendar/ManualCalendar.vue @@ -128,14 +128,12 @@ diff --git a/web-admin/src/views/pl/dispatch-calendar/ManualCalendar.vue b/web-admin/src/views/pl/dispatch-calendar/ManualCalendar.vue index 0d9be2327..d101383b6 100644 --- a/web-admin/src/views/pl/dispatch-calendar/ManualCalendar.vue +++ b/web-admin/src/views/pl/dispatch-calendar/ManualCalendar.vue @@ -179,10 +179,10 @@ > - - + + diff --git a/web-admin/src/views/pl/dispatch-calendar/manual-model.ts b/web-admin/src/views/pl/dispatch-calendar/manual-model.ts index c35801e08..93c6f521c 100644 --- a/web-admin/src/views/pl/dispatch-calendar/manual-model.ts +++ b/web-admin/src/views/pl/dispatch-calendar/manual-model.ts @@ -118,8 +118,6 @@ export function validateManualDraft(body: DispatchCalendarBody): string { return '请选择有效的派车日期' if (!body.vehicleId) 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) return '始发地和目的地最多255个字符' if (manualQuantityText(body.quantity) && !QUANTITY_TEXT.test(manualQuantityText(body.quantity)))