7 Commits 6f4d4d2c59 ... 13570a2aee

Tác giả SHA1 Thông báo Ngày
  wuzilong 13570a2aee fix: exclude off-shelf products from dispatch calendar options. 1 ngày trước cách đây
  wuzilong 677f1b4c5a chore: refresh graph after other calendar kind. 1 ngày trước cách đây
  wuzilong 09c10fc9d7 feat: add other dispatch calendar tab on web and h5. 1 ngày trước cách đây
  wuzilong ebe346110a docs(sql): note other kind on dispatch calendar_kind. 1 ngày trước cách đây
  wuzilong 0eba80d93c feat: allow dispatch calendar kind other on backend. 1 ngày trước cách đây
  wuzilong 088ed0f871 docs: add implementation plan for other dispatch calendar tab. 1 ngày trước cách đây
  wuzilong b0b7e34051 docs: add design for dispatch calendar other kind tab. 1 ngày trước cách đây

+ 1 - 1
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碱派车",
+        comment="日历类型:acid酸派车 alkali碱派车 other其他",
     )
     tenant_no = Column(String(64), nullable=False, comment="租户编号")
     order_num = Column(Integer, nullable=True, comment="排序号")

+ 2 - 2
backend/app/schemas/pl/dispatch_calendar.py

@@ -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"):
-            raise ValueError("日历类型须为 acid 或 alkali")
+        if value not in ("acid", "alkali", "other"):
+            raise ValueError("日历类型须为 acid、alkali 或 other")
         return value
 
 

+ 17 - 6
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")
+CALENDAR_KINDS = ("acid", "alkali", "other")
 
 
 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")
+        raise HTTPException(status_code=422, detail="日历类型须为 acid、alkali 或 other")
     return value
 
 
@@ -84,9 +84,13 @@ def _resolve_snapshots(db: Session, tenant_no: str, user: DbUserContext, body: C
     vehicle = _query(db, tenant_no, user, PlVehicle).filter(PlVehicle.id == body.vehicle_id).first()
     if vehicle is None:
         raise HTTPException(status_code=422, detail="车辆不存在、已删除或无权访问,请重新选择")
-    product = _query(db, tenant_no, user, CrmProduct).filter(CrmProduct.id == body.product_id).first()
+    product = (
+        _query(db, tenant_no, user, CrmProduct)
+        .filter(CrmProduct.id == body.product_id, CrmProduct.status == "on")
+        .first()
+    )
     if product is None:
-        raise HTTPException(status_code=422, detail="产品不存在、已删除或无权访问,请重新选择")
+        raise HTTPException(status_code=422, detail="产品不存在、已下架、已删除或无权访问,请重新选择")
     return {"license_plate": vehicle.license_plate, "product_name": product.product_name}
 
 
@@ -146,5 +150,12 @@ def list_vehicles(db: Session, tenant_no: str, user: DbUserContext, *, q: Option
 
 def list_products(db: Session, tenant_no: str, user: DbUserContext, *, q: Optional[str] = None,
                   page: int = 1, page_size: int = 500) -> dict:
-    return _list_options(db, tenant_no, user, CrmProduct, CrmProduct.product_name,
-                         "productName", q, page, page_size)
+    query = _query(db, tenant_no, user, CrmProduct).filter(CrmProduct.status == "on")
+    keyword = (q or "").strip()
+    if keyword:
+        query = query.filter(CrmProduct.product_name.contains(keyword, autoescape=True))
+    total = query.count()
+    rows = query.order_by(CrmProduct.create_time.desc(), CrmProduct.id.desc()).offset(
+        (page - 1) * page_size
+    ).limit(page_size).all()
+    return {"list": [{"id": row.id, "productName": row.product_name} for row in rows], "total": total}

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 515 - 515
backend/graphify-out/GRAPH_REPORT.md


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 112 - 112
backend/graphify-out/graph.json


+ 39 - 0
backend/tests/test_pl_dispatch_calendar.py

@@ -139,6 +139,22 @@ def test_options_full_library_types_search_pagination(db):
     assert svc.list_products(db, 't1', user(), q='%')['total'] == 0
 
 
+def test_off_shelf_products_excluded_from_options_and_create(db):
+    _, _, _, svc = modules()
+    db.add(CrmProduct(id='p-off', product_no='POFF', product_name='下架液碱',
+                      tenant_no='t1', create_by='u1', status='off'))
+    db.commit()
+    ids = {row['id'] for row in svc.list_products(db, 't1', user())['list']}
+    assert 'p-off' not in ids
+    assert 'p1' in ids and 'p2' in ids
+    assert svc.list_products(db, 't1', user(), q='下架')['total'] == 0
+    with pytest.raises(HTTPException) as exc:
+        create(db, productId='p-off')
+    assert exc.value.status_code == 422
+    assert '下架' in exc.value.detail
+    assert svc.list_records(db, 't1', user(), month='2026-10')['total'] == 0
+
+
 @pytest.mark.parametrize('field,value', [('vehicleId', 'v3'), ('vehicleId', 'v4'), ('vehicleId', 'missing'), ('productId', 'p3'), ('productId', 'p4'), ('productId', 'missing')])
 def test_invalid_cross_tenant_and_deleted_relations_rejected(db, field, value):
     _, _, _, svc = modules()
@@ -219,6 +235,10 @@ def test_http_read_only_manage_permissions_both_clients_and_routes(db, monkeypat
         assert client.get(path, params={'month': '2026-10', 'calendarKind': 'alkali'}).json()['total'] == 1
         assert client.get(path, params={'month': '2026-10', 'calendarKind': 'salt'}).status_code == 422
         assert client.put(f"{path}/{alkali.json()['id']}", json=payload(calendarKind='acid')).status_code == 422
+        other = client.post(path, json=payload(calendarKind='other'))
+        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
     engine.dispose()
 
 
@@ -278,6 +298,25 @@ def test_acid_and_alkali_calendars_stay_separate(db):
     assert kept.destination == '南京' and kept.calendar_kind == 'alkali'
 
 
+def test_other_calendar_stays_separate_from_acid_and_alkali(db):
+    _, update, _, svc = modules()
+    create(db)  # acid default
+    create(db, calendarKind='alkali', origin='碱库')
+    other = create(db, calendarKind='other', origin='其他库')
+    other_page = svc.list_records(db, 't1', user(), month='2026-10', calendar_kind='other')
+    assert other_page['total'] == 1 and other_page['list'][0]['id'] == other.id
+    assert other_page['list'][0]['calendarKind'] == 'other'
+    assert svc.list_records(db, 't1', user(), month='2026-10')['total'] == 1
+    assert svc.list_records(db, 't1', user(), month='2026-10', calendar_kind='alkali')['total'] == 1
+    with pytest.raises(HTTPException) as moved:
+        svc.update_record(db, 't1', user(), other.id, update(**payload(calendarKind='acid')))
+    assert moved.value.status_code == 422
+    kept = svc.update_record(
+        db, 't1', user(), other.id, update(**payload(calendarKind='other', destination='合肥'))
+    )
+    assert kept.destination == '合肥' and kept.calendar_kind == 'other'
+
+
 def test_month_pagination_can_fetch_more_than_500_trips(db):
     model, _, _, svc = modules()
     db.add_all([model(id=f'bulk-{number:04}', dispatch_date=date(2026, 10, 26),

+ 1 - 1
docs/sql/migrations/2026-10-26-manual-dispatch-calendar.sql

@@ -11,7 +11,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其他',
   `tenant_no` varchar(64) NOT NULL COMMENT '租户编号',
   `order_num` int DEFAULT NULL COMMENT '排序号',
   `remark` varchar(500) DEFAULT NULL COMMENT '备注',

+ 1 - 1
docs/sql/migrations/2026-10-27-dispatch-calendar-kind.sql

@@ -10,7 +10,7 @@ SET @col_exists = (
 );
 SET @ddl = IF(
   @col_exists = 0,
-  'ALTER TABLE `pl_dispatch_calendar` ADD COLUMN `calendar_kind` varchar(16) NOT NULL DEFAULT ''acid'' COMMENT ''日历类型:acid酸派车 alkali碱派车'' AFTER `destination`',
+  'ALTER TABLE `pl_dispatch_calendar` ADD COLUMN `calendar_kind` varchar(16) NOT NULL DEFAULT ''acid'' COMMENT ''日历类型:acid酸派车 alkali碱派车 other其他'' AFTER `destination`',
   'SELECT 1'
 );
 PREPARE stmt FROM @ddl;

+ 18 - 0
docs/sql/migrations/2026-10-29-dispatch-calendar-other-kind.sql

@@ -0,0 +1,18 @@
+-- 手工派车日历增加 other(其他日历)。仅更新 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其他''',
+  'SELECT 1'
+);
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;

+ 368 - 0
docs/superpowers/plans/2026-10-29-dispatch-calendar-other-kind.md

@@ -0,0 +1,368 @@
+# 派车日历「其他日历」Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
+
+**Goal:** 在 Web 与小程序 H5 派车日历页的「碱派车日历」后增加「其他日历」Tab,`calendarKind=other`,功能与样式与酸/碱手工日历一致。
+
+**Architecture:** 扩展现有 `calendar_kind` 枚举(`acid | alkali | other`),复用 `ManualCalendar` / API / 权限;仅改校验、类型、入口 Tab 与字段 COMMENT。不建新表、不新增权限码。
+
+**Tech Stack:** Python 3.9 / FastAPI / SQLAlchemy / MySQL 5.7 / Vue 3 / Element Plus / Vant / pytest / tsx。
+
+**Spec:** `docs/superpowers/specs/2026-10-29-dispatch-calendar-other-kind-design.md`
+
+---
+
+## File map
+
+| File | Responsibility |
+|------|----------------|
+| `backend/tests/test_pl_dispatch_calendar.py` | 扩展隔离与校验测试覆盖 `other` |
+| `backend/app/services/pl_dispatch_calendar_service.py` | `CALENDAR_KINDS` 与 422 文案 |
+| `backend/app/schemas/pl/dispatch_calendar.py` | CreateBody `calendar_kind` 校验 |
+| `backend/app/models/pl_dispatch_calendar.py` | 列 COMMENT |
+| `docs/sql/migrations/2026-10-29-dispatch-calendar-other-kind.sql` | 幂等更新 COMMENT |
+| `docs/sql/migrations/2026-10-26-manual-dispatch-calendar.sql` | 建表 COMMENT 与现状对齐(文档一致性) |
+| `docs/sql/migrations/2026-10-27-dispatch-calendar-kind.sql` | ADD COLUMN COMMENT 与现状对齐 |
+| `web-admin/src/api/pl/dispatch-calendar.ts` | `DispatchCalendarKind` 加 `'other'` |
+| `web-admin/src/views/pl/dispatch-calendar/index.vue` | Web Tab + 主题 |
+| `web-admin/src/views/m/pl/dispatch-calendar/index.vue` | H5 Tab |
+| `web-admin/scripts/pl-dispatch-calendar.test.ts` | 可选:断言 Tab 文案顺序(若已有入口断言则扩展) |
+
+不改:`useManualCalendar.ts`、`ManualCalendar.vue` 内部逻辑(已按 prop `calendarKind` 工作)、车辆/产品 API、实际派车信息、菜单权限 SQL。
+
+---
+
+### Task 1: 后端 — `other` 校验与隔离测试先行
+
+**Files:**
+- Modify: `backend/tests/test_pl_dispatch_calendar.py`
+- Modify: `backend/app/services/pl_dispatch_calendar_service.py`
+- Modify: `backend/app/schemas/pl/dispatch_calendar.py`
+- Modify: `backend/app/models/pl_dispatch_calendar.py`
+
+- [x] **Step 1: 扩展失败测试(先跑应红)**
+
+在 `test_acid_and_alkali_calendars_stay_separate` 末尾追加 `other` 断言,或新增同文件测试:
+
+```python
+def test_other_calendar_stays_separate_from_acid_and_alkali(db):
+    _, update, _, svc = modules()
+    create(db)  # acid default
+    create(db, calendarKind='alkali', origin='碱库')
+    other = create(db, calendarKind='other', origin='其他库')
+    other_page = svc.list_records(db, 't1', user(), month='2026-10', calendar_kind='other')
+    assert other_page['total'] == 1 and other_page['list'][0]['id'] == other.id
+    assert other_page['list'][0]['calendarKind'] == 'other'
+    assert svc.list_records(db, 't1', user(), month='2026-10')['total'] == 1
+    assert svc.list_records(db, 't1', user(), month='2026-10', calendar_kind='alkali')['total'] == 1
+    with pytest.raises(HTTPException) as moved:
+        svc.update_record(db, 't1', user(), other.id, update(**payload(calendarKind='acid')))
+    assert moved.value.status_code == 422
+    kept = svc.update_record(
+        db, 't1', user(), other.id, update(**payload(calendarKind='other', destination='合肥'))
+    )
+    assert kept.destination == '合肥' and kept.calendar_kind == 'other'
+```
+
+在 `test_http_read_only_manage_permissions_both_clients_and_routes` 中碱断言后追加:
+
+```python
+        other = client.post(path, json=payload(calendarKind='other'))
+        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
+```
+
+(`{'calendarKind': 'salt'}` 已在 schema 参数化测试中,保持即可。)
+
+- [x] **Step 2: 跑测试确认失败**
+
+```bash
+cd backend && source venv/bin/activate && python -m pytest tests/test_pl_dispatch_calendar.py::test_other_calendar_stays_separate_from_acid_and_alkali -q
+```
+
+Expected: FAIL(schema/service 仍拒绝 `other`)。
+
+- [x] **Step 3: 最小实现**
+
+`backend/app/services/pl_dispatch_calendar_service.py`:
+
+```python
+CALENDAR_KINDS = ("acid", "alkali", "other")
+
+
+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")
+    return value
+```
+
+`backend/app/schemas/pl/dispatch_calendar.py` 的 `validate_calendar_kind`:
+
+```python
+    def validate_calendar_kind(cls, value: str) -> str:
+        if value not in ("acid", "alkali", "other"):
+            raise ValueError("日历类型须为 acid、alkali 或 other")
+        return value
+```
+
+`backend/app/models/pl_dispatch_calendar.py` COMMENT:
+
+```python
+        comment="日历类型:acid酸派车 alkali碱派车 other其他",
+```
+
+- [x] **Step 4: 跑全文件测试**
+
+```bash
+cd backend && source venv/bin/activate && python -m pytest tests/test_pl_dispatch_calendar.py -q
+```
+
+Expected: PASS(全部绿)。
+
+- [x] **Step 5: Commit**
+
+```bash
+git add backend/tests/test_pl_dispatch_calendar.py \
+  backend/app/services/pl_dispatch_calendar_service.py \
+  backend/app/schemas/pl/dispatch_calendar.py \
+  backend/app/models/pl_dispatch_calendar.py
+git commit -m "$(cat <<'EOF'
+feat: allow dispatch calendar kind other on backend.
+
+EOF
+)"
+```
+
+---
+
+### Task 2: SQL COMMENT 迁移
+
+**Files:**
+- Create: `docs/sql/migrations/2026-10-29-dispatch-calendar-other-kind.sql`
+- Modify: `docs/sql/migrations/2026-10-26-manual-dispatch-calendar.sql`(COMMENT 文案)
+- Modify: `docs/sql/migrations/2026-10-27-dispatch-calendar-kind.sql`(COMMENT 文案)
+
+- [x] **Step 1: 新建幂等迁移**
+
+```sql
+-- 手工派车日历增加 other(其他日历)。仅更新 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其他''',
+  'SELECT 1'
+);
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+```
+
+- [x] **Step 2: 同步历史迁移文件中的 COMMENT 字符串**
+
+将两处 `acid酸派车 alkali碱派车` 改为 `acid酸派车 alkali碱派车 other其他`(仅文档/幂等 ADD 文案,不影响已执行库;新环境建表即含完整注释)。
+
+- [x] **Step 3: 在本地库执行一次并复查**
+
+```bash
+# 使用项目惯用方式连接当前库后执行该 SQL;第二次执行应无结构报错
+```
+
+Expected: `calendar_kind` COMMENT 含 `other`;重复执行成功。
+
+- [x] **Step 4: Commit**
+
+```bash
+git add docs/sql/migrations/2026-10-29-dispatch-calendar-other-kind.sql \
+  docs/sql/migrations/2026-10-26-manual-dispatch-calendar.sql \
+  docs/sql/migrations/2026-10-27-dispatch-calendar-kind.sql
+git commit -m "$(cat <<'EOF'
+docs(sql): note other kind on dispatch calendar_kind.
+
+EOF
+)"
+```
+
+---
+
+### Task 3: 前端类型与双端 Tab
+
+**Files:**
+- Modify: `web-admin/src/api/pl/dispatch-calendar.ts`
+- Modify: `web-admin/src/views/pl/dispatch-calendar/index.vue`
+- Modify: `web-admin/src/views/m/pl/dispatch-calendar/index.vue`
+- Modify: `web-admin/scripts/pl-dispatch-calendar.test.ts`(仅当该文件已断言路由/入口时可追加 Tab 顺序;否则可跳过,改用浏览器手测)
+
+- [x] **Step 1: 扩展类型**
+
+`web-admin/src/api/pl/dispatch-calendar.ts`:
+
+```typescript
+export type DispatchCalendarKind = 'acid' | 'alkali' | 'other'
+```
+
+- [x] **Step 2: Web 入口**
+
+`web-admin/src/views/pl/dispatch-calendar/index.vue` 完整替换为:
+
+```vue
+<!-- P08 页内 Tab,P27 日历视图;原实际信息保持只读统计。 -->
+<template>
+  <div
+    class="dispatch-calendar-tabs"
+    :class="{
+      'manual-calendar-theme': active === 'acid' || active === 'alkali' || active === 'other'
+    }"
+  >
+    <el-tabs v-model="active">
+      <el-tab-pane label="酸派车日历" name="acid" />
+      <el-tab-pane label="碱派车日历" name="alkali" />
+      <el-tab-pane label="其他日历" name="other" />
+      <el-tab-pane label="实际派车信息" name="actual" />
+    </el-tabs>
+    <ManualCalendar v-if="active === 'acid'" calendar-kind="acid" title="酸派车日历" />
+    <ManualCalendar v-else-if="active === 'alkali'" calendar-kind="alkali" title="碱派车日历" />
+    <ManualCalendar v-else-if="active === 'other'" calendar-kind="other" title="其他日历" />
+    <ActualCalendar v-else />
+  </div>
+</template>
+<script setup lang="ts">
+  import ManualCalendar from './ManualCalendar.vue'
+  import ActualCalendar from './ActualCalendar.vue'
+  defineOptions({ name: 'PlDispatchCalendar' })
+  const active = ref('acid')
+</script>
+<style scoped>
+  .dispatch-calendar-tabs {
+    display: flex;
+    flex-direction: column;
+    min-height: 0;
+    height: 100%;
+  }
+  .dispatch-calendar-tabs > :deep(.el-tabs) {
+    --el-color-primary: #1f4e79;
+
+    flex: none;
+    padding: 0 18px;
+    background: white;
+  }
+  .dispatch-calendar-tabs > :deep(.el-tabs .el-tabs__header) {
+    margin-bottom: 0;
+  }
+  .dispatch-calendar-tabs > :deep(.page-container) {
+    flex: 1;
+    min-height: 0;
+  }
+</style>
+```
+
+- [x] **Step 3: H5 入口**
+
+在 `web-admin/src/views/m/pl/dispatch-calendar/index.vue`:
+
+模板 Tab 区改为:
+
+```vue
+        <van-tabs v-model:active="active" shrink color="#0a9f6d" title-active-color="#087f57">
+          <van-tab title="酸派车日历" name="acid" />
+          <van-tab title="碱派车日历" name="alkali" />
+          <van-tab title="其他日历" name="other" />
+          <van-tab title="实际派车信息" name="actual" />
+        </van-tabs>
+```
+
+内容区改为:
+
+```vue
+        <ManualCalendar v-if="active === 'acid'" calendar-kind="acid" title="酸派车日历" />
+        <ManualCalendar v-else-if="active === 'alkali'" calendar-kind="alkali" title="碱派车日历" />
+        <ManualCalendar v-else-if="active === 'other'" calendar-kind="other" title="其他日历" />
+        <ActualCalendar v-else />
+```
+
+- [x] **Step 4: 定向测试与 lint**
+
+```bash
+cd web-admin && pnpm exec tsx --test scripts/manual-dispatch-calendar.test.ts scripts/pl-dispatch-calendar.test.ts
+```
+
+Expected: 既有测试 PASS。
+
+对改动文件跑 ESLint / Prettier(项目惯用命令)。Expected: 无新增错误。
+
+- [x] **Step 5: Commit**
+
+```bash
+git add web-admin/src/api/pl/dispatch-calendar.ts \
+  web-admin/src/views/pl/dispatch-calendar/index.vue \
+  web-admin/src/views/m/pl/dispatch-calendar/index.vue
+git commit -m "$(cat <<'EOF'
+feat: add other dispatch calendar tab on web and h5.
+
+EOF
+)"
+```
+
+---
+
+### Task 4: 验证与图谱
+
+**Files:** 无新业务文件;执行命令与记录。
+
+- [x] **Step 1: 后端回归**
+
+```bash
+cd backend && source venv/bin/activate && python -m pytest tests/test_pl_dispatch_calendar.py -q
+```
+
+Expected: PASS。
+
+- [x] **Step 2: 手测清单(Web + H5)**
+
+1. 四个 Tab 顺序:酸 → 碱 → 其他日历 → 实际派车信息  
+2. 「其他日历」新增一条,切到酸/碱看不到;酸/碱记录在「其他」看不到  
+3. 编辑保存、删除、筛选车辆、复制当天计划可用;Web 打印可用  
+4. 「实际派车信息」筛选/汇总无异常;手工主题色在「其他日历」仍为深蓝 `#1f4e79`
+
+- [x] **Step 3: 更新知识图谱**
+
+```bash
+./scripts/update-graph.sh all
+# 或按仓库规则:backend + web-admin 增量;根目录 graphify update .
+```
+
+- [x] **Step 4: 最终 commit(若有图谱或小修)**
+
+仅暂存图谱/修复相关文件后:
+
+```bash
+git commit -m "$(cat <<'EOF'
+chore: refresh graph after other calendar kind.
+
+EOF
+)"
+```
+
+---
+
+## Spec coverage self-check
+
+| Spec 要求 | Task |
+|-----------|------|
+| 两端增加「其他日历」Tab,顺序在碱后、实际前 | Task 3 |
+| 功能/样式与酸碱一致,复用 ManualCalendar | Task 3 |
+| `calendarKind=other`,产品车辆选择器不变 | Task 1 + 3(不改 options API) |
+| 数据隔离、不可改 kind | Task 1 |
+| 无新权限码 | 无菜单任务(刻意不做) |
+| COMMENT 迁移 | Task 2 |
+| 实际派车信息不回归 | Task 4 手测 |
+| 测试 / lint / 图谱 | Task 1、3、4 |

+ 48 - 0
docs/superpowers/specs/2026-10-29-dispatch-calendar-other-kind-design.md

@@ -0,0 +1,48 @@
+# 派车日历增加「其他日历」设计
+
+用户已于 2026-10-29 确认方案 1(扩展 `calendarKind`)并授权撰写本设计。
+
+## 需求与边界
+
+现有 Web `/pl/calendar-module/dispatch-calendar` 与小程序 H5 `/m/pl/dispatch-calendar` 页内 Tab 为:酸派车日历、碱派车日历、实际派车信息。客户需要在「碱派车日历」之后增加「其他日历」,用于酸/碱以外其他产品的手工派车。
+
+「其他日历」功能、交互、样式与酸/碱手工日历完全一致;两端(Web + H5)同步增加。产品与车辆选择器仍共用现有主数据接口,不按产品分类过滤。实际派车信息 Tab 行为与样式不变。不新增菜单或权限码,继续使用现有 `dispatch-calendar:list` / `dispatch-calendar:manage`(Web 与 mini 前缀)。
+
+## 展示与交互
+
+Tab 顺序固定为:
+
+1. 酸派车日历(`acid`)
+2. 碱派车日历(`alkali`)
+3. 其他日历(`other`)
+4. 实际派车信息(`actual`)
+
+手工三类 Tab 均挂载现有 `ManualCalendar`,仅传入不同的 `calendarKind` 与标题;「其他日历」标题文案为「其他日历」。主题 class `manual-calendar-theme` 在 `acid` / `alkali` / `other` 时启用。不新增独立组件、不改手工日历视觉主题。
+
+模式沿用既有:P08 页内 Tab + P01 简单 CRUD + P09 查看弹窗 + P27 日历派生展示。
+
+## 数据与接口
+
+继续使用表 `pl_dispatch_calendar`。`calendar_kind` 允许值由 `acid | alkali` 扩展为 `acid | alkali | other`。列类型已是 `varchar(16)`,无需改列结构;补充幂等迁移仅更新字段 COMMENT,并同步模型注释与 `docs/sql` 基线注释(若仓库有对应 DDL 快照)。
+
+列表、详情、创建、更新、删除均按当前 Tab 的 `calendarKind` 隔离;三类手工数据互不可见。更新时不允许修改 `calendarKind`(与酸/碱现状一致)。非法 `calendarKind` 返回 422。
+
+API 路径与参数形状不变:查询参数 / 请求体字段 `calendarKind` 增加合法值 `other`。车辆、产品选项接口不变。
+
+## 改动面
+
+| 层 | 改动 |
+|----|------|
+| 后端 service / schema / model | `CALENDAR_KINDS` 与校验文案纳入 `other`;COMMENT 同步 |
+| 后端测试 | `other` 隔离、非法 kind、不可跨类型改 kind |
+| Web / H5 入口 | Tab + `ManualCalendar` 挂载;主题 class 覆盖 `other` |
+| 前端类型 | `DispatchCalendarKind` 增加 `'other'` |
+| SQL | 新迁移更新 COMMENT;必要时同步 docs/sql 注释 |
+| 前端相关单测 / 浏览器脚本 | 若硬编码仅 acid/alkali,则扩展覆盖 `other` |
+
+## 验收
+
+- 两端四个 Tab 可见且顺序正确;「其他日历」CRUD、筛选、复制、汇总与酸/碱一致(Web 另含打印)。
+- `other` 记录与酸/碱互不串;不可将记录的 `calendarKind` 改成另一类型。
+- 实际派车信息无回归;非法 `calendarKind` 仍 422。
+- 定向后端测试、前端相关测试、定向 lint、图谱增量更新通过。

+ 8 - 8
graphify-out/manifest.json

@@ -200,8 +200,8 @@
     "hash": "2ef1c6a59c58f94b060fb9d538fa11f7"
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/pl_dispatch_calendar.py": {
-    "mtime": 1793181879.7085412,
-    "hash": "c6b080f46599b6f069aa64daa4a97a8d"
+    "mtime": 1793263711.3085234,
+    "hash": "dca657a8ca1561d583d0e2750eb54f23"
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/pl_models.py": {
     "mtime": 1793140635.557178,
@@ -316,8 +316,8 @@
     "hash": "63fda471e4eabe9ee6c69a6f630d879d"
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/pl/dispatch_calendar.py": {
-    "mtime": 1793181882.8173754,
-    "hash": "e575209b68be7fd570318b4c9728581c"
+    "mtime": 1793263711.3086743,
+    "hash": "1424375630777c4682a36aa4e3f02ca5"
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/pl/vehicle_recharge_summary.py": {
     "mtime": 1793017994.5903165,
@@ -1132,8 +1132,8 @@
     "hash": "338c128ee9a971bb91f6123f12c9d5ca"
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/pl_dispatch_calendar_service.py": {
-    "mtime": 1793181883.5877903,
-    "hash": "44bd692b9b5f9dd3af2d087e6e43863b"
+    "mtime": 1793264064.1046252,
+    "hash": "7358bdae1f92b0c7044986d843270377"
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/pl_plan_archive_export.py": {
     "mtime": 1793064080.6042595,
@@ -2044,8 +2044,8 @@
     "hash": "a1d38e436dd4e926ee3c39a295d930e3"
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_pl_dispatch_calendar.py": {
-    "mtime": 1793181884.5405066,
-    "hash": "4579b80afefec015de91fe8bd78b9958"
+    "mtime": 1793264058.5708253,
+    "hash": "c272c12f8c995b115efad440d7874d93"
   },
   "/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_db_plan_list_filters.py": {
     "mtime": 1788679318.0,

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 460 - 461
web-admin/graphify-out/GRAPH_REPORT.md


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 112 - 112
web-admin/graphify-out/graph.json


+ 1 - 1
web-admin/src/api/pl/dispatch-calendar.ts

@@ -1,6 +1,6 @@
 import request, { pickBxData } from '@/utils/http/bx'
 
-export type DispatchCalendarKind = 'acid' | 'alkali'
+export type DispatchCalendarKind = 'acid' | 'alkali' | 'other'
 
 export interface DispatchCalendarBody {
   dispatchDate: string

+ 2 - 0
web-admin/src/views/m/pl/dispatch-calendar/index.vue

@@ -6,12 +6,14 @@
         <van-tabs v-model:active="active" shrink color="#0a9f6d" title-active-color="#087f57">
           <van-tab title="酸派车日历" name="acid" />
           <van-tab title="碱派车日历" name="alkali" />
+          <van-tab title="其他日历" name="other" />
           <van-tab title="实际派车信息" name="actual" />
         </van-tabs>
       </div>
       <template v-if="ready">
         <ManualCalendar v-if="active === 'acid'" calendar-kind="acid" title="酸派车日历" />
         <ManualCalendar v-else-if="active === 'alkali'" calendar-kind="alkali" title="碱派车日历" />
+        <ManualCalendar v-else-if="active === 'other'" calendar-kind="other" title="其他日历" />
         <ActualCalendar v-else />
       </template>
     </div>

+ 5 - 1
web-admin/src/views/pl/dispatch-calendar/index.vue

@@ -2,15 +2,19 @@
 <template>
   <div
     class="dispatch-calendar-tabs"
-    :class="{ 'manual-calendar-theme': active === 'acid' || active === 'alkali' }"
+    :class="{
+      'manual-calendar-theme': active === 'acid' || active === 'alkali' || active === 'other'
+    }"
   >
     <el-tabs v-model="active">
       <el-tab-pane label="酸派车日历" name="acid" />
       <el-tab-pane label="碱派车日历" name="alkali" />
+      <el-tab-pane label="其他日历" name="other" />
       <el-tab-pane label="实际派车信息" name="actual" />
     </el-tabs>
     <ManualCalendar v-if="active === 'acid'" calendar-kind="acid" title="酸派车日历" />
     <ManualCalendar v-else-if="active === 'alkali'" calendar-kind="alkali" title="碱派车日历" />
+    <ManualCalendar v-else-if="active === 'other'" calendar-kind="other" title="其他日历" />
     <ActualCalendar v-else />
   </div>
 </template>

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác