完善系统拓展与原材料生命周期视图
This commit is contained in:
parent
453b19399b
commit
df9a4b485d
162
.impeccable/design.json
Normal file
162
.impeccable/design.json
Normal file
@ -0,0 +1,162 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"generatedAt": "2026-06-12T00:00:00+08:00",
|
||||
"title": "Design System: ForgeFlow ERP",
|
||||
"extensions": {
|
||||
"colorMeta": {
|
||||
"canvas": {
|
||||
"role": "neutral",
|
||||
"displayName": "ERP Canvas",
|
||||
"canonical": "#f0f2f5",
|
||||
"tonalRamp": ["#ffffff", "#f5f7fb", "#f0f2f5", "#e9edf3"]
|
||||
},
|
||||
"surface": {
|
||||
"role": "neutral",
|
||||
"displayName": "Work Surface",
|
||||
"canonical": "#ffffff",
|
||||
"tonalRamp": ["#ffffff", "#fbfdff", "#f8fafc"]
|
||||
},
|
||||
"primary": {
|
||||
"role": "primary",
|
||||
"displayName": "Operational Blue",
|
||||
"canonical": "#409eff",
|
||||
"tonalRamp": ["#ecf5ff", "#d9ecff", "#409eff", "#2563eb"]
|
||||
},
|
||||
"success": {
|
||||
"role": "semantic",
|
||||
"displayName": "Pass Green",
|
||||
"canonical": "#67c23a",
|
||||
"tonalRamp": ["#f0f9eb", "#e1f3d8", "#67c23a"]
|
||||
},
|
||||
"warning": {
|
||||
"role": "semantic",
|
||||
"displayName": "Review Amber",
|
||||
"canonical": "#e6a23c",
|
||||
"tonalRamp": ["#fdf6ec", "#faecd8", "#e6a23c"]
|
||||
},
|
||||
"danger": {
|
||||
"role": "semantic",
|
||||
"displayName": "Stop Red",
|
||||
"canonical": "#f56c6c",
|
||||
"tonalRamp": ["#fef0f0", "#fde2e2", "#f56c6c"]
|
||||
}
|
||||
},
|
||||
"typographyMeta": {
|
||||
"title": {
|
||||
"displayName": "Title",
|
||||
"purpose": "Panel and drawer titles inside authenticated product screens."
|
||||
},
|
||||
"body": {
|
||||
"displayName": "Body",
|
||||
"purpose": "Chinese-first operational UI text, descriptions, and table cells."
|
||||
},
|
||||
"label": {
|
||||
"displayName": "Label",
|
||||
"purpose": "Field labels, chips, table headers, and compact metadata."
|
||||
}
|
||||
},
|
||||
"shadows": [
|
||||
{
|
||||
"name": "low-surface",
|
||||
"value": "0 6px 16px rgba(31, 45, 61, 0.08), 0 1px 2px rgba(31, 45, 61, 0.06)",
|
||||
"purpose": "Tables, compact repeated cards, and low emphasis containers."
|
||||
},
|
||||
{
|
||||
"name": "panel-surface",
|
||||
"value": "0 12px 28px rgba(31, 45, 61, 0.12), 0 3px 8px rgba(31, 45, 61, 0.08)",
|
||||
"purpose": "Main panels and large workflow containers."
|
||||
},
|
||||
{
|
||||
"name": "floating-surface",
|
||||
"value": "0 20px 46px rgba(31, 45, 61, 0.16), 0 8px 18px rgba(31, 45, 61, 0.1)",
|
||||
"purpose": "Drawers, dropdowns, popovers, and overlays."
|
||||
}
|
||||
],
|
||||
"motion": [
|
||||
{
|
||||
"name": "fast-state",
|
||||
"value": "140ms ease",
|
||||
"purpose": "Small button, link, and row hover feedback."
|
||||
},
|
||||
{
|
||||
"name": "normal-state",
|
||||
"value": "220ms cubic-bezier(0.2, 0.8, 0.2, 1)",
|
||||
"purpose": "Drawer and panel state transitions."
|
||||
}
|
||||
],
|
||||
"breakpoints": [
|
||||
{
|
||||
"name": "tablet",
|
||||
"value": "860px"
|
||||
},
|
||||
{
|
||||
"name": "desktop-collapse",
|
||||
"value": "1180px"
|
||||
}
|
||||
]
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"name": "Primary Button",
|
||||
"kind": "button",
|
||||
"refersTo": "button-primary",
|
||||
"description": "Main action button for saving, creating, and confirming high-value ERP actions.",
|
||||
"html": "<button class=\"ds-btn-primary\">Save document</button>",
|
||||
"css": ".ds-btn-primary { display: inline-flex; align-items: center; justify-content: center; min-height: 38px; padding: 0 14px; border: 1px solid #409eff; border-radius: 12px; background: #409eff; color: #ffffff; font: 700 13px/1 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif; cursor: pointer; transition: background 140ms ease, border-color 140ms ease, box-shadow 140ms ease; } .ds-btn-primary:hover { background: #2563eb; border-color: #2563eb; } .ds-btn-primary:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18); }"
|
||||
},
|
||||
{
|
||||
"name": "Status Badge",
|
||||
"kind": "chip",
|
||||
"refersTo": "status-primary",
|
||||
"description": "Compact semantic status label used across tables and detail drawers.",
|
||||
"html": "<span class=\"ds-status\"><span></span>Approved</span>",
|
||||
"css": ".ds-status { display: inline-flex; align-items: center; gap: 6px; min-height: 24px; padding: 2px 8px; border: 1px solid #d9ecff; border-radius: 4px; background: #ecf5ff; color: #409eff; font: 600 12px/1.45 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif; white-space: nowrap; } .ds-status span { width: 6px; height: 6px; border-radius: 999px; background: currentColor; box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 16%, transparent); }"
|
||||
},
|
||||
{
|
||||
"name": "Data Table Container",
|
||||
"kind": "custom",
|
||||
"refersTo": "table-container",
|
||||
"description": "Scrollable table frame with sticky headers for dense ERP records.",
|
||||
"html": "<div class=\"ds-table\"><table><thead><tr><th>Document</th><th>Status</th></tr></thead><tbody><tr><td>PO-001</td><td>Pending</td></tr></tbody></table></div>",
|
||||
"css": ".ds-table { overflow: auto; border: 1px solid rgba(209, 218, 232, 0.96); border-radius: 16px; background: #ffffff; box-shadow: 0 6px 16px rgba(31, 45, 61, 0.08), 0 1px 2px rgba(31, 45, 61, 0.06); } .ds-table table { width: 100%; border-collapse: separate; border-spacing: 0; font: 14px/1.45 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif; } .ds-table th { position: sticky; top: 0; background: #f8fbff; color: #334155; font-size: 12px; font-weight: 750; text-align: left; } .ds-table th, .ds-table td { padding: 12px; border-bottom: 1px solid #ebeef5; } .ds-table td { color: #42526b; }"
|
||||
}
|
||||
],
|
||||
"narrative": {
|
||||
"northStar": "The Clear Production Desk",
|
||||
"overview": "ForgeFlow ERP is a clear, light, high-density operational interface for a hardware manufacturing company. It should help users trace orders, materials, batches, operations, documents, and costs without visual noise.",
|
||||
"keyCharacteristics": [
|
||||
"Light routine ERP surfaces",
|
||||
"Restrained blue action and selection language",
|
||||
"Chinese-first practical product typography",
|
||||
"Tables, drawers, forms, and status badges as core vocabulary",
|
||||
"Formal document visuals only for business documents"
|
||||
],
|
||||
"rules": [
|
||||
{
|
||||
"name": "The Blue Rarity Rule",
|
||||
"body": "Blue identifies current position and next action; it is not broad decoration.",
|
||||
"section": "colors"
|
||||
},
|
||||
{
|
||||
"name": "The Fixed Scale Rule",
|
||||
"body": "Product screens use fixed UI type sizes, not fluid hero headings.",
|
||||
"section": "typography"
|
||||
},
|
||||
{
|
||||
"name": "The One Depth Signal Rule",
|
||||
"body": "Do not stack thick borders, large radius, glossy gradients, and heavy shadow on one element.",
|
||||
"section": "elevation"
|
||||
}
|
||||
],
|
||||
"dos": [
|
||||
"Do keep routine ERP screens light, restrained, and table-first.",
|
||||
"Do standardize table, drawer, form, status badge, pagination, and action button behavior before styling individual pages.",
|
||||
"Do provide loading, empty, error, no-permission, disabled, saving, and archive-failed states for critical flows."
|
||||
],
|
||||
"donts": [
|
||||
"Don't make screens look obviously AI-generated through generic card grids, decorative gradients, glass effects, or ornamental animation.",
|
||||
"Don't use side accent borders greater than 1px on cards or callouts.",
|
||||
"Don't let old dark industrial styling bleed into everyday ERP work screens."
|
||||
]
|
||||
}
|
||||
}
|
||||
6
.impeccable/live/config.json
Normal file
6
.impeccable/live/config.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"files": ["frontend/index.html"],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
BIN
.impeccable/live/forgeflow-smoke-login.png
Normal file
BIN
.impeccable/live/forgeflow-smoke-login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@ -43,6 +43,7 @@ from app.schemas.dashboard import (
|
||||
SalesOrderLifecycleRead,
|
||||
)
|
||||
from app.services.auth import require_authenticated_user
|
||||
from app.services.material_lifecycle_dag import build_material_lot_lifecycle_graph
|
||||
from app.services.mock_data import get_dashboard_payload
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_authenticated_user)])
|
||||
@ -358,7 +359,7 @@ def _material_lot_rows(limit: int = 100):
|
||||
.outerjoin(issue_subquery, issue_subquery.c.source_lot_id == StockLot.id)
|
||||
.outerjoin(finished_subquery, finished_subquery.c.source_material_lot_id == StockLot.id)
|
||||
.outerjoin(delivery_subquery, delivery_subquery.c.source_material_lot_id == StockLot.id)
|
||||
.where(Item.item_type == "RAW_MATERIAL")
|
||||
.where(Item.item_type == "RAW_MATERIAL", Warehouse.warehouse_type == "RAW")
|
||||
.order_by(StockLot.created_at.desc(), StockLot.id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
@ -387,13 +388,10 @@ def list_dashboard_material_lots(
|
||||
return [DashboardMaterialLotRead.model_validate(dict(row)) for row in rows]
|
||||
|
||||
|
||||
@router.get("/material-lots/{lot_id}/lifecycle", response_model=MaterialLotLifecycleRead)
|
||||
def get_material_lot_lifecycle(lot_id: int, db: Session = Depends(get_db)) -> MaterialLotLifecycleRead:
|
||||
lot_row = db.execute(_material_lot_rows(limit=1).where(StockLot.id == lot_id)).mappings().first()
|
||||
if not lot_row:
|
||||
raise HTTPException(status_code=404, detail="原材料批次不存在")
|
||||
|
||||
lot = DashboardMaterialLotRead.model_validate(dict(lot_row))
|
||||
def _build_legacy_material_lot_lifecycle_graph(
|
||||
db: Session,
|
||||
lot: DashboardMaterialLotRead,
|
||||
) -> tuple[list[LifecycleNode], list[LifecycleEdge]]:
|
||||
nodes: list[LifecycleNode] = []
|
||||
edges: list[LifecycleEdge] = []
|
||||
node_ids: set[str] = set()
|
||||
@ -465,7 +463,7 @@ def get_material_lot_lifecycle(lot_id: int, db: Session = Depends(get_db)) -> Ma
|
||||
.join(WorkOrder, WorkOrder.id == WorkOrderMaterialIssue.work_order_id)
|
||||
.join(Item, Item.id == WorkOrder.product_item_id)
|
||||
.outerjoin(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id)
|
||||
.where(WorkOrderMaterialIssue.source_lot_id == lot_id)
|
||||
.where(WorkOrderMaterialIssue.source_lot_id == lot.lot_id)
|
||||
.order_by(WorkOrderMaterialIssue.issue_time, WorkOrderMaterialIssue.id)
|
||||
).mappings().all()
|
||||
work_order_ids = sorted({row["work_order_id"] for row in issues})
|
||||
@ -741,6 +739,19 @@ def get_material_lot_lifecycle(lot_id: int, db: Session = Depends(get_db)) -> Ma
|
||||
)
|
||||
add_edge(_edge(f"fg-{row['completion_item_id']}", node_id, "库存发货"))
|
||||
|
||||
return nodes, edges
|
||||
|
||||
|
||||
@router.get("/material-lots/{lot_id}/lifecycle", response_model=MaterialLotLifecycleRead)
|
||||
def get_material_lot_lifecycle(lot_id: int, db: Session = Depends(get_db)) -> MaterialLotLifecycleRead:
|
||||
lot_row = db.execute(_material_lot_rows(limit=1).where(StockLot.id == lot_id)).mappings().first()
|
||||
if not lot_row:
|
||||
raise HTTPException(status_code=404, detail="原材料批次不存在")
|
||||
|
||||
lot = DashboardMaterialLotRead.model_validate(dict(lot_row))
|
||||
nodes, edges = build_material_lot_lifecycle_graph(db, lot)
|
||||
if not any(node.type == "production_ledger" for node in nodes):
|
||||
nodes, edges = _build_legacy_material_lot_lifecycle_graph(db, lot)
|
||||
return MaterialLotLifecycleRead(lot=lot, nodes=nodes, edges=edges)
|
||||
|
||||
|
||||
|
||||
456
backend/app/services/material_lifecycle_dag.py
Normal file
456
backend/app/services/material_lifecycle_dag.py
Normal file
@ -0,0 +1,456 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import false, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.models.master_data import Item
|
||||
from app.models.operations import Delivery, DeliveryItem, OperationReport, ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot
|
||||
from app.models.org import Employee
|
||||
from app.models.sales import Customer, SalesOrder
|
||||
from app.schemas.dashboard import DashboardMaterialLotRead, LifecycleDetailItem, LifecycleEdge, LifecycleNode
|
||||
from app.services.document_archives import production_document_batch_no_from_text
|
||||
from app.services.system_config import get_smart_operation_report_enabled
|
||||
|
||||
|
||||
def _num(value: Any) -> float:
|
||||
if value is None:
|
||||
return 0.0
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _fmt_qty(value: Any, digits: int = 2) -> str:
|
||||
numeric = _num(value)
|
||||
if abs(numeric - round(numeric)) < 0.000001:
|
||||
return f"{int(round(numeric))}"
|
||||
return f"{numeric:.{digits}f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def _fmt_weight(value: Any) -> str:
|
||||
return f"{_fmt_qty(value, 3)} kg"
|
||||
|
||||
|
||||
def _fmt_datetime(value: Any) -> str:
|
||||
if not value:
|
||||
return "-"
|
||||
return str(value).replace("T", " ")[:19]
|
||||
|
||||
|
||||
def _detail(label: str, value: Any) -> LifecycleDetailItem:
|
||||
text = "-" if value is None or value == "" else str(value)
|
||||
return LifecycleDetailItem(label=label, value=text)
|
||||
|
||||
|
||||
def _node(
|
||||
*,
|
||||
node_id: str,
|
||||
node_type: str,
|
||||
title: str,
|
||||
stage: str,
|
||||
level: int,
|
||||
subtitle: str | None = None,
|
||||
status: str | None = None,
|
||||
tone: str = "normal",
|
||||
summary: str | None = None,
|
||||
metrics: list[tuple[str, Any]] | None = None,
|
||||
details: list[tuple[str, Any]] | None = None,
|
||||
raw: dict[str, Any] | None = None,
|
||||
) -> LifecycleNode:
|
||||
return LifecycleNode(
|
||||
id=node_id,
|
||||
type=node_type,
|
||||
title=title,
|
||||
subtitle=subtitle,
|
||||
status=status,
|
||||
stage=stage,
|
||||
level=level,
|
||||
tone=tone,
|
||||
summary=summary,
|
||||
metrics=[_detail(label, value) for label, value in (metrics or [])],
|
||||
details=[_detail(label, value) for label, value in (details or [])],
|
||||
raw=raw or {},
|
||||
)
|
||||
|
||||
|
||||
def _edge(source: str, target: str, label: str | None = None) -> LifecycleEdge:
|
||||
return LifecycleEdge(id=f"{source}->{target}", source=source, target=target, label=label)
|
||||
|
||||
|
||||
def _append_node(nodes: list[LifecycleNode], node_ids: set[str], node: LifecycleNode) -> None:
|
||||
if node.id not in node_ids:
|
||||
node_ids.add(node.id)
|
||||
nodes.append(node)
|
||||
|
||||
|
||||
def _append_edge(edges: list[LifecycleEdge], edge_ids: set[str], node_ids: set[str], edge: LifecycleEdge) -> None:
|
||||
if edge.source in node_ids and edge.target in node_ids and edge.id not in edge_ids:
|
||||
edge_ids.add(edge.id)
|
||||
edges.append(edge)
|
||||
|
||||
|
||||
def _settlement_group_key(txn: ProductionBatchLedgerTxn) -> tuple[str, str]:
|
||||
batch_no = production_document_batch_no_from_text(txn.remark)
|
||||
if batch_no:
|
||||
return f"batch:{batch_no}", batch_no
|
||||
return f"txn:{txn.id}", f"{txn.txn_type}-{txn.id}"
|
||||
|
||||
|
||||
def _txn_node_type(txn_type: str) -> str:
|
||||
return {
|
||||
"成品入库": "finished_inbound",
|
||||
"生产余料入库": "surplus_inbound",
|
||||
"生产废料入库": "scrap_inbound",
|
||||
"重新开工": "ledger_reopen",
|
||||
}.get(txn_type, "ledger_writeoff")
|
||||
|
||||
|
||||
def build_material_lot_lifecycle_graph(
|
||||
db: Session,
|
||||
lot: DashboardMaterialLotRead,
|
||||
) -> tuple[list[LifecycleNode], list[LifecycleEdge]]:
|
||||
nodes: list[LifecycleNode] = []
|
||||
edges: list[LifecycleEdge] = []
|
||||
node_ids: set[str] = set()
|
||||
edge_ids: set[str] = set()
|
||||
|
||||
raw_node_id = f"raw-lot-{lot.lot_id}"
|
||||
_append_node(
|
||||
nodes,
|
||||
node_ids,
|
||||
_node(
|
||||
node_id=raw_node_id,
|
||||
node_type="raw_material_lot",
|
||||
title=lot.lot_no,
|
||||
subtitle=f"{lot.material_code} · {lot.material_name}",
|
||||
status=lot.status,
|
||||
stage="原料入库",
|
||||
level=0,
|
||||
summary="原材料批次入库后,按生产台账追踪生产出库、库外余额、入库结算和销售去向。",
|
||||
metrics=[
|
||||
("入库重量", _fmt_weight(lot.inbound_weight_kg)),
|
||||
("剩余重量", _fmt_weight(lot.remaining_weight_kg)),
|
||||
],
|
||||
details=[
|
||||
("原材料编码", lot.material_code),
|
||||
("原材料名称", lot.material_name),
|
||||
("仓库", lot.warehouse_name),
|
||||
],
|
||||
raw=lot.model_dump(),
|
||||
),
|
||||
)
|
||||
|
||||
ledger_rows = db.execute(
|
||||
select(
|
||||
ProductionBatchLedger,
|
||||
Item.item_code.label("product_code"),
|
||||
Item.item_name.label("product_name"),
|
||||
)
|
||||
.join(Item, Item.id == ProductionBatchLedger.product_item_id)
|
||||
.where(ProductionBatchLedger.material_lot_id == lot.lot_id)
|
||||
.order_by(ProductionBatchLedger.id)
|
||||
).all()
|
||||
ledgers = [row.ProductionBatchLedger for row in ledger_rows]
|
||||
ledger_ids = [int(ledger.id) for ledger in ledgers]
|
||||
|
||||
txns_by_ledger_id: dict[int, list[ProductionBatchLedgerTxn]] = {ledger_id: [] for ledger_id in ledger_ids}
|
||||
if ledger_ids:
|
||||
txns = db.scalars(
|
||||
select(ProductionBatchLedgerTxn)
|
||||
.where(ProductionBatchLedgerTxn.production_ledger_id.in_(ledger_ids))
|
||||
.order_by(ProductionBatchLedgerTxn.biz_time, ProductionBatchLedgerTxn.id)
|
||||
).all()
|
||||
for txn in txns:
|
||||
txns_by_ledger_id.setdefault(int(txn.production_ledger_id), []).append(txn)
|
||||
|
||||
finished_node_id_by_lot_id: dict[int, str] = {}
|
||||
settlement_txn_types = {"成品入库", "生产余料入库", "生产废料入库", "结单核销", "该批材料结单", "重新开工"}
|
||||
for row in ledger_rows:
|
||||
ledger = row.ProductionBatchLedger
|
||||
ledger_id = int(ledger.id)
|
||||
ledger_node_id = f"ledger-{ledger_id}"
|
||||
_append_node(
|
||||
nodes,
|
||||
node_ids,
|
||||
_node(
|
||||
node_id=ledger_node_id,
|
||||
node_type="production_ledger",
|
||||
title=f"{ledger.material_lot_no} · {row.product_name}",
|
||||
subtitle=row.product_code,
|
||||
status=ledger.status,
|
||||
stage="生产台账",
|
||||
level=1,
|
||||
summary="一行生产台账代表同一材料库存批次号与产品组合,是材料批次闭环的主链路。",
|
||||
metrics=[
|
||||
("累计领料", _fmt_weight(ledger.total_issued_weight_kg)),
|
||||
("库外余额", _fmt_weight(ledger.outside_weight_kg)),
|
||||
("成品入库", _fmt_qty(ledger.finished_inbound_qty)),
|
||||
("余料入库", _fmt_weight(ledger.surplus_inbound_weight_kg)),
|
||||
("废料入库", _fmt_weight(ledger.scrap_inbound_weight_kg)),
|
||||
],
|
||||
details=[
|
||||
("产品编码", row.product_code),
|
||||
("产品名称", row.product_name),
|
||||
("首次领料", _fmt_datetime(ledger.first_issue_time)),
|
||||
("最近领料", _fmt_datetime(ledger.last_issue_time)),
|
||||
("结单次数", ledger.lock_count),
|
||||
("备注", ledger.remark),
|
||||
],
|
||||
raw={
|
||||
"production_ledger_id": ledger.id,
|
||||
"material_lot_id": ledger.material_lot_id,
|
||||
"product_item_id": ledger.product_item_id,
|
||||
},
|
||||
),
|
||||
)
|
||||
_append_edge(edges, edge_ids, node_ids, _edge(raw_node_id, ledger_node_id, "进入生产台账"))
|
||||
|
||||
issue_txns = [txn for txn in txns_by_ledger_id.get(ledger_id, []) if txn.txn_type == "生产出库"]
|
||||
issue_node_ids: list[str] = []
|
||||
for txn in issue_txns:
|
||||
issue_node_id = f"issue-txn-{txn.id}"
|
||||
issue_node_ids.append(issue_node_id)
|
||||
_append_node(
|
||||
nodes,
|
||||
node_ids,
|
||||
_node(
|
||||
node_id=issue_node_id,
|
||||
node_type="production_issue_txn",
|
||||
title=f"生产出库 {txn.id}",
|
||||
subtitle=ledger.material_lot_no,
|
||||
status="POSTED",
|
||||
tone="success",
|
||||
stage="生产出库",
|
||||
level=2,
|
||||
summary="生产出库事务累加到生产台账,并增加该批材料的库外余额。",
|
||||
metrics=[
|
||||
("出库重量", _fmt_weight(txn.weight_delta_kg)),
|
||||
("出库后库外余额", _fmt_weight(txn.outside_weight_after_kg)),
|
||||
],
|
||||
details=[
|
||||
("业务时间", _fmt_datetime(txn.biz_time)),
|
||||
("来源单据", txn.source_doc_type),
|
||||
("来源ID", txn.source_doc_id),
|
||||
("备注", txn.remark),
|
||||
],
|
||||
raw={"txn_id": txn.id, "production_ledger_id": txn.production_ledger_id},
|
||||
),
|
||||
)
|
||||
_append_edge(edges, edge_ids, node_ids, _edge(ledger_node_id, issue_node_id, "生产出库"))
|
||||
|
||||
outside_node_id = f"outside-balance-{ledger_id}"
|
||||
_append_node(
|
||||
nodes,
|
||||
node_ids,
|
||||
_node(
|
||||
node_id=outside_node_id,
|
||||
node_type="ledger_outside_balance",
|
||||
title="库外余额",
|
||||
subtitle=ledger.material_lot_no,
|
||||
status=ledger.status,
|
||||
stage="库外闭环",
|
||||
level=3,
|
||||
summary="库外余额用于衡量该材料批次尚未通过成品、余料、废料入库或结单核销闭环的重量。",
|
||||
metrics=[
|
||||
("库外余额", _fmt_weight(ledger.outside_weight_kg)),
|
||||
("累计领料", _fmt_weight(ledger.total_issued_weight_kg)),
|
||||
("结单核销", _fmt_weight(ledger.writeoff_weight_kg)),
|
||||
],
|
||||
raw={"production_ledger_id": ledger.id},
|
||||
),
|
||||
)
|
||||
if issue_node_ids:
|
||||
for issue_node_id in issue_node_ids:
|
||||
_append_edge(edges, edge_ids, node_ids, _edge(issue_node_id, outside_node_id, "形成库外余额"))
|
||||
else:
|
||||
_append_edge(edges, edge_ids, node_ids, _edge(ledger_node_id, outside_node_id, "库外余额"))
|
||||
|
||||
settlement_groups: dict[tuple[str, str], list[ProductionBatchLedgerTxn]] = {}
|
||||
for txn in txns_by_ledger_id.get(ledger_id, []):
|
||||
if txn.txn_type in settlement_txn_types:
|
||||
settlement_groups.setdefault(_settlement_group_key(txn), []).append(txn)
|
||||
|
||||
for group_index, ((group_key, group_title), group_txns) in enumerate(settlement_groups.items(), start=1):
|
||||
settlement_node_id = f"settlement-{ledger_id}-{group_key}"
|
||||
total_weight_delta = sum(_num(txn.weight_delta_kg) for txn in group_txns)
|
||||
_append_node(
|
||||
nodes,
|
||||
node_ids,
|
||||
_node(
|
||||
node_id=settlement_node_id,
|
||||
node_type="production_ledger_settlement",
|
||||
title=group_title,
|
||||
subtitle=f"{len(group_txns)} 笔台账事务",
|
||||
status="POSTED",
|
||||
tone="success",
|
||||
stage="入库结算",
|
||||
level=4,
|
||||
summary="按生产单据批次号聚合成品、余料、废料入库及结单核销事务;没有批次号的事务独立成组。",
|
||||
metrics=[
|
||||
("事务数", len(group_txns)),
|
||||
("重量变化", _fmt_weight(total_weight_delta)),
|
||||
],
|
||||
details=[
|
||||
("分组", group_title),
|
||||
("序号", group_index),
|
||||
],
|
||||
raw={"production_ledger_id": ledger.id, "group_key": group_key},
|
||||
),
|
||||
)
|
||||
_append_edge(edges, edge_ids, node_ids, _edge(outside_node_id, settlement_node_id, "入库结算"))
|
||||
|
||||
txns_by_type: dict[str, list[ProductionBatchLedgerTxn]] = {}
|
||||
for txn in group_txns:
|
||||
txns_by_type.setdefault(str(txn.txn_type), []).append(txn)
|
||||
for txn_type, type_txns in txns_by_type.items():
|
||||
child_node_id = f"{_txn_node_type(txn_type)}-{ledger_id}-{group_key}"
|
||||
child_weight_delta = sum(_num(txn.weight_delta_kg) for txn in type_txns)
|
||||
child_qty_delta = sum(_num(txn.qty_delta) for txn in type_txns)
|
||||
_append_node(
|
||||
nodes,
|
||||
node_ids,
|
||||
_node(
|
||||
node_id=child_node_id,
|
||||
node_type=_txn_node_type(txn_type),
|
||||
title=txn_type,
|
||||
subtitle=group_title,
|
||||
status="POSTED",
|
||||
tone="success",
|
||||
stage=txn_type,
|
||||
level=5,
|
||||
summary="该节点来自生产台账事务,并参与材料批次库外闭环。",
|
||||
metrics=[
|
||||
("数量变化", _fmt_qty(child_qty_delta)),
|
||||
("重量变化", _fmt_weight(child_weight_delta)),
|
||||
("事务数", len(type_txns)),
|
||||
],
|
||||
details=[
|
||||
("最近库外余额", _fmt_weight(type_txns[-1].outside_weight_after_kg)),
|
||||
("业务时间", _fmt_datetime(type_txns[-1].biz_time)),
|
||||
],
|
||||
raw={
|
||||
"production_ledger_id": ledger.id,
|
||||
"txn_ids": [txn.id for txn in type_txns],
|
||||
"group_key": group_key,
|
||||
},
|
||||
),
|
||||
)
|
||||
_append_edge(edges, edge_ids, node_ids, _edge(settlement_node_id, child_node_id, txn_type))
|
||||
if _txn_node_type(txn_type) == "finished_inbound":
|
||||
for txn in type_txns:
|
||||
if txn.source_line_id:
|
||||
finished_node_id_by_lot_id[int(txn.source_line_id)] = child_node_id
|
||||
|
||||
if ledger_ids and get_smart_operation_report_enabled(db):
|
||||
report_rows = db.execute(
|
||||
select(
|
||||
OperationReport,
|
||||
Employee.employee_name.label("employee_name"),
|
||||
)
|
||||
.join(Employee, Employee.id == OperationReport.employee_id)
|
||||
.where(OperationReport.production_ledger_id.in_(ledger_ids))
|
||||
.order_by(OperationReport.start_time, OperationReport.id)
|
||||
).all()
|
||||
for row in report_rows:
|
||||
report = row.OperationReport
|
||||
node_id = f"report-{report.id}"
|
||||
_append_node(
|
||||
nodes,
|
||||
node_ids,
|
||||
_node(
|
||||
node_id=node_id,
|
||||
node_type="operation_report",
|
||||
title=report.report_no,
|
||||
subtitle=row.employee_name,
|
||||
status="ABNORMAL" if report.is_abnormal else "NORMAL",
|
||||
tone="danger" if report.is_abnormal else "success",
|
||||
stage="现场报工证据",
|
||||
level=2,
|
||||
summary="只作为现场报工证据展示,不参与材料批次闭环判定。",
|
||||
metrics=[
|
||||
("报工数", _fmt_qty(report.report_qty)),
|
||||
("合格数", _fmt_qty(report.good_qty)),
|
||||
("报废数", _fmt_qty(report.scrap_qty)),
|
||||
],
|
||||
details=[
|
||||
("员工", row.employee_name),
|
||||
("开始", _fmt_datetime(report.start_time)),
|
||||
("结束", _fmt_datetime(report.end_time)),
|
||||
("来源", report.report_source),
|
||||
],
|
||||
raw={"report_id": report.id, "production_ledger_id": report.production_ledger_id},
|
||||
),
|
||||
)
|
||||
_append_edge(edges, edge_ids, node_ids, _edge(f"ledger-{report.production_ledger_id}", node_id, "报工证据"))
|
||||
|
||||
finished_lot = aliased(StockLot)
|
||||
order_customer = aliased(Customer)
|
||||
direct_customer = aliased(Customer)
|
||||
delivery_rows = db.execute(
|
||||
select(
|
||||
DeliveryItem.id.label("delivery_item_id"),
|
||||
DeliveryItem.lot_id.label("delivery_lot_id"),
|
||||
finished_lot.id.label("finished_lot_id"),
|
||||
finished_lot.source_doc_id.label("production_ledger_id"),
|
||||
finished_lot.lot_no.label("lot_no"),
|
||||
Delivery.delivery_no.label("delivery_no"),
|
||||
SalesOrder.order_no.label("order_no"),
|
||||
direct_customer.customer_name.label("direct_customer_name"),
|
||||
order_customer.customer_name.label("order_customer_name"),
|
||||
Delivery.delivery_date.label("delivery_date"),
|
||||
DeliveryItem.delivery_qty.label("delivery_qty"),
|
||||
DeliveryItem.delivery_weight_kg.label("delivery_weight_kg"),
|
||||
DeliveryItem.line_amount.label("line_amount"),
|
||||
DeliveryItem.status.label("status"),
|
||||
)
|
||||
.join(finished_lot, finished_lot.id == DeliveryItem.lot_id)
|
||||
.join(Item, Item.id == finished_lot.item_id)
|
||||
.join(Delivery, Delivery.id == DeliveryItem.delivery_id)
|
||||
.outerjoin(SalesOrder, SalesOrder.id == Delivery.sales_order_id)
|
||||
.outerjoin(order_customer, order_customer.id == SalesOrder.customer_id)
|
||||
.outerjoin(direct_customer, direct_customer.id == Delivery.customer_id)
|
||||
.where(
|
||||
finished_lot.source_doc_id.in_(ledger_ids) if ledger_ids else false(),
|
||||
finished_lot.source_doc_type.in_(["PRODUCTION_LEDGER_IN", "生产台账"]),
|
||||
Item.item_type == "FINISHED_GOOD",
|
||||
)
|
||||
.order_by(Delivery.delivery_date, DeliveryItem.id)
|
||||
).mappings().all()
|
||||
for row in delivery_rows:
|
||||
delivery_node_id = f"delivery-{row['delivery_item_id']}"
|
||||
_append_node(
|
||||
nodes,
|
||||
node_ids,
|
||||
_node(
|
||||
node_id=delivery_node_id,
|
||||
node_type="delivery",
|
||||
title=row["delivery_no"],
|
||||
subtitle=row["order_no"],
|
||||
status=row["status"],
|
||||
stage="销售去向",
|
||||
level=6,
|
||||
summary="销售出库从生产台账生成的成品库存批次追溯到材料批次。",
|
||||
metrics=[
|
||||
("发货数量", _fmt_qty(row["delivery_qty"])),
|
||||
("发货重量", _fmt_weight(row["delivery_weight_kg"])),
|
||||
],
|
||||
details=[
|
||||
("客户", row["direct_customer_name"] or row["order_customer_name"]),
|
||||
("库存批次号", row["lot_no"]),
|
||||
("发货时间", _fmt_datetime(row["delivery_date"])),
|
||||
],
|
||||
raw=dict(row),
|
||||
),
|
||||
)
|
||||
source_node_id = finished_node_id_by_lot_id.get(int(row["delivery_lot_id"] or 0))
|
||||
if not source_node_id:
|
||||
source_node_id = f"ledger-{row['production_ledger_id']}"
|
||||
_append_edge(edges, edge_ids, node_ids, _edge(source_node_id, delivery_node_id, "销售出库"))
|
||||
|
||||
return nodes, edges
|
||||
510
backend/tests/test_dashboard_material_lot_lifecycle.py
Normal file
510
backend/tests/test_dashboard_material_lot_lifecycle.py
Normal file
@ -0,0 +1,510 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import BigInteger, create_engine
|
||||
from sqlalchemy.ext.compiler import compiles
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
|
||||
@compiles(BigInteger, "sqlite")
|
||||
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
|
||||
_ = type_, compiler, kw
|
||||
return "INTEGER"
|
||||
|
||||
|
||||
import app.models.master_data # noqa: E402,F401
|
||||
import app.models.miniapp # noqa: E402,F401
|
||||
import app.models.operations # noqa: E402,F401
|
||||
import app.models.org # noqa: E402,F401
|
||||
import app.models.planning # noqa: E402,F401
|
||||
import app.models.sales # noqa: E402,F401
|
||||
from app.api.routes.dashboard import get_material_lot_lifecycle, list_dashboard_material_lots # noqa: E402
|
||||
from app.models.base import Base # noqa: E402
|
||||
from app.models.master_data import Item, Warehouse # noqa: E402
|
||||
from app.models.operations import Delivery, DeliveryItem, OperationReport, ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot # noqa: E402
|
||||
from app.models.org import Department, Employee, SystemConfig # noqa: E402
|
||||
from app.schemas.dashboard import DashboardMaterialLotRead # noqa: E402
|
||||
from app.services.material_lifecycle_dag import build_material_lot_lifecycle_graph # noqa: E402
|
||||
|
||||
|
||||
class DashboardMaterialLotLifecycleTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
|
||||
Base.metadata.create_all(engine)
|
||||
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
self.db: Session = self.SessionLocal()
|
||||
self.now = datetime(2026, 6, 14, 9, 0)
|
||||
self._seed_lot_and_ledger()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.db.close()
|
||||
|
||||
def _seed_lot_and_ledger(self) -> None:
|
||||
raw_item = Item(
|
||||
id=1,
|
||||
item_code="RM-001",
|
||||
item_name="冷轧钢板",
|
||||
item_type="RAW_MATERIAL",
|
||||
unit_weight_kg=Decimal("1"),
|
||||
status="ACTIVE",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
)
|
||||
product = Item(
|
||||
id=2,
|
||||
item_code="FG-001",
|
||||
item_name="钢制碗",
|
||||
item_type="FINISHED_GOOD",
|
||||
unit_weight_kg=Decimal("0.5"),
|
||||
status="ACTIVE",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
)
|
||||
warehouse = Warehouse(
|
||||
id=1,
|
||||
warehouse_code="WH-RAW",
|
||||
warehouse_name="原材料库",
|
||||
warehouse_type="RAW",
|
||||
status="ACTIVE",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
)
|
||||
lot = StockLot(
|
||||
id=1,
|
||||
lot_no="YL0001",
|
||||
lot_role="RAW_MATERIAL",
|
||||
item_id=1,
|
||||
warehouse_id=1,
|
||||
source_doc_type="期初入库",
|
||||
source_doc_id=1,
|
||||
inbound_qty=Decimal("0"),
|
||||
inbound_weight_kg=Decimal("500"),
|
||||
remaining_qty=Decimal("0"),
|
||||
remaining_weight_kg=Decimal("300"),
|
||||
locked_qty=Decimal("0"),
|
||||
locked_weight_kg=Decimal("0"),
|
||||
unit_cost=Decimal("4"),
|
||||
quality_status="PASS",
|
||||
status="AVAILABLE",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
)
|
||||
ledger = ProductionBatchLedger(
|
||||
id=1,
|
||||
material_lot_id=1,
|
||||
material_lot_no="YL0001",
|
||||
material_item_id=1,
|
||||
product_item_id=2,
|
||||
status="在生产",
|
||||
miniapp_selectable=1,
|
||||
total_issued_weight_kg=Decimal("120"),
|
||||
outside_weight_kg=Decimal("42"),
|
||||
finished_inbound_qty=Decimal("100"),
|
||||
surplus_inbound_weight_kg=Decimal("18"),
|
||||
scrap_inbound_weight_kg=Decimal("6"),
|
||||
writeoff_weight_kg=Decimal("54"),
|
||||
first_issue_time=self.now,
|
||||
last_issue_time=self.now,
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
)
|
||||
dept = Department(
|
||||
id=1,
|
||||
dept_code="D-001",
|
||||
dept_name="生产部",
|
||||
dept_type="PRODUCTION",
|
||||
status="ACTIVE",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
)
|
||||
employee = Employee(
|
||||
id=1,
|
||||
employee_code="EMP-001",
|
||||
employee_name="张三",
|
||||
dept_id=1,
|
||||
is_operator=1,
|
||||
is_workshop_staff=1,
|
||||
status="ACTIVE",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
)
|
||||
txns = [
|
||||
ProductionBatchLedgerTxn(
|
||||
id=1,
|
||||
production_ledger_id=1,
|
||||
txn_type="生产出库",
|
||||
qty_delta=Decimal("0"),
|
||||
weight_delta_kg=Decimal("120"),
|
||||
outside_weight_after_kg=Decimal("120"),
|
||||
source_doc_type="库存流水",
|
||||
source_doc_id=101,
|
||||
biz_time=self.now,
|
||||
operator_user_id=1,
|
||||
remark="生产出库累计到生产台账",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
),
|
||||
ProductionBatchLedgerTxn(
|
||||
id=2,
|
||||
production_ledger_id=1,
|
||||
txn_type="成品入库",
|
||||
qty_delta=Decimal("100"),
|
||||
weight_delta_kg=Decimal("-50"),
|
||||
outside_weight_after_kg=Decimal("70"),
|
||||
source_doc_type="库存流水",
|
||||
source_doc_id=102,
|
||||
biz_time=self.now,
|
||||
operator_user_id=1,
|
||||
remark="单据批次号:PB20260614001",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
),
|
||||
ProductionBatchLedgerTxn(
|
||||
id=3,
|
||||
production_ledger_id=1,
|
||||
txn_type="生产余料入库",
|
||||
qty_delta=Decimal("0"),
|
||||
weight_delta_kg=Decimal("-18"),
|
||||
outside_weight_after_kg=Decimal("52"),
|
||||
source_doc_type="库存流水",
|
||||
source_doc_id=103,
|
||||
biz_time=self.now,
|
||||
operator_user_id=1,
|
||||
remark="单据批次号:PB20260614001",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
),
|
||||
ProductionBatchLedgerTxn(
|
||||
id=4,
|
||||
production_ledger_id=1,
|
||||
txn_type="生产废料入库",
|
||||
qty_delta=Decimal("0"),
|
||||
weight_delta_kg=Decimal("-6"),
|
||||
outside_weight_after_kg=Decimal("46"),
|
||||
source_doc_type="库存流水",
|
||||
source_doc_id=104,
|
||||
biz_time=self.now,
|
||||
operator_user_id=1,
|
||||
remark="单据批次号:PB20260614001",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
),
|
||||
ProductionBatchLedgerTxn(
|
||||
id=5,
|
||||
production_ledger_id=1,
|
||||
txn_type="结单核销",
|
||||
qty_delta=Decimal("0"),
|
||||
weight_delta_kg=Decimal("-4"),
|
||||
outside_weight_after_kg=Decimal("42"),
|
||||
source_doc_type="结单",
|
||||
source_doc_id=105,
|
||||
biz_time=self.now,
|
||||
operator_user_id=1,
|
||||
remark="单据批次号:PB20260614001",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
),
|
||||
]
|
||||
report = OperationReport(
|
||||
id=1,
|
||||
report_no="BG20260614001",
|
||||
production_ledger_id=1,
|
||||
employee_id=1,
|
||||
report_source="MINIAPP",
|
||||
start_time=self.now,
|
||||
end_time=self.now,
|
||||
report_qty=Decimal("100"),
|
||||
good_qty=Decimal("96"),
|
||||
scrap_qty=Decimal("4"),
|
||||
rework_qty=Decimal("0"),
|
||||
downtime_minutes=Decimal("0"),
|
||||
extra_cost_amount=Decimal("0"),
|
||||
is_abnormal=0,
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
)
|
||||
self.db.add_all([raw_item, product, warehouse, lot, ledger, dept, employee, *txns, report])
|
||||
self.db.commit()
|
||||
|
||||
def _lot_read(self) -> DashboardMaterialLotRead:
|
||||
return DashboardMaterialLotRead(
|
||||
lot_id=1,
|
||||
lot_no="YL0001",
|
||||
material_item_id=1,
|
||||
material_code="RM-001",
|
||||
material_name="冷轧钢板",
|
||||
warehouse_name="原材料库",
|
||||
inbound_weight_kg=500,
|
||||
remaining_weight_kg=300,
|
||||
unit_cost=4,
|
||||
inbound_amount=2000,
|
||||
issued_batch_count=0,
|
||||
issued_weight_kg=0,
|
||||
finished_batch_count=0,
|
||||
finished_qty=0,
|
||||
delivery_count=0,
|
||||
status="AVAILABLE",
|
||||
quality_status="PASS",
|
||||
created_at=self.now,
|
||||
)
|
||||
|
||||
def test_production_ledger_graph_replaces_work_order_main_chain(self) -> None:
|
||||
nodes, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
|
||||
|
||||
node_types = {node.type for node in nodes}
|
||||
self.assertIn("production_ledger", node_types)
|
||||
self.assertIn("production_issue_txn", node_types)
|
||||
self.assertIn("ledger_outside_balance", node_types)
|
||||
self.assertIn("production_ledger_settlement", node_types)
|
||||
self.assertIn("finished_inbound", node_types)
|
||||
self.assertIn("surplus_inbound", node_types)
|
||||
self.assertIn("scrap_inbound", node_types)
|
||||
self.assertNotIn("work_order", node_types)
|
||||
|
||||
def test_production_ledger_title_stage_and_outside_balance_metric(self) -> None:
|
||||
nodes, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
|
||||
|
||||
ledger_node = next(node for node in nodes if node.type == "production_ledger")
|
||||
metrics = {item.label: item.value for item in ledger_node.metrics}
|
||||
self.assertEqual(ledger_node.title, "YL0001 · 钢制碗")
|
||||
self.assertEqual(ledger_node.stage, "生产台账")
|
||||
self.assertEqual(metrics["库外余额"], "42 kg")
|
||||
|
||||
def test_material_lot_dashboard_list_excludes_scrap_warehouse_lots(self) -> None:
|
||||
scrap_warehouse = Warehouse(
|
||||
id=3,
|
||||
warehouse_code="WH-SCRAP",
|
||||
warehouse_name="废料库",
|
||||
warehouse_type="SCRAP",
|
||||
status="ACTIVE",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
)
|
||||
scrap_lot = StockLot(
|
||||
id=30,
|
||||
lot_no="SCRAPI-YL0001-001",
|
||||
lot_role="PRODUCTION_SCRAP",
|
||||
item_id=1,
|
||||
warehouse_id=3,
|
||||
source_doc_type="PRODUCTION_SCRAP_IN",
|
||||
source_doc_id=1,
|
||||
inbound_qty=Decimal("0"),
|
||||
inbound_weight_kg=Decimal("5"),
|
||||
remaining_qty=Decimal("0"),
|
||||
remaining_weight_kg=Decimal("5"),
|
||||
locked_qty=Decimal("0"),
|
||||
locked_weight_kg=Decimal("0"),
|
||||
unit_cost=Decimal("4"),
|
||||
quality_status="SCRAP",
|
||||
status="AVAILABLE",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
)
|
||||
self.db.add_all([scrap_warehouse, scrap_lot])
|
||||
self.db.commit()
|
||||
|
||||
rows = list_dashboard_material_lots(limit=100, db=self.db)
|
||||
|
||||
lot_nos = {row.lot_no for row in rows}
|
||||
self.assertIn("YL0001", lot_nos)
|
||||
self.assertNotIn("SCRAPI-YL0001-001", lot_nos)
|
||||
self.assertTrue(all(row.warehouse_name == "原材料库" for row in rows))
|
||||
|
||||
def test_operation_report_respects_smart_operation_report_switch(self) -> None:
|
||||
nodes, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
|
||||
self.assertIn("operation_report", {node.type for node in nodes})
|
||||
|
||||
self.db.add(
|
||||
SystemConfig(
|
||||
config_code="SMART_OPERATION_REPORT_ENABLED",
|
||||
config_name="对接智能报工小程序",
|
||||
config_value="关闭",
|
||||
status="ACTIVE",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
nodes, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
|
||||
self.assertNotIn("operation_report", {node.type for node in nodes})
|
||||
|
||||
def test_route_returns_new_graph_when_production_ledger_exists(self) -> None:
|
||||
response = get_material_lot_lifecycle(1, self.db)
|
||||
|
||||
node_types = {node.type for node in response.nodes}
|
||||
self.assertIn("production_ledger", node_types)
|
||||
self.assertNotIn("work_order", node_types)
|
||||
|
||||
def test_route_legacy_fallback_does_not_crash_without_production_ledger(self) -> None:
|
||||
legacy_lot = StockLot(
|
||||
id=2,
|
||||
lot_no="YL9999",
|
||||
lot_role="RAW_MATERIAL",
|
||||
item_id=1,
|
||||
warehouse_id=1,
|
||||
source_doc_type="期初入库",
|
||||
source_doc_id=2,
|
||||
inbound_qty=Decimal("0"),
|
||||
inbound_weight_kg=Decimal("80"),
|
||||
remaining_qty=Decimal("0"),
|
||||
remaining_weight_kg=Decimal("80"),
|
||||
locked_qty=Decimal("0"),
|
||||
locked_weight_kg=Decimal("0"),
|
||||
unit_cost=Decimal("4"),
|
||||
quality_status="PASS",
|
||||
status="AVAILABLE",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
)
|
||||
self.db.add(legacy_lot)
|
||||
self.db.commit()
|
||||
|
||||
response = get_material_lot_lifecycle(2, self.db)
|
||||
|
||||
self.assertEqual(response.lot.lot_no, "YL9999")
|
||||
node_types = {node.type for node in response.nodes}
|
||||
self.assertIn("raw_material_lot", node_types)
|
||||
self.assertNotIn("production_ledger", node_types)
|
||||
|
||||
def test_delivery_edges_target_finished_inbound_for_exact_finished_lot(self) -> None:
|
||||
finished_lot_a = StockLot(
|
||||
id=10,
|
||||
lot_no="FGL-A",
|
||||
lot_role="INVENTORY",
|
||||
item_id=2,
|
||||
warehouse_id=1,
|
||||
source_doc_type="PRODUCTION_LEDGER_IN",
|
||||
source_doc_id=1,
|
||||
inbound_qty=Decimal("40"),
|
||||
inbound_weight_kg=Decimal("20"),
|
||||
remaining_qty=Decimal("40"),
|
||||
remaining_weight_kg=Decimal("20"),
|
||||
locked_qty=Decimal("0"),
|
||||
locked_weight_kg=Decimal("0"),
|
||||
unit_cost=Decimal("8"),
|
||||
quality_status="PASS",
|
||||
status="AVAILABLE",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
)
|
||||
finished_lot_b = StockLot(
|
||||
id=11,
|
||||
lot_no="FGL-B",
|
||||
lot_role="INVENTORY",
|
||||
item_id=2,
|
||||
warehouse_id=1,
|
||||
source_doc_type="PRODUCTION_LEDGER_IN",
|
||||
source_doc_id=1,
|
||||
inbound_qty=Decimal("60"),
|
||||
inbound_weight_kg=Decimal("30"),
|
||||
remaining_qty=Decimal("0"),
|
||||
remaining_weight_kg=Decimal("0"),
|
||||
locked_qty=Decimal("0"),
|
||||
locked_weight_kg=Decimal("0"),
|
||||
unit_cost=Decimal("8"),
|
||||
quality_status="PASS",
|
||||
status="AVAILABLE",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
)
|
||||
finished_txn_a = ProductionBatchLedgerTxn(
|
||||
id=20,
|
||||
production_ledger_id=1,
|
||||
txn_type="成品入库",
|
||||
qty_delta=Decimal("40"),
|
||||
weight_delta_kg=Decimal("-20"),
|
||||
outside_weight_after_kg=Decimal("70"),
|
||||
source_doc_type="库存流水",
|
||||
source_doc_id=201,
|
||||
source_line_id=10,
|
||||
biz_time=self.now,
|
||||
operator_user_id=1,
|
||||
remark="单据批次号:PB-FGL-A",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
)
|
||||
finished_txn_b = ProductionBatchLedgerTxn(
|
||||
id=21,
|
||||
production_ledger_id=1,
|
||||
txn_type="成品入库",
|
||||
qty_delta=Decimal("60"),
|
||||
weight_delta_kg=Decimal("-30"),
|
||||
outside_weight_after_kg=Decimal("42"),
|
||||
source_doc_type="库存流水",
|
||||
source_doc_id=202,
|
||||
source_line_id=11,
|
||||
biz_time=self.now,
|
||||
operator_user_id=1,
|
||||
remark="单据批次号:PB-FGL-B",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
)
|
||||
delivery = Delivery(
|
||||
id=1,
|
||||
delivery_no="FH20260614001",
|
||||
warehouse_id=1,
|
||||
delivery_date=self.now,
|
||||
status="POSTED",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
)
|
||||
delivery_item = DeliveryItem(
|
||||
id=1,
|
||||
delivery_id=1,
|
||||
line_no=1,
|
||||
product_item_id=2,
|
||||
lot_id=11,
|
||||
delivery_qty=Decimal("10"),
|
||||
delivery_weight_kg=Decimal("5"),
|
||||
unit_price=Decimal("12"),
|
||||
line_amount=Decimal("120"),
|
||||
status="POSTED",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
)
|
||||
self.db.add_all([finished_lot_a, finished_lot_b, finished_txn_a, finished_txn_b, delivery, delivery_item])
|
||||
self.db.commit()
|
||||
|
||||
nodes, edges = build_material_lot_lifecycle_graph(self.db, self._lot_read())
|
||||
|
||||
delivery_node = next(node for node in nodes if node.type == "delivery")
|
||||
finished_a = next(node for node in nodes if node.type == "finished_inbound" and node.subtitle == "PB-FGL-A")
|
||||
finished_b = next(node for node in nodes if node.type == "finished_inbound" and node.subtitle == "PB-FGL-B")
|
||||
delivery_source_ids = {edge.source for edge in edges if edge.target == delivery_node.id}
|
||||
self.assertIn(finished_b.id, delivery_source_ids)
|
||||
self.assertNotIn(finished_a.id, delivery_source_ids)
|
||||
self.assertNotIn("ledger-1", delivery_source_ids)
|
||||
|
||||
def test_reopen_txn_uses_reopen_node_type_not_writeoff(self) -> None:
|
||||
self.db.add(
|
||||
ProductionBatchLedgerTxn(
|
||||
id=30,
|
||||
production_ledger_id=1,
|
||||
txn_type="重新开工",
|
||||
qty_delta=Decimal("0"),
|
||||
weight_delta_kg=Decimal("0"),
|
||||
outside_weight_after_kg=Decimal("42"),
|
||||
source_doc_type="生产台账",
|
||||
source_doc_id=1,
|
||||
biz_time=self.now,
|
||||
operator_user_id=1,
|
||||
remark="重新开工",
|
||||
created_at=self.now,
|
||||
updated_at=self.now,
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
nodes, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
|
||||
|
||||
reopen_node = next(node for node in nodes if node.title == "重新开工")
|
||||
self.assertEqual(reopen_node.type, "ledger_reopen")
|
||||
self.assertEqual(reopen_node.stage, "重新开工")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
1749
docs/superpowers/plans/2026-06-14-material-batch-lifecycle-dag.md
Normal file
1749
docs/superpowers/plans/2026-06-14-material-batch-lifecycle-dag.md
Normal file
File diff suppressed because it is too large
Load Diff
1410
docs/superpowers/plans/2026-06-14-system-extension-workbench.md
Normal file
1410
docs/superpowers/plans/2026-06-14-system-extension-workbench.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -1842,37 +1842,6 @@ a {
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.extension-hero {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.extension-status-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: center;
|
||||
padding: 20px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(126, 198, 255, 0.24);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.extension-status-card strong {
|
||||
color: #f5fbff;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.compact-extension-form {
|
||||
max-width: 1180px;
|
||||
}
|
||||
|
||||
.extension-preview-field {
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.extension-preview-banner {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.panel::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@ -2502,6 +2471,66 @@ a {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.dag-legend {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-width: min(560px, calc(100% - 36px));
|
||||
padding: 10px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(144, 215, 255, 0.22);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(11, 29, 45, 0.9), rgba(6, 16, 27, 0.78)),
|
||||
radial-gradient(circle at 18% 18%, rgba(219, 239, 255, 0.16), transparent 34%);
|
||||
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.dag-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(144, 215, 255, 0.14);
|
||||
background: rgba(79, 180, 255, 0.07);
|
||||
color: #d9edff;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.dag-legend i {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 0 14px currentColor;
|
||||
}
|
||||
|
||||
.dag-legend-ledger {
|
||||
color: #8fd8ff;
|
||||
background: linear-gradient(135deg, #d7f2ff, #4fb4ff);
|
||||
}
|
||||
|
||||
.dag-legend-balance {
|
||||
color: #ffd07d;
|
||||
background: linear-gradient(135deg, #fff0bd, #f29d52);
|
||||
}
|
||||
|
||||
.dag-legend-inbound {
|
||||
color: #80f2bc;
|
||||
background: linear-gradient(135deg, #d9ffe9, #31c983);
|
||||
}
|
||||
|
||||
.dag-legend-evidence {
|
||||
color: #bac8d8;
|
||||
background: linear-gradient(135deg, #f3f7fb, #7d91a8);
|
||||
}
|
||||
|
||||
.dag-transform {
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
@ -2735,6 +2764,84 @@ a {
|
||||
background: linear-gradient(145deg, rgba(42, 20, 24, 0.98), rgba(42, 31, 23, 0.94));
|
||||
}
|
||||
|
||||
.dag-node-raw {
|
||||
border-color: rgba(144, 215, 255, 0.3);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(12, 34, 54, 0.98), rgba(10, 24, 38, 0.95)),
|
||||
radial-gradient(circle at 12% 10%, rgba(185, 219, 247, 0.2), transparent 36%);
|
||||
}
|
||||
|
||||
.dag-node-ledger {
|
||||
border-color: rgba(144, 215, 255, 0.52);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(9, 30, 52, 0.98), rgba(14, 48, 72, 0.95)),
|
||||
radial-gradient(circle at 16% 12%, rgba(79, 180, 255, 0.28), transparent 38%);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.3), 0 0 24px rgba(79, 180, 255, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.dag-node-issue {
|
||||
border-color: rgba(117, 197, 255, 0.4);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(10, 26, 43, 0.98), rgba(16, 54, 78, 0.94)),
|
||||
repeating-linear-gradient(135deg, rgba(144, 215, 255, 0.07) 0 1px, transparent 1px 8px);
|
||||
}
|
||||
|
||||
.dag-node-balance {
|
||||
border-color: rgba(255, 188, 91, 0.58);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(48, 34, 18, 0.98), rgba(46, 49, 34, 0.93)),
|
||||
radial-gradient(circle at 18% 12%, rgba(242, 157, 82, 0.3), transparent 38%);
|
||||
}
|
||||
|
||||
.dag-node-settlement {
|
||||
border-color: rgba(178, 221, 255, 0.54);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(16, 33, 52, 0.98), rgba(28, 47, 64, 0.94)),
|
||||
linear-gradient(90deg, rgba(219, 239, 255, 0.12), transparent 44%);
|
||||
}
|
||||
|
||||
.dag-node-inbound {
|
||||
border-color: rgba(98, 230, 162, 0.48);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(11, 42, 36, 0.98), rgba(12, 55, 46, 0.94)),
|
||||
radial-gradient(circle at 18% 10%, rgba(98, 230, 162, 0.22), transparent 38%);
|
||||
}
|
||||
|
||||
.dag-node-writeoff {
|
||||
border-color: rgba(255, 130, 111, 0.56);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(47, 21, 22, 0.98), rgba(52, 36, 28, 0.94)),
|
||||
repeating-linear-gradient(135deg, rgba(255, 111, 97, 0.1) 0 2px, transparent 2px 10px);
|
||||
}
|
||||
|
||||
.dag-node-reopen {
|
||||
border-color: rgba(170, 210, 255, 0.56);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(18, 30, 58, 0.98), rgba(27, 43, 70, 0.94)),
|
||||
radial-gradient(circle at 82% 12%, rgba(144, 215, 255, 0.22), transparent 34%);
|
||||
}
|
||||
|
||||
.dag-node-evidence {
|
||||
border-style: dashed;
|
||||
border-color: rgba(185, 199, 214, 0.5);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(22, 29, 39, 0.92), rgba(29, 39, 51, 0.86)),
|
||||
linear-gradient(135deg, rgba(185, 199, 214, 0.08), transparent 48%);
|
||||
color: #e2ebf3;
|
||||
}
|
||||
|
||||
.dag-node-evidence .dag-node-stage,
|
||||
.dag-node-evidence small {
|
||||
color: #bac8d8;
|
||||
}
|
||||
|
||||
.dag-node-delivery {
|
||||
border-color: rgba(118, 231, 255, 0.5);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(9, 35, 48, 0.98), rgba(14, 48, 62, 0.94)),
|
||||
radial-gradient(circle at 82% 14%, rgba(118, 231, 255, 0.2), transparent 34%);
|
||||
}
|
||||
|
||||
.dag-detail-panel {
|
||||
min-width: 0;
|
||||
padding: 22px;
|
||||
@ -5382,8 +5489,7 @@ body::before,
|
||||
.detail-item,
|
||||
.detail-block,
|
||||
.calculator-selected-material,
|
||||
.calculator-result-grid article,
|
||||
.extension-status-card {
|
||||
.calculator-result-grid article {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
@ -6845,8 +6951,7 @@ select option {
|
||||
.detail-item,
|
||||
.detail-block,
|
||||
.calculator-selected-material,
|
||||
.calculator-result-grid article,
|
||||
.extension-status-card {
|
||||
.calculator-result-grid article {
|
||||
border-color: rgba(222, 230, 241, 0.98);
|
||||
border-radius: 12px;
|
||||
background:
|
||||
@ -18272,6 +18377,664 @@ body .warehouse-ledger-table .document-archive-icon {
|
||||
}
|
||||
}
|
||||
|
||||
.system-extension-workbench {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-bottom: 92px;
|
||||
}
|
||||
|
||||
.extension-workbench-head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 0.8fr) minmax(0, 1.6fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.extension-workbench-head h2 {
|
||||
margin: 2px 0 0;
|
||||
color: var(--text-strong);
|
||||
font-size: clamp(24px, 2.2vw, 34px);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.extension-status-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.extension-status-pill {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.extension-status-pill span {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.extension-status-pill strong {
|
||||
display: inline-block;
|
||||
max-width: 128px;
|
||||
overflow: hidden;
|
||||
color: var(--text-strong);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.extension-status-success {
|
||||
border-color: rgba(34, 197, 94, 0.28);
|
||||
}
|
||||
|
||||
.extension-status-warning {
|
||||
border-color: rgba(245, 158, 11, 0.34);
|
||||
}
|
||||
|
||||
.extension-status-info {
|
||||
border-color: rgba(14, 165, 233, 0.28);
|
||||
}
|
||||
|
||||
.extension-status-muted {
|
||||
border-color: rgba(148, 163, 184, 0.26);
|
||||
}
|
||||
|
||||
.extension-config-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 188px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.extension-nav-rail {
|
||||
position: sticky;
|
||||
top: 88px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 18px;
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.extension-nav-button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 10px 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 13px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.extension-nav-button::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.extension-nav-mark {
|
||||
flex: 0 0 auto;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.5);
|
||||
box-shadow: 0 0 0 4px rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.extension-nav-button strong {
|
||||
color: var(--text-strong);
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.extension-nav-button:hover,
|
||||
.extension-nav-button.active {
|
||||
border-color: rgba(31, 78, 121, 0.2);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
.extension-nav-blue.active .extension-nav-mark {
|
||||
background: #0ea5e9;
|
||||
}
|
||||
|
||||
.extension-nav-steel.active .extension-nav-mark {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.extension-nav-green.active .extension-nav-mark {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.extension-nav-orange.active .extension-nav-mark {
|
||||
background: #f97316;
|
||||
}
|
||||
|
||||
.extension-module-panel {
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
.extension-module-head {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.extension-module-head h3 {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.system-extension-workbench .warning-tag {
|
||||
border-color: rgba(245, 158, 11, 0.28);
|
||||
background: rgba(255, 247, 237, 0.92);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.extension-broadcast-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 540px), 1fr));
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.extension-broadcast-ledger {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.extension-form-card,
|
||||
.extension-list-card {
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(203, 213, 225, 0.74);
|
||||
border-radius: 17px;
|
||||
background: #ffffff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.extension-list-card-full {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.system-extension-workbench .form-section-title {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.system-extension-workbench .form-section-title strong {
|
||||
color: var(--text-strong);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.extension-list-title {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.extension-title-stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.extension-add-button {
|
||||
flex: 0 0 auto;
|
||||
min-width: 104px;
|
||||
}
|
||||
|
||||
.system-extension-workbench .form-field,
|
||||
.extension-modal-card .form-field {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.system-extension-workbench .form-field span,
|
||||
.extension-modal-card .form-field span {
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.system-extension-workbench .form-field input,
|
||||
.system-extension-workbench .form-field select,
|
||||
.system-extension-workbench .form-field textarea,
|
||||
.extension-modal-card .form-field input,
|
||||
.extension-modal-card .form-field select,
|
||||
.extension-modal-card .form-field textarea {
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
border-color: rgba(148, 163, 184, 0.42);
|
||||
border-radius: 12px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.extension-field-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.extension-field-grid-2 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
|
||||
}
|
||||
|
||||
.extension-field-grid-3 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 190px), 1fr));
|
||||
}
|
||||
|
||||
.extension-broadcast-status-row {
|
||||
grid-template-columns: minmax(min(100%, 220px), 0.4fr) minmax(min(100%, 320px), 1fr);
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.extension-checkbox-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.extension-checkbox-grid .checkbox-field {
|
||||
gap: 7px;
|
||||
min-height: 32px;
|
||||
padding: 7px 10px;
|
||||
border-color: rgba(148, 163, 184, 0.28);
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.extension-checkbox-grid .checkbox-field input {
|
||||
flex: 0 0 auto;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.extension-preview-disclosure,
|
||||
.extension-detail-note {
|
||||
min-width: 0;
|
||||
border: 1px dashed rgba(148, 163, 184, 0.42);
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.extension-preview-disclosure summary,
|
||||
.extension-detail-note summary {
|
||||
padding: 10px 12px;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.extension-detail-note p {
|
||||
margin: 0;
|
||||
padding: 0 12px 12px;
|
||||
color: #64748b;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.extension-banner-preview {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: end;
|
||||
padding: 0 10px 10px;
|
||||
}
|
||||
|
||||
.extension-banner-preview > span {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.extension-table-wrap {
|
||||
max-height: 430px;
|
||||
}
|
||||
|
||||
.extension-setting-block {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(71, 85, 105, 0.14);
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #f8fafc, #eef4fa);
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.extension-setting-block .setting-kicker {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.extension-setting-block strong {
|
||||
margin-right: auto;
|
||||
color: #0f172a;
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.extension-setting-block em {
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
background: rgba(14, 165, 233, 0.1);
|
||||
color: #0369a1;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.extension-mode-switch {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.26);
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.extension-mode-switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.extension-mode-switch .switch-track {
|
||||
position: relative;
|
||||
width: 54px;
|
||||
height: 30px;
|
||||
border-radius: 999px;
|
||||
background: #cbd5e1;
|
||||
box-shadow: inset 0 2px 8px rgba(15, 23, 42, 0.16);
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
|
||||
.extension-mode-switch .switch-track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.24);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.extension-mode-switch.active .switch-track {
|
||||
background: #0f766e;
|
||||
}
|
||||
|
||||
.extension-mode-switch.active .switch-track::after {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.extension-mode-switch strong {
|
||||
display: block;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.extension-mode-switch small {
|
||||
display: inline-flex;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
margin-top: 6px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.extension-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 6500;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(15, 23, 42, 0.34);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.extension-modal-card {
|
||||
width: min(860px, calc(100vw - 48px));
|
||||
max-height: min(760px, calc(100dvh - 48px));
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)),
|
||||
radial-gradient(circle at 100% 0%, rgba(14, 165, 233, 0.12), transparent 30%);
|
||||
box-shadow: 0 34px 88px rgba(15, 23, 42, 0.26);
|
||||
}
|
||||
|
||||
.extension-modal-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 20px 14px;
|
||||
border-bottom: 1px solid rgba(203, 213, 225, 0.72);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.extension-modal-head h3 {
|
||||
margin: 2px 0 0;
|
||||
color: var(--text-strong);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.extension-modal-close {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.extension-dialog-form {
|
||||
padding: 18px 20px 20px;
|
||||
}
|
||||
|
||||
.extension-dialog-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin: 4px -20px -20px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid rgba(203, 213, 225, 0.72);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.extension-save-dock {
|
||||
position: fixed;
|
||||
right: 28px;
|
||||
bottom: 28px;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
max-width: min(680px, calc(100vw - 56px));
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0 22px 54px rgba(15, 23, 42, 0.18);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.extension-save-dock > span {
|
||||
padding: 0 8px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.extension-save-dock .ghost-button,
|
||||
.extension-save-dock .primary-button {
|
||||
flex: 0 0 auto;
|
||||
min-width: 96px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.extension-save-dock .semantic-action-label {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.system-extension-workbench .status-chip.status-info {
|
||||
background: rgba(14, 165, 233, 0.12);
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.system-extension-workbench .status-chip.status-success {
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.system-extension-workbench .status-chip.status-warning {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.system-extension-workbench .status-chip.status-danger {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.system-extension-workbench .status-chip.status-active {
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.system-extension-workbench .status-chip.status-paused {
|
||||
background: rgba(148, 163, 184, 0.16);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
@media (max-width: 1440px) {
|
||||
.extension-workbench-head {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.extension-config-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.extension-nav-rail {
|
||||
position: static;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.extension-broadcast-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.extension-status-strip {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.extension-list-title {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.extension-list-title,
|
||||
.extension-title-stack {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.extension-add-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.extension-broadcast-status-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.extension-modal-backdrop {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.extension-modal-card {
|
||||
width: calc(100vw - 24px);
|
||||
max-height: calc(100dvh - 24px);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.extension-modal-head,
|
||||
.extension-dialog-form {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.extension-dialog-actions {
|
||||
margin-inline: -14px;
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.extension-save-dock {
|
||||
right: 14px;
|
||||
bottom: 14px;
|
||||
left: 14px;
|
||||
justify-content: flex-end;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.extension-save-dock > span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Final warehouse document action contract: submit buttons live at the paper document bottom. */
|
||||
body .workflow-drawer.form-drawer:has(.warehouse-document-operation-form) {
|
||||
width: min(1280px, calc(100vw - 24px)) !important;
|
||||
|
||||
@ -96,6 +96,7 @@ function createSemanticIcon(iconName) {
|
||||
function setButtonContent(button, semantics) {
|
||||
let icon = button.querySelector(".semantic-action-icon");
|
||||
let label = button.querySelector(".semantic-action-label");
|
||||
const resolvedLabel = button.dataset.semanticLabel || semantics.label;
|
||||
|
||||
if (button.dataset.semanticActionIcon !== semantics.icon || !icon || !label) {
|
||||
button.textContent = "";
|
||||
@ -105,7 +106,7 @@ function setButtonContent(button, semantics) {
|
||||
button.append(icon, label);
|
||||
}
|
||||
|
||||
label.textContent = semantics.label;
|
||||
label.textContent = resolvedLabel;
|
||||
}
|
||||
|
||||
function clearSemanticButton(button) {
|
||||
@ -148,9 +149,10 @@ function enhanceButton(button) {
|
||||
button.dataset.buttonTone = semantics.tone;
|
||||
button.dataset.semanticActionIcon = semantics.icon;
|
||||
setButtonContent(button, semantics);
|
||||
button.setAttribute("aria-label", semantics.label);
|
||||
const resolvedLabel = button.dataset.semanticLabel || semantics.label;
|
||||
button.setAttribute("aria-label", resolvedLabel);
|
||||
if (!button.title || button.dataset.autoIconTitle === "true") {
|
||||
button.title = semantics.label;
|
||||
button.title = resolvedLabel;
|
||||
button.dataset.autoIconTitle = "true";
|
||||
}
|
||||
}
|
||||
|
||||
63
frontend/src/views/DashboardView.test.js
Normal file
63
frontend/src/views/DashboardView.test.js
Normal file
@ -0,0 +1,63 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(join(__dirname, "DashboardView.vue"), "utf8");
|
||||
const mainCss = readFileSync(resolve(__dirname, "../styles/main.css"), "utf8");
|
||||
|
||||
describe("DashboardView material lot lifecycle DAG", () => {
|
||||
it("uses closed-loop material batch copy and stage labels", () => {
|
||||
assert.match(source, /材料库存批次闭环图/);
|
||||
assert.match(source, /生产台账/);
|
||||
assert.match(source, /库外闭环/);
|
||||
assert.match(source, /入库结果/);
|
||||
assert.doesNotMatch(source, /原材料生命周期 DAG/);
|
||||
});
|
||||
|
||||
it("knows all production ledger lifecycle node types", () => {
|
||||
[
|
||||
"production_ledger",
|
||||
"production_issue_txn",
|
||||
"ledger_outside_balance",
|
||||
"production_ledger_settlement",
|
||||
"finished_inbound",
|
||||
"surplus_inbound",
|
||||
"scrap_inbound",
|
||||
"ledger_writeoff",
|
||||
"ledger_reopen",
|
||||
"operation_report",
|
||||
"delivery"
|
||||
].forEach((nodeType) => {
|
||||
assert.match(source, new RegExp(nodeType));
|
||||
});
|
||||
});
|
||||
|
||||
it("frames operation reports as evidence instead of lifecycle control nodes", () => {
|
||||
assert.match(source, /报工证据/);
|
||||
assert.match(source, /报工仅作证据/);
|
||||
assert.doesNotMatch(source, /stage:\s*standardStageLabel\(node\)/);
|
||||
assert.match(source, /stageBandLabel/);
|
||||
});
|
||||
|
||||
it("uses parent-aware row placement before business priority", () => {
|
||||
assert.match(source, /buildParentByTarget/);
|
||||
assert.match(source, /buildParentRank/);
|
||||
assert.match(source, /parentRank/);
|
||||
assert.match(source, /compareDagNode\(left,\s*right,\s*parentRank/);
|
||||
});
|
||||
|
||||
it("styles the new lifecycle legend and node type treatments", () => {
|
||||
[
|
||||
".dag-node-ledger",
|
||||
".dag-node-balance",
|
||||
".dag-node-settlement",
|
||||
".dag-node-evidence",
|
||||
".dag-legend"
|
||||
].forEach((selector) => {
|
||||
assert.match(mainCss, new RegExp(selector.replace(".", "\\.")));
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -87,7 +87,7 @@
|
||||
<section class="lifecycle-modal">
|
||||
<header class="lifecycle-modal-head">
|
||||
<div>
|
||||
<p class="eyebrow">原材料生命周期 DAG</p>
|
||||
<p class="eyebrow">材料库存批次闭环图</p>
|
||||
<h3>{{ lifecycle?.lot?.lot_no || activeMaterialLot?.lot_no || "库存批次号流转" }}</h3>
|
||||
<p>{{ lifecycle?.lot ? `${lifecycle.lot.material_code} · ${lifecycle.lot.material_name}` : activeMaterialLot?.material_name || "-" }}</p>
|
||||
</div>
|
||||
@ -96,7 +96,7 @@
|
||||
<input v-model="clickHighlightEnabled" type="checkbox" @change="handleClickHighlightModeChange" />
|
||||
<span>点击常亮</span>
|
||||
</label>
|
||||
<span class="table-chip">拖动画布 · 点击节点看详情</span>
|
||||
<span class="table-chip">拖动画布 · 点击节点看详情 · 报工仅作证据</span>
|
||||
<button class="ghost-button" type="button" title="放大" @click="zoomCanvasBy(0.08)">放大</button>
|
||||
<button class="ghost-button" type="button" title="缩小" @click="zoomCanvasBy(-0.08)">缩小</button>
|
||||
<button class="ghost-button" type="button" @click="resetCanvas">重置视图</button>
|
||||
@ -104,7 +104,7 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="loadingLifecycle" class="dag-loading-card">正在加载库存批次号全链路数据...</div>
|
||||
<div v-if="loadingLifecycle" class="dag-loading-card">正在加载材料库存批次闭环图...</div>
|
||||
|
||||
<div v-else class="lifecycle-content">
|
||||
<div
|
||||
@ -115,6 +115,12 @@
|
||||
@mouseup="stopPan"
|
||||
@mouseleave="handleDagViewportMouseLeave"
|
||||
>
|
||||
<div class="dag-legend" @mousedown.stop>
|
||||
<span><i class="dag-legend-ledger"></i>生产台账</span>
|
||||
<span><i class="dag-legend-balance"></i>库外余额</span>
|
||||
<span><i class="dag-legend-inbound"></i>入库结果</span>
|
||||
<span><i class="dag-legend-evidence"></i>报工证据</span>
|
||||
</div>
|
||||
<div class="dag-transform" :style="canvasTransformStyle">
|
||||
<div class="dag-canvas" :style="{ width: `${canvasSize.width}px`, height: `${canvasSize.height}px` }">
|
||||
<div
|
||||
@ -228,6 +234,7 @@
|
||||
class="dag-node-card"
|
||||
:class="[
|
||||
`dag-node-${node.tone}`,
|
||||
dagNodeTypeClass(node),
|
||||
{
|
||||
'dag-node-active': selectedNode?.id === node.id,
|
||||
'dag-node-locked': persistentDagNodeIds.has(node.id)
|
||||
@ -242,7 +249,7 @@
|
||||
>
|
||||
<span class="dag-node-stage">{{ node.stage }}</span>
|
||||
<strong>{{ node.title }}</strong>
|
||||
<small>{{ node.subtitle || formatDashboardNodeTypeLabel(node.type) }}</small>
|
||||
<small>{{ dagNodeSubtitle(node) }}</small>
|
||||
<div class="dag-node-footer">
|
||||
<span>{{ displayStatus(node.status) }}</span>
|
||||
<span>{{ node.metrics?.[0]?.value || "" }}</span>
|
||||
@ -305,8 +312,29 @@ const EDGE_GAP_PER_LINE = 68;
|
||||
const ROW_GAP = 168;
|
||||
const LEFT_OFFSET = 78;
|
||||
const TOP_OFFSET = 104;
|
||||
const REPORT_ROW_GAP = 150;
|
||||
const BRANCH_GAP = 46;
|
||||
const STAGE_LABELS = {
|
||||
0: "原料入库",
|
||||
1: "生产台账",
|
||||
2: "生产出库",
|
||||
3: "库外闭环",
|
||||
4: "入库结算",
|
||||
5: "入库结果",
|
||||
6: "销售去向"
|
||||
};
|
||||
const NODE_TYPE_CLASS = {
|
||||
raw_material_lot: "dag-node-raw",
|
||||
production_ledger: "dag-node-ledger",
|
||||
production_issue_txn: "dag-node-issue",
|
||||
ledger_outside_balance: "dag-node-balance",
|
||||
production_ledger_settlement: "dag-node-settlement",
|
||||
finished_inbound: "dag-node-inbound",
|
||||
surplus_inbound: "dag-node-inbound",
|
||||
scrap_inbound: "dag-node-inbound",
|
||||
ledger_writeoff: "dag-node-writeoff",
|
||||
ledger_reopen: "dag-node-reopen",
|
||||
operation_report: "dag-node-evidence",
|
||||
delivery: "dag-node-delivery"
|
||||
};
|
||||
|
||||
const materialLots = ref([]);
|
||||
const loadingMaterialLots = ref(false);
|
||||
@ -360,57 +388,10 @@ const layoutNodes = computed(() => {
|
||||
}
|
||||
|
||||
const edges = lifecycle.value?.edges || [];
|
||||
const nodeById = new Map(nodes.map((node, index) => [node.id, { ...node, __index: index }]));
|
||||
const childrenBySource = new Map();
|
||||
const parentByTarget = new Map();
|
||||
edges.forEach((edge) => {
|
||||
if (!childrenBySource.has(edge.source)) {
|
||||
childrenBySource.set(edge.source, []);
|
||||
}
|
||||
childrenBySource.get(edge.source).push(edge.target);
|
||||
if (!parentByTarget.has(edge.target)) {
|
||||
parentByTarget.set(edge.target, edge.source);
|
||||
}
|
||||
});
|
||||
|
||||
const parentByTarget = buildParentByTarget(nodes, edges);
|
||||
const parentRank = buildParentRank(nodes, edges, parentByTarget);
|
||||
const levelX = buildAdaptiveLevelX(nodes, edges);
|
||||
const yById = new Map();
|
||||
const operationNodes = nodes
|
||||
.filter((node) => node.type === "work_order_operation")
|
||||
.sort(compareDagNode);
|
||||
const reportNodes = nodes
|
||||
.filter((node) => node.type === "operation_report")
|
||||
.sort(compareDagNode);
|
||||
const reportGroups = new Map();
|
||||
reportNodes.forEach((node) => {
|
||||
const parentId = parentByTarget.get(node.id) || "__unlinked_reports";
|
||||
if (!reportGroups.has(parentId)) {
|
||||
reportGroups.set(parentId, []);
|
||||
}
|
||||
reportGroups.get(parentId).push(node);
|
||||
});
|
||||
|
||||
let cursorY = TOP_OFFSET;
|
||||
operationNodes.forEach((operation) => {
|
||||
const reports = reportGroups.get(operation.id) || [];
|
||||
const rowCount = Math.max(1, reports.length);
|
||||
const groupHeight = Math.max(NODE_HEIGHT, rowCount * NODE_HEIGHT + (rowCount - 1) * (REPORT_ROW_GAP - NODE_HEIGHT));
|
||||
yById.set(operation.id, cursorY + Math.max(0, (groupHeight - NODE_HEIGHT) / 2));
|
||||
reports.forEach((report, index) => {
|
||||
yById.set(report.id, cursorY + index * REPORT_ROW_GAP);
|
||||
});
|
||||
cursorY += groupHeight + BRANCH_GAP;
|
||||
});
|
||||
|
||||
const unlinkedReports = reportGroups.get("__unlinked_reports") || [];
|
||||
unlinkedReports.forEach((report) => {
|
||||
if (!yById.has(report.id)) {
|
||||
yById.set(report.id, cursorY);
|
||||
cursorY += REPORT_ROW_GAP;
|
||||
}
|
||||
});
|
||||
|
||||
const branchHeight = Math.max(NODE_HEIGHT, cursorY - TOP_OFFSET - BRANCH_GAP);
|
||||
const levels = new Map();
|
||||
nodes.forEach((node) => {
|
||||
if (!levels.has(node.level)) {
|
||||
@ -420,20 +401,9 @@ const layoutNodes = computed(() => {
|
||||
});
|
||||
|
||||
Array.from(levels.entries()).forEach(([level, rows]) => {
|
||||
const sortedRows = rows.slice().sort(compareDagNode);
|
||||
if (level <= 2) {
|
||||
const distributed = distributeLevelRows(sortedRows, branchHeight);
|
||||
distributed.forEach(({ node, y }) => yById.set(node.id, y));
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedRows = rows.slice().sort((left, right) => compareDagNode(left, right, parentRank));
|
||||
sortedRows.forEach((node, index) => {
|
||||
if (yById.has(node.id)) {
|
||||
return;
|
||||
}
|
||||
const parent = nodeById.get(parentByTarget.get(node.id));
|
||||
const parentY = parent ? yById.get(parent.id) : null;
|
||||
yById.set(node.id, Number.isFinite(parentY) ? parentY : TOP_OFFSET + index * ROW_GAP);
|
||||
yById.set(node.id, TOP_OFFSET + index * ROW_GAP);
|
||||
});
|
||||
});
|
||||
|
||||
@ -566,11 +536,11 @@ const stageBands = computed(() => {
|
||||
layoutNodes.value.forEach((node) => {
|
||||
if (!stages.has(node.level)) {
|
||||
stages.set(node.level, {
|
||||
level: node.level,
|
||||
label: node.stage,
|
||||
left: Math.max(0, node.x - 18),
|
||||
width: NODE_WIDTH + 36
|
||||
});
|
||||
level: node.level,
|
||||
label: stageBandLabel(node.level),
|
||||
left: Math.max(0, node.x - 18),
|
||||
width: NODE_WIDTH + 36
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(stages.values()).sort((left, right) => left.level - right.level);
|
||||
@ -841,10 +811,18 @@ function segmentKey(leftLevel, rightLevel) {
|
||||
return `${leftLevel}->${rightLevel}`;
|
||||
}
|
||||
|
||||
function compareDagNode(left, right) {
|
||||
function compareDagNode(left, right, parentRank = new Map()) {
|
||||
if (left.level !== right.level) {
|
||||
return left.level - right.level;
|
||||
}
|
||||
const parentDelta = String(parentRank.get(left.id) || "").localeCompare(String(parentRank.get(right.id) || ""), "zh-Hans-CN");
|
||||
if (parentDelta) {
|
||||
return parentDelta;
|
||||
}
|
||||
const priorityDelta = dagRowPriority(left) - dagRowPriority(right);
|
||||
if (priorityDelta) {
|
||||
return priorityDelta;
|
||||
}
|
||||
const leftSeq = Number(String(left.title || "").match(/OP(\d+)/i)?.[1] || 0);
|
||||
const rightSeq = Number(String(right.title || "").match(/OP(\d+)/i)?.[1] || 0);
|
||||
if (leftSeq || rightSeq) {
|
||||
@ -853,19 +831,106 @@ function compareDagNode(left, right) {
|
||||
return String(left.title || left.id).localeCompare(String(right.title || right.id), "zh-Hans-CN");
|
||||
}
|
||||
|
||||
function distributeLevelRows(rows, branchHeight) {
|
||||
if (rows.length <= 1) {
|
||||
return rows.map((node) => ({
|
||||
node,
|
||||
y: TOP_OFFSET + Math.max(0, (branchHeight - NODE_HEIGHT) / 2)
|
||||
}));
|
||||
function buildParentByTarget(nodes, edges) {
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||
const parentByTarget = new Map();
|
||||
edges.forEach((edge) => {
|
||||
const source = nodeById.get(edge.source);
|
||||
const target = nodeById.get(edge.target);
|
||||
if (!source || !target || parentByTarget.has(edge.target)) {
|
||||
return;
|
||||
}
|
||||
parentByTarget.set(edge.target, edge.source);
|
||||
});
|
||||
return parentByTarget;
|
||||
}
|
||||
|
||||
function buildParentRank(nodes, edges, parentByTarget) {
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||
const childrenByParent = new Map();
|
||||
edges.forEach((edge) => {
|
||||
if (!nodeById.has(edge.source) || !nodeById.has(edge.target)) {
|
||||
return;
|
||||
}
|
||||
if (!childrenByParent.has(edge.source)) {
|
||||
childrenByParent.set(edge.source, []);
|
||||
}
|
||||
childrenByParent.get(edge.source).push(edge.target);
|
||||
});
|
||||
|
||||
const rootOrder = nodes
|
||||
.filter((node) => !parentByTarget.has(node.id))
|
||||
.slice()
|
||||
.sort(compareDagNode)
|
||||
.map((node) => node.id);
|
||||
const rootIndexById = new Map(rootOrder.map((id, index) => [id, index]));
|
||||
const siblingIndexById = new Map();
|
||||
childrenByParent.forEach((childIds) => {
|
||||
childIds
|
||||
.slice()
|
||||
.sort((leftId, rightId) => compareDagNode(nodeById.get(leftId), nodeById.get(rightId)))
|
||||
.forEach((childId, index) => siblingIndexById.set(childId, index));
|
||||
});
|
||||
|
||||
const rankById = new Map();
|
||||
const visiting = new Set();
|
||||
const rankFor = (nodeId) => {
|
||||
if (rankById.has(nodeId)) {
|
||||
return rankById.get(nodeId);
|
||||
}
|
||||
if (visiting.has(nodeId)) {
|
||||
return padRank(rootIndexById.get(nodeId) ?? 9999);
|
||||
}
|
||||
visiting.add(nodeId);
|
||||
const parentId = parentByTarget.get(nodeId);
|
||||
const rank = parentId && nodeById.has(parentId)
|
||||
? `${rankFor(parentId)}.${padRank(siblingIndexById.get(nodeId) ?? 9999)}`
|
||||
: padRank(rootIndexById.get(nodeId) ?? nodes.findIndex((node) => node.id === nodeId));
|
||||
visiting.delete(nodeId);
|
||||
rankById.set(nodeId, rank);
|
||||
return rank;
|
||||
};
|
||||
|
||||
nodes.forEach((node) => rankFor(node.id));
|
||||
return rankById;
|
||||
}
|
||||
|
||||
function padRank(value) {
|
||||
return String(Math.max(0, Number(value) || 0)).padStart(4, "0");
|
||||
}
|
||||
|
||||
function dagRowPriority(node) {
|
||||
if (["raw_material_lot", "production_ledger", "production_issue_txn", "production_ledger_settlement", "finished_inbound", "delivery"].includes(node.type)) {
|
||||
return 0;
|
||||
}
|
||||
const usableHeight = Math.max(branchHeight - NODE_HEIGHT, (rows.length - 1) * ROW_GAP);
|
||||
const gap = usableHeight / Math.max(1, rows.length - 1);
|
||||
return rows.map((node, index) => ({
|
||||
node,
|
||||
y: TOP_OFFSET + index * gap
|
||||
}));
|
||||
if (["ledger_outside_balance", "surplus_inbound"].includes(node.type)) {
|
||||
return 1;
|
||||
}
|
||||
if (["operation_report", "scrap_inbound"].includes(node.type)) {
|
||||
return 2;
|
||||
}
|
||||
if (node.type === "ledger_writeoff") {
|
||||
return 3;
|
||||
}
|
||||
if (node.type === "ledger_reopen") {
|
||||
return 4;
|
||||
}
|
||||
return 9;
|
||||
}
|
||||
|
||||
function stageBandLabel(level) {
|
||||
return STAGE_LABELS[Number(level)] || `阶段 ${level}`;
|
||||
}
|
||||
|
||||
function dagNodeTypeClass(node) {
|
||||
return NODE_TYPE_CLASS[node.type] || "";
|
||||
}
|
||||
|
||||
function dagNodeSubtitle(node) {
|
||||
if (node.type === "operation_report") {
|
||||
return node.subtitle ? `报工证据 · ${node.subtitle}` : "报工证据";
|
||||
}
|
||||
return node.subtitle || formatDashboardNodeTypeLabel(node.type);
|
||||
}
|
||||
|
||||
function resolveLevelCollisions(nodes, yById) {
|
||||
|
||||
79
frontend/src/views/SystemExtensionView.test.js
Normal file
79
frontend/src/views/SystemExtensionView.test.js
Normal file
@ -0,0 +1,79 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(join(__dirname, "SystemExtensionView.vue"), "utf8");
|
||||
const mainCss = readFileSync(resolve(__dirname, "../styles/main.css"), "utf8");
|
||||
|
||||
describe("SystemExtensionView workbench redesign", () => {
|
||||
it("removes the old hero explanation card and admin-only copy", () => {
|
||||
assert.doesNotMatch(source, /extension-hero/);
|
||||
assert.doesNotMatch(source, /extension-status-card/);
|
||||
assert.doesNotMatch(source, /广播通知与 AI 助手配置中心/);
|
||||
assert.doesNotMatch(source, /ADMIN ONLY/);
|
||||
});
|
||||
|
||||
it("renders a compact system extension workbench shell", () => {
|
||||
[
|
||||
"system-extension-workbench",
|
||||
"extension-status-strip",
|
||||
"extension-config-shell",
|
||||
"extension-nav-rail",
|
||||
"extension-module-panel",
|
||||
"extension-save-dock"
|
||||
].forEach((className) => {
|
||||
assert.match(source, new RegExp(className));
|
||||
assert.match(mainCss, new RegExp(`\\.${className}`));
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps auxiliary explanations out of the permanent workspace", () => {
|
||||
assert.doesNotMatch(source, /<small>{{ section\.desc }}<\/small>/);
|
||||
assert.doesNotMatch(source, /activeSectionMeta\.eyebrow/);
|
||||
assert.doesNotMatch(source, /extension-impact-list/);
|
||||
assert.match(source, /extension-detail-note/);
|
||||
assert.match(source, /extension-preview-disclosure/);
|
||||
assert.match(source, /extension-field-grid/);
|
||||
});
|
||||
|
||||
it("opens broadcast editing from a dialog instead of a permanent form block", () => {
|
||||
assert.match(source, /showBroadcastDialog/);
|
||||
assert.match(source, /openNewBroadcastDialog/);
|
||||
assert.match(source, /extension-modal-backdrop/);
|
||||
assert.match(source, /extension-broadcast-ledger/);
|
||||
assert.match(source, /data-semantic-label="新增广播"/);
|
||||
assert.match(source, /新增广播/);
|
||||
assert.doesNotMatch(source, /<section v-if="activeSection === 'broadcast'" class="extension-broadcast-grid">/);
|
||||
});
|
||||
|
||||
it("exposes the four configuration modules as a navigation directory", () => {
|
||||
["广播通知", "库存批次规则", "生产对接模式", "AI 助手配置"].forEach((label) => {
|
||||
assert.match(source, new RegExp(label));
|
||||
});
|
||||
|
||||
assert.match(source, /activeSection/);
|
||||
assert.match(source, /extensionSections/);
|
||||
});
|
||||
|
||||
it("uses a fixed save dock with explicit save and reset actions", () => {
|
||||
assert.match(source, /放弃修改/);
|
||||
assert.match(source, /保存配置/);
|
||||
assert.match(source, /saveActiveSection/);
|
||||
assert.match(source, /resetActiveSection/);
|
||||
assert.match(source, /activeSectionDirty/);
|
||||
});
|
||||
|
||||
it("keeps existing system extension API endpoints unchanged", () => {
|
||||
[
|
||||
"/system-extension/broadcasts",
|
||||
"/system-extension/assistant-config",
|
||||
"/system-extension/raw-material-lot-prefix",
|
||||
"/system-extension/smart-operation-report"
|
||||
].forEach((endpoint) => {
|
||||
assert.ok(source.includes(endpoint), `${endpoint} endpoint should remain in source`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,278 +1,325 @@
|
||||
<template>
|
||||
<section class="page-grid">
|
||||
<section class="panel panel-hero extension-hero">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">系统拓展</p>
|
||||
<span
|
||||
class="title-with-help"
|
||||
title="该模块仅系统管理员可见,用于维护全局右上角喇叭广播,以及后续 AI 聊天机器人接入免费 LLM API 的配置。"
|
||||
<section class="system-extension-workbench">
|
||||
<header class="extension-workbench-head">
|
||||
<div>
|
||||
<p class="eyebrow">系统管理</p>
|
||||
<h2>系统拓展</h2>
|
||||
</div>
|
||||
<div class="extension-status-strip" aria-label="系统拓展状态概览">
|
||||
<article
|
||||
v-for="card in statusCards"
|
||||
:key="card.label"
|
||||
class="extension-status-pill"
|
||||
:class="`extension-status-${card.tone}`"
|
||||
>
|
||||
<h3>广播通知与 AI 助手配置中心</h3>
|
||||
</span>
|
||||
<span>{{ card.label }}</span>
|
||||
<strong>{{ card.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
<div class="extension-status-card">
|
||||
<span class="panel-tag">ADMIN ONLY</span>
|
||||
<strong>{{ assistantConfig.enabled ? "真实 LLM 已启用" : "当前为占位助手模式" }}</strong>
|
||||
<p>{{ assistantConfig.model_name || "尚未配置模型名称" }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">广播内容管理</p>
|
||||
<h3>右上角小喇叭横幅</h3>
|
||||
</div>
|
||||
<div class="workflow-inline-actions">
|
||||
<span class="panel-tag">{{ editingBroadcastId ? "编辑广播" : "新增广播" }}</span>
|
||||
<button class="ghost-button" type="button" @click="resetBroadcastForm">新建</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div>
|
||||
<div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>
|
||||
|
||||
<div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div>
|
||||
<div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>
|
||||
<section class="extension-config-shell">
|
||||
<nav class="extension-nav-rail" aria-label="系统拓展配置目录">
|
||||
<button
|
||||
v-for="section in extensionSections"
|
||||
:key="section.key"
|
||||
class="extension-nav-button"
|
||||
:class="[{ active: activeSection === section.key }, `extension-nav-${section.accent}`]"
|
||||
type="button"
|
||||
:aria-pressed="activeSection === section.key"
|
||||
@click="activeSection = section.key"
|
||||
>
|
||||
<span class="extension-nav-mark" aria-hidden="true"></span>
|
||||
<strong>{{ section.title }}</strong>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<form class="stack-form compact-extension-form" @submit.prevent="saveBroadcast">
|
||||
<div class="double-field">
|
||||
<label class="form-field">
|
||||
<span>广播标题 <i class="required-mark">*</i></span>
|
||||
<input v-model.trim="broadcastForm.title" type="text" required />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>提示类型</span>
|
||||
<select v-model="broadcastForm.tone">
|
||||
<option value="INFO">信息</option>
|
||||
<option value="SUCCESS">成功</option>
|
||||
<option value="WARNING">预警</option>
|
||||
<option value="DANGER">重要</option>
|
||||
</select>
|
||||
</label>
|
||||
<main class="extension-module-panel">
|
||||
<div class="extension-module-head">
|
||||
<div>
|
||||
<h3>{{ activeSectionMeta.title }}</h3>
|
||||
</div>
|
||||
<span v-if="activeSectionDirty" class="panel-tag warning-tag">有未保存修改</span>
|
||||
<span v-else class="panel-tag">已同步</span>
|
||||
</div>
|
||||
|
||||
<label class="form-field">
|
||||
<span>广播内容 <i class="required-mark">*</i></span>
|
||||
<input v-model.trim="broadcastForm.content" type="text" required />
|
||||
</label>
|
||||
<section v-if="activeSection === 'broadcast'" class="extension-broadcast-ledger">
|
||||
<section class="extension-list-card extension-list-card-full">
|
||||
<div class="form-section-title extension-list-title">
|
||||
<div class="extension-title-stack">
|
||||
<strong>广播记录</strong>
|
||||
<span class="panel-tag">{{ filteredBroadcasts.length }} 条</span>
|
||||
</div>
|
||||
<button
|
||||
class="primary-button extension-add-button"
|
||||
type="button"
|
||||
data-semantic-label="新增广播"
|
||||
@click="openNewBroadcastDialog"
|
||||
>
|
||||
新增广播
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<span>通知考勤点 <i class="required-mark">*</i></span>
|
||||
<div class="checkbox-grid">
|
||||
<label v-for="point in attendancePoints" :key="point.name" class="checkbox-field">
|
||||
<input v-model="broadcastForm.attendance_point_names" type="checkbox" :value="point.name" />
|
||||
<span>{{ point.name }}</span>
|
||||
<TableControls
|
||||
v-model:search="broadcastControls.search.value"
|
||||
v-model:sort-key="broadcastControls.sortKey.value"
|
||||
v-model:sort-direction="broadcastControls.sortDirection.value"
|
||||
:sort-options="broadcastControls.sortOptions"
|
||||
placeholder="搜索标题、内容、状态、类型"
|
||||
/>
|
||||
|
||||
<div class="table-wrap extension-table-wrap">
|
||||
<table class="data-table compact-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标题</th>
|
||||
<th>考勤点</th>
|
||||
<th>内容</th>
|
||||
<th>类型</th>
|
||||
<th>状态</th>
|
||||
<th>开始</th>
|
||||
<th>结束</th>
|
||||
<th>排序</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in paginatedBroadcasts" :key="item.broadcast_id">
|
||||
<td>{{ item.title }}</td>
|
||||
<td>{{ formatAttendancePoints(item.attendance_point_names) }}</td>
|
||||
<td>{{ item.content }}</td>
|
||||
<td><span class="status-chip" :class="`status-${String(item.tone || 'INFO').toLowerCase()}`">{{ formatToneLabel(item.tone) }}</span></td>
|
||||
<td><span class="status-chip" :class="item.status === 'ACTIVE' ? 'status-active' : 'status-paused'">{{ item.status === "ACTIVE" ? "启用" : "暂停" }}</span></td>
|
||||
<td>{{ formatDateTime(item.starts_at) }}</td>
|
||||
<td>{{ formatDateTime(item.ends_at) }}</td>
|
||||
<td>{{ item.sort_no }}</td>
|
||||
<td class="action-row">
|
||||
<button class="ghost-button" type="button" @click="editBroadcast(item)">编辑</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!filteredBroadcasts.length">
|
||||
<td colspan="9" class="empty-row">暂无广播内容</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<PaginationBar v-model:page="broadcastPage" v-model:page-size="broadcastPageSize" :total="filteredBroadcasts.length" />
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<form
|
||||
v-else-if="activeSection === 'rawLotPrefix'"
|
||||
id="extension-form-rawLotPrefix"
|
||||
class="stack-form extension-form-card"
|
||||
@submit.prevent="saveActiveSection"
|
||||
>
|
||||
<div class="extension-setting-block" title="序号达到 9999 后自然扩展为下一位数,不截断、不补新规则。">
|
||||
<span class="setting-kicker">当前规则</span>
|
||||
<strong>{{ rawLotPrefixForm.config_value || "YL" }}0001</strong>
|
||||
<em>自动递增</em>
|
||||
</div>
|
||||
|
||||
<div class="extension-field-grid extension-field-grid-2">
|
||||
<label class="form-field">
|
||||
<span>前缀</span>
|
||||
<input v-model.trim="rawLotPrefixForm.config_value" type="text" maxlength="20" placeholder="默认 YL" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>说明</span>
|
||||
<input v-model.trim="rawLotPrefixForm.remark" type="text" placeholder="例如:原材料库存批次号默认前缀" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="triple-field">
|
||||
<label class="form-field">
|
||||
<span>开始时间</span>
|
||||
<input v-model="broadcastForm.starts_at" type="datetime-local" />
|
||||
<form
|
||||
v-else-if="activeSection === 'productionMode'"
|
||||
id="extension-form-productionMode"
|
||||
class="stack-form extension-form-card"
|
||||
@submit.prevent="saveActiveSection"
|
||||
>
|
||||
<label class="extension-mode-switch" :class="{ active: smartOperationReportForm.enabled }">
|
||||
<input v-model="smartOperationReportForm.enabled" type="checkbox" />
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span>
|
||||
<strong>对接智能报工小程序</strong>
|
||||
<small class="extension-inline-state">{{ smartOperationReportForm.enabled ? "当前:已对接" : "当前:未对接" }}</small>
|
||||
</span>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>结束时间</span>
|
||||
<input v-model="broadcastForm.ends_at" type="datetime-local" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>排序号</span>
|
||||
<input v-model.number="broadcastForm.sort_no" type="number" step="1" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="double-field">
|
||||
<label class="form-field">
|
||||
<span>状态</span>
|
||||
<select v-model="broadcastForm.status">
|
||||
<option value="ACTIVE">启用</option>
|
||||
<option value="PAUSED">暂停</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="form-field extension-preview-field">
|
||||
<span>横幅预览</span>
|
||||
<div class="broadcast-banner extension-preview-banner" :class="`broadcast-${broadcastForm.tone.toLowerCase()}`">
|
||||
<span class="broadcast-speaker" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" role="img">
|
||||
<path d="M4 10.5H7.5L16 6.5V17.5L7.5 13.5H4V10.5Z" />
|
||||
<path d="M7.5 13.5L9.2 19H12.2L10.4 14.8" />
|
||||
<path d="M18.3 9.2L20.5 8" />
|
||||
<path d="M18.7 12H21.4" />
|
||||
<path d="M18.3 14.8L20.5 16" />
|
||||
</svg>
|
||||
</span>
|
||||
<strong>{{ broadcastForm.title || "广播标题" }}</strong>
|
||||
<span>{{ broadcastForm.content || "广播内容会显示在系统右上角" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="extension-detail-note">
|
||||
<summary>查看影响范围</summary>
|
||||
<p>开启时显示工序报工和小程序证据;关闭时隐藏工序报工,生产按 ERP 入库闭环推进。</p>
|
||||
</details>
|
||||
|
||||
<button class="primary-button" type="submit">{{ editingBroadcastId ? "更新广播" : "保存广播" }}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">仓库编号配置</p>
|
||||
<h3>原材料库存批次号前缀</h3>
|
||||
</div>
|
||||
<span class="panel-tag">{{ rawLotPrefixConfig.config_value || "YL" }}</span>
|
||||
</div>
|
||||
|
||||
<form class="stack-form compact-extension-form" @submit.prevent="saveRawLotPrefixConfig">
|
||||
<div class="double-field">
|
||||
<label class="form-field">
|
||||
<span>前缀</span>
|
||||
<input v-model.trim="rawLotPrefixForm.config_value" type="text" maxlength="20" placeholder="默认 YL" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>说明</span>
|
||||
<input v-model.trim="rawLotPrefixForm.remark" type="text" placeholder="例如:原材料库存批次号默认前缀" />
|
||||
<input v-model.trim="smartOperationReportForm.remark" type="text" placeholder="例如:客户未购买小程序模块,暂按ERP入库反推" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="workflow-tip">
|
||||
生成效果示例:{{ rawLotPrefixForm.config_value || "YL" }}0001;序号达到 9999 后会自然扩展为 {{ rawLotPrefixForm.config_value || "YL" }}10000。
|
||||
</div>
|
||||
<button class="primary-button" type="submit">保存前缀</button>
|
||||
</form>
|
||||
</form>
|
||||
|
||||
<form
|
||||
v-else-if="activeSection === 'assistant'"
|
||||
id="extension-form-assistant"
|
||||
class="stack-form extension-form-card"
|
||||
@submit.prevent="saveActiveSection"
|
||||
>
|
||||
<div class="extension-field-grid extension-field-grid-3">
|
||||
<label class="form-field">
|
||||
<span>助手名称</span>
|
||||
<input v-model.trim="assistantForm.assistant_name" type="text" required />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>供应商</span>
|
||||
<input v-model.trim="assistantForm.provider_name" type="text" placeholder="例如:OpenRouter / SiliconFlow" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>模型名称</span>
|
||||
<input v-model.trim="assistantForm.model_name" type="text" placeholder="例如:deepseek-chat" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="extension-field-grid extension-field-grid-2">
|
||||
<label class="form-field">
|
||||
<span>API Base URL</span>
|
||||
<input v-model.trim="assistantForm.api_base_url" type="text" placeholder="https://api.example.com/v1" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>API Key</span>
|
||||
<input v-model.trim="assistantForm.api_key" type="password" :placeholder="assistantConfig.api_key_configured ? '留空表示不修改现有密钥' : '请输入 API Key'" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="extension-field-grid extension-field-grid-2">
|
||||
<label class="form-field">
|
||||
<span>温度</span>
|
||||
<input v-model.number="assistantForm.temperature" type="number" min="0" max="2" step="0.1" />
|
||||
</label>
|
||||
<label class="extension-mode-switch" :class="{ active: assistantForm.enabled }">
|
||||
<input v-model="assistantForm.enabled" type="checkbox" />
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span>
|
||||
<strong>启用真实 LLM 调用</strong>
|
||||
<small class="extension-inline-state">{{ assistantConfig.api_key_configured ? "密钥已配置" : "未配置密钥" }}</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="form-field">
|
||||
<span>每次新建对话的系统提示词</span>
|
||||
<textarea v-model.trim="assistantForm.system_prompt" rows="6"></textarea>
|
||||
</label>
|
||||
</form>
|
||||
</main>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">生产模式配置</p>
|
||||
<h3>对接智能报工小程序</h3>
|
||||
</div>
|
||||
<span class="panel-tag">{{ smartOperationReportForm.enabled ? "已对接" : "未对接" }}</span>
|
||||
<Teleport to="body">
|
||||
<div v-if="showBroadcastDialog" class="extension-modal-backdrop" @click.self="closeBroadcastDialog">
|
||||
<section class="extension-modal-card" role="dialog" aria-modal="true" aria-labelledby="broadcast-dialog-title">
|
||||
<header class="extension-modal-head">
|
||||
<div>
|
||||
<p class="eyebrow">广播通知</p>
|
||||
<h3 id="broadcast-dialog-title">{{ editingBroadcastId ? "编辑广播" : "新增广播" }}</h3>
|
||||
</div>
|
||||
<button class="ghost-button extension-modal-close" type="button" @click="closeBroadcastDialog">关闭</button>
|
||||
</header>
|
||||
|
||||
<form id="extension-form-broadcast" class="stack-form extension-dialog-form" @submit.prevent="saveBroadcast">
|
||||
<div class="extension-field-grid extension-field-grid-2">
|
||||
<label class="form-field">
|
||||
<span>广播标题 <i class="required-mark">*</i></span>
|
||||
<input v-model.trim="broadcastForm.title" type="text" required />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>提示类型</span>
|
||||
<select v-model="broadcastForm.tone">
|
||||
<option value="INFO">信息</option>
|
||||
<option value="SUCCESS">成功</option>
|
||||
<option value="WARNING">预警</option>
|
||||
<option value="DANGER">重要</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="form-field">
|
||||
<span>广播内容 <i class="required-mark">*</i></span>
|
||||
<input v-model.trim="broadcastForm.content" type="text" required />
|
||||
</label>
|
||||
|
||||
<div class="form-field">
|
||||
<span>通知考勤点 <i class="required-mark">*</i></span>
|
||||
<div class="checkbox-grid extension-checkbox-grid">
|
||||
<label v-for="point in attendancePoints" :key="point.name" class="checkbox-field">
|
||||
<input v-model="broadcastForm.attendance_point_names" type="checkbox" :value="point.name" />
|
||||
<span>{{ point.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="extension-field-grid extension-field-grid-3">
|
||||
<label class="form-field">
|
||||
<span>开始时间</span>
|
||||
<input v-model="broadcastForm.starts_at" type="datetime-local" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>结束时间</span>
|
||||
<input v-model="broadcastForm.ends_at" type="datetime-local" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>排序号</span>
|
||||
<input v-model.number="broadcastForm.sort_no" type="number" step="1" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="extension-field-grid extension-broadcast-status-row">
|
||||
<label class="form-field">
|
||||
<span>状态</span>
|
||||
<select v-model="broadcastForm.status">
|
||||
<option value="ACTIVE">启用</option>
|
||||
<option value="PAUSED">暂停</option>
|
||||
</select>
|
||||
</label>
|
||||
<details class="extension-preview-disclosure">
|
||||
<summary>查看横幅预览</summary>
|
||||
<div class="extension-banner-preview">
|
||||
<div class="broadcast-banner" :class="`broadcast-${broadcastForm.tone.toLowerCase()}`">
|
||||
<span class="broadcast-speaker" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" role="img">
|
||||
<path d="M4 10.5H7.5L16 6.5V17.5L7.5 13.5H4V10.5Z" />
|
||||
<path d="M7.5 13.5L9.2 19H12.2L10.4 14.8" />
|
||||
<path d="M18.3 9.2L20.5 8" />
|
||||
<path d="M18.7 12H21.4" />
|
||||
<path d="M18.3 14.8L20.5 16" />
|
||||
</svg>
|
||||
</span>
|
||||
<strong>{{ broadcastForm.title || "广播标题" }}</strong>
|
||||
<span>{{ broadcastForm.content || "广播内容" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<footer class="extension-dialog-actions">
|
||||
<button class="ghost-button" type="button" @click="closeBroadcastDialog">取消</button>
|
||||
<button class="primary-button" type="submit">{{ editingBroadcastId ? "保存修改" : "保存广播" }}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<form class="stack-form compact-extension-form" @submit.prevent="saveSmartOperationReportConfig">
|
||||
<label class="checkbox-field extension-switch-field">
|
||||
<input v-model="smartOperationReportForm.enabled" type="checkbox" />
|
||||
<span>对接智能报工小程序</span>
|
||||
</label>
|
||||
<div class="workflow-tip">
|
||||
{{ smartOperationReportForm.enabled ? "开启后显示工序报工,并按小程序报工数据辅助计算生产进度。" : "关闭后隐藏工序报工,生产进度按ERP入库数据和BOM反推。" }}
|
||||
</div>
|
||||
<label class="form-field">
|
||||
<span>说明</span>
|
||||
<input v-model.trim="smartOperationReportForm.remark" type="text" placeholder="例如:客户未购买小程序模块,暂按ERP入库反推" />
|
||||
</label>
|
||||
<button class="primary-button" type="submit">保存生产模式</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">广播列表</p>
|
||||
<h3>当前系统广播记录</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TableControls
|
||||
v-model:search="broadcastControls.search.value"
|
||||
v-model:sort-key="broadcastControls.sortKey.value"
|
||||
v-model:sort-direction="broadcastControls.sortDirection.value"
|
||||
:sort-options="broadcastControls.sortOptions"
|
||||
placeholder="搜索标题、内容、状态、类型"
|
||||
/>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="data-table compact-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标题</th>
|
||||
<th>考勤点</th>
|
||||
<th>内容</th>
|
||||
<th>类型</th>
|
||||
<th>状态</th>
|
||||
<th>开始</th>
|
||||
<th>结束</th>
|
||||
<th>排序</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in paginatedBroadcasts" :key="item.broadcast_id">
|
||||
<td>{{ item.title }}</td>
|
||||
<td>{{ formatAttendancePoints(item.attendance_point_names) }}</td>
|
||||
<td>{{ item.content }}</td>
|
||||
<td>{{ formatToneLabel(item.tone) }}</td>
|
||||
<td>{{ item.status === "ACTIVE" ? "启用" : "暂停" }}</td>
|
||||
<td>{{ formatDateTime(item.starts_at) }}</td>
|
||||
<td>{{ formatDateTime(item.ends_at) }}</td>
|
||||
<td>{{ item.sort_no }}</td>
|
||||
<td class="action-row"><button class="ghost-button" type="button" @click="editBroadcast(item)">编辑</button></td>
|
||||
</tr>
|
||||
<tr v-if="!filteredBroadcasts.length">
|
||||
<td colspan="9" class="empty-row">暂无广播内容</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<PaginationBar v-model:page="broadcastPage" v-model:page-size="broadcastPageSize" :total="filteredBroadcasts.length" />
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">AI 机器人配置</p>
|
||||
<h3>免费 LLM API 与新对话系统提示词</h3>
|
||||
</div>
|
||||
<span class="panel-tag">{{ assistantConfig.api_key_configured ? "密钥已配置" : "未配置密钥" }}</span>
|
||||
</div>
|
||||
|
||||
<form class="stack-form" @submit.prevent="saveAssistantConfig">
|
||||
<div class="triple-field">
|
||||
<label class="form-field">
|
||||
<span>助手名称</span>
|
||||
<input v-model.trim="assistantForm.assistant_name" type="text" required />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>供应商</span>
|
||||
<input v-model.trim="assistantForm.provider_name" type="text" placeholder="例如:OpenRouter / SiliconFlow" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>模型名称</span>
|
||||
<input v-model.trim="assistantForm.model_name" type="text" placeholder="例如:deepseek-chat" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="double-field">
|
||||
<label class="form-field">
|
||||
<span>API Base URL</span>
|
||||
<input v-model.trim="assistantForm.api_base_url" type="text" placeholder="https://api.example.com/v1" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>API Key</span>
|
||||
<input v-model.trim="assistantForm.api_key" type="password" :placeholder="assistantConfig.api_key_configured ? '留空表示不修改现有密钥' : '请输入 API Key'" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="double-field">
|
||||
<label class="form-field">
|
||||
<span>温度</span>
|
||||
<input v-model.number="assistantForm.temperature" type="number" min="0" max="2" step="0.1" />
|
||||
</label>
|
||||
<label class="form-field checkbox-field">
|
||||
<input v-model="assistantForm.enabled" type="checkbox" />
|
||||
<span>启用真实 LLM 调用</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="form-field">
|
||||
<span>每次新建对话的系统提示词</span>
|
||||
<textarea v-model.trim="assistantForm.system_prompt" rows="5"></textarea>
|
||||
</label>
|
||||
|
||||
<button class="primary-button" type="submit">保存 AI 配置</button>
|
||||
</form>
|
||||
</section>
|
||||
<div v-if="activeSectionDirty" class="extension-save-dock">
|
||||
<span>{{ activeSectionMeta.title }}有未保存修改</span>
|
||||
<button class="ghost-button" type="button" @click="resetActiveSection">放弃修改</button>
|
||||
<button class="primary-button" type="submit" :form="activeSectionFormId">保存配置</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
|
||||
import PaginationBar from "../components/PaginationBar.vue";
|
||||
import TableControls from "../components/TableControls.vue";
|
||||
@ -291,6 +338,46 @@ const smartOperationReportConfig = ref({});
|
||||
const feedbackMessage = ref("");
|
||||
const errorMessage = ref("");
|
||||
const editingBroadcastId = ref(null);
|
||||
const showBroadcastDialog = ref(false);
|
||||
const activeSection = ref("broadcast");
|
||||
const extensionLoaded = ref(false);
|
||||
const sectionSnapshots = reactive({
|
||||
broadcast: "",
|
||||
rawLotPrefix: "",
|
||||
productionMode: "",
|
||||
assistant: ""
|
||||
});
|
||||
|
||||
const extensionSections = [
|
||||
{
|
||||
key: "broadcast",
|
||||
eyebrow: "Broadcast",
|
||||
title: "广播通知",
|
||||
desc: "维护右上角小喇叭通知",
|
||||
accent: "blue"
|
||||
},
|
||||
{
|
||||
key: "rawLotPrefix",
|
||||
eyebrow: "Lot Rule",
|
||||
title: "库存批次规则",
|
||||
desc: "维护原材料库存批次号前缀",
|
||||
accent: "steel"
|
||||
},
|
||||
{
|
||||
key: "productionMode",
|
||||
eyebrow: "Production",
|
||||
title: "生产对接模式",
|
||||
desc: "控制是否对接智能报工小程序",
|
||||
accent: "green"
|
||||
},
|
||||
{
|
||||
key: "assistant",
|
||||
eyebrow: "AI Assistant",
|
||||
title: "AI 助手配置",
|
||||
desc: "维护 LLM 供应商、模型与提示词",
|
||||
accent: "orange"
|
||||
}
|
||||
];
|
||||
|
||||
const broadcastForm = reactive(buildEmptyBroadcastForm());
|
||||
const assistantForm = reactive(buildEmptyAssistantForm());
|
||||
@ -364,6 +451,17 @@ function resetBroadcastForm() {
|
||||
editingBroadcastId.value = null;
|
||||
Object.assign(broadcastForm, buildEmptyBroadcastForm());
|
||||
broadcastForm.attendance_point_names = attendancePoints.value.map((point) => point.name);
|
||||
captureSectionSnapshot("broadcast");
|
||||
}
|
||||
|
||||
function openNewBroadcastDialog() {
|
||||
resetBroadcastForm();
|
||||
showBroadcastDialog.value = true;
|
||||
}
|
||||
|
||||
function closeBroadcastDialog() {
|
||||
showBroadcastDialog.value = false;
|
||||
resetBroadcastForm();
|
||||
}
|
||||
|
||||
function editBroadcast(item) {
|
||||
@ -380,12 +478,110 @@ function editBroadcast(item) {
|
||||
ends_at: toDateTimeInput(item.ends_at),
|
||||
sort_no: Number(item.sort_no || 0)
|
||||
});
|
||||
captureSectionSnapshot("broadcast");
|
||||
showBroadcastDialog.value = true;
|
||||
}
|
||||
|
||||
function formatAttendancePoints(pointNames) {
|
||||
return pointNames?.length ? pointNames.join(" / ") : "-";
|
||||
}
|
||||
|
||||
function clonePlain(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function serializeValue(value) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function getSectionState(sectionKey) {
|
||||
if (sectionKey === "broadcast") {
|
||||
return {
|
||||
editingBroadcastId: editingBroadcastId.value,
|
||||
...clonePlain(broadcastForm)
|
||||
};
|
||||
}
|
||||
|
||||
if (sectionKey === "rawLotPrefix") {
|
||||
return clonePlain(rawLotPrefixForm);
|
||||
}
|
||||
|
||||
if (sectionKey === "productionMode") {
|
||||
return clonePlain(smartOperationReportForm);
|
||||
}
|
||||
|
||||
if (sectionKey === "assistant") {
|
||||
return {
|
||||
...clonePlain(assistantForm),
|
||||
api_key: ""
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function captureSectionSnapshot(sectionKey) {
|
||||
sectionSnapshots[sectionKey] = serializeValue(getSectionState(sectionKey));
|
||||
}
|
||||
|
||||
function captureAllSectionSnapshots() {
|
||||
extensionSections.forEach((section) => {
|
||||
captureSectionSnapshot(section.key);
|
||||
});
|
||||
}
|
||||
|
||||
function restoreSectionSnapshot(sectionKey) {
|
||||
const snapshot = sectionSnapshots[sectionKey] ? JSON.parse(sectionSnapshots[sectionKey]) : null;
|
||||
if (!snapshot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (sectionKey === "broadcast") {
|
||||
editingBroadcastId.value = snapshot.editingBroadcastId ?? null;
|
||||
Object.assign(broadcastForm, {
|
||||
title: snapshot.title || "",
|
||||
content: snapshot.content || "",
|
||||
attendance_point_names: Array.isArray(snapshot.attendance_point_names)
|
||||
? [...snapshot.attendance_point_names]
|
||||
: attendancePoints.value.map((point) => point.name),
|
||||
tone: snapshot.tone || "INFO",
|
||||
status: snapshot.status || "ACTIVE",
|
||||
starts_at: snapshot.starts_at || "",
|
||||
ends_at: snapshot.ends_at || "",
|
||||
sort_no: Number(snapshot.sort_no ?? 10)
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sectionKey === "rawLotPrefix") {
|
||||
rawLotPrefixForm.config_value = snapshot.config_value || "YL";
|
||||
rawLotPrefixForm.remark = snapshot.remark || "";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sectionKey === "productionMode") {
|
||||
smartOperationReportForm.enabled = snapshot.enabled !== false;
|
||||
smartOperationReportForm.remark = snapshot.remark || "";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sectionKey === "assistant") {
|
||||
Object.assign(assistantForm, {
|
||||
assistant_name: snapshot.assistant_name || "嘉恒工艺助手",
|
||||
provider_name: snapshot.provider_name || "",
|
||||
api_base_url: snapshot.api_base_url || "",
|
||||
api_key: "",
|
||||
model_name: snapshot.model_name || "",
|
||||
temperature: Number(snapshot.temperature ?? 0.3),
|
||||
system_prompt: snapshot.system_prompt || buildEmptyAssistantForm().system_prompt,
|
||||
enabled: Boolean(snapshot.enabled)
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function applyAssistantConfig(config) {
|
||||
assistantConfig.value = config || {};
|
||||
Object.assign(assistantForm, {
|
||||
@ -412,6 +608,62 @@ function applySmartOperationReportConfig(config) {
|
||||
smartOperationReportForm.remark = config?.remark || "";
|
||||
}
|
||||
|
||||
const activeSectionMeta = computed(() => {
|
||||
return extensionSections.find((section) => section.key === activeSection.value) || extensionSections[0];
|
||||
});
|
||||
|
||||
const activeSectionFormId = computed(() => `extension-form-${activeSection.value}`);
|
||||
|
||||
const activeBroadcastCount = computed(() => {
|
||||
return broadcasts.value.filter((item) => item.status === "ACTIVE").length;
|
||||
});
|
||||
|
||||
const assistantStateLabel = computed(() => {
|
||||
if (assistantConfig.value.enabled && assistantConfig.value.api_key_configured) {
|
||||
return "已启用";
|
||||
}
|
||||
if (assistantConfig.value.api_key_configured) {
|
||||
return "密钥已配置";
|
||||
}
|
||||
return "占位模式";
|
||||
});
|
||||
|
||||
const statusCards = computed(() => [
|
||||
{
|
||||
label: "广播启用",
|
||||
value: `${activeBroadcastCount.value} 条`,
|
||||
tone: activeBroadcastCount.value ? "success" : "muted"
|
||||
},
|
||||
{
|
||||
label: "库存批次前缀",
|
||||
value: rawLotPrefixConfig.value.config_value || rawLotPrefixForm.config_value || "YL",
|
||||
tone: "info"
|
||||
},
|
||||
{
|
||||
label: "报工小程序",
|
||||
value: smartOperationReportForm.enabled ? "已对接" : "未对接",
|
||||
tone: smartOperationReportForm.enabled ? "success" : "warning"
|
||||
},
|
||||
{
|
||||
label: "AI 助手",
|
||||
value: assistantStateLabel.value,
|
||||
tone: assistantConfig.value.enabled ? "success" : "muted"
|
||||
}
|
||||
]);
|
||||
|
||||
const activeSectionDirty = computed(() => {
|
||||
if (!extensionLoaded.value) {
|
||||
return false;
|
||||
}
|
||||
if (activeSection.value === "broadcast") {
|
||||
return false;
|
||||
}
|
||||
if (activeSection.value === "assistant" && assistantForm.api_key.trim()) {
|
||||
return true;
|
||||
}
|
||||
return sectionSnapshots[activeSection.value] !== serializeValue(getSectionState(activeSection.value));
|
||||
});
|
||||
|
||||
async function loadAll() {
|
||||
const [pointRows, broadcastRows, config, rawLotPrefix, smartOperationReport] = await Promise.all([
|
||||
fetchResource("/master-data/miniapp-attendance-points", []),
|
||||
@ -451,10 +703,18 @@ async function saveBroadcast() {
|
||||
? await putResource(`/system-extension/broadcasts/${editingBroadcastId.value}`, payload)
|
||||
: await postResource("/system-extension/broadcasts", payload);
|
||||
feedbackMessage.value = `广播「${result.title}」已保存`;
|
||||
showBroadcastDialog.value = false;
|
||||
resetBroadcastForm();
|
||||
broadcasts.value = await fetchResource("/system-extension/broadcasts", []);
|
||||
try {
|
||||
broadcasts.value = await fetchResource("/system-extension/broadcasts", []);
|
||||
} catch (refreshError) {
|
||||
errorMessage.value = `广播已保存,但列表刷新失败:${refreshError.message || "请稍后刷新页面"}`;
|
||||
}
|
||||
captureSectionSnapshot("broadcast");
|
||||
return true;
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message || "广播保存失败";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -475,8 +735,11 @@ async function saveAssistantConfig() {
|
||||
const result = await putResource("/system-extension/assistant-config", payload);
|
||||
applyAssistantConfig(result);
|
||||
feedbackMessage.value = "AI 机器人配置已保存";
|
||||
captureSectionSnapshot("assistant");
|
||||
return true;
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message || "AI 配置保存失败";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -490,8 +753,11 @@ async function saveRawLotPrefixConfig() {
|
||||
});
|
||||
applyRawLotPrefixConfig(result);
|
||||
feedbackMessage.value = "原材料库存批次号前缀已保存";
|
||||
captureSectionSnapshot("rawLotPrefix");
|
||||
return true;
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message || "原材料库存批次号前缀保存失败";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -506,12 +772,46 @@ async function saveSmartOperationReportConfig() {
|
||||
applySmartOperationReportConfig(result);
|
||||
resetSmartOperationReportConfigCache(result);
|
||||
feedbackMessage.value = "生产模式配置已保存";
|
||||
captureSectionSnapshot("productionMode");
|
||||
return true;
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message || "生产模式配置保存失败";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveActiveSection() {
|
||||
if (activeSection.value === "broadcast") {
|
||||
return saveBroadcast();
|
||||
}
|
||||
|
||||
if (activeSection.value === "rawLotPrefix") {
|
||||
return saveRawLotPrefixConfig();
|
||||
}
|
||||
|
||||
if (activeSection.value === "productionMode") {
|
||||
return saveSmartOperationReportConfig();
|
||||
}
|
||||
|
||||
if (activeSection.value === "assistant") {
|
||||
return saveAssistantConfig();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function resetActiveSection() {
|
||||
restoreSectionSnapshot(activeSection.value);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAll();
|
||||
try {
|
||||
await loadAll();
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message || "系统拓展配置加载失败";
|
||||
} finally {
|
||||
captureAllSectionSnapshots();
|
||||
extensionLoaded.value = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user