|
|
@@ -0,0 +1,376 @@
|
|
|
+# 地磅域日期列头双三角排序 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 (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** 地磅出入库 `date`、地磅单 `inventoryDate` 列头双三角排序;列表与导出一致;地磅计划本批跳过。
|
|
|
+
|
|
|
+**Architecture:** 复用已有 `ColumnHeaderFilter` inject + `useColumnSort`。两页现网为拉全量 + 前端切片:列表改客户端排序;`_fetch_*` 接白名单 `order_by` 保证导出序。`inventoryDate` 经 outerjoin 出入库表的 `date`。
|
|
|
+
|
|
|
+**Tech Stack:** Vue 3、`useColumnSort`、FastAPI、SQLAlchemy、`resolve_datetime_order_by`
|
|
|
+
|
|
|
+**Spec:** `docs/superpowers/specs/2026-09-08-db-date-column-sort-design.md`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件结构
|
|
|
+
|
|
|
+| 操作 | 文件 | 职责 |
|
|
|
+|------|------|------|
|
|
|
+| Create | `backend/app/services/db/list_sort.py` | 出入库/地磅单白名单 + parse |
|
|
|
+| Create | `backend/tests/test_db_list_sort.py` | 单测 |
|
|
|
+| Modify | `backend/app/api/db/inventory.py` | `_fetch_inventory` 排序;ExportBody 显式 sort 字段 |
|
|
|
+| Modify | `backend/app/api/db/weighing.py` | `_fetch_weighing` join+排序;ExportBody |
|
|
|
+| Modify | `web-admin/src/views/db/inventory/index.vue` | provide + applySortAndPage |
|
|
|
+| Modify | `web-admin/src/views/db/weighing/index.vue` | provide + loadData 排序 |
|
|
|
+
|
|
|
+**不做:** `plans`、`dashboard`;毛/皮重时间列。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 1: `list_sort` helper + 测试
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `backend/app/services/db/list_sort.py`
|
|
|
+- Create: `backend/tests/test_db_list_sort.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 写失败测试**
|
|
|
+
|
|
|
+```python
|
|
|
+# backend/tests/test_db_list_sort.py
|
|
|
+from app.models.db_models import DbInboundOutbound, DbWeighingSlip
|
|
|
+from app.services.db.list_sort import (
|
|
|
+ order_inventory,
|
|
|
+ order_weighing,
|
|
|
+ parse_sort_params,
|
|
|
+ weighing_needs_inventory_join,
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+def test_inventory_date_asc():
|
|
|
+ expr = order_inventory("date", "asc")
|
|
|
+ assert "date" in str(expr).lower()
|
|
|
+ assert "asc" in str(expr).lower()
|
|
|
+
|
|
|
+
|
|
|
+def test_inventory_invalid_falls_back():
|
|
|
+ default = DbInboundOutbound.date.desc()
|
|
|
+ assert order_inventory("hack", "asc", default=default) is default
|
|
|
+
|
|
|
+
|
|
|
+def test_weighing_inventory_date_desc():
|
|
|
+ expr = order_weighing("inventoryDate", "desc")
|
|
|
+ assert "date" in str(expr).lower()
|
|
|
+ assert "desc" in str(expr).lower()
|
|
|
+
|
|
|
+
|
|
|
+def test_weighing_needs_join():
|
|
|
+ assert weighing_needs_inventory_join("inventoryDate", "asc") is True
|
|
|
+ assert weighing_needs_inventory_join("inventoryDate", "nope") is False
|
|
|
+ assert weighing_needs_inventory_join(None, "asc") is False
|
|
|
+
|
|
|
+
|
|
|
+def test_parse_sort_params_camel():
|
|
|
+ sf, so = parse_sort_params({"sortField": "date", "sortOrder": "asc"})
|
|
|
+ assert sf == "date"
|
|
|
+ assert so == "asc"
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 跑测应 FAIL**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && PYTHONPATH=. pytest tests/test_db_list_sort.py -v
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 实现 helper**
|
|
|
+
|
|
|
+```python
|
|
|
+# backend/app/services/db/list_sort.py
|
|
|
+from __future__ import annotations
|
|
|
+from typing import Optional
|
|
|
+
|
|
|
+from app.models.db_models import DbInboundOutbound, DbWeighingSlip
|
|
|
+from app.services.crm.list_sort import resolve_datetime_order_by
|
|
|
+
|
|
|
+INVENTORY_WHITELIST = {"date": DbInboundOutbound.date}
|
|
|
+WEIGHING_WHITELIST = {"inventoryDate": DbInboundOutbound.date}
|
|
|
+
|
|
|
+
|
|
|
+def order_inventory(sort_field: Optional[str], sort_order: Optional[str], default=None):
|
|
|
+ if default is None:
|
|
|
+ default = DbInboundOutbound.date.desc()
|
|
|
+ return resolve_datetime_order_by(sort_field, sort_order, INVENTORY_WHITELIST, default)
|
|
|
+
|
|
|
+
|
|
|
+def order_weighing(sort_field: Optional[str], sort_order: Optional[str], default=None):
|
|
|
+ if default is None:
|
|
|
+ default = DbWeighingSlip.gross_weight_time.desc()
|
|
|
+ return resolve_datetime_order_by(sort_field, sort_order, WEIGHING_WHITELIST, default)
|
|
|
+
|
|
|
+
|
|
|
+def weighing_needs_inventory_join(sort_field: Optional[str], sort_order: Optional[str]) -> bool:
|
|
|
+ field = (sort_field or "").strip()
|
|
|
+ order = (sort_order or "").strip().lower()
|
|
|
+ return field == "inventoryDate" and order in ("asc", "desc")
|
|
|
+
|
|
|
+
|
|
|
+def parse_sort_params(params: dict) -> tuple:
|
|
|
+ sf = params.get("sortField") or params.get("sort_field")
|
|
|
+ so = params.get("sortOrder") or params.get("sort_order")
|
|
|
+ return (str(sf).strip() if sf else None), (str(so).strip() if so else None)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 测通并 Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+PYTHONPATH=. pytest tests/test_db_list_sort.py -q
|
|
|
+git add backend/app/services/db/list_sort.py backend/tests/test_db_list_sort.py
|
|
|
+git commit -m "feat(db): add datetime list sort helpers for inventory and weighing."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2: 出入库 `_fetch_inventory` 接排序
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/app/api/db/inventory.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Import + ExportBody 字段**
|
|
|
+
|
|
|
+```python
|
|
|
+from app.services.db.list_sort import order_inventory, parse_sort_params
|
|
|
+```
|
|
|
+
|
|
|
+在 `InventoryExportBody` 增加:
|
|
|
+
|
|
|
+```python
|
|
|
+sort_field: Optional[str] = Field(default=None, alias="sortField")
|
|
|
+sort_order: Optional[str] = Field(default=None, alias="sortOrder")
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 改 `_fetch_inventory` 排序时机**
|
|
|
+
|
|
|
+去掉 `_base_query(...).order_by(date.desc(), id.desc())` 的链式 `order_by`。在 `apply_db_scope` 之后、`query.all()` 之前:
|
|
|
+
|
|
|
+```python
|
|
|
+sf, so = parse_sort_params(params)
|
|
|
+default = (DbInboundOutbound.date.desc(), DbInboundOutbound.id.desc())
|
|
|
+primary = order_inventory(sf, so, default=DbInboundOutbound.date.desc())
|
|
|
+if sf and so and (sf or "").strip() == "date" and (so or "").strip().lower() in ("asc", "desc"):
|
|
|
+ query = query.order_by(primary, DbInboundOutbound.id.desc())
|
|
|
+else:
|
|
|
+ query = query.order_by(*default)
|
|
|
+```
|
|
|
+
|
|
|
+(若 `order_inventory` 对非法已回退 default 单列,可用:`query.order_by(order_inventory(sf, so), DbInboundOutbound.id.desc())`,默认即 date.desc + id.desc。)
|
|
|
+
|
|
|
+推荐简化:
|
|
|
+
|
|
|
+```python
|
|
|
+sf, so = parse_sort_params(params)
|
|
|
+query = query.order_by(
|
|
|
+ order_inventory(sf, so, default=DbInboundOutbound.date.desc()),
|
|
|
+ DbInboundOutbound.id.desc(),
|
|
|
+)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git commit -m "feat(db): support datetime column sort on inventory lists and export."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 3: 地磅单 `_fetch_weighing` join + 排序
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/app/api/db/weighing.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Import + ExportBody**
|
|
|
+
|
|
|
+```python
|
|
|
+from sqlalchemy import and_, func, or_
|
|
|
+from app.services.db.list_sort import (
|
|
|
+ order_weighing,
|
|
|
+ parse_sort_params,
|
|
|
+ weighing_needs_inventory_join,
|
|
|
+)
|
|
|
+```
|
|
|
+
|
|
|
+`WeighingExportBody` 增加 `sortField` / `sortOrder`(同 Task 2)。
|
|
|
+
|
|
|
+- [ ] **Step 2: 改 `_fetch_weighing`**
|
|
|
+
|
|
|
+去掉开头 `.order_by(DbWeighingSlip.gross_weight_time.desc())`。在 scope 之后、`.all()` 之前:
|
|
|
+
|
|
|
+```python
|
|
|
+sf, so = parse_sort_params(params)
|
|
|
+if weighing_needs_inventory_join(sf, so):
|
|
|
+ query = query.outerjoin(
|
|
|
+ DbInboundOutbound,
|
|
|
+ and_(
|
|
|
+ DbWeighingSlip.inbound_outbound_id == DbInboundOutbound.id,
|
|
|
+ DbInboundOutbound.del_flag == 0,
|
|
|
+ ),
|
|
|
+ )
|
|
|
+query = query.order_by(
|
|
|
+ order_weighing(sf, so, default=DbWeighingSlip.gross_weight_time.desc()),
|
|
|
+ DbWeighingSlip.id.desc(),
|
|
|
+)
|
|
|
+weighings = query.all()
|
|
|
+```
|
|
|
+
|
|
|
+后续 inventory_date_map 逻辑不变。
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git commit -m "feat(db): support inventoryDate column sort on weighing lists and export."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 4: 接线出入库前端
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `web-admin/src/views/db/inventory/index.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: import / provide**
|
|
|
+
|
|
|
+```ts
|
|
|
+import { useColumnSort } from '@/composables/useColumnSort'
|
|
|
+// vue import 增加 provide
|
|
|
+```
|
|
|
+
|
|
|
+在 `useServerColumnFilters` 之后:
|
|
|
+
|
|
|
+```ts
|
|
|
+const {
|
|
|
+ sortField,
|
|
|
+ sortOrder,
|
|
|
+ setSort,
|
|
|
+ clearSort,
|
|
|
+ isSortActive,
|
|
|
+ toQueryParams: toSortQueryParams,
|
|
|
+ watchVisibleColumns: watchSortVisibleColumns
|
|
|
+} = useColumnSort({
|
|
|
+ onChange: () => {
|
|
|
+ currentPage.value = 1
|
|
|
+ applySortAndPage()
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+provide('crmColumnSort', { setSort, clearSort, isSortActive, sortField, sortOrder })
|
|
|
+```
|
|
|
+
|
|
|
+对已有 `visibleFilterProps` 调用 `watchSortVisibleColumns(visibleFilterProps)`。
|
|
|
+
|
|
|
+- [ ] **Step 2: 改 `applySortAndPage`**
|
|
|
+
|
|
|
+```ts
|
|
|
+const applySortAndPage = () => {
|
|
|
+ const field = sortField.value || 'date'
|
|
|
+ const order = sortOrder.value || 'desc'
|
|
|
+ const list = [...allRecords.value].sort((a, b) => {
|
|
|
+ const av = String(a[field] || '')
|
|
|
+ const bv = String(b[field] || '')
|
|
|
+ if (!av && !bv) return 0
|
|
|
+ if (!av) return order === 'asc' ? -1 : 1
|
|
|
+ if (!bv) return order === 'asc' ? 1 : -1
|
|
|
+ const cmp = av.localeCompare(bv, 'zh-CN')
|
|
|
+ return order === 'asc' ? cmp : -cmp
|
|
|
+ })
|
|
|
+ total.value = list.length
|
|
|
+ const start = (currentPage.value - 1) * pageSize.value
|
|
|
+ const end = start + pageSize.value
|
|
|
+ tableData.value = list.slice(start, end)
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: `buildListQueryParams` / 导出 params 合并**
|
|
|
+
|
|
|
+```ts
|
|
|
+const params: Record<string, any> = { ...toQueryParams(), ...toSortQueryParams() }
|
|
|
+```
|
|
|
+
|
|
|
+导出 `getFilterParams` 若单独组装,同样合并 `toSortQueryParams()`。
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git commit -m "feat(web-admin): wire date column sort on db inventory page."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 5: 接线地磅单前端
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `web-admin/src/views/db/weighing/index.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1:** 同 Task 4 接入 `useColumnSort` + `provide` + `watchSortVisibleColumns`;`onChange` → `currentPage=1` 后对 `allData` 重排并刷新切片(可不重新请求,也可 `loadData()`)。
|
|
|
+
|
|
|
+- [ ] **Step 2: 改 `loadData` 内排序**
|
|
|
+
|
|
|
+现网:
|
|
|
+
|
|
|
+```ts
|
|
|
+allData.value = records.slice().sort((a, b) => getRecordDate(b).localeCompare(getRecordDate(a)))
|
|
|
+```
|
|
|
+
|
|
|
+改为按 `sortField`/`sortOrder`(缺省 `inventoryDate` 语义用 `getRecordDate`,`desc`):
|
|
|
+
|
|
|
+```ts
|
|
|
+function sortWeighingRows(rows: WeighingRecord[]) {
|
|
|
+ const order = sortOrder.value || 'desc'
|
|
|
+ return [...rows].sort((a, b) => {
|
|
|
+ const av = getRecordDate(a)
|
|
|
+ const bv = getRecordDate(b)
|
|
|
+ if (!av && !bv) return 0
|
|
|
+ if (!av) return order === 'asc' ? -1 : 1
|
|
|
+ if (!bv) return order === 'asc' ? 1 : -1
|
|
|
+ const cmp = av.localeCompare(bv)
|
|
|
+ return order === 'asc' ? cmp : -cmp
|
|
|
+ })
|
|
|
+}
|
|
|
+// loadData:
|
|
|
+allData.value = sortWeighingRows(records)
|
|
|
+```
|
|
|
+
|
|
|
+`onChange` 若不重新拉数:`allData.value = sortWeighingRows(allData.value)`。
|
|
|
+
|
|
|
+- [ ] **Step 3:** `buildListQueryParams` / 导出合并 `toSortQueryParams()`。
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git commit -m "feat(web-admin): wire date column sort on db weighing page."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 6: 回归与图谱
|
|
|
+
|
|
|
+- [ ] **Step 1:** `PYTHONPATH=. pytest tests/test_db_list_sort.py tests/test_bx_list_sort.py -q`
|
|
|
+- [ ] **Step 2:** `pnpm dlx vitest run src/composables/useColumnSort.test.ts`(在 `web-admin`)
|
|
|
+- [ ] **Step 3:** 手工:出入库日期三角;地磅单日期三角;计划无三角;导出序
|
|
|
+- [ ] **Step 4:** `./scripts/update-graph.sh all` 后提交图谱:
|
|
|
+
|
|
|
+```bash
|
|
|
+git commit -m "chore: update graphify after DB date column sort."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Spec 覆盖自检
|
|
|
+
|
|
|
+| Spec 项 | Task |
|
|
|
+|---------|------|
|
|
|
+| 出入库 `date` | 2, 4 |
|
|
|
+| 地磅单 `inventoryDate` JOIN | 1, 3, 5 |
|
|
|
+| 跳过计划 | 文件结构「不做」 |
|
|
|
+| 导出一致 | 2, 3 + 前端 params |
|
|
|
+| ColumnHeaderFilter 已具备 | 无需 Task |
|
|
|
+| 空值规则 | 4, 5 客户端 + MySQL JOIN |
|
|
|
+
|
|
|
+无 TBD。
|