feat(reject-history): multi-pareto 3×2 grid with cross-filter linkage

Replace single-dimension Pareto dropdown with 6 simultaneous Pareto charts
(不良原因, PACKAGE, TYPE, WORKFLOW, 站點, 機台) in a responsive 3-column grid.
Clicking items in one Pareto cross-filters the other 5 (exclude-self logic),
and the detail table applies all dimension selections with AND logic.

Backend:
- Add batch-pareto endpoint (cache-only, no Oracle queries)
- Add _apply_cross_filter() with exclude-self pattern
- Extend view/export endpoints for multi-dimension sel_* params

Frontend:
- New ParetoGrid.vue wrapping 6 ParetoSection instances
- Simplify ParetoSection: remove dimension dropdown, keep TOP20 toggle
- Replace single-dimension state with paretoSelections reactive object
- Adaptive x-axis labels (font size, rotation, hideOverlap) for compact grid
- Responsive grid: 3-col desktop, 2-col tablet, 1-col mobile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-03-02 14:43:35 +08:00
parent e83d8e1a36
commit 2568fd836c
20 changed files with 1558 additions and 534 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-02

View File

@@ -0,0 +1,59 @@
## Context
The reject history page displays a single Pareto chart with a dropdown to switch between 6 dimensions (reason, package, type, workflow, workcenter, equipment). Users lose cross-dimensional context when switching. The cached dataset in `reject_dataset_cache.py` already supports all 6 dimensions via `compute_dimension_pareto()` and the `_DIM_TO_DF_COLUMN` mapping. The goal is to show all 6 simultaneously in a 3×2 grid with cross-filter linkage.
Current architecture:
- **Backend**: `compute_dimension_pareto()` computes one dimension at a time from a cached Pandas DataFrame (in-memory, populated by the primary Oracle query and keyed by `query_id`). All Pareto computation is performed on this cached DataFrame — no additional Oracle queries are made.
- **Frontend**: `App.vue` holds `paretoDimension` (single ref), `selectedParetoValues` (single array), and `paretoDisplayScope`. Client-side reason Pareto is computed via `allParetoItems` computed property from `analyticsRawItems`. Other dimensions call `fetchDimensionPareto()` which hits `GET /api/reject-history/reason-pareto?dimension=X`.
## Goals / Non-Goals
**Goals:**
- Display all 6 Pareto dimensions simultaneously in a 3-column responsive grid
- Cross-filter linkage: clicking items in dimension X filters all OTHER dimensions (exclude-self)
- Detail table reflects ALL dimension selections (AND logic)
- Single batch API call for all 6 dimensions (avoid 6 round-trips)
- Unify all Pareto computation on backend (remove client-side reason Pareto)
**Non-Goals:**
- No changes to the primary query SQL or data model
- No changes to the cache infrastructure or TTL
- No new Oracle fallback paths (batch-pareto is cache-only)
- No drag-and-drop or reorderable grid layout
## Decisions
### 1. Single batch API vs 6 separate calls
**Decision**: Single `GET /api/reject-history/batch-pareto` endpoint returning all 6 dimensions.
**Rationale**: Cross-filter requires all dimensions to see each other's selections. A single call eliminates 6 round-trips and guarantees consistency across all 6 results. The cached DataFrame is shared in-memory so iterating 6 dimensions is negligible overhead.
### 2. Cross-filter logic: exclude-self pattern
**Decision**: When computing dimension X's Pareto, apply selections from all OTHER dimensions but NOT X's own.
**Rationale**: If X's own selections were applied, the Pareto would only show selected items — defeating the purpose of showing the full distribution. Exclude-self lets users see "given these TYPE and WORKFLOW selections, what is the REASON distribution?"
### 3. Remove client-side reason Pareto
**Decision**: Remove the `allParetoItems` computed property that builds reason Pareto client-side from `analyticsRawItems`. All 6 dimensions (including reason) computed by backend `compute_batch_pareto()`.
**Rationale**: Unifies computation path. The backend already has the logic; duplicating it client-side for one dimension creates divergence risk and doesn't support cross-filtering.
### 4. Retain TOP20/ALL display scope toggle
**Decision**: Keep the TOP20/全部顯示 toggle for applicable dimensions (TYPE, WORKFLOW, 機台). Apply uniformly across all 6 charts via a global control (not per-chart selectors).
**Rationale**: Even after 80% cumulative filtering, dimensions like TYPE, WORKFLOW, and 機台 can still produce many items. The TOP20 truncation remains valuable for readability in the compact grid layout. Moving to a single global toggle (instead of per-chart selectors) reduces UI clutter while preserving the functionality.
### 5. ParetoGrid wrapper vs inline loop
**Decision**: New `ParetoGrid.vue` component wrapping 6 `ParetoSection.vue` instances.
**Rationale**: Separates grid layout concerns from individual chart rendering. `ParetoSection.vue` is simplified to a pure display component. `ParetoGrid.vue` handles the dimension iteration and event delegation.
### 6. Multi-dimension selection URL encoding
**Decision**: Use `sel_reason`, `sel_package`, `sel_type`, `sel_workflow`, `sel_workcenter`, `sel_equipment` as separate array params (replacing single `pareto_dimension` + `pareto_values`).
**Rationale**: Each dimension has independent selections. Encoding them as separate params is explicit, debuggable, and backward-compatible (old URLs with `pareto_dimension` simply won't have `sel_*` params).
### 7. Multi-dimension detail/export filter
**Decision**: Extend `_apply_pareto_selection_filter()` to accept a `pareto_selections: dict` and apply all dimensions cumulatively (AND logic). Keep backward compat with single `pareto_dimension`/`pareto_values` for the existing single-dimension endpoints until they are removed.
**Rationale**: Detail table and CSV export need to filter by all 6 dimensions simultaneously. AND logic is the natural interpretation: "show rows matching these reasons AND these types AND these workflows."
## Risks / Trade-offs
- **6 charts on screen may feel crowded on smaller screens** → Responsive breakpoints: 3-col desktop, 2-col tablet, 1-col mobile. Chart height reduced from 340px to ~240px.
- **Batch pareto payload is larger (6× single)** → Still small (6 arrays of ≤20 items each). Negligible compared to the primary query.
- **Per-chart scope toggles removed in favor of global toggle** → Individual TOP20/ALL selectors inside each chart would clutter the compact grid. A single global TOP20/ALL control in supplementary filters provides the same functionality with cleaner UX.
- **Cross-filter causes re-fetch on every click** → Mitigated by cache-only computation (no Oracle queries). Typical response time < 50ms.

View File

@@ -0,0 +1,43 @@
## Why
目前報廢歷史的柏拉圖一次只顯示一個維度,需透過下拉選單切換,無法同時看到跨維度的分佈與交叉關係。改為同時顯示 6 個柏拉圖3×2 grid並支援跨圖即時聯動篩選——點擊任一柏拉圖的項目後其餘 5 個柏拉圖即時重新計算(排除自身維度的篩選),下方明細表則套用所有維度的篩選結果。
## What Changes
### 前端
- 移除維度切換下拉選單(`ParetoSection.vue` 的 dimension selector
- 新增 `ParetoGrid.vue` 元件,以 3 欄 grid 同時渲染 6 個獨立柏拉圖不良原因、PACKAGE、TYPE、WORKFLOW、站點、機台
- 每個柏拉圖支援多選(現有行為),點擊後即時聯動:
- 其他 5 個柏拉圖重新計算(套用來自其他維度的選取,但不套用自身維度的選取)
- 明細表套用所有 6 個維度的選取結果
- 選取狀態以 chip 顯示在明細表上方,按維度分組
### 後端
- 新增批次柏拉圖 API endpoint`GET /api/reject-history/batch-pareto`),對快取中的 Pandas DataFrame 進行重算,一次回傳 6 個維度的柏拉圖資料(不重查 Oracle 資料庫)
- 每個維度的計算套用「排除自身」的交叉篩選邏輯:計算 Reason Pareto 時套用其他 5 維度的選取,但不套用 Reason 自身的選取
- 移除前端 client-side 的 reason Pareto 計算(統一由後端從快取計算)
### 移除
- 移除維度切換選單和 `onDimensionChange` 邏輯
- 移除現有的單維度 `fetchDimensionPareto` 流程
### 保留
- 保留 TOP20/全部顯示切換功能TYPE、WORKFLOW、機台維度在 80% 過濾後仍可能有大量項目TOP20 截斷對使用者仍有價值)
## Capabilities
### New Capabilities
(none)
### Modified Capabilities
- `reject-history-api`: 新增批次柏拉圖 endpoint支援跨維度交叉篩選
- `reject-history-page`: 柏拉圖從單維度切換改為 6 圖同時顯示 + 即時聯動
## Impact
- 前端:`App.vue`(狀態管理重構)、`ParetoSection.vue`(改為純展示元件)、新增 `ParetoGrid.vue`
- 後端:`reject_dataset_cache.py`(新增批次計算,資料來源為快取的 Pandas DataFrame`reject_history_routes.py`(新增 endpoint
- API新增 `GET /api/reject-history/batch-pareto` endpointcache-only不查 Oracle
- 無資料庫/SQL 變更

View File

@@ -0,0 +1,78 @@
## ADDED Requirements
### Requirement: Reject History API SHALL provide batch Pareto endpoint with cross-filter
The API SHALL provide a batch Pareto endpoint that returns all 6 dimension Pareto results in a single response, supporting cross-dimension filtering with exclude-self logic.
#### Scenario: Batch Pareto response structure
- **WHEN** `GET /api/reject-history/batch-pareto` is called with valid `query_id`
- **THEN** response SHALL be `{ success: true, data: { dimensions: { reason: {...}, package: {...}, type: {...}, workflow: {...}, workcenter: {...}, equipment: {...} } } }`
- **THEN** each dimension object SHALL include `items` array with same schema as reason-pareto items (`reason`, `metric_value`, `pct`, `cumPct`, `MOVEIN_QTY`, `REJECT_TOTAL_QTY`, `DEFECT_QTY`, `count`)
#### Scenario: Cross-filter exclude-self logic
- **WHEN** `sel_reason=A&sel_type=X` is provided
- **THEN** reason Pareto SHALL be computed with type=X filter applied (but NOT reason=A filter)
- **THEN** type Pareto SHALL be computed with reason=A filter applied (but NOT type=X filter)
- **THEN** package/workflow/workcenter/equipment Paretos SHALL be computed with both reason=A AND type=X filters applied
#### Scenario: Empty selections return unfiltered Paretos
- **WHEN** batch-pareto is called with no `sel_*` parameters
- **THEN** all 6 dimensions SHALL return their full Pareto distribution (same as calling reason-pareto individually with no cross-filter)
#### Scenario: Cache-only computation
- **WHEN** `query_id` does not exist in cache
- **THEN** the endpoint SHALL return HTTP 400 with error message indicating cache miss
- **THEN** the endpoint SHALL NOT fall back to Oracle query
#### Scenario: Supplementary and policy filters apply
- **WHEN** batch-pareto is called with supplementary filters (packages, workcenter_groups, reason) and policy toggles
- **THEN** all 6 dimension Paretos SHALL be computed after applying policy and supplementary filters first (before cross-filter)
#### Scenario: Data source is cached DataFrame only
- **WHEN** batch-pareto computes dimension Paretos
- **THEN** computation SHALL operate on the in-memory cached Pandas DataFrame (populated by the primary query)
- **THEN** the endpoint SHALL NOT issue any additional Oracle database queries
- **THEN** response time SHALL be sub-100ms since all computation is in-memory
#### Scenario: Display scope (TOP20) support
- **WHEN** `pareto_display_scope=top20` is provided
- **THEN** applicable dimensions (type, workflow, equipment) SHALL truncate results to top 20 items after sorting
- **WHEN** `pareto_display_scope` is omitted or `all`
- **THEN** all items SHALL be returned (subject to pareto_scope 80% filter if active)
### Requirement: Reject History API SHALL support multi-dimension Pareto selection in view and export
The detail view and export endpoints SHALL accept multiple dimension selections simultaneously and apply them with AND logic.
#### Scenario: Multi-dimension filter on view endpoint
- **WHEN** `GET /api/reject-history/view` is called with `sel_reason=A&sel_type=X`
- **THEN** returned rows SHALL match reason=A AND type=X (both filters applied simultaneously)
#### Scenario: Multi-dimension filter on export endpoint
- **WHEN** `GET /api/reject-history/export-cached` is called with `sel_reason=A&sel_type=X`
- **THEN** exported CSV SHALL contain only rows matching reason=A AND type=X
#### Scenario: Backward compatibility with single-dimension params
- **WHEN** `pareto_dimension` and `pareto_values` are provided (legacy format)
- **THEN** the API SHALL still accept and apply them as before
- **WHEN** both `sel_*` params and legacy params are provided
- **THEN** `sel_*` params SHALL take precedence
## MODIFIED Requirements
### Requirement: Reject History API SHALL provide reason Pareto endpoint
The API SHALL return sorted reason distribution data with cumulative percentages. The endpoint supports dimension selection via `dimension` parameter for single-dimension queries.
#### Scenario: Pareto response payload
- **WHEN** `GET /api/reject-history/reason-pareto` is called
- **THEN** each item SHALL include `reason`, `category`, selected metric value, `pct`, and `cumPct`
- **THEN** items SHALL be sorted descending by selected metric
#### Scenario: Metric mode validation
- **WHEN** `metric_mode` is provided
- **THEN** accepted values SHALL be `reject_total` or `defect`
- **THEN** invalid `metric_mode` SHALL return HTTP 400
#### Scenario: Dimension selection
- **WHEN** `dimension` parameter is provided with a valid value (reason, package, type, workflow, workcenter, equipment)
- **THEN** the endpoint SHALL return Pareto data for that dimension
- **WHEN** `dimension` is not provided
- **THEN** the endpoint SHALL default to `reason`

View File

@@ -0,0 +1,92 @@
## MODIFIED Requirements
### Requirement: Reject History page SHALL provide reason Pareto analysis
The page SHALL display 6 Pareto charts simultaneously (不良原因, PACKAGE, TYPE, WORKFLOW, 站點, 機台) in a 3-column grid layout with cross-filter linkage, replacing the single-dimension dropdown switcher.
#### Scenario: Multi-Pareto grid layout
- **WHEN** Pareto data is loaded
- **THEN** 6 Pareto charts SHALL be rendered simultaneously in a 3-column grid (3×2)
- **THEN** each chart SHALL display one dimension: 不良原因, PACKAGE, TYPE, WORKFLOW, 站點, 機台
- **THEN** there SHALL be no dimension dropdown selector
#### Scenario: Pareto rendering and ordering
- **WHEN** Pareto data is loaded
- **THEN** items in each Pareto SHALL be sorted by selected metric descending
- **THEN** each Pareto SHALL show a cumulative percentage line
#### Scenario: Pareto 80% filter is managed in supplementary filters
- **WHEN** the page first loads Pareto
- **THEN** supplementary filters SHALL include "Pareto 僅顯示累計前 80%" control
- **THEN** the control SHALL default to enabled
- **THEN** the 80% filter SHALL apply uniformly to all 6 Pareto charts
#### Scenario: Cross-filter linkage between Pareto charts
- **WHEN** user clicks an item in one Pareto chart (e.g., selects reason "A")
- **THEN** the other 5 Pareto charts SHALL recalculate with the selection applied as a filter
- **THEN** the clicked Pareto chart itself SHALL NOT be filtered by its own selections
- **THEN** the detail table below SHALL apply ALL selections from ALL dimensions
#### Scenario: Pareto click filtering supports multi-select
- **WHEN** user clicks Pareto bars or table rows in any dimension
- **THEN** clicked items SHALL become active selected chips
- **THEN** multiple selected items SHALL be supported within each dimension
- **THEN** multiple dimensions SHALL support simultaneous selections
#### Scenario: Re-click clears selected item only
- **WHEN** user clicks an already selected Pareto item
- **THEN** only that item SHALL be removed from selection
- **THEN** remaining selected items across all dimensions SHALL stay active
- **THEN** all Pareto charts SHALL recalculate to reflect the updated selections
#### Scenario: Filter chips display all dimension selections
- **WHEN** items are selected across multiple Pareto dimensions
- **THEN** selected items SHALL be displayed as chips grouped by dimension label
- **THEN** each chip SHALL show the dimension label and selected value (e.g., "TYPE: X")
- **THEN** clicking a chip's remove button SHALL deselect that item and trigger recalculation
#### Scenario: Responsive grid layout
- **WHEN** viewport is desktop width (>1200px)
- **THEN** Pareto charts SHALL render in a 3-column grid
- **WHEN** viewport is medium width (768px1200px)
- **THEN** Pareto charts SHALL render in a 2-column grid
- **WHEN** viewport is below 768px
- **THEN** Pareto charts SHALL stack in a single column
#### Scenario: TOP20/ALL display scope control
- **WHEN** Pareto grid is displayed
- **THEN** supplementary filters SHALL include a global "只顯示 TOP 20" toggle
- **THEN** when enabled, applicable dimensions (TYPE, WORKFLOW, 機台) SHALL truncate to top 20 items
- **THEN** the toggle SHALL apply uniformly to all applicable Pareto charts (not per-chart selectors)
- **THEN** dimensions not in the applicable set (不良原因, PACKAGE, 站點) SHALL be unaffected by this toggle
## MODIFIED Requirements
### Requirement: Reject History page SHALL be structured as modular sub-components
The page template SHALL delegate sections to focused sub-components, following the hold-history architecture pattern.
#### Scenario: Component decomposition
- **WHEN** the page source is examined
- **THEN** the filter panel SHALL be a separate `FilterPanel.vue` component
- **THEN** the KPI summary cards SHALL be a separate `SummaryCards.vue` component
- **THEN** the trend chart SHALL be a separate `TrendChart.vue` component
- **THEN** the pareto grid SHALL be a separate `ParetoGrid.vue` component containing 6 `ParetoSection.vue` instances
- **THEN** each individual pareto chart+table SHALL be a `ParetoSection.vue` component
- **THEN** the detail table with pagination SHALL be a separate `DetailTable.vue` component
#### Scenario: App.vue acts as orchestrator
- **WHEN** the page runs
- **THEN** `App.vue` SHALL hold all reactive state and API logic
- **THEN** sub-components SHALL receive data via props and communicate via events
### Requirement: Reject History page SHALL support CSV export from current filter context
The page SHALL allow users to export records using the exact active filters.
#### Scenario: Export with all active filters
- **WHEN** user clicks "匯出 CSV"
- **THEN** export request SHALL include current primary filters, supplementary filters, trend-date filters, metric filters, and all Pareto-selected items from all 6 dimensions
- **THEN** downloaded file SHALL contain exactly the same rows currently represented by the detail list filter context
#### Scenario: Export remains UTF-8 Excel compatible
- **WHEN** CSV export is downloaded
- **THEN** the file SHALL be encoded in UTF-8 with BOM
- **THEN** Chinese headers and values SHALL render correctly in common spreadsheet tools

View File

@@ -0,0 +1,51 @@
## 1. Backend — Batch Pareto with Cross-Filter
- [x] 1.1 Add `_apply_cross_filter(df, selections, exclude_dim)` helper in `reject_dataset_cache.py` — applies all dimension selections except `exclude_dim` using `_DIM_TO_DF_COLUMN` mapping
- [x] 1.2 Add `compute_batch_pareto()` function in `reject_dataset_cache.py` — iterates all 6 dimensions from cached DataFrame (no Oracle query), applies policy → supplementary → trend-date → cross-filter, supports `pareto_display_scope=top20` truncation for applicable dimensions, returns `{"dimensions": {...}}`
- [x] 1.3 Add `_parse_multi_pareto_selections()` helper in `reject_history_routes.py` — parses `sel_reason`, `sel_package`, `sel_type`, `sel_workflow`, `sel_workcenter`, `sel_equipment` from query params
- [x] 1.4 Add `GET /api/reject-history/batch-pareto` endpoint in `reject_history_routes.py` — cache-only (no Oracle fallback), accepts `pareto_display_scope` param, calls `compute_batch_pareto()`
## 2. Backend — Multi-Dimension Detail/Export Filter
- [x] 2.1 Extend `_apply_pareto_selection_filter()` in `reject_dataset_cache.py` to accept `pareto_selections: dict` (multi-dimension AND logic), keeping backward compat with single `pareto_dimension`/`pareto_values`
- [x] 2.2 Update `apply_view()` and `export_csv_from_cache()` in `reject_dataset_cache.py` to pass multi-dimension selections
- [x] 2.3 Update `view` and `export-cached` endpoints in `reject_history_routes.py` to parse and forward `sel_*` params
## 3. Frontend — State Refactor (App.vue)
- [x] 3.1 Replace single-dimension state (`paretoDimension`, `selectedParetoValues`, `dimensionParetoItems`, `dimensionParetoLoading`) with `paretoSelections` reactive object and `paretoData` reactive object; keep `paretoDisplayScope` ref for global TOP20/ALL toggle
- [x] 3.2 Add `fetchBatchPareto()` function — calls `GET /api/reject-history/batch-pareto` with `sel_*` params, updates all 6 `paretoData` entries
- [x] 3.3 Rewrite `onParetoItemToggle(dimension, value)` — toggle in `paretoSelections[dimension]`, call `fetchBatchPareto()` + `refreshView()`, reset page
- [x] 3.4 Remove dead code: `allParetoItems`, `filteredParetoItems`, `activeParetoItems` computed, `fetchDimensionPareto()`, `refreshDimensionParetoIfActive()`, `onDimensionChange()`, `PARETO_DIMENSION_LABELS`; keep `PARETO_TOP20_DIMENSIONS` and `paretoDisplayScope` for global TOP20/ALL toggle
- [x] 3.5 Update `activeFilterChips` computed — loop all 6 dimensions, generate chip per selected value with dimension label
- [x] 3.6 Update chip removal handler to call `onParetoItemToggle(dim, value)`
## 4. Frontend — URL State
- [x] 4.1 Update `updateUrlState()` — replace `pareto_dimension`/`pareto_values` with `sel_reason`, `sel_package`, etc. array params; keep `pareto_display_scope` for TOP20/ALL
- [x] 4.2 Update `restoreFromUrl()` — parse `sel_*` params into `paretoSelections` object
- [x] 4.3 Update `buildViewParams()` in `reject-history-filters.js` — replace `paretoDimension`/`paretoValues` with `paretoSelections` dict, emit `sel_*` params
## 5. Frontend — Components
- [x] 5.1 Simplify `ParetoSection.vue` — remove dimension selector dropdown and `DIMENSION_OPTIONS`; keep per-chart TOP20 truncation logic (controlled by parent via `displayScope` prop); add dimension label map; emit `item-toggle` with value only (parent handles dimension routing)
- [x] 5.2 Create `ParetoGrid.vue` — 3-column grid container rendering 6 `ParetoSection` instances, props: `paretoData`, `paretoSelections`, `loading`, `metricLabel`, `selectedDates`; emit `item-toggle(dimension, value)`
- [x] 5.3 Update `App.vue` template — replace single `<ParetoSection>` with `<ParetoGrid>`
## 6. Frontend — Styling
- [x] 6.1 Add `.pareto-grid` CSS class in `style.css` — 3-column grid with responsive breakpoints (2-col at ≤1200px, 1-col at ≤768px)
- [x] 6.2 Adjust `.pareto-chart-wrap` height from 340px to ~240px for compact multi-chart display
- [x] 6.3 Adjust `.pareto-layout` for vertical stack (chart above table) in grid context
## 7. Integration & Wire-Up
- [x] 7.1 Wire `fetchBatchPareto()` into `loadAllData()` flow — call after primary query completes
- [x] 7.2 Wire supplementary filter changes and trend-date changes to trigger `fetchBatchPareto()`
- [x] 7.3 Wire export button to include all 6 dimension selections in export request
## 8. Testing
- [x] 8.1 Add unit tests for `compute_batch_pareto()` cross-filter logic in `tests/test_reject_dataset_cache.py`
- [x] 8.2 Add route tests for `GET /api/reject-history/batch-pareto` endpoint in `tests/test_reject_history_routes.py`
- [x] 8.3 Add tests for multi-dimension `_apply_pareto_selection_filter()` in `tests/test_reject_dataset_cache.py`