23 Commits
Author SHA1 Message Date
汤学会 1f378d4754 优化纸质单据抬头日期间距 2026-06-15 14:51:06 +08:00
汤学会 bca020554c 修复纸质单据布局并统一退货单据样式 2026-06-15 14:25:39 +08:00
汤学会 eb472ad455 新增宽表自定义横向滚动条 2026-06-15 13:12:49 +08:00
汤学会 ac18942a4b 优化前端提示文案与宽表滚动体验 2026-06-15 12:06:46 +08:00
汤学会 6dd9529a16 修复产品需规保存与表格布局问题 2026-06-15 10:51:54 +08:00
汤学会 4c06295535 完善组织树操作与空响应处理 2026-06-15 04:27:33 +08:00
汤学会 c42d190f4b 优化组织人员路径与授权弹窗 2026-06-15 03:28:35 +08:00
汤学会 bad87029d3 修复组织树上级节点人员汇总 2026-06-15 02:38:54 +08:00
汤学会 b74a4ad6d4 优化组织树灰色虚线连接 2026-06-15 02:21:33 +08:00
汤学会 914c8ed99f 修复组织树连接线层级关系 2026-06-15 01:56:59 +08:00
汤学会 dac7844d4c 修复组织树连线仅连接直接下属 2026-06-15 01:48:37 +08:00
汤学会 3313dc3bbb fix: improve org tree hierarchy and user menu dismissal 2026-06-15 01:33:32 +08:00
汤学会 c6a5628bb6 fix: hide internal production ledger integration hints 2026-06-15 00:47:28 +08:00
汤学会 26bcd97d15 fix: avoid tablespace privilege requirement during init backup 2026-06-15 00:29:57 +08:00
汤学会 e308a6a7f2 docs: add Chinese system initialization runbook 2026-06-15 00:16:08 +08:00
汤学会 f957899500 feat: add standard system initialization tooling 2026-06-15 00:14:21 +08:00
汤学会 442713b4ef style: refine Baihua login logo plate 2026-06-14 21:25:04 +08:00
汤学会 e90d296047 chore: customize branding for Baihua 2026-06-14 21:05:49 +08:00
汤学会 df9a4b485d 完善系统拓展与原材料生命周期视图 2026-06-14 20:38:53 +08:00
汤学会 453b19399b 统一仓库纸质单据体验 2026-06-14 16:23:27 +08:00
汤学会 fc39d81564 修复生产台账入库单摘要竖排问题 2026-06-13 00:55:13 +08:00
汤学会 a00e817065 修复纸面单据长值溢出和日期格式 2026-06-13 00:18:37 +08:00
汤学会 06c7b800fc 修复PDF归档长字段排版错乱 2026-06-12 18:02:06 +08:00
136 changed files with 18103 additions and 1350 deletions
+162
View 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
View File
@@ -0,0 +1,6 @@
{
"files": ["frontend/index.html"],
"insertBefore": "</body>",
"commentSyntax": "html",
"cspChecked": true
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+33
View File
@@ -0,0 +1,33 @@
# Product
## Register
product
## Users
百华五金 ERP 面向采购、销售、仓库、生产、财务、售后和管理人员。用户通常在真实业务现场或办公室中处理订单、入库、出库、生产台账、发货、权限和经营数据,需要快速、准确、少解释地完成工作。
## Product Purpose
系统用于把五金制造企业的采购、销售、仓储、生产、报工、发货、售后、财务和系统管理流程串联起来。成功的界面应该让业务人员感觉是在填写正式单据、查看可信台账、执行明确动作,而不是在操作一个临时 demo。
## Brand Personality
可靠、利落、工业质感。界面可以有五金制造业的材料感和秩序感,但不能为了装饰牺牲效率、阅读清晰度和业务可信度。
## Anti-references
不要 demo 感强的卡片拼贴、解释大框、老式 Web 表单混入正式单据、按钮文案词不达意、状态无颜色区分、表格列名错位、值把表格撑到难以操作、抽屉内部滚动条阻断整页滚动。
## Design Principles
1. 业务动作要像正式单据,不像随手填卡片。
2. 表格和台账优先服务核对效率,字段名清楚,长值可查看全量但不破坏布局。
3. 状态、风险和动作要有明确视觉区分,避免所有按钮和状态一个颜色。
4. 系统解释应收敛到需要时出现,不能常驻占用主要工作空间。
5. 视觉升级必须保留原有业务字段和流程语义,装修不改业务骨架。
## Accessibility & Inclusion
默认按生产型后台工具处理:文字对比度要足够,交互目标不能过小,键盘焦点可见,动画只用于状态反馈并尊重 reduced-motion。中文业务字段、系统状态和备注优先使用中文。
+1 -1
View File
@@ -1,6 +1,6 @@
# ForgeFlow ERP # ForgeFlow ERP
宁波嘉恒智能科技有限公司五金行业 ERP 初始骨架,采用前后端分离架构: 宁波百华智能科技有限公司五金行业 ERP 初始骨架,采用前后端分离架构:
- 前端:Vue 3 + Vite - 前端:Vue 3 + Vite
- 后端:FastAPI - 后端:FastAPI
+20 -9
View File
@@ -43,6 +43,7 @@ from app.schemas.dashboard import (
SalesOrderLifecycleRead, SalesOrderLifecycleRead,
) )
from app.services.auth import require_authenticated_user 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 from app.services.mock_data import get_dashboard_payload
router = APIRouter(dependencies=[Depends(require_authenticated_user)]) 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(issue_subquery, issue_subquery.c.source_lot_id == StockLot.id)
.outerjoin(finished_subquery, finished_subquery.c.source_material_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) .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()) .order_by(StockLot.created_at.desc(), StockLot.id.desc())
.limit(limit) .limit(limit)
) )
@@ -387,13 +388,10 @@ def list_dashboard_material_lots(
return [DashboardMaterialLotRead.model_validate(dict(row)) for row in rows] return [DashboardMaterialLotRead.model_validate(dict(row)) for row in rows]
@router.get("/material-lots/{lot_id}/lifecycle", response_model=MaterialLotLifecycleRead) def _build_legacy_material_lot_lifecycle_graph(
def get_material_lot_lifecycle(lot_id: int, db: Session = Depends(get_db)) -> MaterialLotLifecycleRead: db: Session,
lot_row = db.execute(_material_lot_rows(limit=1).where(StockLot.id == lot_id)).mappings().first() lot: DashboardMaterialLotRead,
if not lot_row: ) -> tuple[list[LifecycleNode], list[LifecycleEdge]]:
raise HTTPException(status_code=404, detail="原材料批次不存在")
lot = DashboardMaterialLotRead.model_validate(dict(lot_row))
nodes: list[LifecycleNode] = [] nodes: list[LifecycleNode] = []
edges: list[LifecycleEdge] = [] edges: list[LifecycleEdge] = []
node_ids: set[str] = set() 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(WorkOrder, WorkOrder.id == WorkOrderMaterialIssue.work_order_id)
.join(Item, Item.id == WorkOrder.product_item_id) .join(Item, Item.id == WorkOrder.product_item_id)
.outerjoin(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_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) .order_by(WorkOrderMaterialIssue.issue_time, WorkOrderMaterialIssue.id)
).mappings().all() ).mappings().all()
work_order_ids = sorted({row["work_order_id"] for row in issues}) 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, "库存发货")) 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) return MaterialLotLifecycleRead(lot=lot, nodes=nodes, edges=edges)
+84 -1
View File
@@ -88,6 +88,87 @@ from app.services.system_permissions import list_employee_options
router = APIRouter(dependencies=[Depends(require_authenticated_user)]) router = APIRouter(dependencies=[Depends(require_authenticated_user)])
DEFAULT_PROCESS_CODE = "PROC-DEFAULT"
DEFAULT_PROCESS_NAME = "系统默认工序"
DEFAULT_WORK_CENTER_CODE = "WC-DEFAULT"
DEFAULT_WORK_CENTER_NAME = "系统默认工作中心"
DEFAULT_ORG_ROOT_CODE = "ORG_ROOT"
def _default_operation_department(db: Session) -> Department:
department = db.scalar(select(Department).where(Department.dept_code == DEFAULT_ORG_ROOT_CODE).limit(1))
if department:
return department
department = db.scalar(select(Department).order_by(Department.id.asc()).limit(1))
if department:
return department
now = datetime.now()
department = Department(
dept_code=DEFAULT_ORG_ROOT_CODE,
dept_name="总公司",
parent_id=None,
org_node_type="COMPANY",
dept_type="ADMIN",
manager_name=None,
manager_employee_id=None,
status="ACTIVE",
sort_no=0,
remark="系统自动创建的默认组织根节点",
created_at=now,
updated_at=now,
)
db.add(department)
db.flush()
return department
def _ensure_default_operation_foundation(db: Session) -> None:
changed = False
now = datetime.now()
process_exists = db.scalar(select(Process.id).limit(1))
if not process_exists:
db.add(
Process(
process_code=DEFAULT_PROCESS_CODE,
process_name=DEFAULT_PROCESS_NAME,
process_type="INTERNAL",
is_report_required=1,
is_quality_gate=0,
status="ACTIVE",
remark="系统自动创建,用于产品需规快速建档时生成默认工艺路线",
created_at=now,
updated_at=now,
)
)
changed = True
work_center_exists = db.scalar(select(WorkCenter.id).limit(1))
if not work_center_exists:
department = _default_operation_department(db)
db.add(
WorkCenter(
center_code=DEFAULT_WORK_CENTER_CODE,
center_name=DEFAULT_WORK_CENTER_NAME,
dept_id=department.id,
center_type="INTERNAL",
shift_pattern="默认班次",
capacity_hours_per_day=Decimal("8.00"),
status="ACTIVE",
remark="系统自动创建,用于产品需规快速建档时生成默认工艺路线",
created_at=now,
updated_at=now,
)
)
changed = True
if changed:
try:
db.commit()
except IntegrityError:
db.rollback()
MATERIAL_EXCEL_HEADERS = ["原材料编码", "原材料名称", "材质", "规格", "废料单价", "性能要求", "状态"] MATERIAL_EXCEL_HEADERS = ["原材料编码", "原材料名称", "材质", "规格", "废料单价", "性能要求", "状态"]
MATERIAL_EXCEL_REQUIRED_HEADERS = ["原材料名称", "材质", "规格"] MATERIAL_EXCEL_REQUIRED_HEADERS = ["原材料名称", "材质", "规格"]
MATERIAL_STATUS_LABELS = { MATERIAL_STATUS_LABELS = {
@@ -597,6 +678,7 @@ def list_work_centers(
limit: int = Query(default=100, ge=1, le=500), limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> list[WorkCenterRead]: ) -> list[WorkCenterRead]:
_ensure_default_operation_foundation(db)
stmt = ( stmt = (
select( select(
WorkCenter.id.label("id"), WorkCenter.id.label("id"),
@@ -766,6 +848,7 @@ def list_processes(
limit: int = Query(default=100, ge=1, le=500), limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> list[ProcessRead]: ) -> list[ProcessRead]:
_ensure_default_operation_foundation(db)
rows = db.execute( rows = db.execute(
select( select(
Process.id.label("process_id"), Process.id.label("process_id"),
@@ -1866,7 +1949,7 @@ def _default_miniapp_product_import_point_name(db: Session) -> str:
.order_by(MiniAppAttendancePoint.name.asc()) .order_by(MiniAppAttendancePoint.name.asc())
.limit(1) .limit(1)
) )
or "宁波嘉恒智能科技有限公司" or "宁波百华智能科技有限公司"
) )
+2 -2
View File
@@ -119,13 +119,13 @@ def _get_notice_creator_phone(db: Session) -> str:
def _assistant_config_read(row: AiAssistantConfig | None) -> AiAssistantConfigRead: def _assistant_config_read(row: AiAssistantConfig | None) -> AiAssistantConfigRead:
if not row: if not row:
return AiAssistantConfigRead( return AiAssistantConfigRead(
assistant_name="嘉恒工艺助手", assistant_name="百华工艺助手",
provider_name="", provider_name="",
api_base_url="", api_base_url="",
api_key=None, api_key=None,
model_name="", model_name="",
temperature=0.3, temperature=0.3,
system_prompt="你是宁波嘉恒智能五金 ERP 的企业助手,优先解释业务流程、字段含义、数据异常和操作步骤。", system_prompt="你是宁波百华智能五金 ERP 的企业助手,优先解释业务流程、字段含义、数据异常和操作步骤。",
enabled=False, enabled=False,
config_id=None, config_id=None,
api_key_configured=False, api_key_configured=False,
+1 -1
View File
@@ -6,7 +6,7 @@ from sqlalchemy.engine import URL
class Settings(BaseSettings): class Settings(BaseSettings):
app_name: str = Field(default="Jiaheng Hardware ERP API", alias="APP_NAME") app_name: str = Field(default="Baihua Hardware ERP API", alias="APP_NAME")
app_env: str = Field(default="dev", alias="APP_ENV") app_env: str = Field(default="dev", alias="APP_ENV")
app_host: str = Field(default="0.0.0.0", alias="APP_HOST") app_host: str = Field(default="0.0.0.0", alias="APP_HOST")
app_port: int = Field(default=8000, alias="APP_PORT") app_port: int = Field(default=8000, alias="APP_PORT")
+1 -1
View File
@@ -49,5 +49,5 @@ def root() -> dict[str, str]:
return { return {
"name": settings.app_name, "name": settings.app_name,
"env": settings.app_env, "env": settings.app_env,
"message": "Jiaheng Hardware ERP API is running.", "message": "Baihua Hardware ERP API is running.",
} }
+4 -1
View File
@@ -1,4 +1,6 @@
from app.models.master_data import Bom, BomItem, Item, Material, Product, StockBalance, Warehouse from app.models import miniapp as _miniapp # noqa: F401
from app.models import operations as _operations # noqa: F401
from app.models.master_data import Bom, BomItem, Item, ItemCategory, Material, Product, StockBalance, Warehouse
from app.models.document_archive import DocumentArchive from app.models.document_archive import DocumentArchive
from app.models.org import Department, Employee, Permission, Role, RolePermission, User, UserRole from app.models.org import Department, Employee, Permission, Role, RolePermission, User, UserRole
from app.models.planning import MaterialDemand from app.models.planning import MaterialDemand
@@ -12,6 +14,7 @@ __all__ = [
"Bom", "Bom",
"BomItem", "BomItem",
"Item", "Item",
"ItemCategory",
"Material", "Material",
"MaterialDemand", "MaterialDemand",
"Permission", "Permission",
+14
View File
@@ -17,6 +17,20 @@ class Unit(Base):
updated_at: Mapped[object] = mapped_column(DateTime) updated_at: Mapped[object] = mapped_column(DateTime)
class ItemCategory(Base):
__tablename__ = "md_item_category"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
category_code: Mapped[str] = mapped_column(String(50))
category_name: Mapped[str] = mapped_column(String(100))
parent_id: Mapped[int | None] = mapped_column(ForeignKey("md_item_category.id"), nullable=True)
item_type: Mapped[str] = mapped_column(String(32))
sort_no: Mapped[int] = mapped_column(Integer, server_default=text("0"))
status: Mapped[str] = mapped_column(String(32), server_default=text("'ACTIVE'"))
created_at: Mapped[object] = mapped_column(DateTime)
updated_at: Mapped[object] = mapped_column(DateTime)
class Item(Base): class Item(Base):
__tablename__ = "md_item" __tablename__ = "md_item"
+215 -8
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from sqlalchemy import BigInteger, Boolean, Date, DateTime, DECIMAL, ForeignKey, Integer, String, Text, text from sqlalchemy import JSON, BigInteger, Boolean, Date, DateTime, DECIMAL, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base from app.models.base import Base
@@ -13,6 +13,16 @@ class MiniAppAttendancePoint(Base):
latitude: Mapped[float | None] = mapped_column(DECIMAL(10, 7), nullable=True) latitude: Mapped[float | None] = mapped_column(DECIMAL(10, 7), nullable=True)
longitude: Mapped[float | None] = mapped_column(DECIMAL(10, 7), nullable=True) longitude: Mapped[float | None] = mapped_column(DECIMAL(10, 7), nullable=True)
radius_meters: Mapped[int] = mapped_column(Integer, server_default=text("500")) radius_meters: Mapped[int] = mapped_column(Integer, server_default=text("500"))
day_start: Mapped[str] = mapped_column(String(5), server_default=text("'08:00'"))
day_end: Mapped[str] = mapped_column(String(5), server_default=text("'17:20'"))
lunch_start: Mapped[str] = mapped_column(String(5), server_default=text("'11:40'"))
lunch_end: Mapped[str] = mapped_column(String(5), server_default=text("'12:40'"))
dinner_start: Mapped[str] = mapped_column(String(5), server_default=text("'17:20'"))
dinner_end: Mapped[str] = mapped_column(String(5), server_default=text("'18:00'"))
overtime_start: Mapped[str] = mapped_column(String(5), server_default=text("'18:00'"))
overtime_end: Mapped[str] = mapped_column(String(5), server_default=text("'20:00'"))
night_start: Mapped[str] = mapped_column(String(5), server_default=text("'20:00'"))
night_end: Mapped[str] = mapped_column(String(5), server_default=text("'06:00'"))
remark: Mapped[str | None] = mapped_column(String(500), nullable=True) remark: Mapped[str | None] = mapped_column(String(500), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, server_default=text("1")) is_active: Mapped[bool] = mapped_column(Boolean, server_default=text("1"))
created_at: Mapped[object] = mapped_column(DateTime) created_at: Mapped[object] = mapped_column(DateTime)
@@ -49,7 +59,7 @@ class MiniAppPersonAttendancePoint(Base):
class MiniAppProduct(Base): class MiniAppProduct(Base):
__tablename__ = "products" __tablename__ = "products"
attendance_point_name: Mapped[str] = mapped_column(String(128), primary_key=True, server_default=text("'宁波嘉恒智能科技有限公司'")) attendance_point_name: Mapped[str] = mapped_column(String(128), primary_key=True, server_default=text("''"))
project_no: Mapped[str] = mapped_column(String(64), primary_key=True) project_no: Mapped[str] = mapped_column(String(64), primary_key=True)
product_name: Mapped[str] = mapped_column(String(255), primary_key=True) product_name: Mapped[str] = mapped_column(String(255), primary_key=True)
device_no: Mapped[str] = mapped_column(String(64), primary_key=True) device_no: Mapped[str] = mapped_column(String(64), primary_key=True)
@@ -64,18 +74,96 @@ class MiniAppProduct(Base):
waste_price_yuan_per_kg: Mapped[float | None] = mapped_column(DECIMAL(12, 4), nullable=True) waste_price_yuan_per_kg: Mapped[float | None] = mapped_column(DECIMAL(12, 4), nullable=True)
stamping_method: Mapped[str | None] = mapped_column(String(128), nullable=True) stamping_method: Mapped[str | None] = mapped_column(String(128), nullable=True)
operator_count: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("1")) operator_count: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("1"))
process_unit_price_yuan: Mapped[float] = mapped_column(DECIMAL(12, 4), server_default=text("0"))
standard_beat: Mapped[float] = mapped_column(DECIMAL(12, 3), server_default=text("0")) standard_beat: Mapped[float] = mapped_column(DECIMAL(12, 3), server_default=text("0"))
standard_workload: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0")) standard_workload: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0"))
created_at: Mapped[object] = mapped_column(DateTime) created_at: Mapped[object] = mapped_column(DateTime)
updated_at: Mapped[object] = mapped_column(DateTime) updated_at: Mapped[object] = mapped_column(DateTime)
__table_args__ = (
Index("idx_products_device_no", "device_no"),
Index("idx_products_material_code", "material_code"),
)
class MiniAppEquipment(Base):
__tablename__ = "equipment"
attendance_point_name: Mapped[str] = mapped_column(String(128), primary_key=True, server_default=text("''"))
device_no: Mapped[str] = mapped_column(String(64), primary_key=True)
device_type: Mapped[str] = mapped_column(String(32), server_default=text("'冲压设备'"))
remark: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[object] = mapped_column(DateTime)
updated_at: Mapped[object] = mapped_column(DateTime)
class MiniAppWorkSchedule(Base):
__tablename__ = "work_schedules"
id: Mapped[int] = mapped_column(Integer, primary_key=True, server_default=text("1"))
day_start: Mapped[str] = mapped_column(String(5), server_default=text("'08:00'"))
day_end: Mapped[str] = mapped_column(String(5), server_default=text("'17:20'"))
lunch_start: Mapped[str] = mapped_column(String(5), server_default=text("'11:40'"))
lunch_end: Mapped[str] = mapped_column(String(5), server_default=text("'12:40'"))
dinner_start: Mapped[str] = mapped_column(String(5), server_default=text("'17:20'"))
dinner_end: Mapped[str] = mapped_column(String(5), server_default=text("'18:00'"))
overtime_start: Mapped[str] = mapped_column(String(5), server_default=text("'18:00'"))
overtime_end: Mapped[str] = mapped_column(String(5), server_default=text("'20:00'"))
night_start: Mapped[str] = mapped_column(String(5), server_default=text("'20:00'"))
night_end: Mapped[str] = mapped_column(String(5), server_default=text("'06:00'"))
attendance_latitude: Mapped[float | None] = mapped_column(DECIMAL(10, 7), nullable=True)
attendance_longitude: Mapped[float | None] = mapped_column(DECIMAL(10, 7), nullable=True)
attendance_radius_meters: Mapped[int] = mapped_column(Integer, server_default=text("500"))
auto_submit_hours: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("15"))
updated_by: Mapped[str | None] = mapped_column(String(20), nullable=True)
created_at: Mapped[object] = mapped_column(DateTime)
updated_at: Mapped[object] = mapped_column(DateTime)
class MiniAppWorkSession(Base):
__tablename__ = "work_sessions"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("''"))
employee_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"))
start_at: Mapped[object] = mapped_column(DateTime)
end_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
status: Mapped[str] = mapped_column(String(32), server_default=text("'active'"))
created_at: Mapped[object] = mapped_column(DateTime)
updated_at: Mapped[object] = mapped_column(DateTime)
__table_args__ = (
Index("idx_sessions_employee_status", "employee_phone", "status"),
)
class MiniAppWorkSessionDevice(Base):
__tablename__ = "work_session_devices"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(ForeignKey("work_sessions.id"))
attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("''"))
device_no: Mapped[str] = mapped_column(String(255))
process_name: Mapped[str] = mapped_column(String(128), server_default=text("''"))
scanned_at: Mapped[object] = mapped_column(DateTime)
sort_order: Mapped[int] = mapped_column(Integer, server_default=text("0"))
released_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
released_by: Mapped[str | None] = mapped_column(String(20), nullable=True)
release_reason: Mapped[str | None] = mapped_column(String(120), nullable=True)
__table_args__ = (
Index("idx_session_devices_session", "session_id"),
Index("idx_session_devices_device", "device_no"),
Index("idx_session_devices_release", "released_at"),
)
class MiniAppProductionReport(Base): class MiniAppProductionReport(Base):
__tablename__ = "production_reports" __tablename__ = "production_reports"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("'宁波嘉恒智能科技有限公司'")) attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("''"))
session_id: Mapped[int] = mapped_column(BigInteger) session_id: Mapped[int] = mapped_column(ForeignKey("work_sessions.id"), unique=True)
employee_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone")) employee_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"))
report_date: Mapped[object] = mapped_column(Date) report_date: Mapped[object] = mapped_column(Date)
start_at: Mapped[object] = mapped_column(DateTime) start_at: Mapped[object] = mapped_column(DateTime)
@@ -94,16 +182,32 @@ class MiniAppProductionReport(Base):
reviewer_phone: Mapped[str | None] = mapped_column(String(20), nullable=True) reviewer_phone: Mapped[str | None] = mapped_column(String(20), nullable=True)
reviewed_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) reviewed_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
reject_reason: Mapped[str | None] = mapped_column(String(500), nullable=True) reject_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
review_remark: Mapped[str | None] = mapped_column(String(500), nullable=True)
submitted_at: Mapped[object] = mapped_column(DateTime) submitted_at: Mapped[object] = mapped_column(DateTime)
is_system_auto_submitted: Mapped[bool] = mapped_column(Boolean, server_default=text("0"))
auto_submit_reason: Mapped[str | None] = mapped_column(String(120), nullable=True)
is_multi_person_assistant: Mapped[bool] = mapped_column(Boolean, server_default=text("0"))
multi_person_source_report_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
is_voided: Mapped[bool] = mapped_column(Boolean, server_default=text("0"))
voided_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
voided_by: Mapped[str | None] = mapped_column(ForeignKey("personnel.phone"), nullable=True)
unvoid_deadline_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
updated_at: Mapped[object] = mapped_column(DateTime) updated_at: Mapped[object] = mapped_column(DateTime)
__table_args__ = (
Index("idx_reports_employee_date", "employee_phone", "report_date"),
Index("idx_reports_status_date", "status", "report_date"),
Index("idx_reports_reviewer", "reviewer_phone", "reviewed_at"),
Index("idx_reports_voided", "is_voided", "voided_at"),
)
class MiniAppProductionReportItem(Base): class MiniAppProductionReportItem(Base):
__tablename__ = "production_report_items" __tablename__ = "production_report_items"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
report_id: Mapped[int] = mapped_column(ForeignKey("production_reports.id")) report_id: Mapped[int] = mapped_column(ForeignKey("production_reports.id"))
attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("'宁波嘉恒智能科技有限公司'")) attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("''"))
device_no: Mapped[str] = mapped_column(String(64)) device_no: Mapped[str] = mapped_column(String(64))
project_no: Mapped[str] = mapped_column(String(64)) project_no: Mapped[str] = mapped_column(String(64))
product_name: Mapped[str] = mapped_column(String(255)) product_name: Mapped[str] = mapped_column(String(255))
@@ -112,6 +216,8 @@ class MiniAppProductionReportItem(Base):
raw_material_batch_no: Mapped[str | None] = mapped_column(String(128), nullable=True) raw_material_batch_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
process_name: Mapped[str | None] = mapped_column(String(128), nullable=True) process_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
stamping_method: Mapped[str | None] = mapped_column(String(128), nullable=True) stamping_method: Mapped[str | None] = mapped_column(String(128), nullable=True)
operator_count: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("1"))
process_unit_price_yuan: Mapped[float] = mapped_column(DECIMAL(12, 4), server_default=text("0"))
standard_beat: Mapped[float] = mapped_column(DECIMAL(12, 3), server_default=text("0")) standard_beat: Mapped[float] = mapped_column(DECIMAL(12, 3), server_default=text("0"))
standard_workload: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0")) standard_workload: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0"))
good_qty: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0")) good_qty: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0"))
@@ -119,13 +225,95 @@ class MiniAppProductionReportItem(Base):
scrap_qty: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0")) scrap_qty: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0"))
allocated_minutes: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("0")) allocated_minutes: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("0"))
started_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) started_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
remark: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[object] = mapped_column(DateTime) created_at: Mapped[object] = mapped_column(DateTime)
__table_args__ = (
Index("idx_report_items_report", "report_id"),
Index("idx_report_items_product", "project_no", "product_name"),
)
class MiniAppReportAuditLog(Base):
__tablename__ = "report_audit_logs"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
report_id: Mapped[int] = mapped_column(ForeignKey("production_reports.id"))
reviewer_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"))
action: Mapped[str] = mapped_column(String(64))
before_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
after_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
remark: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[object] = mapped_column(DateTime)
__table_args__ = (
Index("idx_audit_report", "report_id"),
Index("idx_audit_reviewer", "reviewer_phone", "created_at"),
)
class MiniAppMoldLockFeedback(Base):
__tablename__ = "mold_lock_feedbacks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("''"))
mold_name: Mapped[str] = mapped_column(String(255))
process_name: Mapped[str] = mapped_column(String(128), server_default=text("''"))
session_device_id: Mapped[int] = mapped_column(ForeignKey("work_session_devices.id"))
reporter_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"))
occupied_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"))
status: Mapped[str] = mapped_column(String(32), server_default=text("'pending'"))
read_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
handled_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
handled_by: Mapped[str | None] = mapped_column(ForeignKey("personnel.phone"), nullable=True)
created_at: Mapped[object] = mapped_column(DateTime)
__table_args__ = (
Index("idx_mold_lock_feedback_point_read", "attendance_point_name", "read_at", "created_at"),
Index("idx_mold_lock_feedback_status", "status", "created_at"),
)
class MiniAppDeviceQRCode(Base):
__tablename__ = "device_qrcodes"
attendance_point_name: Mapped[str] = mapped_column(String(128), primary_key=True, server_default=text("''"))
device_no: Mapped[str] = mapped_column(String(255), primary_key=True)
process_name: Mapped[str] = mapped_column(String(128), primary_key=True, server_default=text("''"))
qr_scene: Mapped[str] = mapped_column(String(128))
qr_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_by: Mapped[str] = mapped_column(ForeignKey("personnel.phone"))
created_at: Mapped[object] = mapped_column(DateTime)
class MiniAppDeviceQRCodeBatchTask(Base):
__tablename__ = "device_qrcode_batch_tasks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
file_name: Mapped[str] = mapped_column(String(255))
status: Mapped[str] = mapped_column(String(32), server_default=text("'pending'"))
item_count: Mapped[int] = mapped_column(Integer, server_default=text("0"))
completed_count: Mapped[int] = mapped_column(Integer, server_default=text("0"))
failed_count: Mapped[int] = mapped_column(Integer, server_default=text("0"))
items_json: Mapped[list] = mapped_column(JSON)
zip_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
error_message: Mapped[str | None] = mapped_column(String(1000), nullable=True)
created_by: Mapped[str] = mapped_column(ForeignKey("personnel.phone"))
started_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
finished_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[object] = mapped_column(DateTime)
updated_at: Mapped[object] = mapped_column(DateTime)
__table_args__ = (
Index("idx_device_qrcode_batch_tasks_creator", "created_by", "created_at"),
Index("idx_device_qrcode_batch_tasks_status", "status", "created_at"),
)
class MiniAppNotice(Base): class MiniAppNotice(Base):
__tablename__ = "notices" __tablename__ = "notices"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(120)) title: Mapped[str] = mapped_column(String(120))
content: Mapped[str] = mapped_column(Text) content: Mapped[str] = mapped_column(Text)
sort_order: Mapped[int] = mapped_column(Integer, server_default=text("0")) sort_order: Mapped[int] = mapped_column(Integer, server_default=text("0"))
@@ -141,3 +329,22 @@ class MiniAppNoticePoint(Base):
notice_id: Mapped[int] = mapped_column(ForeignKey("notices.id"), primary_key=True) notice_id: Mapped[int] = mapped_column(ForeignKey("notices.id"), primary_key=True)
attendance_point_name: Mapped[str] = mapped_column(ForeignKey("attendance_points.name"), primary_key=True) attendance_point_name: Mapped[str] = mapped_column(ForeignKey("attendance_points.name"), primary_key=True)
created_at: Mapped[object] = mapped_column(DateTime) created_at: Mapped[object] = mapped_column(DateTime)
class MiniAppReconciliationLedgerEntry(Base):
__tablename__ = "reconciliation_ledger_entries"
attendance_point_name: Mapped[str] = mapped_column(String(128), primary_key=True, server_default=text("''"))
year: Mapped[int] = mapped_column(Integer, primary_key=True)
month: Mapped[int] = mapped_column(Integer, primary_key=True)
product_name: Mapped[str] = mapped_column(String(255), primary_key=True)
reconciled_good_qty: Mapped[float] = mapped_column(DECIMAL(14, 2), server_default=text("0"))
return_qty: Mapped[float] = mapped_column(DECIMAL(14, 2), server_default=text("0"))
updated_by: Mapped[str | None] = mapped_column(String(20), nullable=True)
created_at: Mapped[object] = mapped_column(DateTime)
updated_at: Mapped[object] = mapped_column(DateTime)
__table_args__ = (
Index("idx_reconciliation_product", "product_name"),
Index("idx_reconciliation_updated", "updated_at"),
)
+33
View File
@@ -559,6 +559,39 @@ class WorkOrderMaterialIssue(Base):
updated_at: Mapped[object] = mapped_column(DateTime) updated_at: Mapped[object] = mapped_column(DateTime)
class MaterialIssue(Base):
__tablename__ = "pp_material_issue"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
issue_no: Mapped[str] = mapped_column(String(50))
work_order_id: Mapped[int] = mapped_column(ForeignKey("pp_work_order.id"))
warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
issue_time: Mapped[object] = mapped_column(DateTime)
issuer_employee_id: Mapped[int | None] = mapped_column(ForeignKey("hr_employee.id"), nullable=True)
status: Mapped[str] = mapped_column(String(32), server_default=text("'CREATED'"))
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[object] = mapped_column(DateTime)
updated_at: Mapped[object] = mapped_column(DateTime)
class MaterialIssueItem(Base):
__tablename__ = "pp_material_issue_item"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
material_issue_id: Mapped[int] = mapped_column(ForeignKey("pp_material_issue.id"))
work_order_material_id: Mapped[int] = mapped_column(ForeignKey("pp_work_order_material.id"))
material_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
lot_id: Mapped[int] = mapped_column(ForeignKey("wh_stock_lot.id"))
location_id: Mapped[int | None] = mapped_column(ForeignKey("wh_location.id"), nullable=True)
issue_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
issue_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
status: Mapped[str] = mapped_column(String(32), server_default=text("'POSTED'"))
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[object] = mapped_column(DateTime)
updated_at: Mapped[object] = mapped_column(DateTime)
class ProductionBatchLedger(Base): class ProductionBatchLedger(Base):
__tablename__ = "pp_production_batch_ledger" __tablename__ = "pp_production_batch_ledger"
__table_args__ = ( __table_args__ = (
+1 -1
View File
@@ -167,7 +167,7 @@ class AiAssistantConfig(Base):
__tablename__ = "sys_ai_assistant_config" __tablename__ = "sys_ai_assistant_config"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
assistant_name: Mapped[str] = mapped_column(String(100), server_default=text("'嘉恒工艺助手'")) assistant_name: Mapped[str] = mapped_column(String(100), server_default=text("'百华工艺助手'"))
provider_name: Mapped[str | None] = mapped_column(String(100), nullable=True) provider_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
api_base_url: Mapped[str | None] = mapped_column(String(255), nullable=True) api_base_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
api_key: Mapped[str | None] = mapped_column(String(512), nullable=True) api_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
+1 -1
View File
@@ -30,7 +30,7 @@ class BroadcastMessageRead(BroadcastMessageCreate):
class AiAssistantConfigUpdate(BaseModel): class AiAssistantConfigUpdate(BaseModel):
assistant_name: str = Field(default="嘉恒工艺助手", min_length=1, max_length=100) assistant_name: str = Field(default="百华工艺助手", min_length=1, max_length=100)
provider_name: str | None = None provider_name: str | None = None
api_base_url: str | None = None api_base_url: str | None = None
api_key: str | None = None api_key: str | None = None
+74 -13
View File
@@ -59,6 +59,16 @@ ARCHIVE_MIN_ROW_HEIGHT = 24
ARCHIVE_CELL_HORIZONTAL_PADDING = 8 ARCHIVE_CELL_HORIZONTAL_PADDING = 8
ARCHIVE_CELL_VERTICAL_PADDING = 8 ARCHIVE_CELL_VERTICAL_PADDING = 8
ARCHIVE_CELL_FONT_SIZE = 8 ARCHIVE_CELL_FONT_SIZE = 8
ARCHIVE_METADATA_FONT_SIZE = 10
ARCHIVE_METADATA_LEFT_X = 42
ARCHIVE_METADATA_RIGHT_X = 340
ARCHIVE_METADATA_COLUMN_GAP = 10
ARCHIVE_METADATA_LEFT_WIDTH = ARCHIVE_METADATA_RIGHT_X - ARCHIVE_METADATA_LEFT_X - ARCHIVE_METADATA_COLUMN_GAP
ARCHIVE_METADATA_RIGHT_WIDTH = A4[0] - ARCHIVE_TABLE_RIGHT_MARGIN - ARCHIVE_METADATA_RIGHT_X
ARCHIVE_METADATA_TOP_Y_OFFSET = 92
ARCHIVE_METADATA_MIN_ROW_HEIGHT = 20
ARCHIVE_METADATA_ROW_PADDING = 7
ARCHIVE_METADATA_TABLE_GAP = 18
ARCHIVE_STATUS_LABELS = { ARCHIVE_STATUS_LABELS = {
"ACTIVE": "启用", "ACTIVE": "启用",
@@ -1024,6 +1034,59 @@ def _draw_cell(
text_y -= line_height text_y -= line_height
def _archive_metadata_rows(context: ArchiveContext) -> list[tuple[tuple[str, str], tuple[str, str]]]:
contact_text = f"{context.contact_name or ''} 电话: {context.contact_phone or ''}"
tax_text = f"{decimal_text((context.tax_rate or 0) * 100)}%"
return [
(("单据编号", context.document_no), ("单据日期", date_text(context.document_date))),
((context.partner_label, context.partner_name), (context.due_date_label, date_text(context.due_date))),
(("联系人", contact_text), ("状态", context.status)),
((context.address_label or "地址", context.address or ""), ("税率", tax_text)),
]
def _archive_metadata_line_groups(
context: ArchiveContext,
font_name: str,
size: int = ARCHIVE_METADATA_FONT_SIZE,
) -> list[tuple[list[str], list[str]]]:
groups: list[tuple[list[str], list[str]]] = []
for (left_label, left_value), (right_label, right_value) in _archive_metadata_rows(context):
left_text = f"{left_label}: {left_value or ''}"
right_text = f"{right_label}: {right_value or ''}"
groups.append(
(
_wrap_text_to_lines(left_text, font_name, size, ARCHIVE_METADATA_LEFT_WIDTH),
_wrap_text_to_lines(right_text, font_name, size, ARCHIVE_METADATA_RIGHT_WIDTH),
)
)
return groups
def _metadata_row_height(line_count: int, size: int = ARCHIVE_METADATA_FONT_SIZE) -> float:
return max(ARCHIVE_METADATA_MIN_ROW_HEIGHT, line_count * _cell_line_height(size) + ARCHIVE_METADATA_ROW_PADDING)
def _archive_table_y_for_context(context: ArchiveContext, font_name: str) -> float:
_, height = A4
y = height - ARCHIVE_METADATA_TOP_Y_OFFSET
for left_lines, right_lines in _archive_metadata_line_groups(context, font_name):
y -= _metadata_row_height(max(len(left_lines), len(right_lines), 1))
return min(height - ARCHIVE_TABLE_TOP_Y_OFFSET, y - ARCHIVE_METADATA_TABLE_GAP)
def _draw_metadata_lines(
pdf: canvas.Canvas,
x: float,
y: float,
lines: list[str],
line_height: float,
) -> None:
for line in lines:
pdf.drawString(x, y, line)
y -= line_height
def _archive_table_columns() -> list[tuple[str, int, str]]: def _archive_table_columns() -> list[tuple[str, int, str]]:
return [ return [
("行", 26, "center"), ("行", 26, "center"),
@@ -1045,7 +1108,7 @@ def _draw_archive_page_frame(
bold_font_name: str, bold_font_name: str,
page_no: int, page_no: int,
total_pages: int, total_pages: int,
) -> None: ) -> float:
width, height = A4 width, height = A4
pdf.setFillColorRGB(0.99, 0.975, 0.92) pdf.setFillColorRGB(0.99, 0.975, 0.92)
pdf.rect(0, 0, width, height, stroke=0, fill=1) pdf.rect(0, 0, width, height, stroke=0, fill=1)
@@ -1061,15 +1124,13 @@ def _draw_archive_page_frame(
pdf.drawRightString(width - 42, height - 62, "PDF归档") pdf.drawRightString(width - 42, height - 62, "PDF归档")
pdf.drawRightString(width - 42, height - 80, f"第 {page_no}/{total_pages} 页") pdf.drawRightString(width - 42, height - 80, f"第 {page_no}/{total_pages} 页")
pdf.setFont(font_name, 10) pdf.setFont(font_name, ARCHIVE_METADATA_FONT_SIZE)
pdf.drawString(42, height - 92, f"单据编号: {context.document_no}") line_height = _cell_line_height(ARCHIVE_METADATA_FONT_SIZE)
pdf.drawString(42, height - 112, f"{context.partner_label}: {context.partner_name}") metadata_y = height - ARCHIVE_METADATA_TOP_Y_OFFSET
pdf.drawString(42, height - 132, f"联系人: {context.contact_name or ''} 电话: {context.contact_phone or ''}") for left_lines, right_lines in _archive_metadata_line_groups(context, font_name):
pdf.drawString(42, height - 152, f"{context.address_label or '地址'}: {context.address or ''}") _draw_metadata_lines(pdf, ARCHIVE_METADATA_LEFT_X, metadata_y, left_lines, line_height)
pdf.drawString(340, height - 92, f"单据日期: {date_text(context.document_date)}") _draw_metadata_lines(pdf, ARCHIVE_METADATA_RIGHT_X, metadata_y, right_lines, line_height)
pdf.drawString(340, height - 112, f"{context.due_date_label}: {date_text(context.due_date)}") metadata_y -= _metadata_row_height(max(len(left_lines), len(right_lines), 1))
pdf.drawString(340, height - 132, f"状态: {context.status}")
pdf.drawString(340, height - 152, f"税率: {decimal_text((context.tax_rate or 0) * 100)}%")
pdf.setFont(bold_font_name, 26) pdf.setFont(bold_font_name, 26)
pdf.setStrokeColorRGB(0.55, 0.05, 0.05) pdf.setStrokeColorRGB(0.55, 0.05, 0.05)
@@ -1079,6 +1140,7 @@ def _draw_archive_page_frame(
pdf.rotate(-12) pdf.rotate(-12)
pdf.setStrokeColor(colors.black) pdf.setStrokeColor(colors.black)
pdf.setFillColor(colors.black) pdf.setFillColor(colors.black)
return min(height - ARCHIVE_TABLE_TOP_Y_OFFSET, metadata_y - ARCHIVE_METADATA_TABLE_GAP)
def _draw_archive_table_header(pdf: canvas.Canvas, y: float, bold_font_name: str) -> None: def _draw_archive_table_header(pdf: canvas.Canvas, y: float, bold_font_name: str) -> None:
@@ -1180,14 +1242,13 @@ def render_pdf_archive(context: ArchiveContext, output_path: str | Path) -> None
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)
font_name, bold_font_name = _register_archive_font() font_name, bold_font_name = _register_archive_font()
pdf = canvas.Canvas(str(output), pagesize=A4) pdf = canvas.Canvas(str(output), pagesize=A4)
height = A4[1] table_y = _archive_table_y_for_context(context, font_name)
table_y = height - ARCHIVE_TABLE_TOP_Y_OFFSET
row_h = ARCHIVE_MIN_ROW_HEIGHT row_h = ARCHIVE_MIN_ROW_HEIGHT
max_body_height = table_y - row_h - ARCHIVE_TABLE_BOTTOM_Y max_body_height = table_y - row_h - ARCHIVE_TABLE_BOTTOM_Y
pages = _line_pages(context.lines, max_body_height, font_name) pages = _line_pages(context.lines, max_body_height, font_name)
for page_index, page_lines in enumerate(pages, start=1): for page_index, page_lines in enumerate(pages, start=1):
_draw_archive_page_frame(pdf, context, font_name, bold_font_name, page_index, len(pages)) table_y = _draw_archive_page_frame(pdf, context, font_name, bold_font_name, page_index, len(pages))
_draw_archive_table_header(pdf, table_y, bold_font_name) _draw_archive_table_header(pdf, table_y, bold_font_name)
y = _draw_archive_table_rows( y = _draw_archive_table_rows(
pdf, pdf,
@@ -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
+1 -1
View File
@@ -6,7 +6,7 @@ from app.schemas.domain import CostInsight, InventoryLotSummary, MiniAppField, O
def get_dashboard_payload() -> DashboardPayload: def get_dashboard_payload() -> DashboardPayload:
return DashboardPayload( return DashboardPayload(
company="宁波嘉恒智能科技有限公司", company="宁波百华智能科技有限公司",
overview=[ overview=[
OverviewMetric( OverviewMetric(
code="sales_open", code="sales_open",
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -58,7 +58,7 @@ MENU_PERMISSION_TREE = [
("MENU_PURCHASE_ORDER", "采购订单", []), ("MENU_PURCHASE_ORDER", "采购订单", []),
("MENU_PURCHASE_RECEIPT", "到货入库", []), ("MENU_PURCHASE_RECEIPT", "到货入库", []),
("MENU_QUALITY_INSPECTION", "质量检验", []), ("MENU_QUALITY_INSPECTION", "质量检验", []),
("MENU_INVENTORY_LEDGER", "嘉恒仓库", []), ("MENU_INVENTORY_LEDGER", "百华仓库", []),
], ],
), ),
( (
@@ -85,7 +85,10 @@ MENU_PERMISSION_TREE = [
ORG_ROOT_CODE = "ORG_ROOT" ORG_ROOT_CODE = "ORG_ROOT"
ORG_ROOT_NAME = "总公司" ORG_ROOT_NAME = "总公司"
VALID_CHILD_NODE_TYPES = { VALID_CHILD_NODE_TYPES = {
"COMPANY": {"BRANCH": "总公司下只能新增分公司"}, "COMPANY": {
"BRANCH": "总公司下只能新增分公司或直属部门",
"DEPARTMENT": "总公司下只能新增分公司或直属部门",
},
"BRANCH": {"DEPARTMENT": "分公司下只能新增部门"}, "BRANCH": {"DEPARTMENT": "分公司下只能新增部门"},
"DEPARTMENT": {"GROUP": "部门下只能新增小组"}, "DEPARTMENT": {"GROUP": "部门下只能新增小组"},
"GROUP": {}, "GROUP": {},
+1
View File
@@ -0,0 +1 @@
"""Operational scripts for ForgeFlow ERP backend."""
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
import argparse
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from sqlalchemy import create_engine # noqa: E402
from app.core.config import get_settings # noqa: E402
from app.services.system_initializer import ( # noqa: E402
SystemInitializeOptions,
create_database_if_missing,
fresh_initialize_database,
reset_existing_database_with_backup,
)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Initialize ForgeFlow ERP database for customer deployment.")
subparsers = parser.add_subparsers(dest="mode", required=True)
def add_common_arguments(subparser: argparse.ArgumentParser) -> None:
subparser.add_argument("--company-name", required=True)
subparser.add_argument("--admin-name", required=True)
subparser.add_argument("--admin-phone", required=True)
subparser.add_argument("--admin-password", required=True)
subparser.add_argument(
"--smart-operation-report",
choices=["on", "off"],
default="on",
help="Enable or disable smart miniapp operation report integration.",
)
subparser.add_argument("--delete-files", action="store_true")
subparser.add_argument("--allow-any-database", action="store_true")
fresh = subparsers.add_parser("fresh", help="Create a database if needed, create schema, and seed a clean skeleton.")
add_common_arguments(fresh)
reset = subparsers.add_parser("reset", help="Back up and reset an existing database into a clean skeleton.")
add_common_arguments(reset)
reset.add_argument("--confirm-reset", action="store_true")
return parser.parse_args(argv)
def _options_from_args(args: argparse.Namespace) -> SystemInitializeOptions:
return SystemInitializeOptions(
mode=args.mode,
company_name=args.company_name,
admin_name=args.admin_name,
admin_phone=args.admin_phone,
admin_password=args.admin_password,
smart_operation_report_enabled=args.smart_operation_report == "on",
confirm_reset=bool(getattr(args, "confirm_reset", False)),
delete_files=bool(args.delete_files),
allow_any_database=bool(args.allow_any_database),
)
def _print_result(settings: object, result: object, options: SystemInitializeOptions) -> None:
print(f"mode={options.mode}")
print(f"database={settings.mysql_host}:{settings.mysql_port}/{settings.mysql_database}")
print(f"backup_path={result.backup_path or ''}")
print(f"summary_path={result.summary_path or ''}")
print(f"admin_username={result.seeded.get('admin_username', '')}")
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
settings = get_settings()
options = _options_from_args(args)
engine = create_engine(settings.mysql_dsn, pool_pre_ping=True, future=True)
if options.mode == "fresh":
create_database_if_missing(settings)
result = fresh_initialize_database(engine, options)
else:
result = reset_existing_database_with_backup(engine, settings, options)
_print_result(settings, result, options)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS sys_broadcast_message (
CREATE TABLE IF NOT EXISTS sys_ai_assistant_config ( CREATE TABLE IF NOT EXISTS sys_ai_assistant_config (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
assistant_name VARCHAR(100) NOT NULL DEFAULT '嘉恒工艺助手' COMMENT '助手名称', assistant_name VARCHAR(100) NOT NULL DEFAULT '百华工艺助手' COMMENT '助手名称',
provider_name VARCHAR(100) NULL COMMENT 'LLM供应商名称', provider_name VARCHAR(100) NULL COMMENT 'LLM供应商名称',
api_base_url VARCHAR(255) NULL COMMENT 'LLM API基础地址', api_base_url VARCHAR(255) NULL COMMENT 'LLM API基础地址',
api_key VARCHAR(512) NULL COMMENT 'LLM API密钥', api_key VARCHAR(512) NULL COMMENT 'LLM API密钥',
@@ -71,7 +71,7 @@ SET role_name = '采购专员', remark = '供应商、采购订单、到货和
WHERE role_code = 'PURCHASER'; WHERE role_code = 'PURCHASER';
UPDATE sys_role UPDATE sys_role
SET role_name = '仓库管理人员', remark = '到货、质检、嘉恒仓库、发货台账相关作业' SET role_name = '仓库管理人员', remark = '到货、质检、百华仓库、发货台账相关作业'
WHERE role_code = 'WAREHOUSE'; WHERE role_code = 'WAREHOUSE';
UPDATE sys_role UPDATE sys_role
+1 -1
View File
@@ -152,7 +152,7 @@ CREATE TABLE IF NOT EXISTS sys_broadcast_message (
CREATE TABLE IF NOT EXISTS sys_ai_assistant_config ( CREATE TABLE IF NOT EXISTS sys_ai_assistant_config (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
assistant_name VARCHAR(100) NOT NULL DEFAULT '嘉恒工艺助手' COMMENT '助手名称', assistant_name VARCHAR(100) NOT NULL DEFAULT '百华工艺助手' COMMENT '助手名称',
provider_name VARCHAR(100) NULL COMMENT 'LLM供应商名称', provider_name VARCHAR(100) NULL COMMENT 'LLM供应商名称',
api_base_url VARCHAR(255) NULL COMMENT 'LLM API基础地址', api_base_url VARCHAR(255) NULL COMMENT 'LLM API基础地址',
api_key VARCHAR(512) NULL COMMENT 'LLM API密钥', api_key VARCHAR(512) NULL COMMENT 'LLM API密钥',
+2 -2
View File
@@ -308,7 +308,7 @@ SELECT
(SELECT id FROM md_unit WHERE unit_code = 'PCS'), (SELECT id FROM md_unit WHERE unit_code = 'PCS'),
(SELECT id FROM md_unit WHERE unit_code = 'PCS'), (SELECT id FROM md_unit WHERE unit_code = 'PCS'),
500.000000, 0.000000, 500.000000, 0.000000,
0.0000, 'ACTIVE', '宁波嘉恒智能本体示例成品,用于演示销售、MRP、工单、成本闭环' 0.0000, 'ACTIVE', '宁波百华智能本体示例成品,用于演示销售、MRP、工单、成本闭环'
FROM DUAL FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM md_item WHERE item_code = 'FG-BT-001'); WHERE NOT EXISTS (SELECT 1 FROM md_item WHERE item_code = 'FG-BT-001');
@@ -594,7 +594,7 @@ INSERT INTO em_equipment (
SELECT SELECT
'EQ-QA-01', '终检包装工位 01', '检验工位', 'EQ-QA-01', '终检包装工位 01', '检验工位',
(SELECT id FROM md_work_center WHERE center_code = 'WC-QA-01'), (SELECT id FROM md_work_center WHERE center_code = 'WC-QA-01'),
'嘉恒内部配置', 'QA-STATION', '2024-03-10', 1.50, 4.00, 300.00, '百华内部配置', 'QA-STATION', '2024-03-10', 1.50, 4.00, 300.00,
DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 25 DAY), DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 20 DAY), DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 25 DAY), DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 20 DAY),
'IDLE', '终检与包装工位' 'IDLE', '终检与包装工位'
FROM DUAL FROM DUAL
@@ -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()
@@ -43,6 +43,7 @@ from app.services.document_archives import ( # noqa: E402
get_latest_archive, get_latest_archive,
render_pdf_archive, render_pdf_archive,
_archive_table_columns, _archive_table_columns,
_draw_archive_page_frame,
_draw_cell, _draw_cell,
_draw_archive_footer, _draw_archive_footer,
) )
@@ -732,6 +733,77 @@ class DocumentArchiveServiceTest(unittest.TestCase):
rendered_width = pdfmetrics.stringWidth(line, fake_pdf.font_name, fake_pdf.size) rendered_width = pdfmetrics.stringWidth(line, fake_pdf.font_name, fake_pdf.size)
self.assertLessEqual(rendered_width, 54) self.assertLessEqual(rendered_width, 54)
def test_draw_archive_page_frame_wraps_long_metadata_inside_columns(self) -> None:
class FakePdf:
def __init__(self) -> None:
self.font_name = "Helvetica"
self.size = 10
self.drawn_texts: list[tuple[float, float, str, str, int]] = []
def setFillColorRGB(self, *args, **kwargs) -> None:
_ = args, kwargs
def rect(self, *args, **kwargs) -> None:
_ = args, kwargs
def setStrokeColor(self, *args, **kwargs) -> None:
_ = args, kwargs
def setStrokeColorRGB(self, *args, **kwargs) -> None:
_ = args, kwargs
def setFillColor(self, *args, **kwargs) -> None:
_ = args, kwargs
def setFont(self, font_name, size) -> None:
self.font_name = font_name
self.size = size
def drawRightString(self, x, y, text) -> None:
_ = x, y, text
def drawCentredString(self, x, y, text) -> None:
_ = x, y, text
def drawString(self, x, y, text) -> None:
self.drawn_texts.append((x, y, text, self.font_name, self.size))
def rotate(self, *args, **kwargs) -> None:
_ = args, kwargs
fake_pdf = FakePdf()
context = ArchiveContext(
document_type=DOCUMENT_TYPE_SALES_ORDER,
business_id=100,
document_no="生产领料-JH_不锈钢板-用于测试00101_0001-苹果电脑后盖-超长批次-客户特殊版本-返工追溯链路",
title="生产领料出库单归档",
partner_label="生产产品",
partner_name="苹果电脑后盖-测试",
document_date=date(2026, 6, 11),
due_date_label="生产台账",
due_date=2,
status="在生产",
tax_rate=0,
total_amount=636,
contact_name="",
contact_phone="",
address="原材料库",
)
table_y = _draw_archive_page_frame(fake_pdf, context, "Helvetica", "Helvetica-Bold", 1, 1)
metadata_lines = [
(text, font_name, size)
for x, y, text, font_name, size in fake_pdf.drawn_texts
if x == 42 and y > A4[1] - 190
]
document_no_lines = [text for text, _, _ in metadata_lines if "单据编号" in text or "返工追溯" in text]
self.assertGreaterEqual(len(document_no_lines), 2)
self.assertLess(table_y, A4[1] - 190)
for text, font_name, size in metadata_lines:
rendered_width = pdfmetrics.stringWidth(text, font_name, size)
self.assertLessEqual(rendered_width, 288)
def test_generate_archive_failed_row_uses_free_version_after_collision(self) -> None: def test_generate_archive_failed_row_uses_free_version_after_collision(self) -> None:
order = self._seed_sales_order() order = self._seed_sales_order()
@@ -0,0 +1,59 @@
from __future__ import annotations
import unittest
from sqlalchemy import BigInteger, create_engine, func, select
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 import master_data # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.operations import Process, WorkCenter # noqa: E402
from app.models.org import Department # noqa: E402
class MasterDataDefaultOperationFoundationTest(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()
def tearDown(self) -> None:
self.db.close()
def test_list_processes_seeds_default_process_for_empty_system(self) -> None:
self.assertEqual(self.db.scalar(select(func.count(Process.id))), 0)
rows = master_data.list_processes(limit=100, db=self.db)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].process_code, "PROC-DEFAULT")
self.assertEqual(rows[0].process_name, "系统默认工序")
self.assertEqual(self.db.scalar(select(func.count(Process.id))), 1)
def test_list_work_centers_seeds_default_work_center_for_empty_system(self) -> None:
self.assertEqual(self.db.scalar(select(func.count(Department.id))), 0)
self.assertEqual(self.db.scalar(select(func.count(WorkCenter.id))), 0)
rows = master_data.list_work_centers(limit=100, db=self.db)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].center_code, "WC-DEFAULT")
self.assertEqual(rows[0].center_name, "系统默认工作中心")
self.assertEqual(rows[0].dept_name, "总公司")
self.assertEqual(self.db.scalar(select(func.count(Department.id))), 1)
self.assertEqual(self.db.scalar(select(func.count(WorkCenter.id))), 1)
@@ -265,7 +265,7 @@ class SalesOrderDeliveryTraceTest(unittest.TestCase):
def _seed_inventory_ledger_employee_permission(self) -> Employee: def _seed_inventory_ledger_employee_permission(self) -> Employee:
return self._seed_employee_permission( return self._seed_employee_permission(
"MENU_INVENTORY_LEDGER", "MENU_INVENTORY_LEDGER",
"嘉恒仓库", "百华仓库",
"INVENTORY_LEDGER", "INVENTORY_LEDGER",
"WAREHOUSE", "WAREHOUSE",
"仓库人员", "仓库人员",
@@ -214,7 +214,7 @@ class SelectedStockLotProductionIssueTest(unittest.TestCase):
self.db.add( self.db.add(
MiniAppProductionReport( MiniAppProductionReport(
id=9000 + item_id, id=9000 + item_id,
attendance_point_name="宁波嘉恒智能科技有限公司", attendance_point_name="宁波百华智能科技有限公司",
session_id=8000 + item_id, session_id=8000 + item_id,
employee_phone="13800000000", employee_phone="13800000000",
report_date=timestamp.date(), report_date=timestamp.date(),
@@ -238,7 +238,7 @@ class SelectedStockLotProductionIssueTest(unittest.TestCase):
item = MiniAppProductionReportItem( item = MiniAppProductionReportItem(
id=item_id, id=item_id,
report_id=9000 + item_id, report_id=9000 + item_id,
attendance_point_name="宁波嘉恒智能科技有限公司", attendance_point_name="宁波百华智能科技有限公司",
device_no="", device_no="",
project_no=self.product.item_code, project_no=self.product.item_code,
product_name=self.product.item_name, product_name=self.product.item_name,
@@ -0,0 +1,152 @@
from __future__ import annotations
import io
import unittest
from types import SimpleNamespace
from unittest.mock import patch
class SystemInitializerCliTest(unittest.TestCase):
def test_parse_fresh_arguments(self) -> None:
from scripts.system_initialize import parse_args
args = parse_args(
[
"fresh",
"--company-name",
"百华",
"--admin-name",
"超级管理员",
"--admin-phone",
"13800000000",
"--admin-password",
"secret123",
"--smart-operation-report",
"off",
"--delete-files",
"--allow-any-database",
]
)
self.assertEqual(args.mode, "fresh")
self.assertEqual(args.company_name, "百华")
self.assertEqual(args.admin_name, "超级管理员")
self.assertEqual(args.admin_phone, "13800000000")
self.assertEqual(args.admin_password, "secret123")
self.assertEqual(args.smart_operation_report, "off")
self.assertTrue(args.delete_files)
self.assertTrue(args.allow_any_database)
self.assertFalse(hasattr(args, "confirm_reset"))
def test_parse_reset_arguments(self) -> None:
from scripts.system_initialize import parse_args
args = parse_args(
[
"reset",
"--company-name",
"百华",
"--admin-name",
"超级管理员",
"--admin-phone",
"13800000000",
"--admin-password",
"secret123",
"--confirm-reset",
"--smart-operation-report",
"off",
]
)
self.assertEqual(args.mode, "reset")
self.assertEqual(args.company_name, "百华")
self.assertEqual(args.admin_phone, "13800000000")
self.assertTrue(args.confirm_reset)
self.assertEqual(args.smart_operation_report, "off")
def test_main_fresh_creates_database_then_initializes_without_real_db(self) -> None:
from scripts import system_initialize
settings = SimpleNamespace(
mysql_host="127.0.0.1",
mysql_port=3306,
mysql_database="erp_test",
mysql_dsn="mysql+pymysql://root:secret@127.0.0.1:3306/erp_test",
)
result = SimpleNamespace(
backup_path=None,
summary_path="/tmp/summary.json",
seeded={"admin_username": "13800000000"},
)
with (
patch.object(system_initialize, "get_settings", return_value=settings),
patch.object(system_initialize, "create_engine", return_value="engine") as create_engine_mock,
patch.object(system_initialize, "create_database_if_missing", return_value="erp_test") as create_db_mock,
patch.object(system_initialize, "fresh_initialize_database", return_value=result) as fresh_mock,
patch.object(system_initialize, "reset_existing_database_with_backup") as reset_mock,
patch("sys.stdout", new_callable=io.StringIO) as stdout,
):
exit_code = system_initialize.main(
[
"fresh",
"--company-name",
"百华",
"--admin-name",
"超级管理员",
"--admin-phone",
"13800000000",
"--admin-password",
"secret123",
"--smart-operation-report",
"on",
]
)
self.assertEqual(exit_code, 0)
create_db_mock.assert_called_once_with(settings)
create_engine_mock.assert_called_once_with(settings.mysql_dsn, pool_pre_ping=True, future=True)
fresh_mock.assert_called_once()
reset_mock.assert_not_called()
self.assertIn("mode=fresh", stdout.getvalue())
self.assertIn("database=127.0.0.1:3306/erp_test", stdout.getvalue())
self.assertIn("summary_path=/tmp/summary.json", stdout.getvalue())
self.assertIn("admin_username=13800000000", stdout.getvalue())
def test_main_reset_calls_backup_reset_without_swallowing_service_errors(self) -> None:
from scripts import system_initialize
settings = SimpleNamespace(
mysql_host="127.0.0.1",
mysql_port=3306,
mysql_database="erp_test",
mysql_dsn="mysql+pymysql://root:secret@127.0.0.1:3306/erp_test",
)
with (
patch.object(system_initialize, "get_settings", return_value=settings),
patch.object(system_initialize, "create_engine", return_value="engine"),
patch.object(
system_initialize,
"reset_existing_database_with_backup",
side_effect=ValueError("Refusing to reset database without --confirm-reset"),
),
):
with self.assertRaisesRegex(ValueError, "confirm-reset"):
system_initialize.main(
[
"reset",
"--company-name",
"百华",
"--admin-name",
"超级管理员",
"--admin-phone",
"13800000000",
"--admin-password",
"secret123",
]
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,97 @@
from __future__ import annotations
from pathlib import Path
from sqlalchemy import create_engine, inspect
import app.models # noqa: F401
from app.models.base import Base
MINIAPP_TABLES = {
"attendance_points",
"personnel",
"person_roles",
"person_attendance_points",
"products",
"equipment",
"work_schedules",
"work_sessions",
"work_session_devices",
"production_reports",
"production_report_items",
"report_audit_logs",
"mold_lock_feedbacks",
"device_qrcodes",
"device_qrcode_batch_tasks",
"notices",
"notice_points",
"reconciliation_ledger_entries",
}
REQUIRED_ERP_TABLES = {
"sys_department",
"hr_employee",
"sys_role",
"sys_permission",
"sys_role_permission",
"sys_user",
"sys_user_role",
"sys_system_config",
"sys_ai_assistant_config",
"md_unit",
"md_item_category",
"md_item",
"md_material",
"md_product",
"md_bom",
"md_bom_item",
"wh_warehouse",
"wh_location",
"wh_stock_lot",
"wh_stock_balance",
"wh_inventory_txn",
"pp_material_issue",
"pp_material_issue_item",
"pp_production_batch_ledger",
"pp_production_batch_ledger_txn",
"document_archives",
}
def _erp_model_table_names() -> set[str]:
models_dir = Path(__file__).resolve().parents[1] / "app" / "models"
table_names: set[str] = set()
for model_file in models_dir.glob("*.py"):
if model_file.name in {"__init__.py", "base.py", "miniapp.py"}:
continue
for line in model_file.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if stripped.startswith("__tablename__"):
table_names.add(stripped.split("=", 1)[1].strip().strip("\"'"))
return table_names
def test_app_models_registers_erp_and_miniapp_tables() -> None:
expected_tables = _erp_model_table_names() | REQUIRED_ERP_TABLES | MINIAPP_TABLES
missing_tables = expected_tables - set(Base.metadata.tables)
assert missing_tables == set()
def test_reset_table_groups_are_registered_in_metadata() -> None:
from app.services.system_initializer import ACCOUNT_RESET_TABLES, BUSINESS_RESET_TABLES, MINIAPP_RESET_TABLES, SKELETON_TABLES
expected_tables = set(BUSINESS_RESET_TABLES) | set(MINIAPP_RESET_TABLES) | set(ACCOUNT_RESET_TABLES) | set(SKELETON_TABLES)
assert expected_tables.issubset(set(Base.metadata.tables))
def test_registered_models_create_all_in_sqlite_memory() -> None:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
created_tables = set(inspect(engine).get_table_names())
assert (MINIAPP_TABLES | REQUIRED_ERP_TABLES).issubset(created_tables)
@@ -0,0 +1,329 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from sqlalchemy import create_engine, inspect, select, text
from sqlalchemy.orm import Session, sessionmaker
import app.models # noqa: F401
from app.models.base import Base
from app.models.org import User
from app.services.auth import verify_password
def _options(*, confirm_reset: bool = False, mode: str = "reset"):
from app.services.system_initializer import SystemInitializeOptions
return SystemInitializeOptions(
mode=mode,
company_name="百华",
admin_name="超级管理员",
admin_phone="13800000000",
admin_password="secret123",
confirm_reset=confirm_reset,
)
class SystemInitializerResetTest(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(self.engine)
self.SessionLocal = sessionmaker(bind=self.engine, autoflush=False, autocommit=False, future=True)
self.db: Session = self.SessionLocal()
def tearDown(self) -> None:
self.db.close()
def _seed_dirty_data(self) -> None:
self.db.execute(
text(
"""
INSERT INTO sys_department
(id, dept_code, dept_name, org_node_type, dept_type, status, sort_no, created_at, updated_at)
VALUES
(100, 'DIRTY_ROOT', '旧公司', 'COMPANY', 'ADMIN', 'ACTIVE', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
"""
)
)
self.db.execute(
text(
"""
INSERT INTO hr_employee
(id, employee_code, employee_name, dept_id, mobile, is_operator, is_workshop_staff, status, created_at, updated_at)
VALUES
(100, 'EMP_OLD', '旧管理员', 100, '13900000000', 0, 0, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
"""
)
)
self.db.execute(
text(
"""
INSERT INTO sys_user
(id, username, password_hash, employee_id, dept_id, nickname, is_super_admin, status, created_at, updated_at)
VALUES
(100, '13900000000', 'old', 100, 100, '旧管理员', 0, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
"""
)
)
self.db.execute(
text(
"""
INSERT INTO personnel (phone, name, is_temporary, created_at, updated_at)
VALUES ('13900000000', '旧小程序用户', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
"""
)
)
self.db.execute(
text(
"""
INSERT INTO md_customer
(id, customer_code, customer_name, status, created_at, updated_at)
VALUES
(100, 'C_OLD', '旧客户', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
"""
)
)
self.db.commit()
def test_reset_requires_confirm_reset_and_does_not_clear_data(self) -> None:
from app.services.system_initializer import reset_existing_database
self._seed_dirty_data()
with self.assertRaisesRegex(ValueError, "confirm-reset"):
reset_existing_database(self.db, _options(confirm_reset=False))
self.db.rollback()
self.assertEqual(self.db.execute(text("SELECT COUNT(*) FROM sys_user")).scalar_one(), 1)
self.assertEqual(self.db.execute(text("SELECT COUNT(*) FROM md_customer")).scalar_one(), 1)
def test_reset_clears_business_account_and_miniapp_data_then_recreates_admin(self) -> None:
from app.services.system_initializer import reset_existing_database
self._seed_dirty_data()
result = reset_existing_database(self.db, _options(confirm_reset=True))
self.db.commit()
self.assertEqual(result.mode, "reset")
self.assertEqual(result.counts_before["sys_user"], 1)
self.assertEqual(result.counts_before["md_customer"], 1)
self.assertEqual(result.counts_before["personnel"], 1)
self.assertEqual(result.counts_after["md_customer"], 0)
self.assertEqual(result.counts_after["sys_user"], 1)
self.assertEqual(result.counts_after["personnel"], 1)
self.assertEqual(result.seeded["admin_username"], "13800000000")
self.assertEqual(self.db.execute(text("SELECT COUNT(*) FROM md_customer")).scalar_one(), 0)
self.assertIsNone(self.db.scalar(select(User).where(User.username == "13900000000")))
admin = self.db.scalar(select(User).where(User.username == "13800000000"))
self.assertIsNotNone(admin)
assert admin is not None
self.assertEqual(admin.is_super_admin, 1)
self.assertTrue(verify_password("secret123", admin.password_hash))
def test_reset_keeps_sqlite_foreign_keys_enabled(self) -> None:
from app.services.system_initializer import reset_existing_database
self.db.execute(text("PRAGMA foreign_keys = ON"))
self._seed_dirty_data()
reset_existing_database(self.db, _options(confirm_reset=True))
self.db.commit()
self.assertEqual(self.db.execute(text("PRAGMA foreign_keys")).scalar_one(), 1)
def test_write_summary_report_writes_expected_json(self) -> None:
from app.services.system_initializer import SystemInitializeResult, write_summary_report
result = SystemInitializeResult(
mode="reset",
database="erp_test",
backup_path="/tmp/backup.sql",
counts_before={"sys_user": 2},
counts_after={"sys_user": 1},
seeded={"admin_username": "13800000000"},
)
with tempfile.TemporaryDirectory() as tmpdir:
summary_path = write_summary_report(Path(tmpdir), result)
payload = json.loads(Path(summary_path).read_text(encoding="utf-8"))
self.assertEqual(payload["mode"], "reset")
self.assertEqual(payload["database"], "erp_test")
self.assertEqual(payload["backup_path"], "/tmp/backup.sql")
self.assertEqual(payload["summary_path"], summary_path)
self.assertEqual(payload["counts_before"], {"sys_user": 2})
self.assertEqual(payload["counts_after"], {"sys_user": 1})
self.assertEqual(payload["seeded"], {"admin_username": "13800000000"})
self.assertRegex(payload["created_at"], r"^\d{4}-\d{2}-\d{2}T")
def test_default_output_dir_uses_cleanup_backups_folder(self) -> None:
from app.services.system_initializer import default_output_dir
self.assertEqual(default_output_dir().name, "cleanup_backups")
self.assertEqual(default_output_dir().parent.name, "outputs")
def test_backup_mysql_database_uses_defaults_file_without_password_argument(self) -> None:
from app.services.system_initializer import backup_mysql_database
class Settings:
mysql_host = "127.0.0.1"
mysql_port = 3306
mysql_user = "root"
mysql_password = "secret"
mysql_database = "jiaheng_erp"
commands: list[list[str]] = []
def fake_run(command: list[str], check: bool) -> None:
commands.append(command)
defaults_arg = next(part for part in command if part.startswith("--defaults-extra-file="))
defaults_path = Path(defaults_arg.split("=", 1)[1])
self.assertTrue(defaults_path.exists())
self.assertIn("password=secret", defaults_path.read_text(encoding="utf-8"))
self.assertEqual(defaults_path.stat().st_mode & 0o777, 0o600)
with tempfile.TemporaryDirectory() as tmpdir:
with patch("app.services.system_initializer.subprocess.run", side_effect=fake_run):
backup_path = backup_mysql_database(Settings(), tmpdir)
self.assertTrue(backup_path.endswith(".sql"))
self.assertEqual(len(commands), 1)
self.assertIn("--no-tablespaces", commands[0])
self.assertFalse(any("secret" in part for part in commands[0]))
defaults_arg = next(part for part in commands[0] if part.startswith("--defaults-extra-file="))
self.assertFalse(Path(defaults_arg.split("=", 1)[1]).exists())
class SystemInitializerFreshTest(unittest.TestCase):
def test_fresh_create_all_builds_key_tables_and_seeds_skeleton(self) -> None:
from app.services.system_initializer import fresh_initialize_database
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
result = fresh_initialize_database(engine, _options(mode="fresh"))
created_tables = set(inspect(engine).get_table_names())
self.assertIn("sys_user", created_tables)
self.assertIn("md_customer", created_tables)
self.assertIn("attendance_points", created_tables)
self.assertEqual(result.mode, "fresh")
self.assertEqual(result.counts_after["sys_user"], 1)
self.assertEqual(result.counts_after["wh_warehouse"], 6)
self.assertEqual(result.seeded["admin_username"], "13800000000")
with Session(engine, future=True) as db:
self.assertIsNotNone(db.scalar(select(User).where(User.username == "13800000000")))
class SystemInitializerFileCleanupTest(unittest.TestCase):
def test_delete_managed_files_removes_only_children_and_keeps_directories(self) -> None:
from app.services.system_initializer import delete_managed_files
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
archive_dir = root / "document_archives"
photo_dir = root / "uploads" / "logistics"
nested_dir = archive_dir / "batch"
archive_dir.mkdir(parents=True)
photo_dir.mkdir(parents=True)
nested_dir.mkdir()
(archive_dir / "a.pdf").write_text("pdf", encoding="utf-8")
(nested_dir / "nested.pdf").write_text("nested", encoding="utf-8")
(photo_dir / "b.png").write_text("png", encoding="utf-8")
summary = delete_managed_files([archive_dir, photo_dir])
self.assertEqual(summary["deleted_file_count"], 3)
self.assertEqual(summary["deleted_dir_count"], 1)
self.assertTrue(archive_dir.exists())
self.assertTrue(photo_dir.exists())
self.assertEqual(list(archive_dir.iterdir()), [])
self.assertEqual(list(photo_dir.iterdir()), [])
def test_reset_with_backup_skips_file_cleanup_by_default(self) -> None:
from app.services import system_initializer
from app.services.system_initializer import SystemInitializeResult, reset_existing_database_with_backup
class Settings:
mysql_database = "erp_test"
reset_result = SystemInitializeResult(
mode="reset",
counts_before={"sys_user": 2},
counts_after={"sys_user": 1},
seeded={"admin_username": "13800000000"},
)
with tempfile.TemporaryDirectory() as tmpdir:
with (
patch.object(system_initializer, "backup_mysql_database", return_value="/tmp/backup.sql"),
patch.object(system_initializer, "reset_existing_database", return_value=reset_result),
patch.object(system_initializer, "delete_managed_files") as delete_mock,
patch.object(system_initializer, "Session") as session_mock,
):
session_mock.return_value.__enter__.return_value = MagicMock()
result = reset_existing_database_with_backup(
engine=object(),
settings=Settings(),
options=_options(confirm_reset=True),
output_dir=tmpdir,
)
delete_mock.assert_not_called()
self.assertNotIn("deleted_file_count", result.seeded)
def test_reset_with_backup_includes_file_cleanup_when_requested(self) -> None:
from app.services import system_initializer
from app.services.system_initializer import SystemInitializeResult, SystemInitializeOptions, reset_existing_database_with_backup
class Settings:
mysql_database = "erp_test"
reset_result = SystemInitializeResult(
mode="reset",
counts_before={"sys_user": 2},
counts_after={"sys_user": 1},
seeded={"admin_username": "13800000000"},
)
options = SystemInitializeOptions(
mode="reset",
company_name="百华",
admin_name="超级管理员",
admin_phone="13800000000",
admin_password="secret123",
confirm_reset=True,
delete_files=True,
)
with tempfile.TemporaryDirectory() as tmpdir:
with (
patch.object(system_initializer, "backup_mysql_database", return_value="/tmp/backup.sql"),
patch.object(system_initializer, "reset_existing_database", return_value=reset_result),
patch.object(
system_initializer,
"delete_managed_files",
return_value={"deleted_file_count": 3, "deleted_dir_count": 2},
) as delete_mock,
patch.object(system_initializer, "Session") as session_mock,
):
session_mock.return_value.__enter__.return_value = MagicMock()
result = reset_existing_database_with_backup(
engine=object(),
settings=Settings(),
options=options,
output_dir=tmpdir,
)
delete_mock.assert_called_once()
self.assertEqual(result.seeded["deleted_file_count"], 3)
self.assertEqual(result.seeded["deleted_dir_count"], 2)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,177 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine, select, text
from sqlalchemy.orm import Session, sessionmaker
import app.models # noqa: E402,F401
from app.models.base import Base # noqa: E402
from app.models.master_data import Warehouse # noqa: E402
from app.models.miniapp import MiniAppPersonAttendancePoint, MiniAppPersonnel, MiniAppPersonRole # noqa: E402
from app.models.operations import WarehouseLocation # noqa: E402
from app.models.org import AiAssistantConfig, Department, Employee, Permission, Role, RolePermission, SystemConfig, User, UserRole # noqa: E402
from app.services.auth import verify_password # noqa: E402
from app.services.system_permissions import MENU_PERMISSION_TREE # noqa: E402
class SystemInitializerSeedTest(unittest.TestCase):
def test_table_groups_have_no_duplicates_and_include_expected_tables(self) -> None:
from app.services.system_initializer import (
ACCOUNT_RESET_TABLES,
BUSINESS_RESET_TABLES,
MINIAPP_RESET_TABLES,
RESET_TABLES_IN_DELETE_ORDER,
SKELETON_TABLES,
)
all_tables = BUSINESS_RESET_TABLES + MINIAPP_RESET_TABLES + ACCOUNT_RESET_TABLES + SKELETON_TABLES
reset_tables = BUSINESS_RESET_TABLES + MINIAPP_RESET_TABLES + ACCOUNT_RESET_TABLES
self.assertEqual(len(all_tables), len(set(all_tables)))
self.assertEqual(set(reset_tables).issubset(set(RESET_TABLES_IN_DELETE_ORDER)), True)
self.assertEqual(len(RESET_TABLES_IN_DELETE_ORDER), len(set(RESET_TABLES_IN_DELETE_ORDER)))
self.assertIn("so_sales_order", BUSINESS_RESET_TABLES)
self.assertIn("wh_stock_lot", BUSINESS_RESET_TABLES)
self.assertIn("pp_production_batch_ledger", BUSINESS_RESET_TABLES)
self.assertIn("production_reports", MINIAPP_RESET_TABLES)
self.assertIn("work_sessions", MINIAPP_RESET_TABLES)
self.assertIn("sys_user", ACCOUNT_RESET_TABLES)
self.assertIn("sys_permission", SKELETON_TABLES)
self.assertIn("wh_warehouse", SKELETON_TABLES)
class SystemInitializerSkeletonSeedTest(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()
def tearDown(self) -> None:
self.db.close()
def test_seed_system_skeleton_creates_required_defaults(self) -> None:
from app.services.system_initializer import SystemInitializeOptions, seed_system_skeleton
options = SystemInitializeOptions(
mode="reset",
company_name="百华",
admin_name="超级管理员",
admin_phone="13800000000",
admin_password="secret123",
smart_operation_report_enabled=False,
confirm_reset=True,
)
result = seed_system_skeleton(self.db, options)
self.db.commit()
admin_user = self.db.scalar(select(User).where(User.username == "13800000000"))
self.assertIsNotNone(admin_user)
assert admin_user is not None
self.assertEqual(admin_user.is_super_admin, 1)
self.assertTrue(verify_password("secret123", admin_user.password_hash))
admin_employee = self.db.scalar(select(Employee).where(Employee.mobile == "13800000000"))
self.assertIsNotNone(admin_employee)
assert admin_employee is not None
self.assertEqual(admin_employee.employee_name, "超级管理员")
root = self.db.scalar(select(Department).where(Department.dept_code == "ORG_ROOT"))
self.assertIsNotNone(root)
assert root is not None
self.assertEqual(root.dept_name, "百华")
expected_permission_codes = set()
def walk(nodes: list[tuple[str, str, list]]) -> None:
for code, _name, children in nodes:
expected_permission_codes.add(code)
walk(children)
walk(MENU_PERMISSION_TREE)
actual_permission_codes = set(self.db.scalars(select(Permission.permission_code)).all())
self.assertEqual(expected_permission_codes, actual_permission_codes)
admin_role = self.db.scalar(select(Role).where(Role.role_code == "ADMIN"))
self.assertIsNotNone(admin_role)
assert admin_role is not None
self.assertEqual(
self.db.query(RolePermission).filter(RolePermission.role_id == admin_role.id).count(),
len(expected_permission_codes),
)
role_names = {role.role_code: role.role_name for role in self.db.scalars(select(Role)).all()}
self.assertEqual(
role_names,
{
"ADMIN": "超级管理员",
"PURCHASER": "采购专员",
"WAREHOUSE": "仓库管理人员",
"SALES": "销售人员",
},
)
self.assertEqual(self.db.query(UserRole).count(), 1)
self.assertEqual(self.db.query(Warehouse).count(), 6)
self.assertEqual(self.db.query(WarehouseLocation).count(), 6)
self.assertEqual({row.warehouse_type for row in self.db.scalars(select(Warehouse)).all()}, {"RAW", "SEMI", "FINISHED", "AUX", "SCRAP", "RETURN"})
self.assertEqual(self.db.query(WarehouseLocation).filter(WarehouseLocation.is_default == 1).count(), 6)
prefix = self.db.scalar(select(SystemConfig).where(SystemConfig.config_code == "RAW_MATERIAL_LOT_PREFIX"))
self.assertIsNotNone(prefix)
assert prefix is not None
self.assertEqual(prefix.config_value, "YL")
smart = self.db.scalar(select(SystemConfig).where(SystemConfig.config_code == "SMART_OPERATION_REPORT_ENABLED"))
self.assertIsNotNone(smart)
assert smart is not None
self.assertEqual(smart.config_value, "关闭")
ai_config = self.db.scalar(select(AiAssistantConfig))
self.assertIsNotNone(ai_config)
assert ai_config is not None
self.assertEqual(ai_config.assistant_name, "百华工艺助手")
units = dict(self.db.execute(text("SELECT unit_code, unit_name FROM md_unit")).all())
self.assertEqual(units, {"KG": "kg", "PCS": "件", "SET": "套"})
categories = dict(self.db.execute(text("SELECT category_code, category_name FROM md_item_category")).all())
self.assertEqual(categories, {"RAW": "原材料", "SEMI": "半成品", "FINISHED": "成品", "AUX": "辅料", "SCRAP": "废料"})
miniapp_admin = self.db.get(MiniAppPersonnel, "13800000000")
self.assertIsNotNone(miniapp_admin)
assert miniapp_admin is not None
self.assertEqual(miniapp_admin.name, "超级管理员")
self.assertIsNotNone(self.db.get(MiniAppPersonRole, {"phone": "13800000000", "role": "admin"}))
self.assertIsNotNone(
self.db.get(
MiniAppPersonAttendancePoint,
{"phone": "13800000000", "attendance_point_name": "百华"},
)
)
self.assertEqual(result.seeded["warehouse_count"], 6)
self.assertEqual(result.seeded["admin_username"], "13800000000")
def test_seed_system_skeleton_defaults_smart_operation_report_to_enabled(self) -> None:
from app.services.system_initializer import SystemInitializeOptions, seed_system_skeleton
options = SystemInitializeOptions(
mode="fresh",
company_name="百华",
admin_name="超级管理员",
admin_phone="13800000000",
admin_password="secret123",
)
result = seed_system_skeleton(self.db, options)
self.db.commit()
smart = self.db.scalar(select(SystemConfig).where(SystemConfig.config_code == "SMART_OPERATION_REPORT_ENABLED"))
self.assertIsNotNone(smart)
assert smart is not None
self.assertEqual(smart.config_value, "开启")
self.assertEqual(result.seeded["smart_operation_report_enabled"], True)
if __name__ == "__main__":
unittest.main()
@@ -398,6 +398,32 @@ class SystemPermissionManagementTest(unittest.TestCase):
self.assertEqual(tree[0].children[0].parent_id, self.company.id) self.assertEqual(tree[0].children[0].parent_id, self.company.id)
self.assertEqual(tree[0].children[0].node_type, "BRANCH") self.assertEqual(tree[0].children[0].node_type, "BRANCH")
def test_create_org_node_allows_company_direct_department(self) -> None:
from app.schemas.system_permissions import OrgNodeCreate
from app.services.system_permissions import create_org_node, build_org_tree
direct_department = create_org_node(
self.db,
OrgNodeCreate(
parent_id=self.company.id,
node_type="DEPARTMENT",
node_label="总公司直属部门",
dept_code="DIRECT-DEPT",
dept_type="ADMIN",
manager_employee_id=None,
sort_no=1,
remark=None,
status="ACTIVE",
),
)
tree = build_org_tree(self.db)
self.assertEqual(direct_department.parent_id, self.company.id)
self.assertEqual(direct_department.node_type, "DEPARTMENT")
self.assertTrue(
any(child.node_label == "总公司直属部门" and child.node_type == "DEPARTMENT" for child in tree[0].children)
)
def test_create_org_node_rejects_invalid_child_level(self) -> None: def test_create_org_node_rejects_invalid_child_level(self) -> None:
from fastapi import HTTPException from fastapi import HTTPException
from app.schemas.system_permissions import OrgNodeCreate from app.schemas.system_permissions import OrgNodeCreate
@@ -408,9 +434,9 @@ class SystemPermissionManagementTest(unittest.TestCase):
self.db, self.db,
OrgNodeCreate( OrgNodeCreate(
parent_id=self.company.id, parent_id=self.company.id,
node_type="DEPARTMENT", node_type="GROUP",
node_label="错误部门", node_label="错误小组",
dept_code="BAD-DEPT", dept_code="BAD-GROUP",
dept_type="ADMIN", dept_type="ADMIN",
manager_employee_id=None, manager_employee_id=None,
sort_no=1, sort_no=1,
@@ -419,7 +445,7 @@ class SystemPermissionManagementTest(unittest.TestCase):
), ),
) )
self.assertEqual(ctx.exception.status_code, 400) self.assertEqual(ctx.exception.status_code, 400)
self.assertIn("总公司下只能新增分公司", str(ctx.exception.detail)) self.assertIn("总公司下只能新增分公司或直属部门", str(ctx.exception.detail))
def test_delete_org_node_rejects_nodes_with_children_or_bound_employees(self) -> None: def test_delete_org_node_rejects_nodes_with_children_or_bound_employees(self) -> None:
from fastapi import HTTPException from fastapi import HTTPException
+1 -1
View File
@@ -1,4 +1,4 @@
# 嘉恒智能五金 ERP 生产使用案例 # 百华智能五金 ERP 生产使用案例
## 案例背景 ## 案例背景
@@ -2,7 +2,7 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Change 嘉恒仓库盘库 into a current-selected-warehouse-only flow: select a warehouse bar first, click 盘库, confirm/export one locked warehouse sheet, import only that same stocktake workbook, show a modal diff immediately, and confirm to reset that warehouse inventory and unlock it. **Goal:** Change 百华仓库盘库 into a current-selected-warehouse-only flow: select a warehouse bar first, click 盘库, confirm/export one locked warehouse sheet, import only that same stocktake workbook, show a modal diff immediately, and confirm to reset that warehouse inventory and unlock it.
**Architecture:** Backend becomes the source of truth for single-warehouse stocktake rules, workbook validation token checks, import overwrite behavior, and current-warehouse history filtering. Frontend removes multi-warehouse selection from the stocktake dialog and renders two blocks: a stocktake flow block for the currently selected warehouse and a stocktake records block filtered to that warehouse. The diff review is opened as a modal immediately after each import; cancel closes the modal and leaves the warehouse locked for re-import. **Architecture:** Backend becomes the source of truth for single-warehouse stocktake rules, workbook validation token checks, import overwrite behavior, and current-warehouse history filtering. Frontend removes multi-warehouse selection from the stocktake dialog and renders two blocks: a stocktake flow block for the currently selected warehouse and a stocktake records block filtered to that warehouse. The diff review is opened as a modal immediately after each import; cancel closes the modal and leaves the warehouse locked for re-import.
@@ -1072,7 +1072,7 @@ Expected: Vite exits code 0. Existing chunk warning is acceptable.
Use the running ERP UI and verify: Use the running ERP UI and verify:
```text ```text
1. Select 原材料库 in 嘉恒仓库 warehouse bar. 1. Select 原材料库 in 百华仓库 warehouse bar.
2. Click 盘库. 2. Click 盘库.
3. Dialog shows 当前仓库:原材料库 and no multi-warehouse checkbox cards. 3. Dialog shows 当前仓库:原材料库 and no multi-warehouse checkbox cards.
4. Click 确认盘库并导出仓库清单. 4. Click 确认盘库并导出仓库清单.
@@ -2,7 +2,7 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a "盘库" feature in 嘉恒仓库 that locks selected warehouses, exports stocktake sheets, imports counted results, shows a side-by-side diff, posts adjustments, records audit logs, and unlocks warehouses after confirmation. **Goal:** Build a "盘库" feature in 百华仓库 that locks selected warehouses, exports stocktake sheets, imports counted results, shows a side-by-side diff, posts adjustments, records audit logs, and unlocks warehouses after confirmation.
**Architecture:** Backend owns stocktake locking, snapshots, Excel import/export, diff calculation, and inventory adjustment transactions. Frontend adds a stocktake entry above the warehouse bar, drives a wizard-style dialog, and renders a code-diff-like two-table comparison before confirmation. Inventory mutation endpoints must call a shared lock guard so locked warehouses cannot be changed outside stocktake. **Architecture:** Backend owns stocktake locking, snapshots, Excel import/export, diff calculation, and inventory adjustment transactions. Frontend adds a stocktake entry above the warehouse bar, drives a wizard-style dialog, and renders a code-diff-like two-table comparison before confirmation. Inventory mutation endpoints must call a shared lock guard so locked warehouses cannot be changed outside stocktake.
@@ -1955,7 +1955,7 @@ Create `frontend/src/components/StocktakeDialog.vue`:
<section class="stocktake-shell"> <section class="stocktake-shell">
<header class="stocktake-head"> <header class="stocktake-head">
<div> <div>
<p class="eyebrow">嘉恒仓库 · 盘库</p> <p class="eyebrow">百华仓库 · 盘库</p>
<h3>{{ activeStocktake ? activeStocktake.stocktake_no : "新建盘库" }}</h3> <h3>{{ activeStocktake ? activeStocktake.stocktake_no : "新建盘库" }}</h3>
</div> </div>
<button class="ghost-button" type="button" @click="$emit('close')">关闭</button> <button class="ghost-button" type="button" @click="$emit('close')">关闭</button>
@@ -2125,7 +2125,7 @@ async function startStocktake() {
try { try {
activeStocktake.value = await postResource("/inventory/stocktakes", { activeStocktake.value = await postResource("/inventory/stocktakes", {
warehouse_ids: selectedWarehouseIds.value, warehouse_ids: selectedWarehouseIds.value,
remark: "嘉恒仓库盘库" remark: "百华仓库盘库"
}); });
activeStep.value = "export"; activeStep.value = "export";
emit("message", `盘库单 ${activeStocktake.value.stocktake_no} 已创建并锁库`); emit("message", `盘库单 ${activeStocktake.value.stocktake_no} 已创建并锁库`);
@@ -2684,7 +2684,7 @@ Expected: PASS.
Run the app using the project's normal backend/frontend commands, then verify: Run the app using the project's normal backend/frontend commands, then verify:
```text ```text
1. 打开 采购与库存 -> 嘉恒仓库。 1. 打开 采购与库存 -> 百华仓库。
2. 点击仓库 bar 上方“盘库”。 2. 点击仓库 bar 上方“盘库”。
3. 默认勾选当前仓库。 3. 默认勾选当前仓库。
4. 点击“开始盘库并锁库”。 4. 点击“开始盘库并锁库”。
@@ -2,7 +2,7 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rebuild the 嘉恒仓库盘库 interaction so users can clearly start locked stocktakes, export/import sheets, review side-by-side differences, confirm adjustments, and continue unfinished stocktakes without confusing tab jumps or silent states. **Goal:** Rebuild the 百华仓库盘库 interaction so users can clearly start locked stocktakes, export/import sheets, review side-by-side differences, confirm adjustments, and continue unfinished stocktakes without confusing tab jumps or silent states.
**Architecture:** Keep the existing backend stocktake tables and core service flow. Add a small frontend workflow helper to centralize stocktake state decisions, then refactor `StocktakeDialog.vue` into a status-driven panel instead of a freely clickable five-tab wizard. Backend changes are limited to regression tests unless implementation finds an API defect. **Architecture:** Keep the existing backend stocktake tables and core service flow. Add a small frontend workflow helper to centralize stocktake state decisions, then refactor `StocktakeDialog.vue` into a status-driven panel instead of a freely clickable five-tab wizard. Backend changes are limited to regression tests unless implementation finds an API defect.
@@ -1243,7 +1243,7 @@ Expected: command exits with code `0`.
Start backend and frontend using the project's normal local commands, then verify these paths in the browser: Start backend and frontend using the project's normal local commands, then verify these paths in the browser:
```text ```text
1. Open 嘉恒仓库 and click 盘库. 1. Open 百华仓库 and click 盘库.
2. Warehouse cards show 可盘库 or active stocktake status. 2. Warehouse cards show 可盘库 or active stocktake status.
3. Start a stocktake for one warehouse. 3. Start a stocktake for one warehouse.
4. Dialog moves to 导出清单 and shows the selected warehouse name. 4. Dialog moves to 导出清单 and shows the selected warehouse name.
@@ -12,7 +12,7 @@
## Scope And Decisions ## Scope And Decisions
- Add this only under `嘉恒仓库 -> 原材料库 -> 出库 -> 退货出库`. - Add this only under `百华仓库 -> 原材料库 -> 出库 -> 退货出库`.
- This is for material that already entered the warehouse and was later found quality/performance-unqualified. - This is for material that already entered the warehouse and was later found quality/performance-unqualified.
- This is not the same as purchase quality inspection rejection. Existing `PENDING_QC` / `REJECTED` quality flow stays unchanged. - This is not the same as purchase quality inspection rejection. Existing `PENDING_QC` / `REJECTED` quality flow stays unchanged.
- Raw material remains weight-only. Do not introduce raw-material quantity fields. - Raw material remains weight-only. Do not introduce raw-material quantity fields.
@@ -1956,7 +1956,7 @@ Expected: first command finds the new feature in planned files. Second command m
Run the backend and frontend using the project’s existing local start commands. If the commands are already running, reuse them. Then verify: Run the backend and frontend using the project’s existing local start commands. If the commands are already running, reuse them. Then verify:
```text ```text
1. Open 嘉恒仓库. 1. Open 百华仓库.
2. Select 原材料库. 2. Select 原材料库.
3. Confirm 出库 row shows 生产出库、委外出库、报废出库、退货出库. 3. Confirm 出库 row shows 生产出库、委外出库、报废出库、退货出库.
4. Click 退货出库. 4. Click 退货出库.
@@ -2,7 +2,7 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add 嘉恒仓库“退货库” for customer-returned products that can be reworked, move the current “退货处理” entry into warehouse inbound operations, and add 废料库“退货废料入库” for customer-returned products judged as scrap. **Goal:** Add 百华仓库“退货库” for customer-returned products that can be reworked, move the current “退货处理” entry into warehouse inbound operations, and add 废料库“退货废料入库” for customer-returned products judged as scrap.
**Architecture:** Treat customer returns as inventory movement. `RETURN` warehouse lots represent returned finished goods waiting for rework and are locked until `返工出库`; customer-return scrap bypasses the return warehouse and enters `SCRAP` directly while retaining delivery-line and source-material traceability. The old 发货与售后退货入口 is removed from navigation, but existing return order/disposition tables remain the trace backbone. **Architecture:** Treat customer returns as inventory movement. `RETURN` warehouse lots represent returned finished goods waiting for rework and are locked until `返工出库`; customer-return scrap bypasses the return warehouse and enters `SCRAP` directly while retaining delivery-line and source-material traceability. The old 发货与售后退货入口 is removed from navigation, but existing return order/disposition tables remain the trace backbone.
@@ -12,11 +12,11 @@
## Confirmed Business Rules ## Confirmed Business Rules
- “发货与售后”的“退货处理”入口去掉,退货登记逻辑移动到“嘉恒仓库 -> 退货库 -> 入库 -> 退货入库”。 - “发货与售后”的“退货处理”入口去掉,退货登记逻辑移动到“百华仓库 -> 退货库 -> 入库 -> 退货入库”。
- “退货库”只存放客户退回后判定可以返工的产品。 - “退货库”只存放客户退回后判定可以返工的产品。
- 退货库入库后的批次默认“待返工/锁定”,不能作为成品库存销售。 - 退货库入库后的批次默认“待返工/锁定”,不能作为成品库存销售。
- 退货库出库只有“返工出库”,出库后扣减退货库库存并生成返工处置/返工工单。 - 退货库出库只有“返工出库”,出库后扣减退货库库存并生成返工处置/返工工单。
- 客户退回后判定报废的产品不进入退货库,用户在“嘉恒仓库 -> 废料库 -> 入库 -> 退货废料入库”登记。 - 客户退回后判定报废的产品不进入退货库,用户在“百华仓库 -> 废料库 -> 入库 -> 退货废料入库”登记。
- 退货库需要“期初入库”,用于初始化历史退货待返工库存。 - 退货库需要“期初入库”,用于初始化历史退货待返工库存。
- 退货品明细必须展示发货单号或发货批次号,并通过发货明细追溯成品批次、原材料库存批次号、来源材料。 - 退货品明细必须展示发货单号或发货批次号,并通过发货明细追溯成品批次、原材料库存批次号、来源材料。
@@ -26,7 +26,7 @@
- Add `RETURN` warehouse and default location. - Add `RETURN` warehouse and default location.
- Add `delivery_item_id` to `rt_return_item`. - Add `delivery_item_id` to `rt_return_item`.
- Modify `backend/app/api/routes/master_data.py` - Modify `backend/app/api/routes/master_data.py`
- Add 嘉恒退货库 definition and auto-ensure location. - Add 百华退货库 definition and auto-ensure location.
- Modify `backend/app/api/routes/inventory.py` - Modify `backend/app/api/routes/inventory.py`
- Add `RETURN` warehouse label, sample opening rows, inbound/outbound permissions, lot number rules, and customer-return scrap inbound operation. - Add `RETURN` warehouse label, sample opening rows, inbound/outbound permissions, lot number rules, and customer-return scrap inbound operation.
- Modify `backend/app/services/stocktake.py` - Modify `backend/app/services/stocktake.py`
@@ -1418,7 +1418,7 @@ Update the workspace title/description:
```vue ```vue
title="成品发货与交付管理" title="成品发货与交付管理"
description="发货退回后的库存处理已迁移到嘉恒仓库的退货库和废料库。" description="发货退回后的库存处理已迁移到百华仓库的退货库和废料库。"
``` ```
- [ ] **Step 2: Remove return-management child menu from App** - [ ] **Step 2: Remove return-management child menu from App**
@@ -1563,7 +1563,7 @@ Expected: static test passes and build exits 0.
Open the ERP frontend and verify: Open the ERP frontend and verify:
```text ```text
1. 嘉恒仓库出现第六个仓库:退货库。 1. 百华仓库出现第六个仓库:退货库。
2. 退货库入库区有:期初入库、退货入库。 2. 退货库入库区有:期初入库、退货入库。
3. 退货库出库区有:返工出库。 3. 退货库出库区有:返工出库。
4. 废料库入库区有:退货废料入库。 4. 废料库入库区有:退货废料入库。
@@ -2,7 +2,7 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a fifth "废料库" to 嘉恒仓库 for production scrap, offcuts, defective goods, opening stock, outsourcing scrap return, and scrap sale outbound. **Goal:** Add a fifth "废料库" to 百华仓库 for production scrap, offcuts, defective goods, opening stock, outsourcing scrap return, and scrap sale outbound.
**Architecture:** Reuse the current warehouse ledger architecture: one new warehouse type `SCRAP`, one tab in `InventoryLedgerView.vue`, existing `wh_stock_lot` / `wh_stock_balance` / `wh_inventory_txn` tables, existing stocktake flow, and existing opening inventory import/export. Scrap inventory is weight-only and can reference existing material/product items rather than introducing a new scrap catalog. **Architecture:** Reuse the current warehouse ledger architecture: one new warehouse type `SCRAP`, one tab in `InventoryLedgerView.vue`, existing `wh_stock_lot` / `wh_stock_balance` / `wh_inventory_txn` tables, existing stocktake flow, and existing opening inventory import/export. Scrap inventory is weight-only and can reference existing material/product items rather than introducing a new scrap catalog.
@@ -1324,7 +1324,7 @@ npm run dev
``` ```
Expected manual checks: Expected manual checks:
- 嘉恒仓库 shows five warehouse tabs: 原材料库、半成品库、成品库、辅料库、废料库. - 百华仓库 shows five warehouse tabs: 原材料库、半成品库、成品库、辅料库、废料库.
- 废料库 tab uses the same card style and selected style as the other four warehouses. - 废料库 tab uses the same card style and selected style as the other four warehouses.
- 废料库 list is weight-only and does not show quantity columns. - 废料库 list is weight-only and does not show quantity columns.
- 入库 bar shows 生产废料入库、期初入库、委外废料入库. - 入库 bar shows 生产废料入库、期初入库、委外废料入库.
@@ -2,7 +2,7 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make `嘉恒仓库 -> 成品库 -> 销售出库` the only place that creates delivery outbound records, while `发货与售后 -> 发货管理` becomes a read-only delivery ledger with traceability. **Goal:** Make `百华仓库 -> 成品库 -> 销售出库` the only place that creates delivery outbound records, while `发货与售后 -> 发货管理` becomes a read-only delivery ledger with traceability.
**Architecture:** Keep the existing `/sales/deliveries` API as the single write path for delivery creation. Extend the warehouse sales-out drawer to support both sales-order delivery and direct customer delivery, and remove the create path from the delivery management page. Preserve traceability through existing `DeliveryItem.lot_id -> StockLot.source_material_lot_id/source_material_sub_batch_no/source_material_summary` fields. **Architecture:** Keep the existing `/sales/deliveries` API as the single write path for delivery creation. Extend the warehouse sales-out drawer to support both sales-order delivery and direct customer delivery, and remove the create path from the delivery management page. Preserve traceability through existing `DeliveryItem.lot_id -> StockLot.source_material_lot_id/source_material_sub_batch_no/source_material_summary` fields.
@@ -872,7 +872,7 @@ Expected:
Open the ERP frontend and verify: Open the ERP frontend and verify:
```text ```text
嘉恒仓库 -> 成品库 -> 销售出库 百华仓库 -> 成品库 -> 销售出库
``` ```
Expected UI behavior: Expected UI behavior:
@@ -2,7 +2,7 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a warehouse-level “流水” entry in 嘉恒仓库 so each of the six warehouses can view paginated, searchable, sortable inbound/outbound inventory transactions. **Goal:** Add a warehouse-level “流水” entry in 百华仓库 so each of the six warehouses can view paginated, searchable, sortable inbound/outbound inventory transactions.
**Architecture:** Keep `wh_inventory_txn` as the single source of truth. Add a new backend pagination endpoint for warehouse-level transaction ledger queries, then add a right-side drawer in `InventoryLedgerView.vue` that follows the active warehouse tab and displays summary cards, filters, and transaction rows. Keep the existing material-level stock detail drawer unchanged. **Architecture:** Keep `wh_inventory_txn` as the single source of truth. Add a new backend pagination endpoint for warehouse-level transaction ledger queries, then add a right-side drawer in `InventoryLedgerView.vue` that follows the active warehouse tab and displays summary cards, filters, and transaction rows. Keep the existing material-level stock detail drawer unchanged.
@@ -12,7 +12,7 @@
## Confirmed Requirements ## Confirmed Requirements
- Add a “流水” icon/text button in 嘉恒仓库, positioned on the right side of the existing inbound/outbound operation bar. - Add a “流水” icon/text button in 百华仓库, positioned on the right side of the existing inbound/outbound operation bar.
- The button follows the active warehouse tab: 原材料库、半成品库、成品库、辅料库、废料库、退货库. - The button follows the active warehouse tab: 原材料库、半成品库、成品库、辅料库、废料库、退货库.
- Clicking opens a right-side drawer titled `{当前仓库名} · 出入库流水`. - Clicking opens a right-side drawer titled `{当前仓库名} · 出入库流水`.
- The drawer shows summary cards, filters, and a paginated table. - The drawer shows summary cards, filters, and a paginated table.
@@ -774,7 +774,7 @@ In the same Vue file, before `<StocktakeDialog`, add:
```vue ```vue
<FormDrawer <FormDrawer
:open="warehouseLedgerDrawerOpen" :open="warehouseLedgerDrawerOpen"
eyebrow="嘉恒仓库 · 库级流水" eyebrow="百华仓库 · 库级流水"
:title="`${activeInventoryLabel} · 出入库流水`" :title="`${activeInventoryLabel} · 出入库流水`"
tag="库存流水" tag="库存流水"
wide wide
@@ -1214,7 +1214,7 @@ http://localhost:5173/inventory-ledger
Manual expected results after login: Manual expected results after login:
- 嘉恒仓库 page renders. - 百华仓库 page renders.
- Select 原材料库, click `流水`, drawer title is `原材料库 · 出入库流水`. - Select 原材料库, click `流水`, drawer title is `原材料库 · 出入库流水`.
- Switch 成品库 while the drawer is open, drawer reloads and title becomes `成品库 · 出入库流水`. - Switch 成品库 while the drawer is open, drawer reloads and title becomes `成品库 · 出入库流水`.
- Search box can filter by a known material or batch keyword. - Search box can filter by a known material or batch keyword.
@@ -704,7 +704,7 @@ Expected: all pass.
Open the ERP in the browser and check: Open the ERP in the browser and check:
```text ```text
1. 嘉恒仓库 -> 成品库 -> 产品明细抽屉 1. 百华仓库 -> 成品库 -> 产品明细抽屉
2. 库存批次明细表 2. 库存批次明细表
3. 库存流水表 3. 库存流水表
4. 采购订单详情抽屉 4. 采购订单详情抽屉
@@ -104,4 +104,4 @@ Expected: all backend tests pass.
Run frontend dev server and open `http://127.0.0.1:5173/` in the in-app browser. Run frontend dev server and open `http://127.0.0.1:5173/` in the in-app browser.
Expected: page loads with title `嘉恒智能五金 ERP` and no console errors. Expected: page loads with title `百华智能五金 ERP` and no console errors.
@@ -2,7 +2,7 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把嘉恒仓库的半成品库从“通用物料/产品库存”改为“产品 + 已完成工序”的半成品库存模型。 **Goal:** 把百华仓库的半成品库从“通用物料/产品库存”改为“产品 + 已完成工序”的半成品库存模型。
**Architecture:** 后端继续使用 `md_item.id` 表示产品,但用 `StockLot.source_line_id` 和 `InventoryTxn.source_line_id` 记录产品需规清单中的 `route_operation_id`,从而区分同一产品不同工序阶段的半成品。前端半成品入库从 `/master-data/process-routes` 展开产品工序选项,半成品出库和库存明细按 `item_id + source_line_id` 过滤与展示,避免跨工序混扣库存。 **Architecture:** 后端继续使用 `md_item.id` 表示产品,但用 `StockLot.source_line_id` 和 `InventoryTxn.source_line_id` 记录产品需规清单中的 `route_operation_id`,从而区分同一产品不同工序阶段的半成品。前端半成品入库从 `/master-data/process-routes` 展开产品工序选项,半成品出库和库存明细按 `item_id + source_line_id` 过滤与展示,避免跨工序混扣库存。
@@ -575,7 +575,7 @@ Expected: PASS.
- [ ] **Step 4: Manual UI sanity check if local server is already running** - [ ] **Step 4: Manual UI sanity check if local server is already running**
Open the inventory page, select `嘉恒仓库 -> 半成品库` and verify: Open the inventory page, select `百华仓库 -> 半成品库` and verify:
```text ```text
半成品入库字段显示“产品工序” 半成品入库字段显示“产品工序”
@@ -6,7 +6,7 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add `嘉恒仓库 -> 成品库 -> 返工入库`, binding finished-goods rework inbound to existing `退货库 -> 返工出库` disposition records, with raw-material stock-lot traceability and quantity-based finished inventory updates. **Goal:** Add `百华仓库 -> 成品库 -> 返工入库`, binding finished-goods rework inbound to existing `退货库 -> 返工出库` disposition records, with raw-material stock-lot traceability and quantity-based finished inventory updates.
**Architecture:** Reuse the existing generic warehouse inbound route and drawer. Add a new inbound business type `REWORK_IN` that is allowed only in `FINISHED` warehouses, validates `RETURN_DISPOSITION` source documents, derives remaining rework-return quantity from existing finished inbound stock lots, and records raw-material source stock lot trace fields. The frontend adds a finished-warehouse operation, loads return dispositions, filters pending/partial rework dispositions by product, and submits `source_doc_type/source_doc_id/source_line_id` through the generic inbound flow. **Architecture:** Reuse the existing generic warehouse inbound route and drawer. Add a new inbound business type `REWORK_IN` that is allowed only in `FINISHED` warehouses, validates `RETURN_DISPOSITION` source documents, derives remaining rework-return quantity from existing finished inbound stock lots, and records raw-material source stock lot trace fields. The frontend adds a finished-warehouse operation, loads return dispositions, filters pending/partial rework dispositions by product, and submits `source_doc_type/source_doc_id/source_line_id` through the generic inbound flow.
@@ -977,7 +977,7 @@ http://127.0.0.1:5173/
Manual checks: Manual checks:
- Login if needed. - Login if needed.
- Navigate to `嘉恒仓库`. - Navigate to `百华仓库`.
- Select `成品库`. - Select `成品库`.
- Confirm the inbound operation row shows `返工入库`. - Confirm the inbound operation row shows `返工入库`.
- Click `返工入库`. - Click `返工入库`.
@@ -319,7 +319,7 @@ nano /opt/ForgeFlow-ERP/backend/.env
推荐内容: 推荐内容:
```env ```env
APP_NAME=Jiaheng Hardware ERP API APP_NAME=Baihua Hardware ERP API
APP_ENV=staging-dev APP_ENV=staging-dev
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8000 APP_PORT=8000
@@ -802,7 +802,7 @@ API 请求地址是 http://<腾讯云公网IP>:8000/api/...
验证: 验证:
```text ```text
嘉恒仓库六大库能打开,出入库弹窗能打开 百华仓库六大库能打开,出入库弹窗能打开
``` ```
- [ ] **Step 4: 生产执行页面** - [ ] **Step 4: 生产执行页面**
@@ -1497,7 +1497,7 @@ ERP 工单台账中返工工单累计良品 8、废品 2。
Manual flow: Manual flow:
```text ```text
嘉恒仓库 -> 成品库 -> 返工入库 百华仓库 -> 成品库 -> 返工入库
-> 选择返工工单 JH0001 对应工单 -> 选择返工工单 JH0001 对应工单
-> 入库 8 件 -> 入库 8 件
``` ```
@@ -1515,7 +1515,7 @@ Expected:
Manual flow: Manual flow:
```text ```text
嘉恒仓库 -> 废料库 -> 返工废料入库 百华仓库 -> 废料库 -> 返工废料入库
-> 选择同一返工工单 -> 选择同一返工工单
-> 入库 2 件对应废料重量 -> 入库 2 件对应废料重量
-> 勾选结单 -> 勾选结单
@@ -2,7 +2,7 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为嘉恒仓库 6 大库的入库与出库操作区分别增加“特殊入库”“特殊出库”,允许在强制填写说明后,精确到库存明细行进行受控库存调整,并把说明写入所有变更记录。 **Goal:** 为百华仓库 6 大库的入库与出库操作区分别增加“特殊入库”“特殊出库”,允许在强制填写说明后,精确到库存明细行进行受控库存调整,并把说明写入所有变更记录。
**Architecture:** 新增独立的“特殊调整”后端服务与审计主/明细表,库存变动仍复用现有 `wh_stock_lot`、`wh_stock_balance`、`wh_inventory_txn` 三层账本。前端在现有 `InventoryLedgerView.vue` 的仓库出入库操作矩阵中增加特殊操作入口,打开专用抽屉完成说明填写、明细选择、调整前后预览和提交。 **Architecture:** 新增独立的“特殊调整”后端服务与审计主/明细表,库存变动仍复用现有 `wh_stock_lot`、`wh_stock_balance`、`wh_inventory_txn` 三层账本。前端在现有 `InventoryLedgerView.vue` 的仓库出入库操作矩阵中增加特殊操作入口,打开专用抽屉完成说明填写、明细选择、调整前后预览和提交。
@@ -1743,8 +1743,8 @@ http://127.0.0.1:5173/
Manual checks: Manual checks:
- 嘉恒仓库 6 大库的入库操作区都有“特殊入库”。 - 百华仓库 6 大库的入库操作区都有“特殊入库”。
- 嘉恒仓库 6 大库的出库操作区都有“特殊出库”。 - 百华仓库 6 大库的出库操作区都有“特殊出库”。
- 未填写说明时,不能添加/提交特殊调整明细。 - 未填写说明时,不能添加/提交特殊调整明细。
- 原材料库特殊出库只能填写重量,提交后对应库存批次重量减少。 - 原材料库特殊出库只能填写重量,提交后对应库存批次重量减少。
- 辅料库特殊入库只按数量增加,不写入重量。 - 辅料库特殊入库只按数量增加,不写入重量。
@@ -2,7 +2,7 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement原材料库存批次号全库递增、生产出库单批次合并工单、小程序按材料库存批次号报工、以及嘉恒仓库“生产工单入库”统一闭环成品/余料/废料。 **Goal:** Implement原材料库存批次号全库递增、生产出库单批次合并工单、小程序按材料库存批次号报工、以及百华仓库“生产工单入库”统一闭环成品/余料/废料。
**Architecture:** Add a small system-config service for the raw lot prefix, keep ERP production work orders as the internal container, and make raw stock lot IDs the external production-facing identifier. Production outbound becomes single-lot and merges into a deterministic daily work order; production work-order inbound becomes one transactional backend API that posts finished goods, returns surplus to the original raw lot, posts scrap, and optionally settles the work order. **Architecture:** Add a small system-config service for the raw lot prefix, keep ERP production work orders as the internal container, and make raw stock lot IDs the external production-facing identifier. Production outbound becomes single-lot and merges into a deterministic daily work order; production work-order inbound becomes one transactional backend API that posts finished goods, returns surplus to the original raw lot, posts scrap, and optionally settles the work order.
@@ -1859,7 +1859,7 @@ Add a `FormDrawer` in `InventoryLedgerView.vue`:
```html ```html
<FormDrawer <FormDrawer
:open="productionWorkOrderInboundDrawerOpen" :open="productionWorkOrderInboundDrawerOpen"
eyebrow="嘉恒仓库 · 生产工单入库" eyebrow="百华仓库 · 生产工单入库"
title="生产工单入库" title="生产工单入库"
tag="工单闭环" tag="工单闭环"
wide wide
@@ -2144,12 +2144,12 @@ Smoke flow:
1. Open `http://127.0.0.1:5173`. 1. Open `http://127.0.0.1:5173`.
2. Go to 系统拓展 and verify 原材料库存批次号前缀 shows `YL`. 2. Go to 系统拓展 and verify 原材料库存批次号前缀 shows `YL`.
3. Go to 嘉恒仓库原材料库 and create a raw inbound test lot; verify lot number is `YL0001` or the next available `YL` sequence. 3. Go to 百华仓库原材料库 and create a raw inbound test lot; verify lot number is `YL0001` or the next available `YL` sequence.
4. Use 原材料库 · 生产出库, select one material stock lot and one product, submit. 4. Use 原材料库 · 生产出库, select one material stock lot and one product, submit.
5. Verify 工单台账 has work order `YYYYMMDD-YLxxxx-产品名称`. 5. Verify 工单台账 has work order `YYYYMMDD-YLxxxx-产品名称`.
6. Submit another production outbound for the same day, same lot, same product. 6. Submit another production outbound for the same day, same lot, same product.
7. Verify it merges into the same work order and adds another issue line. 7. Verify it merges into the same work order and adds another issue line.
8. Open 嘉恒仓库 · 生产工单入库, select that work order, enter finished qty, verify surplus/scrap defaults are filled. 8. Open 百华仓库 · 生产工单入库, select that work order, enter finished qty, verify surplus/scrap defaults are filled.
9. Change surplus or scrap beyond 15%; verify deviation remark is required. 9. Change surplus or scrap beyond 15%; verify deviation remark is required.
10. Check “本次入库后结单”, submit, confirm second prompt, verify work order status becomes 已结单. 10. Check “本次入库后结单”, submit, confirm second prompt, verify work order status becomes 已结单.
@@ -42,11 +42,11 @@
- Modify `backend/app/api/routes/inventory.py` - Modify `backend/app/api/routes/inventory.py`
- Validate warehouse form personnel fields against `MENU_INVENTORY_LEDGER`. - Validate warehouse form personnel fields against `MENU_INVENTORY_LEDGER`.
- Modify `backend/app/api/routes/production.py` - Modify `backend/app/api/routes/production.py`
- Validate completion/production inbound receiver fields against `MENU_INVENTORY_LEDGER` when the UI flow belongs to 嘉恒仓库. - Validate completion/production inbound receiver fields against `MENU_INVENTORY_LEDGER` when the UI flow belongs to 百华仓库.
- Modify `backend/app/api/routes/sales.py` - Modify `backend/app/api/routes/sales.py`
- Validate delivery shipper fields against `MENU_DELIVERY_MANAGEMENT` for 发货概览 delivery endpoints. - Validate delivery shipper fields against `MENU_DELIVERY_MANAGEMENT` for 发货概览 delivery endpoints.
- Modify `backend/app/api/routes/returns.py` - Modify `backend/app/api/routes/returns.py`
- Validate return handler fields against the page permission used by that route. Current return-management route redirects to 嘉恒仓库, so warehouse return flows use `MENU_INVENTORY_LEDGER`. - Validate return handler fields against the page permission used by that route. Current return-management route redirects to 百华仓库, so warehouse return flows use `MENU_INVENTORY_LEDGER`.
- Modify `backend/app/api/routes/equipment.py` - Modify `backend/app/api/routes/equipment.py`
- Validate maintenance owner fields against `MENU_EQUIPMENT_MANAGEMENT`. - Validate maintenance owner fields against `MENU_EQUIPMENT_MANAGEMENT`.
@@ -1610,7 +1610,7 @@ Start or use the existing local app, log in as an admin, and verify:
3. 销售订单新增:能看到“销售人员”,选项只包含有客户名录/销售订单权限的人。 3. 销售订单新增:能看到“销售人员”,选项只包含有客户名录/销售订单权限的人。
4. 采购订单新增:采购员只包含有采购订单权限的人。 4. 采购订单新增:采购员只包含有采购订单权限的人。
5. 到货入库:接收人只包含有到货入库权限的人。 5. 到货入库:接收人只包含有到货入库权限的人。
6. 嘉恒仓库内接收人/经办人/发货人只包含有嘉恒仓库权限的人。 6. 百华仓库内接收人/经办人/发货人只包含有百华仓库权限的人。
7. 设备管理负责人只包含有设备管理权限的人。 7. 设备管理负责人只包含有设备管理权限的人。
8. 尝试用接口提交一个无权限人员 ID,后端返回中文错误,不保存单据。 8. 尝试用接口提交一个无权限人员 ID,后端返回中文错误,不保存单据。
``` ```
@@ -187,7 +187,7 @@ class SystemPermissionManagementTest(unittest.TestCase):
now = datetime(2026, 6, 8, 9, 0, 0) now = datetime(2026, 6, 8, 9, 0, 0)
self.company = Department( self.company = Department(
dept_code="JH", dept_code="JH",
dept_name="嘉恒总公司", dept_name="百华总公司",
parent_id=None, parent_id=None,
org_node_type="COMPANY", org_node_type="COMPANY",
dept_type="ADMIN", dept_type="ADMIN",
@@ -331,7 +331,7 @@ SET role_name = '采购专员', remark = '供应商、采购订单、到货和
WHERE role_code = 'PURCHASER'; WHERE role_code = 'PURCHASER';
UPDATE sys_role UPDATE sys_role
SET role_name = '仓库管理人员', remark = '到货、质检、嘉恒仓库、发货台账相关作业' SET role_name = '仓库管理人员', remark = '到货、质检、百华仓库、发货台账相关作业'
WHERE role_code = 'WAREHOUSE'; WHERE role_code = 'WAREHOUSE';
UPDATE sys_role UPDATE sys_role
@@ -498,7 +498,7 @@ Append these tests to `SystemPermissionManagementTest` in `backend/tests/test_sy
tree = build_org_tree(self.db) tree = build_org_tree(self.db)
self.assertEqual(len(tree), 1) self.assertEqual(len(tree), 1)
self.assertEqual(tree[0].node_label, "嘉恒总公司") self.assertEqual(tree[0].node_label, "百华总公司")
purchase = tree[0].children[0] purchase = tree[0].children[0]
self.assertEqual(purchase.node_label, "采购部") self.assertEqual(purchase.node_label, "采购部")
self.assertEqual(purchase.manager_employee_id, self.manager_employee_id) self.assertEqual(purchase.manager_employee_id, self.manager_employee_id)
@@ -792,7 +792,7 @@ MENU_PERMISSION_TREE = [
("MENU_PURCHASE_ORDER", "采购订单", []), ("MENU_PURCHASE_ORDER", "采购订单", []),
("MENU_PURCHASE_RECEIPT", "到货入库", []), ("MENU_PURCHASE_RECEIPT", "到货入库", []),
("MENU_QUALITY_INSPECTION", "质量检验", []), ("MENU_QUALITY_INSPECTION", "质量检验", []),
("MENU_INVENTORY_LEDGER", "嘉恒仓库", []), ("MENU_INVENTORY_LEDGER", "百华仓库", []),
], ],
), ),
( (
@@ -2,7 +2,7 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让嘉恒仓库 6 大库所有出入库填单都采用正式纸质单据风格,保存后生成 PDF 留档,并支持在各库流水中预览、下载、重新生成 PDF,以及按当前筛选时间范围导出流水 Excel 清单。 **Goal:** 让百华仓库 6 大库所有出入库填单都采用正式纸质单据风格,保存后生成 PDF 留档,并支持在各库流水中预览、下载、重新生成 PDF,以及按当前筛选时间范围导出流水 Excel 清单。
**Architecture:** 采用“双轨归档”:生产主链路继续使用 `生产领料出库单`、`生产入库结算单`,其它仓库出入库统一使用新增 `仓库出入库单`。后端以库存流水 `wh_inventory_txn` 为核心收集归档上下文和导出 Excel;前端抽取通用纸质单据底座,在仓库流水抽屉复用现有 `DocumentArchiveActions`。 **Architecture:** 采用“双轨归档”:生产主链路继续使用 `生产领料出库单`、`生产入库结算单`,其它仓库出入库统一使用新增 `仓库出入库单`。后端以库存流水 `wh_inventory_txn` 为核心收集归档上下文和导出 Excel;前端抽取通用纸质单据底座,在仓库流水抽屉复用现有 `DocumentArchiveActions`。
@@ -1133,7 +1133,7 @@ Create `frontend/src/components/documentForms/WarehouseDocumentFormShell.vue`:
<form class="warehouse-document-form" @submit.prevent="$emit('submit')"> <form class="warehouse-document-form" @submit.prevent="$emit('submit')">
<header class="warehouse-document-head"> <header class="warehouse-document-head">
<div> <div>
<span class="warehouse-document-kicker">嘉恒仓库</span> <span class="warehouse-document-kicker">百华仓库</span>
<h2>{{ title }}</h2> <h2>{{ title }}</h2>
</div> </div>
<dl> <dl>
@@ -431,8 +431,8 @@ class DocumentArchiveServiceTest(unittest.TestCase):
self.customer = Customer( self.customer = Customer(
id=1, id=1,
customer_code="CUS001", customer_code="CUS001",
customer_name="嘉恒客户", customer_name="百华客户",
short_name="嘉恒", short_name="百华",
status="ACTIVE", status="ACTIVE",
created_at=now, created_at=now,
updated_at=now, updated_at=now,
@@ -1789,7 +1789,7 @@ const props = defineProps({
}, },
companyName: { companyName: {
type: String, type: String,
default: "嘉恒仓库 ERP" default: "百华仓库 ERP"
}, },
tone: { tone: {
type: String, type: String,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
# Warehouse Paper Form Unification Phase 1 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make warehouse operation documents use one cohesive paper-form visual language, starting with the production-ledger controlled inbound forms that currently mix modern web controls into the paper sheet.
**Architecture:** Keep all business state, computed values, validation, and submit payloads in `InventoryLedgerView.vue` unchanged. Introduce paper-specific wrappers/classes around the existing controls so selectors, selected-lot tables, live summaries, remarks, and settle controls visually belong to the same document grid as the approved “客料入库单”. Avoid global restyling of `.form-field`, `.double-field`, `.table-wrap`, or shared controls used outside paper documents.
**Tech Stack:** Vue 3 SFC, Vite, project CSS in `frontend/src/styles/main.css`, Node built-in test runner.
---
## File Structure
- Modify `frontend/src/views/InventoryLedgerView.vue`: convert the generic warehouse drawer’s production-ledger controlled inbound branch to paper-specific markup while preserving existing bindings and branch conditions.
- Modify `frontend/src/styles/main.css`: add reusable paper document classes for control grids, inline summary rows, source-lot selector/table panels, and settle confirmation rows.
- Modify `frontend/src/views/InventoryLedgerView.test.js`: add regression coverage that production surplus/scrap inbound forms use the paper-specific classes and that the old naked modern classes are not the only visual contract.
## Design Direction
- Aesthetic: “工业纸质单据”.
- Purpose: Support fast warehouse entry while making each operation feel like a formal business document, not a demo web form.
- Differentiation anchor: Thick ledger grid lines, label/value cells, stamped meta strips, and paper-native control panels.
- DFII: 13. Impact 4, fit 5, feasibility 5, performance 4, consistency risk 5. This is strong because it reuses the approved客料入库 visual system, but must be scoped to avoid touching unrelated app forms.
## Task 1: Lock The Regression Tests
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.test.js`
- Read: `frontend/src/views/InventoryLedgerView.vue`
- Read: `frontend/src/styles/main.css`
- [ ] **Step 1: Add paper-form assertions for production-ledger controlled inbound**
Add a test that checks the generic drawer contains production-ledger paper classes and keeps the old branch visually wrapped:
```js
it("wraps production-ledger controlled inbound forms in the shared paper control language", () => {
assert.match(source, /warehouse-paper-control-grid/);
assert.match(source, /warehouse-paper-source-lot-panel/);
assert.match(source, /warehouse-paper-source-lot-table/);
assert.match(source, /warehouse-paper-inline-summary/);
assert.match(source, /warehouse-paper-settle-panel/);
assert.match(source, /warehouse-paper-settle-toggle/);
assert.match(source, /warehouse-paper-settle-grid/);
assert.match(source, /warehouse-paper-settle-remark/);
assert.match(mainCss, /\.warehouse-document-section-grid > \.warehouse-paper-control-grid\s*\{[\s\S]*grid-column: 1 \/ -1;/);
assert.match(mainCss, /\.warehouse-paper-control-grid\s*\{[\s\S]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\);/);
assert.match(mainCss, /\.warehouse-paper-source-lot-table/);
assert.match(mainCss, /\.warehouse-paper-settle-toggle/);
});
```
- [ ] **Step 2: Run the test and confirm it fails before implementation**
Run:
```bash
cd frontend
node --test src/views/InventoryLedgerView.test.js
```
Expected before implementation: failure mentioning one or more missing `warehouse-paper-*` classes.
## Task 2: Convert Production Controlled Inbound Markup
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- [ ] **Step 1: Replace the naked production work-order block**
Replace the `isProductionWorkOrderControlledInbound` `stack-form` block with paper-grid fields:
```vue
<div v-if="isProductionWorkOrderControlledInbound" class="warehouse-paper-control-grid warehouse-paper-control-grid-compact">
<label class="warehouse-paper-field warehouse-paper-field-wide">
<span>{{ isReworkScrapInbound ? "返工工单" : "生产台账" }}</span>
<select v-model.number="genericForm.work_order_id" required @change="applyGenericWorkOrder">
<option disabled value="">{{ isReworkScrapInbound ? "请选择返工工单" : "请选择生产台账" }}</option>
<option v-for="workOrder in genericWorkOrderOptions" :key="workOrder.work_order_id" :value="workOrder.work_order_id">
{{ formatWorkOrderLedgerOption(workOrder, activeOperation?.bizType) }}
</option>
</select>
</label>
<template v-if="selectedGenericWorkOrder">
<article class="warehouse-paper-summary-card warehouse-paper-summary-card-wide">
<span>产品</span>
<strong>{{ selectedGenericWorkOrder.product_code }} · {{ selectedGenericWorkOrder.product_name }}</strong>
</article>
<article class="warehouse-paper-summary-card">
<span>材料库存批次号</span>
<strong>{{ selectedGenericWorkOrder.source_lot_summary || selectedGenericWorkOrder.material_lot_no || "-" }}</strong>
</article>
<article v-if="isProductionScrapInbound" class="warehouse-paper-summary-card">
<span>库外未闭环重量</span>
<strong>{{ formatWeight(selectedGenericWorkOrder.outside_weight_kg || selectedGenericWorkOrder.limits?.max_scrap_inbound_weight_kg) }}</strong>
</article>
<article v-if="isProductionSurplusInbound" class="warehouse-paper-summary-card">
<span>库外未闭环重量</span>
<strong>{{ formatWeight(selectedGenericWorkOrder.outside_weight_kg || selectedGenericWorkOrder.limits?.max_surplus_inbound_weight_kg) }}</strong>
</article>
</template>
</div>
```
- [ ] **Step 2: Convert the core fields to a paper grid**
Replace the main `double-field` and `triple-field` groups in the generic drawer with `warehouse-paper-control-grid`, using the existing exact conditions and v-models. Keep field order: item, warehouse, location, measure basis, weight, quantity, unit cost.
- [ ] **Step 3: Wrap source-lot selector and tables in paper panels**
Keep `StockLotTagSelect` unchanged, but place it inside:
```vue
<div v-if="usesGenericSourceLotSelector" class="warehouse-paper-source-lot-panel">
<div class="warehouse-paper-source-lot-heading">
<span>来源库存批次号</span>
<strong>{{ genericSourceLotSelectPlaceholder }}</strong>
</div>
<StockLotTagSelect ... />
</div>
```
For selected lot tables, keep the same columns and inputs but add `warehouse-paper-source-lot-table` to the wrapping table container so CSS can remove the ordinary web-card feel.
- [ ] **Step 4: Convert summaries and settle controls**
Replace `inventory-live-summary` display with `warehouse-paper-inline-summary`, and replace `work-order-settle-panel` / `work-order-settle-toggle` with paper-specific classes. Keep `genericForm.settle_work_order`, deviation values, and `genericForm.settle_remark` unchanged.
## Task 3: Add Paper-Specific CSS
**Files:**
- Modify: `frontend/src/styles/main.css`
- [ ] **Step 1: Add grid spanning rules**
Add the new paper containers beside existing `.warehouse-paper-grid` rules:
```css
.warehouse-document-section-grid > .warehouse-paper-control-grid,
.warehouse-document-section-grid > .warehouse-paper-source-lot-panel,
.warehouse-document-section-grid > .warehouse-paper-source-lot-table,
.warehouse-document-section-grid > .warehouse-paper-inline-summary,
.warehouse-document-section-grid > .warehouse-paper-settle-panel {
grid-column: 1 / -1;
}
```
- [ ] **Step 2: Reuse approved paper field and summary cells**
Make `.warehouse-paper-control-grid` visually match `.warehouse-paper-grid`, with four columns on desktop, two on tablet, and one on mobile. Make `.warehouse-paper-control-grid-compact` allow summary cards to live in the same grid.
- [ ] **Step 3: Style selector/table/settle panels as document rows**
Style:
```css
.warehouse-paper-source-lot-panel
.warehouse-paper-source-lot-heading
.warehouse-paper-source-lot-table
.warehouse-paper-inline-summary
.warehouse-paper-settle-panel
.warehouse-paper-settle-toggle
.warehouse-paper-settle-grid
.warehouse-paper-settle-remark
```
They should use document borders, paper backgrounds, Songti/Simsun value text where appropriate, and no floating modern-card shadows.
## Task 4: Verify
**Files:**
- Test: `frontend/src/views/InventoryLedgerView.test.js`
- Build: `frontend/package.json`
- [ ] **Step 1: Run focused view tests**
Run:
```bash
cd frontend
node --test src/views/InventoryLedgerView.test.js
```
Expected: all tests pass.
- [ ] **Step 2: Run production build**
Run:
```bash
cd frontend
npm run build
```
Expected: Vite build succeeds. Existing chunk-size warning is acceptable if it remains the same non-blocking warning.
- [ ] **Step 3: Browser verification**
Open `http://localhost:5173/inventory-ledger`, then inspect:
- 原材料库 · 客料入库 remains unchanged and approved.
- 原材料库 · 生产余料入库 uses the same paper-grid visual language.
- The source-lot multi-select, selected-lot table, remark field, and “该批材料余料结单” no longer look like separate modern web cards pasted onto paper.
- Existing submit button and drawer behavior still work.
## Self-Review
- Spec coverage: covers the user’s requested recommended approach by converting the most visible mixed-UI warehouse document first and creating reusable paper classes for subsequent warehouse forms.
- Placeholder scan: no TBD/TODO/implement-later placeholders.
- Type consistency: class names in tests, markup, and CSS use the same `warehouse-paper-*` prefix.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,231 @@
# 系统初始化执行手册
本手册用于在后端服务器上初始化 ForgeFlow ERP 客户数据库。请注意:重置模式会清理目标数据库中的业务数据,正式执行前必须再次确认目标库、备份路径和是否清理实体文件。
## 适用场景
全新初始化模式 `fresh` 适用于全新客户数据库或空数据库结构。它会创建数据库、创建 ERP 与小程序相关全部表,并写入最小系统骨架。
重置初始化模式 `reset` 适用于已经部署过、但需要正式启用前清理测试数据的数据库。它会先执行 MySQL 备份,再清空业务数据、主数据、人员账号、小程序数据,并重建最小系统骨架。
默认不会删除 PDF、照片等实体文件。只有显式增加 `--delete-files` 时,才会清理系统管理范围内的文件目录内容。
## 执行前检查
执行前先进入后端目录并激活虚拟环境:
```bash
cd /home/souplearn/ERP/ForgeFlow-ERP/backend
source .venv/bin/activate
```
确认 `.env` 中数据库配置指向正确目标:
```bash
grep -E "MYSQL_HOST|MYSQL_PORT|MYSQL_DATABASE|MYSQL_USER" .env
```
确认后端能连接数据库:
```bash
curl -s http://127.0.0.1:8000/api/system/health | python3 -m json.tool
```
## 全新数据库初始化
新客户全新部署时使用全新初始化模式 `fresh`:
```bash
python scripts/system_initialize.py fresh \
--company-name 百华 \
--admin-name 超级管理员 \
--admin-phone 13800000000 \
--admin-password '正式密码' \
--smart-operation-report on
```
执行成功后,命令行会输出:
```text
mode=fresh
database=<数据库地址>:<端口>/<数据库名>
backup_path=
summary_path=
admin_username=13800000000
```
## 已有测试库重置
已有测试数据、需要正式启用前清理时使用重置初始化模式 `reset`。该模式必须显式传入确认参数 `--confirm-reset`:
```bash
python scripts/system_initialize.py reset \
--company-name 百华 \
--admin-name 超级管理员 \
--admin-phone 13800000000 \
--admin-password '正式密码' \
--smart-operation-report on \
--confirm-reset
```
执行成功后,命令行会输出:
```text
mode=reset
database=<数据库地址>:<端口>/<数据库名>
backup_path=/path/to/system_init_backup_<timestamp>.sql
summary_path=/path/to/system_init_summary_<timestamp>.json
admin_username=13800000000
```
备份和执行摘要默认写入:
```text
outputs/cleanup_backups/
```
## 可选文件清理
默认重置初始化只清数据库记录,不删除实体文件。
如确实需要同时清理系统托管的历史文件,才增加 `--delete-files`:
```bash
python scripts/system_initialize.py reset \
--company-name 百华 \
--admin-name 超级管理员 \
--admin-phone 13800000000 \
--admin-password '正式密码' \
--smart-operation-report on \
--confirm-reset \
--delete-files
```
`--delete-files` 只会删除以下目录中的子文件和子目录,不会删除目录本身:
```text
outputs/document_archives
outputs/document_archive_batches
backend/uploads/logistics
```
## 初始化后验证
先验证 API 健康状态:
```bash
curl -s http://127.0.0.1:8000/api/system/health | python3 -m json.tool
```
再验证核心数据状态。为了避免密码出现在命令行参数中,使用临时 MySQL 配置文件:
```bash
MYSQL_DEFAULTS_FILE="$(mktemp)"
chmod 600 "$MYSQL_DEFAULTS_FILE"
cat > "$MYSQL_DEFAULTS_FILE" <<EOF
[client]
host=$MYSQL_HOST
port=$MYSQL_PORT
user=$MYSQL_USER
password=$MYSQL_PASSWORD
default-character-set=utf8mb4
EOF
mysql --defaults-extra-file="$MYSQL_DEFAULTS_FILE" "$MYSQL_DATABASE" -e "
SELECT COUNT(*) AS customer_count FROM md_customer;
SELECT COUNT(*) AS supplier_count FROM md_supplier;
SELECT COUNT(*) AS stock_lot_count FROM wh_stock_lot;
SELECT COUNT(*) AS sales_order_count FROM so_sales_order;
SELECT COUNT(*) AS admin_count FROM sys_user WHERE username='13800000000' AND is_super_admin=1;
"
rm -f "$MYSQL_DEFAULTS_FILE"
```
预期结果:
```text
customer_count = 0
supplier_count = 0
stock_lot_count = 0
sales_order_count = 0
admin_count = 1
```
然后用初始化账号登录 ERP:
```text
账号:初始化时填写的管理员手机号
密码:初始化时填写的管理员密码
```
## 执行摘要检查
重置初始化执行后检查 JSON 格式的执行摘要:
```bash
python3 -m json.tool outputs/cleanup_backups/system_init_summary_<timestamp>.json
```
重点确认:
```text
mode
database
backup_path
summary_path
counts_before
counts_after
seeded.admin_username
```
如果使用了 `--delete-files`,还应看到:
```text
seeded.deleted_file_count
seeded.deleted_dir_count
```
## 回滚方式
如果重置初始化后发现目标数据库选错,先停止相关服务,避免继续写入:
```bash
sudo systemctl stop forgeflow-backend
```
然后使用本次生成的备份 SQL 恢复:
```bash
MYSQL_DEFAULTS_FILE="$(mktemp)"
chmod 600 "$MYSQL_DEFAULTS_FILE"
cat > "$MYSQL_DEFAULTS_FILE" <<EOF
[client]
host=$MYSQL_HOST
port=$MYSQL_PORT
user=$MYSQL_USER
password=$MYSQL_PASSWORD
default-character-set=utf8mb4
EOF
mysql --defaults-extra-file="$MYSQL_DEFAULTS_FILE" "$MYSQL_DATABASE" < outputs/cleanup_backups/system_init_backup_<timestamp>.sql
rm -f "$MYSQL_DEFAULTS_FILE"
```
恢复完成后再启动服务:
```bash
sudo systemctl start forgeflow-backend
```
## 安全提醒
正式环境执行重置初始化前必须确认:
1. `.env` 中的 `MYSQL_HOST`、`MYSQL_PORT`、`MYSQL_DATABASE` 是目标测试库或待初始化客户库。
2. 已经明确允许清理该库中的现有数据。
3. `outputs/cleanup_backups` 有足够磁盘空间保存备份。
4. 没有显式传入 `--delete-files` 时,不会删除 PDF、照片等实体文件。
5. 只有确认要清理实体文件时,才允许传入 `--delete-files`。
6. 执行后必须先保存备份 SQL,再做后续验收。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,493 @@
# 组织树与脑图操作能力一致 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让“组织树模式”和“脑图模式”只是展示形式不同,组织节点和人员节点的操作能力完全一致。
**Architecture:** 保留 `SystemPermissionView.vue` 作为唯一组织动作编排层,继续复用现有 `nodeActions(node)`、`runNodeAction(actionKey, node)`、各类 drawer 打开函数和删除/授权函数。`OrgPermissionTree.vue` 只负责展示组织树、选中节点、发出节点菜单事件,不新增第二套业务动作逻辑。
**Tech Stack:** Vue 3 `<script setup>`、现有静态 Node test、Vite、现有 `OrgPermissionTree.vue` / `SystemPermissionView.vue` 组件体系。
---
## File Structure
- Modify: `frontend/src/components/OrgPermissionTree.vue`
- 增加右侧“节点操作”入口。
- 继续支持组织树节点右键菜单。
- 通过 `node-contextmenu` emit 当前节点和菜单坐标,不直接执行业务动作。
- 调整右侧 header 按钮布局,让“节点操作”和“新增人员”共存。
- Modify: `frontend/src/components/OrgPermissionTree.test.js`
- 锁定组织树存在“节点操作”按钮。
- 锁定组织树节点右键仍然 emit `open-menu`。
- 锁定组织树不新增第二套动作函数,只 emit `node-contextmenu`。
- Modify: `frontend/src/views/SystemPermissionView.vue`
- 复用现有 `openContextMenu(eventPayload)` 和 `runNodeAction(actionKey, node)`。
- 必要时给当前节点操作入口提供更稳定的菜单坐标处理。
- 不复制 `nodeActions` 到子组件。
- Modify: `frontend/src/views/SystemPermissionView.test.js`
- 锁定组织树仍绑定 `@node-contextmenu="openContextMenu"`。
- 锁定组织树的“节点操作”最终仍进入同一套 `contextMenuActions` / `runNodeAction`。
- Modify: `frontend/src/styles/main.css` if needed
- 如果组件 scoped 样式不够覆盖全局表格/抽屉样式,补少量全局样式。
- 不引入新的视觉体系。
---
## Task 1: 锁定组织树动作入口契约
**Files:**
- Modify: `frontend/src/components/OrgPermissionTree.test.js`
- [ ] **Step 1: 写失败测试,要求组织树有显式节点操作入口**
在 `OrgPermissionTree.test.js` 末尾追加:
```js
describe("OrgPermissionTree node action parity", () => {
it("exposes a visible current-node action entry that emits the shared node context menu event", () => {
assert.match(source, /节点操作/);
assert.match(source, /@click="openSelectedNodeMenu"/);
assert.match(source, /function openSelectedNodeMenu\(event\)/);
assert.match(source, /emit\("node-contextmenu"/);
assert.doesNotMatch(source, /function runNodeAction\(/);
assert.doesNotMatch(source, /function nodeActions\(/);
});
});
```
- [ ] **Step 2: 运行测试确认失败**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node src/components/OrgPermissionTree.test.js
```
Expected:
```text
not ok ... exposes a visible current-node action entry ...
```
失败原因应是当前源码没有 `节点操作`、`openSelectedNodeMenu`。
- [ ] **Step 3: 不修改生产代码,先提交测试是不需要的**
此任务只建立红灯,不单独提交。
---
## Task 2: 在组织树右侧 header 增加“节点操作”入口
**Files:**
- Modify: `frontend/src/components/OrgPermissionTree.vue`
- [ ] **Step 1: 修改右侧 header 按钮区**
将当前右侧 header 中的单个“新增人员”按钮:
```vue
<button
class="primary-button"
type="button"
:disabled="!canAddEmployee"
@click="addEmployee"
>
新增人员
</button>
```
替换为:
```vue
<div class="employee-head-actions">
<button
class="ghost-button node-action-button"
type="button"
:disabled="!selectedNode"
@click="openSelectedNodeMenu"
>
节点操作
</button>
<button
class="primary-button"
type="button"
:disabled="!canAddEmployee"
@click="addEmployee"
>
新增人员
</button>
</div>
```
- [ ] **Step 2: 添加 `openSelectedNodeMenu` 函数**
在 `openNodeMenu(payload)` 下方添加:
```js
function openSelectedNodeMenu(event) {
if (!selectedNode.value) {
return;
}
const rect = event.currentTarget?.getBoundingClientRect?.();
emit("node-contextmenu", {
node: selectedNode.value,
x: rect ? rect.left : event.clientX,
y: rect ? rect.bottom + 6 : event.clientY
});
}
```
说明:这里仍然只 emit `node-contextmenu`,由父组件现有 `openContextMenu` 接管菜单,不在子组件里实现动作。
- [ ] **Step 3: 添加 scoped 样式**
在 `.employee-head` 附近增加:
```css
.employee-head-actions {
display: inline-flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.node-action-button {
border: 1px solid rgba(148, 163, 184, 0.5);
background: #ffffff;
color: #334155;
}
.node-action-button:hover:not(:disabled) {
background: #eff6ff;
color: #1d4ed8;
}
```
- [ ] **Step 4: 运行组件测试确认 Task 1 变绿**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node src/components/OrgPermissionTree.test.js
```
Expected:
```text
# pass ...
```
---
## Task 3: 锁定父页面复用同一套节点菜单
**Files:**
- Modify: `frontend/src/views/SystemPermissionView.test.js`
- [ ] **Step 1: 写测试确认组织树和脑图共用 `openContextMenu`**
在 `SystemPermissionView.test.js` 末尾追加:
```js
describe("SystemPermissionView organization view action parity", () => {
it("routes both mind map and tree node actions through the same context menu action pipeline", () => {
assert.match(source, /<OrgMindMap[\s\S]*?@node-contextmenu="openContextMenu"/);
assert.match(source, /<OrgPermissionTree[\s\S]*?@node-contextmenu="openContextMenu"/);
assert.match(source, /const contextMenuActions = computed\(\(\) => \(contextMenu\.node \? nodeActions\(contextMenu\.node\) : \[\]\)\);/);
assert.match(source, /function runNodeAction\(actionKey, node\)/);
assert.match(source, /openOrgRenameDrawer\(node\)/);
assert.match(source, /openOrgManagerDrawer\(node\)/);
assert.match(source, /openOrgCreateDrawer\(node,\s*"DEPARTMENT"\)/);
assert.match(source, /openOrgEmployeeDrawer\(node\)/);
});
});
```
- [ ] **Step 2: 运行测试**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node src/views/SystemPermissionView.test.js
```
Expected:
```text
# pass ...
```
如果失败,说明当前源码实际函数名或字符串不同;不要新增第二套函数,先核对现有 `nodeActions` 和 `runNodeAction`,再调整测试为真实函数名。
---
## Task 4: 优化上下文菜单打开位置与组织树使用体验
**Files:**
- Modify: `frontend/src/views/SystemPermissionView.vue`
- [ ] **Step 1: 检查现有 `openContextMenu`**
确认当前函数类似:
```js
function openContextMenu(eventPayload) {
selectedNode.value = eventPayload.node;
contextMenu.visible = true;
contextMenu.x = eventPayload.x;
contextMenu.y = eventPayload.y;
contextMenu.node = eventPayload.node;
}
```
- [ ] **Step 2: 改为带视口保护的位置计算**
将 `openContextMenu` 替换为:
```js
function openContextMenu(eventPayload) {
selectedNode.value = eventPayload.node;
const menuWidth = 220;
const menuHeight = 280;
const viewportWidth = window.innerWidth || 1280;
const viewportHeight = window.innerHeight || 720;
contextMenu.visible = true;
contextMenu.x = Math.max(12, Math.min(Number(eventPayload.x || 12), viewportWidth - menuWidth - 12));
contextMenu.y = Math.max(12, Math.min(Number(eventPayload.y || 12), viewportHeight - menuHeight - 12));
contextMenu.node = eventPayload.node;
}
```
说明:组织树右侧“节点操作”按钮会把菜单开在按钮下方;如果靠右或靠下,菜单不应跑出屏幕。
- [ ] **Step 3: 运行父页面测试**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node src/views/SystemPermissionView.test.js
```
Expected:
```text
# pass ...
```
---
## Task 5: 手动浏览器验收
**Files:**
- No code changes.
- [ ] **Step 1: 打开系统权限管理页面**
Use in-app Browser:
```text
http://localhost:5173/system-permissions
```
- [ ] **Step 2: 验收组织树模式下组织节点操作**
操作:
1. 切换到“组织树模式”。
2. 点击左侧“总公司”节点。
3. 点击右侧“节点操作”。
Expected:
```text
菜单出现,包含:重命名、添加该节点的主管、新增分公司节点。
```
- [ ] **Step 3: 验收分公司节点操作**
操作:
1. 点击“旭升车间”或“嘉恒车间”。
2. 点击“节点操作”。
Expected:
```text
菜单出现,包含:重命名、添加该节点的主管、新增部门节点、删除节点。
```
- [ ] **Step 4: 验收部门节点操作**
操作:
1. 点击“旭升加工部”或“嘉恒生产部”。
2. 点击“节点操作”。
Expected:
```text
菜单出现,包含:重命名、添加该节点的主管、新增小组节点、新增人员、删除节点。
```
- [ ] **Step 5: 验收右键入口**
操作:
1. 在组织树左侧任意组织节点上右键。
Expected:
```text
弹出的菜单内容和右侧“节点操作”按钮一致。
```
- [ ] **Step 6: 验收人员操作仍正常**
操作:
1. 在右侧人员列表点击“人员授权”。
2. 确认弹窗仍为卡片式角色授权界面。
3. 关闭弹窗。
Expected:
```text
人员授权弹窗打开正常,不受节点操作入口影响。
```
---
## Task 6: 回归验证与提交
**Files:**
- All modified files from earlier tasks.
- [ ] **Step 1: 运行关键测试**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node src/components/OrgPermissionTree.test.js
node src/views/SystemPermissionView.test.js
node src/utils/orgTreeEmployees.test.js
node src/App.test.js
```
Expected:
```text
# fail 0
```
- [ ] **Step 2: 运行构建**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```
Expected:
```text
✓ built
```
允许出现当前项目既有的大 chunk warning,不作为阻断。
- [ ] **Step 3: 检查 diff**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git diff --stat
git diff -- frontend/src/components/OrgPermissionTree.vue frontend/src/components/OrgPermissionTree.test.js frontend/src/views/SystemPermissionView.vue frontend/src/views/SystemPermissionView.test.js
```
Expected:
```text
只包含组织树动作入口、菜单位置保护、相关测试和少量样式。
```
- [ ] **Step 4: 等用户确认后提交 main**
Run only after user confirms:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git add frontend/src/components/OrgPermissionTree.vue frontend/src/components/OrgPermissionTree.test.js frontend/src/views/SystemPermissionView.vue frontend/src/views/SystemPermissionView.test.js
git commit -m "补齐组织树节点操作能力"
git push origin main
```
Expected:
```text
main -> main
```
- [ ] **Step 5: 同步 BH_DEV**
Run only after main push succeeds:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git switch BH_DEV
git pull --ff-only origin BH_DEV
git cherry-pick <main_commit_hash>
```
Expected:
```text
[BH_DEV <hash>] 补齐组织树节点操作能力
```
- [ ] **Step 6: 在 BH_DEV 运行同样验证并推送**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node src/components/OrgPermissionTree.test.js
node src/views/SystemPermissionView.test.js
node src/utils/orgTreeEmployees.test.js
node src/App.test.js
npm run build
cd ..
git push origin BH_DEV
```
Expected:
```text
BH_DEV -> BH_DEV
```
---
## Self-Review
**Spec coverage:**
已覆盖“组织树和脑图只是展示不同,功能一致”的核心要求。组织树右键入口和显式“节点操作”入口都会进入现有 `openContextMenu`,菜单项仍由 `nodeActions` 根据节点类型动态决定。
**Placeholder scan:**
未使用 TBD/TODO/后续补充。每个任务都有具体文件、代码片段、命令和预期结果。
**Type consistency:**
计划中使用的函数名与当前代码一致:`openContextMenu`、`nodeActions`、`runNodeAction`、`openOrgCreateDrawer`、`openOrgRenameDrawer`、`openOrgManagerDrawer`、`openOrgEmployeeDrawer`、`openAuthorizeDrawer`、`removeOrgEmployee`、`removeOrgNode`。
@@ -4,14 +4,14 @@
当前系统同时存在两个发货相关入口: 当前系统同时存在两个发货相关入口:
- `嘉恒仓库 -> 成品库 -> 销售出库` - `百华仓库 -> 成品库 -> 销售出库`
- `发货与售后 -> 发货管理` - `发货与售后 -> 发货管理`
两个入口都会影响发货单、成品库存和后续退货追溯,容易让用户不知道应该在哪里执行发货,也容易造成业务口径重复。 两个入口都会影响发货单、成品库存和后续退货追溯,容易让用户不知道应该在哪里执行发货,也容易造成业务口径重复。
## 目标 ## 目标
采用方案 A:将发货执行入口统一到 `嘉恒仓库 -> 成品库 -> 销售出库`,将 `发货与售后 -> 发货管理` 调整为发货台账/交付查询。 采用方案 A:将发货执行入口统一到 `百华仓库 -> 成品库 -> 销售出库`,将 `发货与售后 -> 发货管理` 调整为发货台账/交付查询。
目标效果: 目标效果:
@@ -25,7 +25,7 @@
### 成品库销售出库 ### 成品库销售出库
`嘉恒仓库 -> 成品库 -> 销售出库` 是唯一新增发货入口。 `百华仓库 -> 成品库 -> 销售出库` 是唯一新增发货入口。
该入口支持两种模式: 该入口支持两种模式:
@@ -1,14 +1,14 @@
# 嘉恒仓库库级出入库流水设计 # 百华仓库库级出入库流水设计
## 背景 ## 背景
嘉恒仓库已经承载原材料库、半成品库、成品库、辅料库、废料库、退货库六类库存。当前页面支持点击某个物料查看该物料的批次台账和库存流水,但缺少“站在某一个仓库整体视角查看所有出入库流水”的入口。 百华仓库已经承载原材料库、半成品库、成品库、辅料库、废料库、退货库六类库存。当前页面支持点击某个物料查看该物料的批次台账和库存流水,但缺少“站在某一个仓库整体视角查看所有出入库流水”的入口。
本设计在仓库页新增库级流水入口。入口跟随当前选中的仓库 tab,用于查看当前仓库下所有物料、批次和业务单据产生的出入库流水。 本设计在仓库页新增库级流水入口。入口跟随当前选中的仓库 tab,用于查看当前仓库下所有物料、批次和业务单据产生的出入库流水。
## 目标 ## 目标
- 在嘉恒仓库页面的出入库操作区右侧新增一个“流水”图标按钮,位置对应用户截图红框区域。 - 在百华仓库页面的出入库操作区右侧新增一个“流水”图标按钮,位置对应用户截图红框区域。
- 当前选中哪个仓库 tab,点击按钮就查看该仓库的出入库流水。 - 当前选中哪个仓库 tab,点击按钮就查看该仓库的出入库流水。
- 六大仓库都支持库级流水:原材料库、半成品库、成品库、辅料库、废料库、退货库。 - 六大仓库都支持库级流水:原材料库、半成品库、成品库、辅料库、废料库、退货库。
- 流水数据来自统一库存流水表 `wh_inventory_txn`,不新增另一套流水记录体系。 - 流水数据来自统一库存流水表 `wh_inventory_txn`,不新增另一套流水记录体系。
@@ -23,7 +23,7 @@
## 页面入口 ## 页面入口
在嘉恒仓库页面的出入库操作区右侧新增一个图标按钮。 在百华仓库页面的出入库操作区右侧新增一个图标按钮。
按钮要求: 按钮要求:
@@ -245,7 +245,7 @@ GET /inventory/transaction-ledger
前端静态测试: 前端静态测试:
- 嘉恒仓库页面存在“流水”入口。 - 百华仓库页面存在“流水”入口。
- 六大仓库 tab 都可打开同一流水抽屉。 - 六大仓库 tab 都可打开同一流水抽屉。
- 请求使用 `/inventory/transaction-ledger`。 - 请求使用 `/inventory/transaction-ledger`。
- 旧物料级库存流水仍保留。 - 旧物料级库存流水仍保留。
@@ -256,7 +256,7 @@ GET /inventory/transaction-ledger
浏览器冒烟: 浏览器冒烟:
- 登录后进入嘉恒仓库。 - 登录后进入百华仓库。
- 切换任意仓库 tab。 - 切换任意仓库 tab。
- 点击“流水”按钮。 - 点击“流水”按钮。
- 抽屉标题和当前仓库一致。 - 抽屉标题和当前仓库一致。
@@ -12,7 +12,7 @@
## 目标 ## 目标
- 在 `嘉恒仓库 -> 成品库` 的入库区新增 `返工入库`。 - 在 `百华仓库 -> 成品库` 的入库区新增 `返工入库`。
- `返工入库` 必须选择产品,并关联该产品未完成回库的返工出库记录。 - `返工入库` 必须选择产品,并关联该产品未完成回库的返工出库记录。
- 选择产品后,系统带出该产品对应的原材料库存批次号多选框,用 `JH...` 原材料库存批次号做追溯。 - 选择产品后,系统带出该产品对应的原材料库存批次号多选框,用 `JH...` 原材料库存批次号做追溯。
- 成品库仍以数量为主维度,重量只作为辅助信息或追溯信息,不作为主要库存口径。 - 成品库仍以数量为主维度,重量只作为辅助信息或追溯信息,不作为主要库存口径。
@@ -21,7 +21,7 @@
## 业务入口 ## 业务入口
`嘉恒仓库 -> 成品库 -> 入库 -> 返工入库` `百华仓库 -> 成品库 -> 入库 -> 返工入库`
该入口建议复用当前普通仓库入库抽屉样式,不另起一个完全不同的页面。字段建议为: 该入口建议复用当前普通仓库入库抽屉样式,不另起一个完全不同的页面。字段建议为:
@@ -15,7 +15,7 @@
- 生产工单号改为 `YYYYMMDD-库存批次号-产品名称`,例如 `20260607-YL0001-钢制碗`。 - 生产工单号改为 `YYYYMMDD-库存批次号-产品名称`,例如 `20260607-YL0001-钢制碗`。
- 同一天、同一个库存批次号、同一个产品的生产出库合并为同一张未结单工单。 - 同一天、同一个库存批次号、同一个产品的生产出库合并为同一张未结单工单。
- 小程序原“工单号”业务语义调整为“材料库存批次号”,员工按材料库存批次号报工。 - 小程序原“工单号”业务语义调整为“材料库存批次号”,员工按材料库存批次号报工。
- 嘉恒仓库“流水”按钮旁新增“生产工单入库”入口,统一处理成品入库、生产余料入库、生产废料入库。 - 百华仓库“流水”按钮旁新增“生产工单入库”入口,统一处理成品入库、生产余料入库、生产废料入库。
- “生产工单入库”中系统计算理论成品、理论余料、理论废料作为辅助默认值,用户可修改。 - “生产工单入库”中系统计算理论成品、理论余料、理论废料作为辅助默认值,用户可修改。
- 当用户修改余料或废料后,与理论值偏差超过上下 15%,必须弹窗填写说明,但不禁止入库。 - 当用户修改余料或废料后,与理论值偏差超过上下 15%,必须弹窗填写说明,但不禁止入库。
- “生产工单入库”不默认结单。只有用户主动勾选“本次入库后结单”才结单,并且提交前必须二次确认。 - “生产工单入库”不默认结单。只有用户主动勾选“本次入库后结单”才结单,并且提交前必须二次确认。
@@ -159,13 +159,13 @@ YYYYMMDD-库存批次号-产品名称
## 生产工单入库入口 ## 生产工单入库入口
在嘉恒仓库页面,“流水”按钮旁新增: 在百华仓库页面,“流水”按钮旁新增:
```text ```text
生产工单入库 生产工单入库
``` ```
入口跟随嘉恒仓库整体,不放在某一个具体仓库的入库/出库行里。因为这个操作一次会同时影响成品库、原材料库和废料库。 入口跟随百华仓库整体,不放在某一个具体仓库的入库/出库行里。因为这个操作一次会同时影响成品库、原材料库和废料库。
点击后打开较宽抽屉,避免表格和计算字段拥挤。该入口不使用小弹窗承载主流程。 点击后打开较宽抽屉,避免表格和计算字段拥挤。该入口不使用小弹窗承载主流程。
@@ -443,7 +443,7 @@ max(Q * (G - N) + S * G - W, 0)
- 原材料库生产出库 UI 只能选择一个材料库存批次号。 - 原材料库生产出库 UI 只能选择一个材料库存批次号。
- 生产出库提交 payload 只包含一个库存批次。 - 生产出库提交 payload 只包含一个库存批次。
- 嘉恒仓库流水按钮旁显示“生产工单入库”。 - 百华仓库流水按钮旁显示“生产工单入库”。
- 生产工单入库选择工单后自动填理论值。 - 生产工单入库选择工单后自动填理论值。
- 修改成品数量后自动重算余料和废料理论值。 - 修改成品数量后自动重算余料和废料理论值。
- 偏差超过 15% 时弹出说明框。 - 偏差超过 15% 时弹出说明框。
@@ -465,7 +465,7 @@ max(Q * (G - N) + S * G - W, 0)
3. 调整生产出库为单库存批次,并实现同日同批同产品工单合并。 3. 调整生产出库为单库存批次,并实现同日同批同产品工单合并。
4. 调整小程序报工同步逻辑,按材料库存批次号匹配未结单工单。 4. 调整小程序报工同步逻辑,按材料库存批次号匹配未结单工单。
5. 新增生产工单入库后端接口。 5. 新增生产工单入库后端接口。
6. 新增嘉恒仓库“生产工单入库”前端入口和交互。 6. 新增百华仓库“生产工单入库”前端入口和交互。
7. 联调工单台账、成品入库、余料入库、废料入库、发货追溯。 7. 联调工单台账、成品入库、余料入库、废料入库、发货追溯。
8. 补齐自动化测试和启动检查。 8. 补齐自动化测试和启动检查。
@@ -1,8 +1,8 @@
# 嘉恒仓库全出入库单据纸质化与归档设计 # 百华仓库全出入库单据纸质化与归档设计
## 背景 ## 背景
嘉恒仓库已经有 6 大库:原材料库、半成品库、成品库、辅料库、废料库、退货库。当前出入库业务主要通过抽屉表单完成,保存后形成库存流水。用户已经认可 `原材料库 · 客料入库` 的纸质填单样板,但该样板目前只完成填写 UI,没有生成 PDF 归档,也没有预览和下载入口。 百华仓库已经有 6 大库:原材料库、半成品库、成品库、辅料库、废料库、退货库。当前出入库业务主要通过抽屉表单完成,保存后形成库存流水。用户已经认可 `原材料库 · 客料入库` 的纸质填单样板,但该样板目前只完成填写 UI,没有生成 PDF 归档,也没有预览和下载入口。
现有归档体系已覆盖销售订单、采购订单、到货入库单、质量校验单、生产领料出库单、生产入库结算单。仓库里大量非生产分支仍未纳入正式单据化:客料、委外、销售、退货、返工、特殊出入库、期初等。为避免后续继续碎片化,本方案把“所有仓库出入库填单纸质化、保存后 PDF 归档、流水入口预览下载、流水 Excel 导出”作为统一仓库单据化工程。 现有归档体系已覆盖销售订单、采购订单、到货入库单、质量校验单、生产领料出库单、生产入库结算单。仓库里大量非生产分支仍未纳入正式单据化:客料、委外、销售、退货、返工、特殊出入库、期初等。为避免后续继续碎片化,本方案把“所有仓库出入库填单纸质化、保存后 PDF 归档、流水入口预览下载、流水 Excel 导出”作为统一仓库单据化工程。
@@ -167,7 +167,7 @@
所有仓库出入库抽屉采用统一纸质单据底座: 所有仓库出入库抽屉采用统一纸质单据底座:
- 抬头:嘉恒仓库、单据标题、单据编号或提交后生成提示。 - 抬头:百华仓库、单据标题、单据编号或提交后生成提示。
- 基础信息区:仓库、库位、业务类型、来源/去向、经办人、时间。 - 基础信息区:仓库、库位、业务类型、来源/去向、经办人、时间。
- 明细区:物料/产品/半成品/废料/退货品、库存批次号、来源库存批次号、数量、重量、单价、金额。 - 明细区:物料/产品/半成品/废料/退货品、库存批次号、来源库存批次号、数量、重量、单价、金额。
- 辅助信息区:运单号、运费、辅助照片、用途、包装备注、退货/返工/委外方、说明。 - 辅助信息区:运单号、运费、辅助照片、用途、包装备注、退货/返工/委外方、说明。
@@ -227,7 +227,7 @@ PDF 不得出现:
### 各库流水抽屉 ### 各库流水抽屉
在 `嘉恒仓库 -> 工具 -> 流水` 中新增 `单据留档` 列。 在 `百华仓库 -> 工具 -> 流水` 中新增 `单据留档` 列。
每条流水根据后端返回的归档字段展示: 每条流水根据后端返回的归档字段展示:
@@ -2,7 +2,7 @@
## 背景 ## 背景
系统已经完成销售订单、采购订单、到货入库单、质量校验单的正式单据化与 PDF 归档。嘉恒仓库仍以业务抽屉和库存流水为主,用户在办理生产出库、生产入库、生产余料入库、生产废料入库时,缺少一张可预览、可下载、可留档的正式业务单据。 系统已经完成销售订单、采购订单、到货入库单、质量校验单的正式单据化与 PDF 归档。百华仓库仍以业务抽屉和库存流水为主,用户在办理生产出库、生产入库、生产余料入库、生产废料入库时,缺少一张可预览、可下载、可留档的正式业务单据。
仓库业务入口很多,6 大库中存在采购、生产、委外、退货、销售、特殊调整等多条链路。如果一次性把所有出入库按钮都接入 PDF,容易形成重复模板和重复归档逻辑。本次先以生产主链路作为第一期样板,抽出仓库作业单通用底座,后续再扩展到委外、退货、销售出库、特殊出入库。 仓库业务入口很多,6 大库中存在采购、生产、委外、退货、销售、特殊调整等多条链路。如果一次性把所有出入库按钮都接入 PDF,容易形成重复模板和重复归档逻辑。本次先以生产主链路作为第一期样板,抽出仓库作业单通用底座,后续再扩展到委外、退货、销售出库、特殊出入库。
@@ -190,7 +190,7 @@
### 操作弹窗 ### 操作弹窗
生产主链路现有入口保留,不改变用户从嘉恒仓库进入业务的习惯。 生产主链路现有入口保留,不改变用户从百华仓库进入业务的习惯。
本期只做两类升级: 本期只做两类升级:
@@ -0,0 +1,341 @@
# 全系统纸质单据统一改造设计
## 背景
系统已经开始从普通后台表单向正式业务单据升级:销售订单、采购订单已经接入 `DocumentPaper` 纸质单据和 PDF 归档;仓库中的 `原材料库 · 客料入库单` 已被用户认可为“模板级别”;`生产余料入库单 / 生产废料入库单` 第一阶段也已经开始改成同一套纸质单据语言。
用户明确补充:这不是仓库专属改造,而是全系统所有“单据型业务”都要采用统一纸质单据体系。为了避免仓库、采购、销售、生产各自长出不同样式,本设计把目标升级为“全系统业务单据纸质化 + PDF 归档统一体系”。
## 单据边界
### 纳入纸质化的对象
满足任一条件就纳入:
- 会创建正式业务凭证:销售订单、采购订单、到货入库单、质量校验单、发货单、退货/返工单。
- 会改变库存或形成库存流水:各库入库、出库、特殊调整、盘库。
- 会形成生产追溯闭环:生产出库、生产台账入库、生产入库结算、生产领料出库。
- 用户后续需要预览、下载、批量下载或审计留档。
### 不纳入纸质化的对象
这些仍按系统管理/主数据维护表单处理,不强行改成纸质单据:
- 供应商名录、客户名录、人员管理、设备管理。
- 原材料名录、产品管理、产品需规清单、BOM、工艺路线。
- 系统权限、系统拓展、导入工具、基础配置。
原因:这些是主数据或配置记录,不是业务凭证。强行纸质化会降低维护效率,也会把“单据”的正式感稀释掉。
## 现状清单
### 已基本纸质化且已有 PDF 归档
| 模块 | 页面 | 单据 | 前端状态 | 后端归档类型 | 备注 |
| --- | --- | --- | --- | --- | --- |
| 订单与计划 | `SalesPlanningView.vue` | 销售订单 | 使用 `DocumentPaper`、`DocumentLineTable`、`DocumentArchiveActions` | `销售订单` | 已有预览、下载、重新生成、批量下载 |
| 采购与库存 | `PurchaseOrderView.vue` | 采购订单 | 使用 `DocumentPaper`、`DocumentArchiveActions` | `采购订单` | 已有预览、下载、重新生成、批量下载 |
| 采购与库存 | `PurchaseReceiptView.vue` | 到货入库单 | 使用 `DocumentPaper`、`DocumentArchiveActions` | `到货入库单` | 新增到货入库已纸质化 |
| 采购与库存 | `QualityInspectionView.vue` | 质量校验单 | 使用 `DocumentPaper`、`DocumentLineTable`、`DocumentArchiveActions` | `质量校验单` | 质检放行弹窗仍需继续统一交互 |
| 生产执行 | `ProductionLedgerView.vue` | 生产领料出库单 | 列表详情有 `DocumentArchiveActions` | `生产领料出库单` | 主要是归档入口,填单发生在仓库生产出库 |
| 生产执行 | `ProductionLedgerView.vue` | 生产入库结算单 | 列表详情有 `DocumentArchiveActions` | `生产入库结算单` | 对应生产台账入库 |
### 已套纸质外壳但仍有混用 UI
| 模块 | 页面 | 单据 | 当前问题 | 优先级 |
| --- | --- | --- | --- | --- |
| 百华仓库 | `InventoryLedgerView.vue` | 特殊入库单 / 特殊出库单 | 明细编辑区仍像现代卡片控件,和纸质单据割裂 | P0 |
| 百华仓库 | `InventoryLedgerView.vue` | 生产出库单 | 已使用纸质外壳,但来源批次选择、产品/BOM信息仍需统一纸质表格语言 | P0 |
| 百华仓库 | `InventoryLedgerView.vue` | 生产台账入库结算单 | 已有纸质外壳和摘要格,但三库联动字段、异常说明区域仍需与纸质单据模板对齐 | P0 |
| 百华仓库 | `InventoryLedgerView.vue` | 生产入库单 / 返工入库单 | 结单区和摘要区仍有旧卡片感 | P1 |
| 百华仓库 | `InventoryLedgerView.vue` | 销售出库单 | 已套纸质外壳,但销售订单关联、包装备注、发货明细区域需统一 | P1 |
| 百华仓库 | `InventoryLedgerView.vue` | 原材料退货出库单、退货入库单、退货废料入库单、返工出库单 | 已套纸质外壳,但字段区仍有普通表单/表格痕迹 | P1 |
| 百华仓库 | `InventoryLedgerView.vue` | 期初入库、委外入库/出库、报废出库、售卖出库等通用分支 | 通用分支第一阶段只统一了生产余料/废料一部分,仍需逐个验收 | P1 |
### 需要补充纸质化或确认是否作为单据
| 模块 | 页面 | 业务 | 判断 | 优先级 |
| --- | --- | --- | --- | --- |
| 发货概览 | `DeliveryManagementView.vue` | 发货台账/发货明细 | 如果存在独立发货动作,应形成发货单;若发货统一走成品库销售出库,则只保留流水归档入口 | P1 |
| 发货概览 | `ReturnManagementView.vue` | 退货/返工/废料处置 | 属于业务凭证,需要与退货库单据闭环统一 | P1 |
| 发货概览 | `ScrapReworkView.vue` | 报废返工处理 | 若仍在使用,应按退货/返工处置单统一;若已被仓库/退货库替代,应清理或降级为台账 | P2 |
| 生产执行 | `CompletionReceiptView.vue` | 成品入库 | 旧入口如果仍可用,需要和仓库生产入库/生产台账入库统一;如果已被新流程替代,应标记为旧入口 | P2 |
| 百华仓库 | `StocktakeDialog.vue` | 盘库单 | 盘库会改库存,应纳入纸质盘库单和 PDF 归档,但可排在特殊出入库之后 | P2 |
## 推荐方案
### 方案 A:继续逐页局部改造
每个页面自己套 `DocumentPaper` 和单独 CSS。
优点:
- 单次改动小。
- 速度快。
缺点:
- 很容易再次出现“仓库一套、采购一套、销售一套”。
- 样式复制越来越多。
- 后续 PDF、预览、下载、异常说明、签核栏会重复实现。
结论:不推荐作为长期方案,只适合救急。
### 方案 B:沉淀全系统单据底座,分批迁移
把现在的 `DocumentPaper`、`DocumentGrid`、`DocumentLineTable`、`WarehouseDocumentFormShell` 抽象成全系统单据组件。仓库继续推进,但所有新样式都进入通用单据层。
优点:
- 风格统一。
- 后续新增单据成本低。
- 能保留现有业务逻辑,不需要一次性大重构。
- 适合当前系统已经上线、仍在快速迭代的状态。
缺点:
- 第一批需要先做组件抽象和兼容层。
- 每个单据仍要逐个检查字段和业务交互。
结论:推荐。
### 方案 C:一次性全系统重构
一次把所有单据型页面、归档、PDF 重新设计和重写。
优点:
- 最终一致性最高。
缺点:
- 风险大。
- 容易影响已经可用的采购、销售、仓库主流程。
- 验收压力大。
结论:当前不推荐。
## 目标架构
### 前端组件层
保留现有组件并向通用命名演进:
- `DocumentPaper`:纸张、标题、编号章、签核栏、留存联。
- `DocumentGrid`:只读/展示型字段格。
- `DocumentLineTable`:明细表。
- `DocumentArchiveActions`:预览、下载、重新生成。
新增或演进:
- `DocumentFormShell`:全系统通用填单外壳,替代仓库专属 `WarehouseDocumentFormShell`。
- `DocumentSection`:通用单据分区,替代仓库专属 `WarehouseDocumentSection`。
- `DocumentActionBar`:通用底部操作区,替代仓库专属 `WarehouseDocumentActionBar`。
- `DocumentField`:纸质输入格,承载 input、select、textarea、readonly。
- `DocumentSelectorPanel`:纸质风格的下拉、多选、来源批次选择、关联订单选择。
- `DocumentInlineSummary`:纸质风格的金额/数量/重量汇总条。
- `DocumentSettlePanel`:结单、异常说明、偏差确认等正式确认区。
兼容规则:
- 第一阶段不删除 `WarehouseDocument*` 组件。
- 新通用组件先复用当前纸质 CSS 变量和视觉语言。
- 仓库页面逐步从 `warehouse-paper-*` 类迁移到 `document-paper-*` 类。
- 老类名保留一段时间作为兼容,避免正在使用的仓库单据被打断。
### 前端页面层
每个单据页面遵循同一结构:
1. `DocumentFormShell`
2. 单据头:标题、单据编号、业务日期、公司/模块名。
3. 基础信息区:客户/供应商/仓库/经办人/业务日期/关联单据。
4. 明细区:物料/产品/批次/数量/重量/单价/金额。
5. 辅助信息区:照片、运单号、运费、包装备注、用途、说明。
6. 汇总区:数量、重量、金额、差异、异常说明。
7. 签核区:制单、经办、审核、仓库/财务。
8. 操作区:保存、保存并归档、归档状态动作。
### 后端归档层
继续使用现有 `document_archives` 表和统一路由:
- `POST /document-archives/{document_type_key}/{business_id}/generate`
- `GET /document-archives/{document_type_key}/{business_id}/latest/preview`
- `GET /document-archives/{document_type_key}/{business_id}/latest/download`
- `POST /document-archives/batch-download`
现有支持的归档类型:
- `销售订单`
- `采购订单`
- `到货入库单`
- `质量校验单`
- `生产领料出库单`
- `生产入库结算单`
- `仓库出入库单`
新增归档类型只在确实不是现有类型能表达时添加。优先复用:
- 普通仓库动作统一走 `仓库出入库单`。
- 生产出库走 `生产领料出库单`。
- 生产台账入库走 `生产入库结算单`。
- 采购流程走 `采购订单 / 到货入库单 / 质量校验单`。
- 销售下单走 `销售订单`。
### PDF 规则
PDF 是保存后的数据库事实快照,不从前端表单二次推导。
PDF 必须遵守:
- 不显示英文状态。
- 不显示前端省略号。
- 长文本完整换行,不超格。
- 制单、经办人默认使用当前登录账号或后端记录的操作人。
- 库存批次号使用业务认可的原材料库存批次号,不使用无业务意义的每库内部批次号。
- PDF 样式和前端纸质单据属于同一视觉体系,但不要求像素完全一致。
## 分期执行
### 第 0 期:全系统清单和统一底座
目标:
- 固化本文档的单据清单。
- 新增通用 `DocumentFormShell / DocumentSection / DocumentActionBar / DocumentField / DocumentSelectorPanel`。
- 把仓库当前纸质样式抽象为通用 `document-paper-*`,保留 `warehouse-paper-*` 兼容层。
验收:
- 销售订单、采购订单、客料入库单视觉不退化。
- 新通用组件有基础测试或源码断言。
### 第 1 期:仓库 P0 单据
目标:
- 特殊入库 / 特殊出库。
- 生产出库。
- 生产台账入库结算单。
原因:
- 这些单据业务风险最高,且当前混用 UI 最明显。
- 改完能沉淀“多行明细类单据”“生产闭环类单据”“异常说明类单据”三类模板。
验收:
- 不再出现普通卡片/普通表格突兀嵌在纸里。
- 特殊出入库说明、明细行、调整前后数据完整显示。
- 生产台账入库三库联动显示为一张结算单,不误写成只属于成品库。
### 第 2 期:仓库 P1 单据
目标:
- 生产入库 / 返工入库。
- 销售出库。
- 原材料退货出库、退货入库、退货废料入库、返工出库。
- 期初、委外、报废、售卖等通用分支。
验收:
- 6 大库所有出入库抽屉都统一纸质化。
- 流水里 PDF 预览、下载、重新生成入口完整。
### 第 3 期:采购、销售、质检细节统一
目标:
- 销售订单、采购订单、到货入库、质量校验页面继续消除残留普通控件。
- 关联销售订单、采购明细、质检备注弹窗等改成统一纸质选择/说明面板。
验收:
- 已有 PDF 归档能力不回退。
- 纸质字段、明细表、按钮区和仓库单据保持同一套视觉语言。
### 第 4 期:发货、退货、返工闭环
目标:
- 发货台账按销售订单聚合后的明细留档入口统一。
- 退货、返工、废料处置相关页面和退货库单据闭环。
- 若旧页面被新仓库流程替代,则标记为只读台账或移除入口,避免双流程。
验收:
- 用户能从销售订单、发货台账、退货库流水追溯到同一套 PDF 单据。
### 第 5 期:盘库与批量导出
目标:
- 盘库单纸质化和 PDF 归档。
- 各业务台账按时间/筛选条件批量导出单据或流水 Excel。
验收:
- 盘库调整说明进入 PDF。
- Excel 导出不使用省略号,长字段完整输出。
## 测试策略
### 前端测试
每批至少增加源码断言或组件测试:
- 单据页面使用通用 `DocumentFormShell` 或兼容的纸质单据组件。
- 不在纸质单据主体中裸露旧 `form-field / double-field / triple-field / workflow-tip` 作为主要布局。
- 归档按钮使用 `DocumentArchiveActions`。
- 保存按钮位于单据底部操作区,不悬在单据中间。
### 后端测试
继续沿用现有归档测试风格:
- 归档生成成功写入 `document_archives`。
- PDF 文件以 `%PDF` 开头。
- 归档失败不阻断业务保存。
- 列表返回最新 PDF 归档状态。
- 不同业务类型映射到正确 `document_type`。
### 浏览器验收
每批至少手动/浏览器验证:
- 典型桌面宽度。
- 当前内置浏览器窄视口。
- 单据可滚动、按钮位置合理。
- 长文本不挤坏单据。
- PDF 预览/下载入口可见。
## 设计约束
- 纸质化是表现层和归档层升级,不借机删字段。
- 不改变现有业务计算和提交语义。
- 不把主数据维护表单改成纸质单据。
- 不把 PDF 归档失败作为库存/订单保存失败。
- 不新增英文状态、英文备注或英文系统标志。
- 不在 PDF 中使用省略号。
- 不把仓库特有样式继续扩散到全系统,新增能力进入通用 `documentForms`。
## 下一步推荐
推荐进入第 0 期 + 第 1 期合并执行:
1. 先抽象通用单据底座,保留现有仓库兼容层。
2. 接着改 `特殊入库 / 特殊出库`。
3. 再改 `生产出库`。
4. 再改 `生产台账入库结算单`。
这样能最快解决用户当前肉眼最不舒服的仓库混 UI 问题,同时保证做出来的能力可以继续给采购、销售、质检、发货复用。
## 自检
- 无占位项。
- 已明确哪些页面纳入,哪些页面不纳入。
- 已避免把仓库纸质化做成孤岛。
- 已给出分期和优先级。
- 已保持“先视觉/交互统一,不改变业务逻辑”的边界。
@@ -0,0 +1,388 @@
# 系统标准初始化工具设计
## 背景
当前系统已经从嘉恒测试环境切到百华客制化分支。数据库中存在大量嘉恒/测试业务数据,百华正式启用前需要初始化为干净系统。
这次初始化不能只做一次性清库脚本。后续给其他公司部署时,也需要一套标准工具,能够在全新的 MySQL 数据库上完成建库、建表和系统骨架初始化,同时也能对已经建好表、已经产生测试数据的系统做正式启用前重置。
## 目标
初始化工具提供两个模式:
- `fresh`:面向全新 MySQL 或全新 schema,创建 ERP 数据库下所有 ERP 表和智能报工小程序相关表,并写入最小系统骨架。
- `reset`:面向已有 schema,先备份,再清空业务数据、主数据、人员账号、小程序数据,然后重建最小系统骨架。
初始化完成后,系统应满足:
- 可以用一个超级管理员账号登录 ERP。
- 系统权限、菜单、角色关系完整。
- 六大仓库和默认库位存在,后续可直接做出入库。
- 系统配置存在,例如原材料库存批次前缀、是否对接智能报工小程序。
- 没有嘉恒或测试业务数据、客户、供应商、物料、产品、库存、订单、报工、发货、财务、单据归档数据残留。
- 如果后续客户购买小程序,小程序后端所需表也已经创建好。
## 非目标
- 不做客户真实主数据导入。客户、供应商、物料、产品、BOM、工艺路线后续用现有导入功能导入。
- 默认不删除上传文件和 PDF 实体文件,只清数据库记录。文件删除作为显式参数单独开启。
- 不重命名数据库 schema,例如仍可使用当前 `jiaheng_erp` 作为历史技术库名,避免改动部署配置和小程序连接。
- 不在初始化工具里修改业务逻辑。
## 命令形态
建议新增入口:
```bash
python backend/scripts/system_initialize.py fresh \
--company-name 百华 \
--admin-name 超级管理员 \
--admin-phone 13800000000 \
--admin-password '正式密码'
python backend/scripts/system_initialize.py reset \
--company-name 百华 \
--admin-name 超级管理员 \
--admin-phone 13800000000 \
--admin-password '正式密码' \
--confirm-reset
```
脚本读取 `backend/.env` 中的 MySQL 连接信息:
- `MYSQL_HOST`
- `MYSQL_PORT`
- `MYSQL_DATABASE`
- `MYSQL_USER`
- `MYSQL_PASSWORD`
## 模式一:fresh
`fresh` 用于全新部署,是后续交付其他公司最常用的路径。
流程:
1. 读取 `.env` 中的 MySQL 连接信息。
2. 连接 MySQL server。
3. 如果目标 database 不存在,执行 `CREATE DATABASE`。
4. 切换到目标 database。
5. 创建 ERP 全量表。
6. 创建小程序全量表。
7. 执行所有已纳入标准基线的 patch。
8. 初始化系统权限、角色、角色权限。
9. 初始化系统配置。
10. 初始化单位、物料分类等基础字典。
11. 初始化六大仓库和默认库位。
12. 初始化根组织节点、超级管理员人员、超级管理员系统账号、用户角色绑定。
13. 输出初始化报告。
`fresh` 模式不写入任何业务主数据和业务流水。
## 模式二:reset
`reset` 用于已有系统正式启用前清理,例如这次百华切换。
流程:
1. 读取 `.env` 中的 MySQL 连接信息。
2. 检查必须显式传入 `--confirm-reset`。
3. 打印目标数据库连接摘要,包括 host、port、database、当前时间。
4. 统计关键表行数。
5. 强制导出备份 SQL 到 `outputs/cleanup_backups/system_init_backup_<timestamp>.sql`。
6. `SET FOREIGN_KEY_CHECKS=0`。
7. 清空业务流水表。
8. 清空库存表。
9. 清空业务主数据表。
10. 清空人员、组织、账号、组织绑定、角色绑定。
11. 清空小程序业务数据和小程序主数据。
12. 清空系统广播、单据归档记录。
13. 重置相关自增 ID。
14. 重建系统权限、角色、角色权限、系统配置、单位、分类、仓库、库位、根组织、超级管理员。
15. `SET FOREIGN_KEY_CHECKS=1`。
16. 再次统计关键表行数。
17. 输出 JSON 报告到 `outputs/cleanup_backups/system_init_summary_<timestamp>.json`。
## 小程序表覆盖范围
ERP 当前代码已映射部分小程序表,但小程序后端模型更完整。标准初始化必须覆盖两侧共同需要的完整表集合。
小程序基础主数据:
- `attendance_points`
- `personnel`
- `person_roles`
- `person_attendance_points`
- `products`
- `equipment`
- `work_schedules`
小程序业务数据:
- `work_sessions`
- `work_session_devices`
- `production_reports`
- `production_report_items`
- `report_audit_logs`
- `mold_lock_feedbacks`
- `device_qrcodes`
- `device_qrcode_batch_tasks`
- `notices`
- `notice_points`
- `reconciliation_ledger_entries`
`fresh` 模式需要创建这些表。`reset` 模式需要清空这些表,并只重建必要的考勤点/管理员人员数据。
## 清理表分层
### 业务流水表
销售:
- `so_sales_order_item`
- `so_sales_order`
- `so_delivery_item`
- `so_delivery`
采购:
- `po_purchase_order_sales_order`
- `po_purchase_order_item`
- `po_purchase_order`
- `po_receipt_item`
- `po_receipt`
- `po_purchase_return_item`
- `po_purchase_return`
库存:
- `wh_special_adjustment_line`
- `wh_special_adjustment`
- `wh_stocktake_adjustment`
- `wh_stocktake_line`
- `wh_stocktake_warehouse`
- `wh_stocktake`
- `wh_inventory_txn`
- `wh_stock_balance`
- `wh_stock_lot`
生产:
- `pp_production_batch_ledger_txn`
- `pp_production_batch_ledger`
- `pp_completion_receipt_item`
- `pp_completion_receipt`
- `pp_scrap_record`
- `pp_operation_report`
- `pp_material_issue_item`
- `pp_material_issue`
- `pp_work_order_material_issue`
- `pp_work_order_operation`
- `pp_work_order_material`
- `pp_work_order`
发货与售后:
- `rt_return_disposition`
- `rt_return_item`
- `rt_return_order`
财务:
- `fi_statement_snapshot`
- `fi_cost_allocation`
- `fi_overhead_entry`
- `fi_accounting_period`
- `reconciliation_ledger_entries`
单据归档:
- `document_archives`
MRP:
- `mrp_material_demand`
### 业务主数据表
- `em_maintenance_order`
- `em_maintenance_plan`
- `em_equipment`
- `md_bom_item`
- `md_bom`
- `md_process_route_operation`
- `md_process_route`
- `md_work_center`
- `md_process`
- `md_product`
- `md_material`
- `md_item`
- `md_customer`
- `md_supplier`
### 组织和账号
选择标准为“只保留一个超级管理员”。
需要清空:
- `sys_user_role`
- `sys_user`
- `sys_org_manager_binding`
- `sys_org_employee_binding`
- `hr_employee`
- `sys_department`
需要重建:
- 根组织节点,例如 `COMPANY_ROOT`
- 超级管理员人员
- 超级管理员系统账号
- 超级管理员角色绑定
### 系统骨架
需要保留或重建:
- `sys_permission`
- `sys_role`
- `sys_role_permission`
- `sys_system_config`
- `sys_ai_assistant_config`
- `md_unit`
- `md_item_category`
- `wh_warehouse`
- `wh_location`
需要清空:
- `sys_broadcast_message`
## 系统配置初始化
必须至少初始化:
- `RAW_MATERIAL_LOT_PREFIX`:默认 `YL`。
- `SMART_OPERATION_REPORT_ENABLED`:默认可通过参数设置。百华当前建议按实际购买情况设置。
- AI 助手名称:默认 `<company-name>工艺助手`。
配置项必须使用 upsert,确保 `fresh` 和 `reset` 都可重复执行。
## 仓库初始化
初始化六大库和默认库位:
- 原材料库:`RAW`
- 半成品库:`SEMI`
- 成品库:`FINISHED`
- 辅料库:`AUX`
- 废料库:`SCRAP`
- 退货库:`RETURN`
每个仓库创建一个默认库位。命名可保持当前业务通用名称,例如:
- 原料主库位
- 半成品主库位
- 成品主库位
- 辅料主库位
- 废料主库位
- 退货主库位
仓库名可根据公司名生成,例如 `百华原材料库`,也可以保持通用名 `原材料库`。推荐保持通用名,避免系统内部业务文案过长。
## 备份与报告
`reset` 模式必须生成备份:
```text
outputs/cleanup_backups/system_init_backup_<timestamp>.sql
```
必须生成报告:
```text
outputs/cleanup_backups/system_init_summary_<timestamp>.json
```
报告内容包括:
- 执行模式
- 执行时间
- 数据库 host、port、database
- 备份路径
- 清理表清单
- 每张表清理前行数
- 每张表清理后行数
- 重建骨架数据摘要
- 是否清理文件
- 执行结果
## 文件清理
默认不删除实体文件,只清数据库引用。
如显式传入 `--delete-files`,才允许清理:
- 单据归档 PDF 目录
- 批量下载临时 zip 目录
- 辅助照片上传目录
文件清理必须记录到报告中。
## 安全保护
`reset` 模式必须满足:
- 未传 `--confirm-reset` 时禁止执行删除,只输出预检摘要。
- 执行前必须成功备份。
- 打印目标 database,并要求命令行参数显式确认。
- 默认拒绝清理未知 database,除非传入 `--allow-any-database`。
- 清理前后检查 `FOREIGN_KEY_CHECKS` 恢复为 `1`。
- 清理后必须能查询到一个超级管理员用户。
- 清理后业务流水表行数必须为 `0`。
## 测试策略
单元测试:
- `fresh` 在临时 SQLite 或测试 MySQL schema 上可创建所有 SQLAlchemy metadata 表。
- `reset` 对带测试数据的测试库执行后,业务表为空。
- `reset` 后系统账号、角色、权限、仓库、库位、系统配置存在。
- `reset` 未传 `--confirm-reset` 时不会删除数据。
- 小程序关键表存在,并可写入最小人员、考勤点、产品清单、报工记录。
集成测试:
- 初始化后调用 `/api/system/health` 返回数据库连接正常。
- 初始化后用超级管理员登录成功。
- 初始化后打开仓库页面不会因为缺仓库/库位报错。
- 初始化后打开系统权限管理页面能看到根组织节点和超级管理员。
人工验收:
- 前端登录成功。
- 左侧菜单正常。
- 基础资料中客户、供应商、产品、物料为空。
- 六大仓库库存为空,但仓库入口可正常进入。
- 销售、采购、生产、发货、财务台账为空。
- 系统管理中只有初始化创建的超级管理员账号。
## 实施顺序
1. 补齐小程序完整 SQLAlchemy 模型或标准 SQL,确保初始化可创建小程序全部表。
2. 新增初始化脚本 `backend/scripts/system_initialize.py`。
3. 新增 SQL seed/cleanup 分组文件。
4. 编写测试覆盖 `fresh` 和 `reset`。
5. 在本地测试库执行 `fresh`。
6. 在本地测试库导入当前测试数据后执行 `reset`。
7. 确认报告和备份文件。
8. 用户审核后,在百华服务器数据库执行 `reset`。
## 开放决策
本设计已固定采用“只保留一个超级管理员”的账号策略。
执行前仍需确定:
- 百华超级管理员姓名、手机号、初始密码。
- 百华是否默认开启智能报工小程序对接。
- 是否在本次初始化时删除服务器上的历史上传图片和 PDF 实体文件。默认不删除。
+1 -1
View File
@@ -1,4 +1,4 @@
# 宁波嘉恒智能科技有限公司 ERP 设计方案 # 宁波百华智能科技有限公司 ERP 设计方案
## 1. 项目定位 ## 1. 项目定位
+5 -5
View File
@@ -1,6 +1,6 @@
# 采购使用说明手册 # 采购使用说明手册
适用模块:`采购与库存 -> 采购订单`、`采购与库存 -> 到货入库`、`采购与库存 -> 质量检验`、`采购与库存 -> 嘉恒仓库`。 适用模块:`采购与库存 -> 采购订单`、`采购与库存 -> 到货入库`、`采购与库存 -> 质量检验`、`采购与库存 -> 百华仓库`。
本手册面向采购专员、仓库管理人员和管理人员,用于说明从采购下单、锁单、到货、质检、入库、退货到合同生成的完整操作方式。 本手册面向采购专员、仓库管理人员和管理人员,用于说明从采购下单、锁单、到货、质检、入库、退货到合同生成的完整操作方式。
@@ -251,14 +251,14 @@ flowchart TD
| 已拒收 | 不进入可用库存,采购和仓库需要协同处理。 | | 已拒收 | 不进入可用库存,采购和仓库需要协同处理。 |
| 待质检 | 暂时不可用,不能当作可生产库存。 | | 待质检 | 暂时不可用,不能当作可生产库存。 |
如果材料入仓后才发现质量或性能问题,应在嘉恒仓库的 `原材料库 -> 退货出库` 中办理退货。 如果材料入仓后才发现质量或性能问题,应在百华仓库的 `原材料库 -> 退货出库` 中办理退货。
## 9. 原材料退货出库 ## 9. 原材料退货出库
入口: 入口:
```text ```text
采购与库存 -> 嘉恒仓库 -> 原材料库 -> 出库 -> 退货出库 采购与库存 -> 百华仓库 -> 原材料库 -> 出库 -> 退货出库
``` ```
适用场景: 适用场景:
@@ -284,9 +284,9 @@ flowchart TD
| 某张采购订单是否锁单 | 采购与库存 -> 采购订单 | | 某张采购订单是否锁单 | 采购与库存 -> 采购订单 |
| 某采购订单到了多少 | 采购与库存 -> 到货入库 | | 某采购订单到了多少 | 采购与库存 -> 到货入库 |
| 到货是否质检放行 | 采购与库存 -> 质量检验 | | 到货是否质检放行 | 采购与库存 -> 质量检验 |
| 材料是否形成库存批次 | 采购与库存 -> 嘉恒仓库 -> 原材料库 | | 材料是否形成库存批次 | 采购与库存 -> 百华仓库 -> 原材料库 |
| 某采购订单是否存在退货 | 采购与库存 -> 采购订单,查看警示标记 | | 某采购订单是否存在退货 | 采购与库存 -> 采购订单,查看警示标记 |
| 某材料批次来源采购单 | 嘉恒仓库 -> 原材料库 -> 批次明细 | | 某材料批次来源采购单 | 百华仓库 -> 原材料库 -> 批次明细 |
## 11. 常见问题 ## 11. 常见问题
+9 -9
View File
@@ -1,6 +1,6 @@
# 销售使用说明手册 # 销售使用说明手册
适用模块:`基础资料 -> 客户名录`、`订单与计划 -> 销售订单列表`、`采购与库存 -> 采购订单`、`采购与库存 -> 嘉恒仓库 -> 成品库 -> 销售出库`、`发货概览 -> 发货台账`。 适用模块:`基础资料 -> 客户名录`、`订单与计划 -> 销售订单列表`、`采购与库存 -> 采购订单`、`采购与库存 -> 百华仓库 -> 成品库 -> 销售出库`、`发货概览 -> 发货台账`。
本手册面向销售人员、管理人员和仓库发货人员,用于说明客户维护、销售订单创建、合同生成、采购联动、成品发货、发货追溯和客户退货处理。 本手册面向销售人员、管理人员和仓库发货人员,用于说明客户维护、销售订单创建、合同生成、采购联动、成品发货、发货追溯和客户退货处理。
@@ -174,19 +174,19 @@ flowchart TD
## 8. 销售出库 ## 8. 销售出库
销售出库目前在嘉恒仓库的成品库中办理。 销售出库目前在百华仓库的成品库中办理。
入口: 入口:
```text ```text
采购与库存 -> 嘉恒仓库 -> 成品库 -> 出库 -> 销售出库 采购与库存 -> 百华仓库 -> 成品库 -> 出库 -> 销售出库
``` ```
销售出库用于把成品库存发给客户,并回写销售订单已发数量。 销售出库用于把成品库存发给客户,并回写销售订单已发数量。
操作步骤: 操作步骤:
1. 进入嘉恒仓库。 1. 进入百华仓库。
2. 选择 `成品库`。 2. 选择 `成品库`。
3. 点击出库区的 `销售出库`。 3. 点击出库区的 `销售出库`。
4. 选择是否关联销售订单。 4. 选择是否关联销售订单。
@@ -230,12 +230,12 @@ flowchart TD
## 10. 客户退货处理 ## 10. 客户退货处理
客户退货不再放在旧的 `发货与售后 -> 退货处理` 中,而是在嘉恒仓库中按退回品去向处理。 客户退货不再放在旧的 `发货与售后 -> 退货处理` 中,而是在百华仓库中按退回品去向处理。
| 客户退货判断 | 操作入口 | 说明 | | 客户退货判断 | 操作入口 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| 可返工 | 嘉恒仓库 -> 退货库 -> 入库 -> 退货入库 | 退货品进入退货库,后续可返工出库。 | | 可返工 | 百华仓库 -> 退货库 -> 入库 -> 退货入库 | 退货品进入退货库,后续可返工出库。 |
| 不可返工,直接报废 | 嘉恒仓库 -> 废料库 -> 入库 -> 退货废料入库 | 退货品直接进入废料库。 | | 不可返工,直接报废 | 百华仓库 -> 废料库 -> 入库 -> 退货废料入库 | 退货品直接进入废料库。 |
退货入库时,应选择原发货批次或发货明细,系统会追溯到原销售订单、产品和原材料库存批次。 退货入库时,应选择原发货批次或发货明细,系统会追溯到原销售订单、产品和原材料库存批次。
@@ -262,8 +262,8 @@ flowchart TD
| 生成销售合同 | 订单与计划 -> 销售订单列表 | | 生成销售合同 | 订单与计划 -> 销售订单列表 |
| 看销售订单是否采购联动 | 订单与计划 -> 销售订单列表,或采购与库存 -> 采购订单 | | 看销售订单是否采购联动 | 订单与计划 -> 销售订单列表,或采购与库存 -> 采购订单 |
| 看销售订单是否发货 | 发货概览 -> 发货台账 | | 看销售订单是否发货 | 发货概览 -> 发货台账 |
| 办理销售发货 | 采购与库存 -> 嘉恒仓库 -> 成品库 -> 销售出库 | | 办理销售发货 | 采购与库存 -> 百华仓库 -> 成品库 -> 销售出库 |
| 办理客户退货 | 采购与库存 -> 嘉恒仓库 -> 退货库或废料库 | | 办理客户退货 | 采购与库存 -> 百华仓库 -> 退货库或废料库 |
## 12. 常见问题 ## 12. 常见问题
+7 -7
View File
@@ -1,12 +1,12 @@
# 仓库使用说明手册 # 仓库使用说明手册
适用模块:`采购与库存 -> 嘉恒仓库`、`生产执行 -> 工单台账`、`采购与库存 -> 到货入库`、`采购与库存 -> 质量检验`、`发货概览 -> 发货台账`。 适用模块:`采购与库存 -> 百华仓库`、`生产执行 -> 工单台账`、`采购与库存 -> 到货入库`、`采购与库存 -> 质量检验`、`发货概览 -> 发货台账`。
本手册面向仓库管理人员、生产管理人员和管理人员,用于说明嘉恒仓库六大库的库存口径、出入库操作、库存批次、流水、盘库和追溯方式。 本手册面向仓库管理人员、生产管理人员和管理人员,用于说明百华仓库六大库的库存口径、出入库操作、库存批次、流水、盘库和追溯方式。
## 1. 嘉恒仓库总览 ## 1. 百华仓库总览
嘉恒仓库目前包含六大库: 百华仓库目前包含六大库:
| 仓库 | 主要对象 | 主计量口径 | 说明 | | 仓库 | 主要对象 | 主计量口径 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@@ -29,7 +29,7 @@
```mermaid ```mermaid
flowchart TD flowchart TD
A["进入嘉恒仓库"] --> B["选择仓库"] A["进入百华仓库"] --> B["选择仓库"]
B --> C["查看库存总览"] B --> C["查看库存总览"]
B --> D["选择入库操作"] B --> D["选择入库操作"]
B --> E["选择出库操作"] B --> E["选择出库操作"]
@@ -313,7 +313,7 @@ flowchart TD
入口: 入口:
```text ```text
嘉恒仓库 -> 选择仓库 -> 点击流水图标 百华仓库 -> 选择仓库 -> 点击流水图标
``` ```
流水用于回答: 流水用于回答:
@@ -332,7 +332,7 @@ flowchart TD
入口: 入口:
```text ```text
嘉恒仓库 -> 选择仓库 -> 盘库 百华仓库 -> 选择仓库 -> 盘库
``` ```
盘库规则: 盘库规则:
+4 -4
View File
@@ -3,10 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=4" /> <link rel="icon" type="image/png" href="/logo-mark.png" />
<link rel="alternate icon" type="image/png" href="/logo.png" /> <link rel="alternate icon" type="image/png" href="/logo-mark.png" />
<link rel="apple-touch-icon" href="/logo.png" /> <link rel="apple-touch-icon" href="/logo-mark.png" />
<title>嘉恒智能五金 ERP</title> <title>百华智能五金 ERP</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
-54
View File
@@ -1,54 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="238 65 828 828" role="img" aria-label="嘉恒智能">
<defs>
<linearGradient id="logoBlue" x1="258" y1="166" x2="1047" y2="748" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#12a6df"/>
<stop offset="0.48" stop-color="#087fc3"/>
<stop offset="1" stop-color="#005ba8"/>
</linearGradient>
<linearGradient id="logoBlueDark" x1="313" y1="279" x2="936" y2="688" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0d8ed0"/>
<stop offset="1" stop-color="#004f9c"/>
</linearGradient>
</defs>
<g fill="none" fill-rule="evenodd">
<path
fill="url(#logoBlueDark)"
d="M651 53l22 67 70 1-57 41 21 68-56-42-57 42 22-68-57-41 70-1z"
/>
<path stroke="#087fc3" stroke-width="18" stroke-linecap="round" d="M651 188v88"/>
<path
fill="url(#logoBlue)"
d="M650 244 516 167 238 328v314l278 160 134-77V613L516 691 332 585V385l184-106 134 77z"
/>
<path
fill="url(#logoBlue)"
d="M654 244 788 167l278 161v314L788 802l-134-77V613l134 78 184-106V385L788 279l-134 77z"
/>
<path
fill="url(#logoBlueDark)"
d="M574 179c0-26 28-43 51-30l108 62c16 9 26 26 26 44v142l122 70c23 13 23 47 0 60l-122 70v142c0 18-10 35-26 44l-108 62c-23 13-51-4-51-30V597L452 527c-23-13-23-47 0-60l122-70z"
/>
<path
stroke="#fff"
stroke-width="50"
stroke-linecap="round"
stroke-linejoin="round"
d="M651 275v204M651 479 512 398M651 479l139-81M651 479v205M651 684l-139-81M651 684l139-81"
/>
<path
stroke="#fff"
stroke-width="42"
stroke-linecap="round"
stroke-linejoin="round"
d="M390 433v119l126 73M914 433v119l-126 73"
/>
<path
fill="#087fc3"
d="M229 883c181-76 665-76 846 0-220-38-626-38-846 0z"
/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 509 KiB

After

Width:  |  Height:  |  Size: 120 KiB

-54
View File
@@ -1,54 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1304" height="1028" viewBox="0 0 1304 1028" role="img" aria-label="宁波嘉恒智能科技有限公司 logo">
<defs>
<linearGradient id="logoBlue" x1="258" y1="166" x2="1047" y2="748" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#12a6df"/>
<stop offset="0.48" stop-color="#087fc3"/>
<stop offset="1" stop-color="#005ba8"/>
</linearGradient>
<linearGradient id="logoBlueDark" x1="313" y1="279" x2="936" y2="688" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0d8ed0"/>
<stop offset="1" stop-color="#004f9c"/>
</linearGradient>
</defs>
<g fill="none" fill-rule="evenodd">
<path
fill="url(#logoBlueDark)"
d="M651 53l22 67 70 1-57 41 21 68-56-42-57 42 22-68-57-41 70-1z"
/>
<path stroke="#087fc3" stroke-width="18" stroke-linecap="round" d="M651 188v88"/>
<path
fill="url(#logoBlue)"
d="M650 244 516 167 238 328v314l278 160 134-77V613L516 691 332 585V385l184-106 134 77z"
/>
<path
fill="url(#logoBlue)"
d="M654 244 788 167l278 161v314L788 802l-134-77V613l134 78 184-106V385L788 279l-134 77z"
/>
<path
fill="url(#logoBlueDark)"
d="M574 179c0-26 28-43 51-30l108 62c16 9 26 26 26 44v142l122 70c23 13 23 47 0 60l-122 70v142c0 18-10 35-26 44l-108 62c-23 13-51-4-51-30V597L452 527c-23-13-23-47 0-60l122-70z"
/>
<path
stroke="#fff"
stroke-width="50"
stroke-linecap="round"
stroke-linejoin="round"
d="M651 275v204M651 479 512 398M651 479l139-81M651 479v205M651 684l-139-81M651 684l139-81"
/>
<path
stroke="#fff"
stroke-width="42"
stroke-linecap="round"
stroke-linejoin="round"
d="M390 433v119l126 73M914 433v119l-126 73"
/>
<path
fill="#087fc3"
d="M229 883c181-76 665-76 846 0-220-38-626-38-846 0z"
/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

@@ -5,6 +5,7 @@ const files = [
"src/components/documentForms/DocumentPaper.vue", "src/components/documentForms/DocumentPaper.vue",
"src/components/documentForms/DocumentGrid.vue", "src/components/documentForms/DocumentGrid.vue",
"src/components/documentForms/DocumentLineTable.vue", "src/components/documentForms/DocumentLineTable.vue",
"src/components/documentForms/WarehouseDocumentFormShell.vue",
"src/components/documentForms/DocumentArchiveActions.vue", "src/components/documentForms/DocumentArchiveActions.vue",
"src/styles/main.css" "src/styles/main.css"
]; ];
@@ -39,6 +40,17 @@ for (const snippet of ["预览PDF", "下载PDF", "重新生成"]) {
} }
} }
const warehouseDocumentFormShell = sources.get("src/components/documentForms/WarehouseDocumentFormShell.vue");
for (const snippet of ["formatChineseDate", "displayBusinessDate"]) {
if (!warehouseDocumentFormShell.includes(snippet)) {
throw new Error(`WarehouseDocumentFormShell missing business date formatting snippet: ${snippet}`);
}
}
if (warehouseDocumentFormShell.includes("{{ businessDate || \"-\" }}")) {
throw new Error("WarehouseDocumentFormShell must not render raw businessDate values");
}
for (const forbiddenSnippet of ["ghost-button", "action-button"]) { for (const forbiddenSnippet of ["ghost-button", "action-button"]) {
if (archiveActions.includes(forbiddenSnippet)) { if (archiveActions.includes(forbiddenSnippet)) {
throw new Error(`DocumentArchiveActions must not use global button class: ${forbiddenSnippet}`); throw new Error(`DocumentArchiveActions must not use global button class: ${forbiddenSnippet}`);
@@ -58,6 +70,25 @@ for (const selector of [".document-paper-shell", ".document-paper-sheet", ".docu
} }
} }
for (const selector of [
".document-grid-value input",
".document-grid-value select",
".document-grid-value textarea",
".document-line-table input",
".document-line-table select",
".document-line-table textarea"
]) {
if (!css.includes(selector)) {
throw new Error(`main.css missing document form overflow guard selector: ${selector}`);
}
}
for (const snippet of ["box-sizing: border-box", "min-width: 0", "max-width: 100%", "text-overflow: ellipsis"]) {
if (!css.includes(snippet)) {
throw new Error(`document form controls missing overflow guard declaration: ${snippet}`);
}
}
for (const selector of [ for (const selector of [
".document-archive-button", ".document-archive-button",
".document-archive-button-preview", ".document-archive-button-preview",
@@ -10,7 +10,7 @@ assert.doesNotMatch(source, /YL-NB|buildDefaultLotNo/, "purchase receipt UI must
assert.doesNotMatch(source, /送货单号|supplier_delivery_no/, "purchase receipt UI should not keep the duplicate supplier delivery number field"); assert.doesNotMatch(source, /送货单号|supplier_delivery_no/, "purchase receipt UI should not keep the duplicate supplier delivery number field");
assert.match(source, /后端自动生成库存批次号|系统自动生成库存批次号/, "purchase receipt UI should explain that inventory lot numbers are generated by the backend"); assert.match(source, /后端自动生成库存批次号|系统自动生成库存批次号/, "purchase receipt UI should explain that inventory lot numbers are generated by the backend");
assert.match(source, /uploadResource/, "purchase receipt UI should upload logistics auxiliary photos"); assert.match(source, /uploadResource/, "purchase receipt UI should upload logistics auxiliary photos");
assert.match(source, /selectedPurchaseTargetType\s*===\s*["']RAW["'][\s\S]*运单号[\s\S]*辅助照片/, "raw purchase receipt UI should show waybill and auxiliary photo fields"); assert.match(source, /selectedPurchaseTargetType(?:\.value)?\s*===\s*["']RAW["'][\s\S]*运单号[\s\S]*辅助照片/, "raw purchase receipt UI should show waybill and auxiliary photo fields");
assert.match(source, /validateLogisticsForm\s*\(\s*\)/, "raw purchase receipt submit should validate logistics information before posting"); assert.match(source, /validateLogisticsForm\s*\(\s*\)/, "raw purchase receipt submit should validate logistics information before posting");
assert.match(source, /waybill_no:\s*selectedPurchaseTargetType\.value\s*===\s*["']RAW["']\s*\?\s*form\.waybill_no/, "purchase receipt payload should include raw waybill number"); assert.match(source, /waybill_no:\s*selectedPurchaseTargetType\.value\s*===\s*["']RAW["']\s*\?\s*form\.waybill_no/, "purchase receipt payload should include raw waybill number");
assert.match(source, /order_photo_url:\s*selectedPurchaseTargetType\.value\s*===\s*["']RAW["']\s*\?\s*form\.order_photo_url/, "purchase receipt payload should include raw auxiliary photo URL"); assert.match(source, /order_photo_url:\s*selectedPurchaseTargetType\.value\s*===\s*["']RAW["']\s*\?\s*form\.order_photo_url/, "purchase receipt payload should include raw auxiliary photo URL");
@@ -17,7 +17,8 @@ assert.match(router, /loadSmartOperationReportConfig/, "router should guard oper
assert.match(app, /smartOperationReportEnabled/, "App should track switch state"); assert.match(app, /smartOperationReportEnabled/, "App should track switch state");
assert.match(app, /child\.key\s*===\s*"operation-reports"/, "App should hide operation reports child when disabled"); assert.match(app, /child\.key\s*===\s*"operation-reports"/, "App should hide operation reports child when disabled");
assert.match(systemExtension, /对接智能报工小程序/, "system extension should show switch"); assert.match(systemExtension, /对接智能报工小程序/, "system extension should show switch");
assert.match(productionLedger, /工序明细/, "production ledger page should show operation detail wording when enabled"); assert.doesNotMatch(productionLedger, /当前未对接智能报工小程序/, "production ledger page should not expose disabled integration wording");
assert.match(productionLedger, /当前未对接智能报工小程序/, "production ledger page should explain disabled mode"); assert.doesNotMatch(productionLedger, /工序明细隐藏/, "production ledger page should not expose hidden operation detail wording");
assert.doesNotMatch(productionLedger, /BOM毛重\/净重反推/, "production ledger page should not expose internal calculation wording");
console.log("smart operation report toggle static checks passed"); console.log("smart operation report toggle static checks passed");
+10
View File
@@ -19,3 +19,13 @@ describe("App reset password account picker", () => {
assert.match(source, /formatResetPasswordUserLabel\(user\)/); assert.match(source, /formatResetPasswordUserLabel\(user\)/);
}); });
}); });
describe("App user menu interactions", () => {
it("closes the avatar menu when the user clicks outside the menu shell", () => {
assert.match(source, /ref="userMenuShellRef"/);
assert.match(source, /function handleUserMenuOutsidePointerDown\(event\)/);
assert.match(source, /userMenuShellRef\.value\?\.contains\(event\.target\)/);
assert.match(source, /document\.addEventListener\("pointerdown", handleUserMenuOutsidePointerDown\)/);
assert.match(source, /document\.removeEventListener\("pointerdown", handleUserMenuOutsidePointerDown\)/);
});
});
+24 -12
View File
@@ -4,9 +4,9 @@
<div v-else :class="shellClass"> <div v-else :class="shellClass">
<aside class="sidebar"> <aside class="sidebar">
<div class="brand"> <div class="brand">
<img class="brand-logo" src="/logo.svg" alt="宁波嘉恒智能科技有限公司 logo" /> <img class="brand-logo" src="/logo-mark.png" alt="宁波百华智能科技有限公司 logo" />
<div> <div>
<h1>嘉恒智能</h1> <h1>百华智能</h1>
</div> </div>
</div> </div>
@@ -60,7 +60,7 @@
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="status-chip"> <div class="status-chip">
<span class="status-dot"></span> <span class="status-dot"></span>
采购、嘉恒仓库、生产、发货、财务、售后、系统拓展已接入真实业务流 采购、百华仓库、生产、发货、财务、售后、系统拓展已接入真实业务流
</div> </div>
</div> </div>
</aside> </aside>
@@ -110,7 +110,7 @@
<span class="assistant-bot-eye"></span> <span class="assistant-bot-eye"></span>
</span> </span>
</button> </button>
<div class="user-menu-shell"> <div ref="userMenuShellRef" class="user-menu-shell">
<button class="user-avatar-button" type="button" @click="toggleUserMenu"> <button class="user-avatar-button" type="button" @click="toggleUserMenu">
<span class="user-avatar">{{ avatarInitial }}</span> <span class="user-avatar">{{ avatarInitial }}</span>
<span class="user-avatar-meta"> <span class="user-avatar-meta">
@@ -186,8 +186,7 @@
</span> </span>
<div> <div>
<p class="eyebrow">AI 助手</p> <p class="eyebrow">AI 助手</p>
<h3>嘉恒工艺助手</h3> <h3>百华工艺助手</h3>
<p>当前为内置占位对话,真实 LLM 可在 08 系统拓展中配置。</p>
</div> </div>
</div> </div>
<button class="ghost-button" type="button" @click="closeAssistantDrawer">关闭</button> <button class="ghost-button" type="button" @click="closeAssistantDrawer">关闭</button>
@@ -251,7 +250,6 @@
<div> <div>
<p class="eyebrow">管理员操作</p> <p class="eyebrow">管理员操作</p>
<h3>重置他人密码</h3> <h3>重置他人密码</h3>
<p>用于协助其他用户忘记密码时重置登录密码,仅系统管理员可用。</p>
</div> </div>
<button class="ghost-button" type="button" @click="closePasswordModals">关闭</button> <button class="ghost-button" type="button" @click="closePasswordModals">关闭</button>
</header> </header>
@@ -307,6 +305,7 @@ const activeBroadcasts = ref([]);
const activeBroadcastIndex = ref(0); const activeBroadcastIndex = ref(0);
const assistantDrawerOpen = ref(false); const assistantDrawerOpen = ref(false);
const userMenuOpen = ref(false); const userMenuOpen = ref(false);
const userMenuShellRef = ref(null);
const changePasswordOpen = ref(false); const changePasswordOpen = ref(false);
const resetPasswordOpen = ref(false); const resetPasswordOpen = ref(false);
const passwordSubmitting = ref(false); const passwordSubmitting = ref(false);
@@ -326,7 +325,7 @@ const chatMessages = ref([
{ {
id: 1, id: 1,
role: "assistant", role: "assistant",
content: "你好,我是嘉恒工艺助手。你可以问我系统操作路径、字段含义、业务流程或异常排查。" content: "你好,我是百华工艺助手。你可以问我系统操作路径、字段含义、业务流程或异常排查。"
} }
]); ]);
const changePasswordForm = ref({ const changePasswordForm = ref({
@@ -523,7 +522,7 @@ const navStages = [
key: "procurement", key: "procurement",
step: "04", step: "04",
label: "采购与库存", label: "采购与库存",
desc: "采购、自家料入库、质量检验和嘉恒仓库。", desc: "采购、自家料入库、质量检验和百华仓库。",
tail: "采购", tail: "采购",
icon: "warehouse", icon: "warehouse",
iconPaths: [ iconPaths: [
@@ -589,7 +588,7 @@ const navStages = [
}, },
{ {
key: "inventory-ledger", key: "inventory-ledger",
label: "嘉恒仓库", label: "百华仓库",
icon: "warehouse", icon: "warehouse",
to: { name: "inventory-ledger" }, to: { name: "inventory-ledger" },
routeNames: ["inventory-ledger"], routeNames: ["inventory-ledger"],
@@ -765,7 +764,7 @@ const navStages = [
]; ];
const isAuthLayout = computed(() => route.meta.layout === "auth"); const isAuthLayout = computed(() => route.meta.layout === "auth");
const currentTitle = computed(() => route.meta.title || "嘉恒智能五金 ERP"); const currentTitle = computed(() => route.meta.title || "百华智能五金 ERP");
const shellClass = computed(() => ({ const shellClass = computed(() => ({
shell: true, shell: true,
"admin-shell": true, "admin-shell": true,
@@ -1011,9 +1010,20 @@ function handleTagContextAction(action) {
function handleGlobalKeydown(event) { function handleGlobalKeydown(event) {
if (event.key === "Escape") { if (event.key === "Escape") {
closeTagContextMenu(); closeTagContextMenu();
userMenuOpen.value = false;
} }
} }
function handleUserMenuOutsidePointerDown(event) {
if (!userMenuOpen.value) {
return;
}
if (userMenuShellRef.value?.contains(event.target)) {
return;
}
userMenuOpen.value = false;
}
function updateShellViewportState() { function updateShellViewportState() {
narrowShell.value = window.innerWidth < 1024; narrowShell.value = window.innerWidth < 1024;
if (narrowShell.value) { if (narrowShell.value) {
@@ -1040,6 +1050,7 @@ onMounted(() => {
window.addEventListener("resize", updateShellViewportState); window.addEventListener("resize", updateShellViewportState);
window.addEventListener("click", closeTagContextMenu); window.addEventListener("click", closeTagContextMenu);
window.addEventListener("keydown", handleGlobalKeydown); window.addEventListener("keydown", handleGlobalKeydown);
document.addEventListener("pointerdown", handleUserMenuOutsidePointerDown);
void loadActiveBroadcast(); void loadActiveBroadcast();
void loadSmartOperationFeature(); void loadSmartOperationFeature();
broadcastTimer = window.setInterval(() => { broadcastTimer = window.setInterval(() => {
@@ -1054,6 +1065,7 @@ onUnmounted(() => {
window.removeEventListener("resize", updateShellViewportState); window.removeEventListener("resize", updateShellViewportState);
window.removeEventListener("click", closeTagContextMenu); window.removeEventListener("click", closeTagContextMenu);
window.removeEventListener("keydown", handleGlobalKeydown); window.removeEventListener("keydown", handleGlobalKeydown);
document.removeEventListener("pointerdown", handleUserMenuOutsidePointerDown);
if (broadcastTimer) { if (broadcastTimer) {
window.clearInterval(broadcastTimer); window.clearInterval(broadcastTimer);
broadcastTimer = null; broadcastTimer = null;
@@ -1188,7 +1200,7 @@ function sendAssistantMessage() {
chatMessages.value.push({ chatMessages.value.push({
id: Date.now() + 1, id: Date.now() + 1,
role: "assistant", role: "assistant",
content: "我已收到。当前聊天窗口已就绪,后续在 08 系统拓展配置 LLM API 后,可以把这里切换为真实模型回复。现在我可以先作为操作入口,帮助你记录和梳理 ERP 问题。" content: "已收到。"
}); });
} }
@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(join(__dirname, "FormDrawer.vue"), "utf8");
describe("FormDrawer copy discipline", () => {
it("does not render description copy as an always-visible header paragraph", () => {
assert.doesNotMatch(source, /class="form-drawer-description"/);
assert.doesNotMatch(source, /aria-describedby="description/);
});
});
+3 -5
View File
@@ -1,14 +1,13 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div v-if="open" class="workflow-drawer-mask" @click.self="emit('close')"> <div v-if="open" class="workflow-drawer-mask" @click.self="emit('close')">
<aside class="workflow-drawer form-drawer" :class="drawerClass" :aria-describedby="description ? descriptionId : undefined"> <aside class="workflow-drawer form-drawer" :class="drawerClass">
<header class="workflow-drawer-head"> <header class="workflow-drawer-head">
<div> <div>
<p class="eyebrow">{{ eyebrow }}</p> <p class="eyebrow">{{ eyebrow }}</p>
<div class="drawer-title-row" :title="description || undefined"> <div class="drawer-title-row">
<h3>{{ title }}</h3> <h3>{{ title }}</h3>
</div> </div>
<p v-if="description" :id="descriptionId" class="form-drawer-description">{{ description }}</p>
</div> </div>
<div class="workflow-drawer-head-actions"> <div class="workflow-drawer-head-actions">
<span v-if="tag" class="panel-tag">{{ tag }}</span> <span v-if="tag" class="panel-tag">{{ tag }}</span>
@@ -27,7 +26,7 @@
</template> </template>
<script setup> <script setup>
import { computed, toRef, useId } from "vue"; import { computed, toRef } from "vue";
import { useBodyScrollLock } from "../composables/useBodyScrollLock"; import { useBodyScrollLock } from "../composables/useBodyScrollLock";
import AppIcon from "./AppIcon.vue"; import AppIcon from "./AppIcon.vue";
@@ -64,7 +63,6 @@ const props = defineProps({
}); });
const emit = defineEmits(["close"]); const emit = defineEmits(["close"]);
const descriptionId = `form-drawer-description-${useId()}`;
const drawerClass = computed(() => { const drawerClass = computed(() => {
const normalizedSize = ["normal", "wide", "xl", "fullscreen"].includes(props.size) ? props.size : "normal"; const normalizedSize = ["normal", "wide", "xl", "fullscreen"].includes(props.size) ? props.size : "normal";
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(join(__dirname, "OrgPermissionTree.vue"), "utf8");
describe("OrgPermissionTree hierarchy lines", () => {
it("draws connector lines only between each node and its direct children", () => {
assert.match(source, /\.org-tree-children\s*>\s*\.org-tree-branch::before/);
assert.match(source, /\.org-tree-children\s*>\s*\.org-tree-branch\s*>\s*\.org-tree-node::before/);
assert.doesNotMatch(source, /\.org-tree-children::before/);
assert.doesNotMatch(source, /^\.org-tree-branch::before/m);
assert.match(source, /--tree-connector-x/);
assert.match(source, /--tree-node-start/);
assert.match(source, /--tree-arrow-gap/);
assert.match(source, /--tree-connector-color/);
assert.match(source, /--tree-connector-color:\s*rgba\(148,\s*163,\s*184/);
assert.match(source, /--tree-connector-width:\s*2px/);
assert.match(source, /border-left:\s*var\(--tree-connector-width\)\s*dashed\s*var\(--tree-connector-color\)/);
assert.match(source, /border-top:\s*var\(--tree-connector-width\)\s*dashed\s*var\(--tree-connector-color\)/);
assert.doesNotMatch(source, /background:\s*var\(--tree-connector-color\)/);
});
});
describe("OrgPermissionTree employee list", () => {
it("shows each employee organization path with full-value hover text", () => {
assert.match(source, /<th>组织路径<\/th>/);
assert.match(source, /employeeOrganizationPath\(employee\)/);
assert.match(source, /class="org-path-value"\s+:title="employeeOrganizationPath\(employee\)"/);
assert.match(source, /class="org-path-tail"\s+:title="employeeOrganizationPath\(employee\)"/);
assert.match(source, /function employeeOrganizationTail\(employee\)/);
assert.doesNotMatch(source, /org-path-pill/);
assert.match(source, /<td colspan="7" class="empty-row"/);
});
it("keeps row action buttons inside an inner flex container instead of flexing the table cell", () => {
assert.match(source, /<td class="action-row">\s*<div class="employee-actions">/);
assert.match(source, /\.employee-actions\s*\{[\s\S]*?display:\s*inline-flex;/);
assert.doesNotMatch(source, /\.action-row\s*\{[^}]*display:\s*flex;/);
});
});
describe("OrgPermissionTree node action parity", () => {
it("exposes a visible current-node action entry that emits the shared node context menu event", () => {
assert.match(source, /节点操作/);
assert.match(source, /@click\.stop="openSelectedNodeMenu"/);
assert.match(source, /function openSelectedNodeMenu\(event\)/);
assert.match(source, /emit\("node-contextmenu"/);
assert.doesNotMatch(source, /function runNodeAction\(/);
assert.doesNotMatch(source, /function nodeActions\(/);
});
it("keeps the original tree-node right-click path routed to the shared context menu event", () => {
assert.match(source, /@open-menu="openNodeMenu"/);
assert.match(source, /onContextmenu:\s*openMenu/);
assert.match(source, /function openMenu\(event\)[\s\S]*?branchEmit\("open-menu"/);
assert.match(source, /function openNodeMenu\(payload\)[\s\S]*?emit\("node-contextmenu",\s*payload\)/);
});
});
describe("OrgPermissionTree manager visibility", () => {
it("shows organization managers in both tree nodes and the selected-node header", () => {
assert.match(source, /class="node-manager-line"\s+:title="selectedNodeManagerText"/);
assert.match(source, /const selectedNodeManagerText = computed\(\(\) => selectedNode\.value \? nodeManagerText\(selectedNode\.value\) : "主管:未选择组织"\);/);
assert.match(source, /class:\s*"tree-node-manager",\s*title:\s*nodeManagerText\(branchProps\.node\)/);
assert.match(source, /function nodeManagerNames\(node\)/);
assert.match(source, /function nodeManagerText\(node\)/);
assert.match(source, /主管:未设置/);
});
});
+261 -40
View File
@@ -35,16 +35,27 @@
<div> <div>
<p class="eyebrow">组织人员</p> <p class="eyebrow">组织人员</p>
<h3>{{ selectedNodeLabel }}</h3> <h3>{{ selectedNodeLabel }}</h3>
<p class="permission-note">当前节点人员共 {{ selectedEmployees.length }} 人</p> <p class="node-manager-line" :title="selectedNodeManagerText">{{ selectedNodeManagerText }}</p>
<span class="status-pill employee-count-pill">人员 {{ selectedEmployees.length }}</span>
</div>
<div class="employee-head-actions">
<button
class="ghost-button node-action-button"
type="button"
:disabled="!selectedNode"
@click.stop="openSelectedNodeMenu"
>
节点操作
</button>
<button
class="primary-button"
type="button"
:disabled="!canAddEmployee"
@click="addEmployee"
>
新增人员
</button>
</div> </div>
<button
class="primary-button"
type="button"
:disabled="!canAddEmployee"
@click="addEmployee"
>
新增人员
</button>
</div> </div>
<label class="employee-search-field"> <label class="employee-search-field">
@@ -54,9 +65,19 @@
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table compact-table org-employee-table"> <table class="data-table compact-table org-employee-table">
<colgroup>
<col class="employee-col-name" />
<col class="employee-col-path" />
<col class="employee-col-phone" />
<col class="employee-col-position" />
<col class="employee-col-role" />
<col class="employee-col-status" />
<col class="employee-col-action" />
</colgroup>
<thead> <thead>
<tr> <tr>
<th>姓名</th> <th>姓名</th>
<th>组织路径</th>
<th>电话号码 / 账号</th> <th>电话号码 / 账号</th>
<th>岗位</th> <th>岗位</th>
<th>角色</th> <th>角色</th>
@@ -67,6 +88,10 @@
<tbody> <tbody>
<tr v-for="employee in paginatedEmployees" :key="employeeKey(employee)"> <tr v-for="employee in paginatedEmployees" :key="employeeKey(employee)">
<td>{{ employeeName(employee) }}</td> <td>{{ employeeName(employee) }}</td>
<td class="org-path-cell">
<span class="org-path-value" :title="employeeOrganizationPath(employee)">{{ employeeOrganizationPath(employee) }}</span>
<span class="org-path-tail" :title="employeeOrganizationPath(employee)">{{ employeeOrganizationTail(employee) }}</span>
</td>
<td>{{ employeePhone(employee) }}</td> <td>{{ employeePhone(employee) }}</td>
<td>{{ employeePosition(employee) }}</td> <td>{{ employeePosition(employee) }}</td>
<td>{{ employeeRoles(employee) }}</td> <td>{{ employeeRoles(employee) }}</td>
@@ -74,12 +99,14 @@
<StatusBadge :value="employee?.status" :text="employeeStatus(employee)" /> <StatusBadge :value="employee?.status" :text="employeeStatus(employee)" />
</td> </td>
<td class="action-row"> <td class="action-row">
<button class="ghost-button" type="button" @click="authorizeEmployee(employee)">授权</button> <div class="employee-actions">
<button class="ghost-button danger" type="button" @click="removeEmployee(employee)">移除</button> <button class="ghost-button" type="button" @click="authorizeEmployee(employee)">人员授权</button>
<button class="ghost-button danger" type="button" @click="removeEmployee(employee)">移除记录</button>
</div>
</td> </td>
</tr> </tr>
<tr v-if="!filteredEmployees.length"> <tr v-if="!filteredEmployees.length">
<td colspan="6" class="empty-row">{{ selectedEmployees.length ? "没有符合条件的人员" : "当前节点暂无人员" }}</td> <td colspan="7" class="empty-row">{{ selectedEmployees.length ? "没有符合条件的人员" : "当前节点暂无人员" }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -95,6 +122,7 @@ import { computed, defineComponent, h, ref, watch } from "vue";
import PaginationBar from "./PaginationBar.vue"; import PaginationBar from "./PaginationBar.vue";
import StatusBadge from "./StatusBadge.vue"; import StatusBadge from "./StatusBadge.vue";
import { collectOrganizationEmployees, countOrganizationEmployees } from "../utils/orgTreeEmployees";
import { usePagination } from "../utils/pagination"; import { usePagination } from "../utils/pagination";
const props = defineProps({ const props = defineProps({
@@ -164,13 +192,16 @@ const OrgTreeBranch = defineComponent({
} }
return () => return () =>
h("div", { class: "org-tree-branch" }, [ h("div", {
class: ["org-tree-branch", { "org-tree-branch-root": branchProps.level === 0 }],
style: treeLevelStyle(branchProps.level)
}, [
h( h(
"button", "button",
{ {
type: "button", type: "button",
class: ["org-tree-node", { active: isSelected.value }], class: ["org-tree-node", { active: isSelected.value }],
style: { "--tree-level": branchProps.level }, style: treeLevelStyle(branchProps.level),
onClick: selectCurrent, onClick: selectCurrent,
onContextmenu: openMenu onContextmenu: openMenu
}, },
@@ -186,7 +217,8 @@ const OrgTreeBranch = defineComponent({
h("span", { class: ["tree-node-icon", `tree-node-icon-${String(branchProps.node?.node_type || "ORG").toLowerCase()}`] }, nodeTypeShortLabel(branchProps.node?.node_type)), h("span", { class: ["tree-node-icon", `tree-node-icon-${String(branchProps.node?.node_type || "ORG").toLowerCase()}`] }, nodeTypeShortLabel(branchProps.node?.node_type)),
h("span", { class: "tree-node-main" }, [ h("span", { class: "tree-node-main" }, [
h("strong", null, nodeLabel(branchProps.node)), h("strong", null, nodeLabel(branchProps.node)),
h("small", null, `${nodeTypeLabel(branchProps.node?.node_type)} · ${employeeCount(branchProps.node)} 人`) h("small", null, `${nodeTypeLabel(branchProps.node?.node_type)} · ${employeeCount(branchProps.node)} 人`),
h("small", { class: "tree-node-manager", title: nodeManagerText(branchProps.node) }, nodeManagerText(branchProps.node))
]) ])
] ]
), ),
@@ -225,7 +257,8 @@ const requestedNode = computed(() => findNodeById(organizationNodes.value, props
const selectedNode = computed(() => requestedNode.value || firstRootNode.value); const selectedNode = computed(() => requestedNode.value || firstRootNode.value);
const effectiveSelectedNodeId = computed(() => getNodeId(selectedNode.value) ?? ""); const effectiveSelectedNodeId = computed(() => getNodeId(selectedNode.value) ?? "");
const selectedNodeLabel = computed(() => selectedNode.value ? nodeLabel(selectedNode.value) : "未选择组织"); const selectedNodeLabel = computed(() => selectedNode.value ? nodeLabel(selectedNode.value) : "未选择组织");
const selectedEmployees = computed(() => Array.isArray(selectedNode.value?.employees) ? selectedNode.value.employees : []); const selectedNodeManagerText = computed(() => selectedNode.value ? nodeManagerText(selectedNode.value) : "主管:未选择组织");
const selectedEmployees = computed(() => collectOrganizationEmployees(selectedNode.value));
const canAddEmployee = computed(() => ["DEPARTMENT", "GROUP"].includes(selectedNode.value?.node_type)); const canAddEmployee = computed(() => ["DEPARTMENT", "GROUP"].includes(selectedNode.value?.node_type));
const filteredEmployees = computed(() => { const filteredEmployees = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase(); const keyword = searchKeyword.value.trim().toLowerCase();
@@ -233,7 +266,7 @@ const filteredEmployees = computed(() => {
return selectedEmployees.value; return selectedEmployees.value;
} }
return selectedEmployees.value.filter((employee) => return selectedEmployees.value.filter((employee) =>
[employeeName(employee), employeePhone(employee), employee.employee_code, employee.username] [employeeName(employee), employeeOrganizationPath(employee), employeePhone(employee), employee.employee_code, employee.username]
.filter(Boolean) .filter(Boolean)
.join(" ") .join(" ")
.toLowerCase() .toLowerCase()
@@ -263,6 +296,16 @@ function organizationChildren(node) {
return (Array.isArray(node?.children) ? node.children : []).filter(isOrganizationNode); return (Array.isArray(node?.children) ? node.children : []).filter(isOrganizationNode);
} }
function treeLevelStyle(level) {
const numericLevel = Math.max(Number(level || 0), 0);
return {
"--tree-level": numericLevel,
"--tree-connector-x": `${15 + Math.max(numericLevel - 1, 0) * 22}px`,
"--tree-node-start": `${6 + numericLevel * 22}px`
};
}
function filterOrgTreeByKeyword(nodes, keyword) { function filterOrgTreeByKeyword(nodes, keyword) {
const normalizedKeyword = String(keyword || "").trim().toLowerCase(); const normalizedKeyword = String(keyword || "").trim().toLowerCase();
if (!normalizedKeyword) { if (!normalizedKeyword) {
@@ -344,26 +387,23 @@ function nodeTypeShortLabel(type) {
}[type] || "组"; }[type] || "组";
} }
function employeeCount(node) { function nodeManagerNames(node) {
const employeeKeys = new Set(); if (Array.isArray(node?.manager_names) && node.manager_names.length) {
collectEmployeeKeys(node, employeeKeys); return node.manager_names.filter(Boolean);
return employeeKeys.size; }
if (node?.manager_name) {
return [node.manager_name];
}
return [];
} }
function collectEmployeeKeys(node, employeeKeys) { function nodeManagerText(node) {
if (!node) { const names = nodeManagerNames(node);
return; return names.length ? `主管:${names.join("、")}` : "主管:未设置";
} }
for (const employee of Array.isArray(node.employees) ? node.employees : []) {
employeeKeys.add(String(employeeKey(employee))); function employeeCount(node) {
} return countOrganizationEmployees(node);
for (const child of Array.isArray(node.children) ? node.children : []) {
if (isOrganizationNode(child)) {
collectEmployeeKeys(child, employeeKeys);
} else if (child?.employee_id) {
employeeKeys.add(String(employeeKey(child)));
}
}
} }
function toggleNode(node) { function toggleNode(node) {
@@ -385,6 +425,18 @@ function openNodeMenu(payload) {
emit("node-contextmenu", payload); emit("node-contextmenu", payload);
} }
function openSelectedNodeMenu(event) {
if (!selectedNode.value) {
return;
}
const rect = event.currentTarget?.getBoundingClientRect?.();
emit("node-contextmenu", {
node: selectedNode.value,
x: rect ? rect.left : event.clientX,
y: rect ? rect.bottom + 6 : event.clientY
});
}
function employeeName(employee) { function employeeName(employee) {
return employee?.employee_name || employee?.display_name || employee?.name || employee?.username || "未命名人员"; return employee?.employee_name || employee?.display_name || employee?.name || employee?.username || "未命名人员";
} }
@@ -397,6 +449,15 @@ function employeePosition(employee) {
return employee?.job_title || employee?.position || employee?.post_name || employee?.title || "未设置岗位"; return employee?.job_title || employee?.position || employee?.post_name || employee?.title || "未设置岗位";
} }
function employeeOrganizationPath(employee) {
return employee?.organization_path || employee?.parent_node_label || "未设置组织";
}
function employeeOrganizationTail(employee) {
const pathParts = Array.isArray(employee?.organization_path_parts) ? employee.organization_path_parts.filter(Boolean) : [];
return pathParts.length ? pathParts[pathParts.length - 1] : employeeOrganizationPath(employee);
}
function employeeRoles(employee) { function employeeRoles(employee) {
if (Array.isArray(employee?.role_names) && employee.role_names.length) { if (Array.isArray(employee?.role_names) && employee.role_names.length) {
return employee.role_names.join("、"); return employee.role_names.join("、");
@@ -424,8 +485,8 @@ function employeeKey(employee) {
function employeeWithParent(employee) { function employeeWithParent(employee) {
return { return {
...employee, ...employee,
parent_node_id: getNodeId(selectedNode.value), parent_node_id: employee?.parent_node_id ?? getNodeId(selectedNode.value),
parent_node_label: nodeLabel(selectedNode.value) parent_node_label: employee?.parent_node_label ?? nodeLabel(selectedNode.value)
}; };
} }
@@ -489,6 +550,24 @@ function removeEmployee(employee) {
align-items: center; align-items: center;
} }
.employee-head-actions {
display: inline-flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.node-action-button {
border: 1px solid rgba(148, 163, 184, 0.5);
background: #ffffff;
color: #334155;
}
.node-action-button:hover:not(:disabled) {
background: #eff6ff;
color: #1d4ed8;
}
.eyebrow { .eyebrow {
margin: 0 0 6px; margin: 0 0 6px;
color: #2563eb; color: #2563eb;
@@ -504,13 +583,16 @@ function removeEmployee(employee) {
font-size: 18px; font-size: 18px;
} }
.permission-note,
.empty-state { .empty-state {
margin: 6px 0 0; margin: 6px 0 0;
color: #64748b; color: #64748b;
font-size: 13px; font-size: 13px;
} }
.employee-count-pill {
margin-top: 8px;
}
.status-pill { .status-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -567,6 +649,10 @@ function removeEmployee(employee) {
} }
.org-tree-list { .org-tree-list {
--tree-connector-color: rgba(148, 163, 184, 0.92);
--tree-connector-width: 2px;
--tree-arrow-gap: 6px;
--tree-node-center: 20px;
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
@@ -579,12 +665,30 @@ function removeEmployee(employee) {
} }
.org-tree-branch { .org-tree-branch {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
} }
.org-tree-children > .org-tree-branch::before {
content: "";
position: absolute;
top: calc(-1 * var(--tree-connector-width));
bottom: calc(-1 * var(--tree-connector-width));
left: var(--tree-connector-x);
width: 0;
border-left: var(--tree-connector-width) dashed var(--tree-connector-color);
pointer-events: none;
}
.org-tree-children > .org-tree-branch:last-child::before {
bottom: auto;
height: calc(var(--tree-node-center) + var(--tree-connector-width));
}
.org-tree-node { .org-tree-node {
position: relative;
display: grid; display: grid;
grid-template-columns: 18px 24px minmax(0, 1fr); grid-template-columns: 18px 24px minmax(0, 1fr);
align-items: center; align-items: center;
@@ -600,6 +704,17 @@ function removeEmployee(employee) {
transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease; transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
} }
.org-tree-children > .org-tree-branch > .org-tree-node::before {
content: "";
position: absolute;
top: var(--tree-node-center);
left: var(--tree-connector-x);
width: max(8px, calc(var(--tree-node-start) - var(--tree-connector-x) - var(--tree-arrow-gap)));
height: 0;
border-top: var(--tree-connector-width) dashed var(--tree-connector-color);
pointer-events: none;
}
.org-tree-node:hover, .org-tree-node:hover,
.org-tree-node.active { .org-tree-node.active {
border-color: transparent; border-color: transparent;
@@ -670,6 +785,7 @@ function removeEmployee(employee) {
} }
.org-tree-children { .org-tree-children {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
@@ -734,9 +850,23 @@ function removeEmployee(employee) {
} }
.action-row { .action-row {
display: flex; overflow: visible;
flex-wrap: wrap; white-space: nowrap;
}
.employee-actions {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 8px; gap: 8px;
min-width: max-content;
}
.employee-actions .ghost-button {
flex: 0 0 auto;
min-height: 30px;
padding: 0 10px;
font-size: 12px;
} }
.empty-row { .empty-row {
@@ -749,13 +879,92 @@ function removeEmployee(employee) {
table-layout: fixed; table-layout: fixed;
} }
.org-employee-table .employee-col-name {
width: 10%;
}
.org-employee-table .employee-col-path {
width: 30%;
}
.org-employee-table .employee-col-phone {
width: 13%;
}
.org-employee-table .employee-col-position {
width: 10%;
}
.org-employee-table .employee-col-role {
width: 11%;
}
.org-employee-table .employee-col-status {
width: 7%;
}
.org-employee-table .employee-col-action {
width: 19%;
}
.org-path-cell {
min-width: 0;
}
.org-path-value,
.org-path-tail {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.org-path-value {
color: #334155;
font-size: 13px;
font-weight: 700;
line-height: 1.35;
}
.org-path-tail {
width: fit-content;
max-width: 100%;
margin-top: 3px;
border-radius: 999px;
padding: 2px 7px;
background: #eef6ff;
color: #2563eb;
font-size: 11px;
font-weight: 750;
line-height: 1.2;
}
:deep(.org-tree-branch) { :deep(.org-tree-branch) {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
} }
:deep(.org-tree-children > .org-tree-branch::before) {
content: "";
position: absolute;
top: calc(-1 * var(--tree-connector-width));
bottom: calc(-1 * var(--tree-connector-width));
left: var(--tree-connector-x);
width: 0;
border-left: var(--tree-connector-width) dashed var(--tree-connector-color);
pointer-events: none;
}
:deep(.org-tree-children > .org-tree-branch:last-child::before) {
bottom: auto;
height: calc(var(--tree-node-center) + var(--tree-connector-width));
}
:deep(.org-tree-node) { :deep(.org-tree-node) {
position: relative;
display: grid; display: grid;
grid-template-columns: 18px 24px minmax(0, 1fr); grid-template-columns: 18px 24px minmax(0, 1fr);
align-items: center; align-items: center;
@@ -772,6 +981,17 @@ function removeEmployee(employee) {
transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease; transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
} }
:deep(.org-tree-children > .org-tree-branch > .org-tree-node::before) {
content: "";
position: absolute;
top: var(--tree-node-center);
left: var(--tree-connector-x);
width: max(8px, calc(var(--tree-node-start) - var(--tree-connector-x) - var(--tree-arrow-gap)));
height: 0;
border-top: var(--tree-connector-width) dashed var(--tree-connector-color);
pointer-events: none;
}
:deep(.org-tree-node:hover), :deep(.org-tree-node:hover),
:deep(.org-tree-node.active) { :deep(.org-tree-node.active) {
border-color: transparent; border-color: transparent;
@@ -842,6 +1062,7 @@ function removeEmployee(employee) {
} }
:deep(.org-tree-children) { :deep(.org-tree-children) {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
@@ -48,10 +48,7 @@
<div class="panel-header"> <div class="panel-header">
<div> <div>
<p class="eyebrow">步骤一</p> <p class="eyebrow">步骤一</p>
<span <span class="title-with-help">
class="title-with-help"
title="同一产品编码允许维护多个版本。编辑时如果修改版本号,系统会自动保存成新版本。"
>
<h3>产品资料</h3> <h3>产品资料</h3>
</span> </span>
</div> </div>
@@ -174,7 +171,7 @@
</div> </div>
</label> </label>
<div v-if="!suppliers.length" class="workflow-empty-card">还没有供应商,请先新增供应商。</div> <div v-if="!suppliers.length" class="workflow-empty-card">暂无供应商</div>
</div> </div>
<form v-if="showSupplierCreateForm" class="stack-form workflow-inline-form" @submit.prevent="createSupplier"> <form v-if="showSupplierCreateForm" class="stack-form workflow-inline-form" @submit.prevent="createSupplier">
@@ -276,9 +273,7 @@
</div> </div>
</label> </label>
<div v-if="!filteredMaterials.length" class="workflow-empty-card"> <div v-if="!filteredMaterials.length" class="workflow-empty-card">暂无可选原材料</div>
当前没有可选原材料,请先新建原材料或先勾选供应商。
</div>
</div> </div>
<form v-if="showMaterialCreateForm" class="stack-form workflow-inline-form" @submit.prevent="createMaterial"> <form v-if="showMaterialCreateForm" class="stack-form workflow-inline-form" @submit.prevent="createMaterial">
@@ -398,7 +393,7 @@
</div> </div>
</div> </div>
<div v-if="!currentProductId" class="workflow-empty-card">请先在步骤一保存产品资料,再维护该产品的 BOM。</div> <div v-if="!currentProductId" class="workflow-empty-card">保存产品后维护 BOM</div>
<template v-else> <template v-else>
<div class="table-wrap"> <div class="table-wrap">
@@ -547,14 +542,14 @@
<div class="panel-header"> <div class="panel-header">
<div> <div>
<p class="eyebrow">步骤五</p> <p class="eyebrow">步骤五</p>
<span class="title-with-help" title="工艺路线只绑定当前产品版本,参考节拍用于后续和真实报工节拍对比。"> <span class="title-with-help">
<h3>工艺路线</h3> <h3>工艺路线</h3>
</span> </span>
</div> </div>
<button class="ghost-button" type="button" :disabled="!currentProductId" @click="resetRouteForm">新建工艺路线</button> <button class="ghost-button" type="button" :disabled="!currentProductId" @click="resetRouteForm">新建工艺路线</button>
</div> </div>
<div v-if="!currentProductId" class="workflow-empty-card">请先在步骤一保存产品资料,再维护该产品的工艺路线。</div> <div v-if="!currentProductId" class="workflow-empty-card">保存产品后维护工艺路线</div>
<template v-else> <template v-else>
<div class="table-wrap"> <div class="table-wrap">
+1 -2
View File
@@ -4,7 +4,7 @@
<section class="stocktake-shell"> <section class="stocktake-shell">
<header class="stocktake-head"> <header class="stocktake-head">
<div> <div>
<p class="eyebrow">嘉恒仓库 · 盘库</p> <p class="eyebrow">百华仓库 · 盘库</p>
<h3>{{ activeStocktake ? activeStocktake.stocktake_no : "新建盘库" }}</h3> <h3>{{ activeStocktake ? activeStocktake.stocktake_no : "新建盘库" }}</h3>
</div> </div>
<button class="ghost-button" type="button" @click="$emit('close')">关闭</button> <button class="ghost-button" type="button" @click="$emit('close')">关闭</button>
@@ -36,7 +36,6 @@
<div class="stocktake-action-card stocktake-action-card-wide"> <div class="stocktake-action-card stocktake-action-card-wide">
<span class="stocktake-stage-tag">当前仓库:{{ currentWarehouseName }}</span> <span class="stocktake-stage-tag">当前仓库:{{ currentWarehouseName }}</span>
<strong>01 确认盘库并导出仓库清单</strong> <strong>01 确认盘库并导出仓库清单</strong>
<p>点击后系统会锁定当前仓库,并导出本次盘库 Excel 清单。盘库结束前该仓库不能出入库。</p>
<div class="stocktake-inline-actions"> <div class="stocktake-inline-actions">
<button class="primary-button" type="button" :disabled="busy || !currentWarehouseId" @click="startAndExportStocktake"> <button class="primary-button" type="button" :disabled="busy || !currentWarehouseId" @click="startAndExportStocktake">
{{ busy ? "处理中..." : "确认盘库并导出仓库清单" }} {{ busy ? "处理中..." : "确认盘库并导出仓库清单" }}
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(join(__dirname, "TableControls.vue"), "utf8");
const styles = readFileSync(join(__dirname, "../styles/main.css"), "utf8");
describe("TableControls wide-table scroll dock", () => {
it("creates an app-level horizontal scroll dock for wide mode instead of relying on native scrollbars", () => {
assert.match(source, /function ensureWideTableScrollDock\(/);
assert.match(source, /className = "table-scroll-dock"/);
assert.match(source, /aria-label", "宽表横向滚动"/);
assert.match(source, /table-scroll-dock-track/);
assert.match(source, /table-scroll-dock-thumb/);
assert.match(source, /table-scroll-dock-arrow/);
});
it("keeps the scroll dock synchronized with the table wrapper scroll position", () => {
assert.match(source, /function syncWideTableScrollDock\(/);
assert.match(source, /wideScrollWrap\.scrollLeft/);
assert.match(source, /wideScrollWrap\.scrollWidth - wideScrollWrap\.clientWidth/);
assert.match(source, /wideScrollThumb\.style\.transform/);
assert.match(source, /wideScrollThumb\.style\.width/);
});
it("supports dragging the custom thumb and using left-right step arrows", () => {
assert.match(source, /pointerdown/);
assert.match(source, /setPointerCapture/);
assert.match(source, /function nudgeWideTableScrollDock\(/);
assert.match(source, /wideScrollWrap\.scrollBy/);
});
it("styles the scroll dock as the accepted visible low-friction control", () => {
assert.match(styles, /body \.table-scroll-dock/);
assert.match(styles, /body \.table-scroll-dock-track/);
assert.match(styles, /body \.table-scroll-dock-thumb/);
assert.match(styles, /body \.table-scroll-dock-arrow/);
assert.match(styles, /body \.table-view-wide \+ \.table-scroll-dock/);
});
});
+219
View File
@@ -78,6 +78,13 @@ let headerObserver = null;
let enhanceFrame = 0; let enhanceFrame = 0;
let modeFrame = 0; let modeFrame = 0;
let isEnhancing = false; let isEnhancing = false;
let wideScrollDock = null;
let wideScrollWrap = null;
let wideScrollTrack = null;
let wideScrollThumb = null;
let wideScrollResizeObserver = null;
let wideScrollCleanupHandlers = [];
let wideScrollFrame = 0;
function normalizeLabel(value) { function normalizeLabel(value) {
return String(value || "") return String(value || "")
@@ -131,6 +138,212 @@ function clearTableViewClasses(table) {
delete table.dataset.tableViewMode; delete table.dataset.tableViewMode;
} }
function removeWideTableScrollDock() {
wideScrollCleanupHandlers.forEach((cleanup) => cleanup());
wideScrollCleanupHandlers = [];
if (wideScrollResizeObserver) {
wideScrollResizeObserver.disconnect();
wideScrollResizeObserver = null;
}
if (wideScrollFrame) {
window.cancelAnimationFrame(wideScrollFrame);
wideScrollFrame = 0;
}
wideScrollDock?.remove();
wideScrollDock = null;
wideScrollWrap = null;
wideScrollTrack = null;
wideScrollThumb = null;
}
function getWideScrollMetrics() {
if (!wideScrollWrap || !wideScrollTrack) {
return null;
}
const maxScroll = Math.max(0, wideScrollWrap.scrollWidth - wideScrollWrap.clientWidth);
const trackWidth = wideScrollTrack.clientWidth || 0;
const thumbWidth = maxScroll > 0
? Math.max(72, Math.min(trackWidth, (wideScrollWrap.clientWidth / wideScrollWrap.scrollWidth) * trackWidth))
: trackWidth;
const thumbTravel = Math.max(0, trackWidth - thumbWidth);
const progress = maxScroll > 0 ? wideScrollWrap.scrollLeft / maxScroll : 0;
return { maxScroll, trackWidth, thumbWidth, thumbTravel, progress };
}
function syncWideTableScrollDock() {
const metrics = getWideScrollMetrics();
if (!metrics || !wideScrollDock || !wideScrollThumb) {
return;
}
wideScrollDock.classList.toggle("table-scroll-dock-disabled", metrics.maxScroll <= 1);
wideScrollThumb.style.width = `${metrics.thumbWidth}px`;
wideScrollThumb.style.transform = `translateX(${metrics.thumbTravel * metrics.progress}px)`;
}
function scheduleWideTableScrollDockSync() {
if (wideScrollFrame) {
return;
}
wideScrollFrame = window.requestAnimationFrame(() => {
wideScrollFrame = 0;
syncWideTableScrollDock();
});
}
function nudgeWideTableScrollDock(direction) {
if (!wideScrollWrap) {
return;
}
wideScrollWrap.scrollBy({
left: direction * Math.max(180, wideScrollWrap.clientWidth * 0.42),
behavior: "smooth"
});
}
function createWideTableScrollDock() {
const dock = document.createElement("div");
dock.className = "table-scroll-dock";
dock.setAttribute("aria-label", "宽表横向滚动");
const leftArrow = document.createElement("button");
leftArrow.type = "button";
leftArrow.className = "table-scroll-dock-arrow table-scroll-dock-arrow-left";
leftArrow.setAttribute("aria-label", "向左滚动宽表");
leftArrow.innerHTML = '<span aria-hidden="true">‹</span>';
const track = document.createElement("div");
track.className = "table-scroll-dock-track";
const thumb = document.createElement("button");
thumb.type = "button";
thumb.className = "table-scroll-dock-thumb";
thumb.setAttribute("aria-label", "拖动查看宽表隐藏字段");
thumb.innerHTML = '<span aria-hidden="true"></span>';
const rightArrow = document.createElement("button");
rightArrow.type = "button";
rightArrow.className = "table-scroll-dock-arrow table-scroll-dock-arrow-right";
rightArrow.setAttribute("aria-label", "向右滚动宽表");
rightArrow.innerHTML = '<span aria-hidden="true">›</span>';
track.appendChild(thumb);
dock.append(leftArrow, track, rightArrow);
wideScrollTrack = track;
wideScrollThumb = thumb;
const handleTrackPointerDown = (event) => {
if (!wideScrollWrap || event.target === thumb) {
return;
}
const metrics = getWideScrollMetrics();
if (!metrics?.maxScroll || !metrics.thumbTravel) {
return;
}
const rect = track.getBoundingClientRect();
const targetProgress = Math.min(1, Math.max(0, (event.clientX - rect.left - metrics.thumbWidth / 2) / metrics.thumbTravel));
wideScrollWrap.scrollLeft = targetProgress * metrics.maxScroll;
syncWideTableScrollDock();
};
const handleThumbPointerDown = (event) => {
const metrics = getWideScrollMetrics();
if (!wideScrollWrap || !metrics?.maxScroll || !metrics.thumbTravel) {
return;
}
event.preventDefault();
thumb.setPointerCapture?.(event.pointerId);
dock.classList.add("table-scroll-dock-dragging");
const startX = event.clientX;
const startScrollLeft = wideScrollWrap.scrollLeft;
const handlePointerMove = (moveEvent) => {
const delta = moveEvent.clientX - startX;
wideScrollWrap.scrollLeft = startScrollLeft + (delta / metrics.thumbTravel) * metrics.maxScroll;
syncWideTableScrollDock();
};
const handlePointerUp = () => {
dock.classList.remove("table-scroll-dock-dragging");
thumb.removeEventListener("pointermove", handlePointerMove);
thumb.removeEventListener("pointerup", handlePointerUp);
thumb.removeEventListener("pointercancel", handlePointerUp);
};
thumb.addEventListener("pointermove", handlePointerMove);
thumb.addEventListener("pointerup", handlePointerUp);
thumb.addEventListener("pointercancel", handlePointerUp);
};
const handleKeyDown = (event) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
nudgeWideTableScrollDock(-1);
}
if (event.key === "ArrowRight") {
event.preventDefault();
nudgeWideTableScrollDock(1);
}
};
const handleLeftClick = () => nudgeWideTableScrollDock(-1);
const handleRightClick = () => nudgeWideTableScrollDock(1);
leftArrow.addEventListener("click", handleLeftClick);
rightArrow.addEventListener("click", handleRightClick);
track.addEventListener("pointerdown", handleTrackPointerDown);
thumb.addEventListener("pointerdown", handleThumbPointerDown);
thumb.addEventListener("keydown", handleKeyDown);
wideScrollCleanupHandlers.push(() => leftArrow.removeEventListener("click", handleLeftClick));
wideScrollCleanupHandlers.push(() => rightArrow.removeEventListener("click", handleRightClick));
wideScrollCleanupHandlers.push(() => track.removeEventListener("pointerdown", handleTrackPointerDown));
wideScrollCleanupHandlers.push(() => thumb.removeEventListener("pointerdown", handleThumbPointerDown));
wideScrollCleanupHandlers.push(() => thumb.removeEventListener("keydown", handleKeyDown));
return dock;
}
function ensureWideTableScrollDock(wrap) {
if (!wrap || tableMode.value !== "wide") {
removeWideTableScrollDock();
return;
}
if (wideScrollWrap !== wrap) {
removeWideTableScrollDock();
}
wideScrollWrap = wrap;
if (!wideScrollDock) {
wideScrollDock = createWideTableScrollDock();
wrap.insertAdjacentElement("afterend", wideScrollDock);
const handleWrapScroll = () => scheduleWideTableScrollDockSync();
wideScrollWrap.addEventListener("scroll", handleWrapScroll, { passive: true });
window.addEventListener("resize", scheduleWideTableScrollDockSync);
if (typeof ResizeObserver !== "undefined") {
wideScrollResizeObserver = new ResizeObserver(scheduleWideTableScrollDockSync);
wideScrollResizeObserver.observe(wideScrollWrap);
const table = wideScrollWrap.querySelector("table");
if (table) {
wideScrollResizeObserver.observe(table);
}
}
wideScrollCleanupHandlers.push(() => wideScrollWrap?.removeEventListener("scroll", handleWrapScroll));
wideScrollCleanupHandlers.push(() => window.removeEventListener("resize", scheduleWideTableScrollDockSync));
} else if (wideScrollDock.previousElementSibling !== wrap) {
wrap.insertAdjacentElement("afterend", wideScrollDock);
}
scheduleWideTableScrollDockSync();
}
function disconnectModeObserver() { function disconnectModeObserver() {
if (modeTableObserver) { if (modeTableObserver) {
modeTableObserver.disconnect(); modeTableObserver.disconnect();
@@ -320,6 +533,11 @@ function applyTableViewMode(table = findTableAfterControl()) {
refreshCellTooltips(table); refreshCellTooltips(table);
bindCellTooltip(table); bindCellTooltip(table);
observeTableForViewMode(table); observeTableForViewMode(table);
if (isWide) {
ensureWideTableScrollDock(wrap);
} else {
removeWideTableScrollDock();
}
} }
function scheduleApplyTableViewMode() { function scheduleApplyTableViewMode() {
@@ -573,6 +791,7 @@ onBeforeUnmount(() => {
modeFrame = 0; modeFrame = 0;
} }
clearTableViewClasses(modeTable); clearTableViewClasses(modeTable);
removeWideTableScrollDock();
disconnectModeObserver(); disconnectModeObserver();
clearTooltipListeners(); clearTooltipListeners();
clearEnhancedHeaders(); clearEnhancedHeaders();
@@ -0,0 +1,53 @@
<template>
<span ref="anchorRef" class="document-action-anchor" aria-hidden="true"></span>
<Teleport :to="actionHost" :disabled="!actionHost">
<div
class="document-action-bar"
:class="[
$attrs.class,
{ 'document-action-bar--with-state': $slots.state }
]"
@click.capture="handleActionClick"
>
<div v-if="$slots.state" class="document-action-state">
<slot name="state" />
</div>
<div class="document-action-buttons">
<slot />
</div>
</div>
</Teleport>
</template>
<script setup>
import { nextTick, onMounted, ref } from "vue";
defineOptions({
inheritAttrs: false
});
const anchorRef = ref(null);
const actionHost = ref(null);
onMounted(async () => {
await nextTick();
const form = anchorRef.value?.closest?.("form");
actionHost.value = form?.querySelector?.("[data-document-action-host]") || form?.querySelector?.("[data-warehouse-document-action-host]") || null;
});
function handleActionClick(event) {
const target = event.target;
const submitter = target?.closest?.("button[type='submit'], input[type='submit']");
if (!submitter || submitter.disabled) {
return;
}
const form = anchorRef.value?.closest?.("form");
if (!form) {
return;
}
event.preventDefault();
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
}
</script>
@@ -0,0 +1,55 @@
<template>
<component
:is="as"
class="document-paper-field"
:class="[
spanClass,
{
'document-paper-field-readonly': readonly,
'document-paper-field-upload': upload
}
]"
>
<span>{{ label }}</span>
<slot>
<p class="document-paper-readonly-value">{{ value || "-" }}</p>
</slot>
</component>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
label: {
type: String,
required: true
},
value: {
type: [String, Number],
default: ""
},
span: {
type: [Number, String],
default: 1
},
as: {
type: String,
default: "label"
},
readonly: {
type: Boolean,
default: false
},
upload: {
type: Boolean,
default: false
}
});
const spanClass = computed(() => {
if (String(props.span) === "full") return "document-paper-field-full";
if (Number(props.span) === 2) return "document-paper-field-wide";
return "";
});
</script>
@@ -0,0 +1,73 @@
<template>
<form class="document-form-shell" @submit.prevent="emit('submit')">
<DocumentPaper
:title="title"
:document-no="documentNo"
:company-name="companyName"
:tone="tone"
:signature-labels="signatureLabels"
>
<div class="document-form-meta" :aria-label="metaAriaLabel">
<span class="document-form-meta-pair">
<span>{{ metaLabel }}</span>
<strong>{{ companyName }}</strong>
</span>
<span class="document-form-meta-pair document-form-meta-date">
<span>{{ dateLabel }}</span>
<strong>{{ displayBusinessDate }}</strong>
</span>
</div>
<main class="document-form-body">
<slot />
</main>
</DocumentPaper>
</form>
</template>
<script setup>
import { computed } from "vue";
import DocumentPaper from "./DocumentPaper.vue";
import { formatChineseDate } from "../../utils/formatters";
const props = defineProps({
title: {
type: String,
required: true
},
documentNo: {
type: String,
default: ""
},
businessDate: {
type: [String, Date],
default: ""
},
tone: {
type: String,
default: "green"
},
companyName: {
type: String,
default: "宁波百华智能科技有限公司"
},
metaLabel: {
type: String,
default: "业务联单"
},
dateLabel: {
type: String,
default: "业务日期"
},
signatureLabels: {
type: Array,
default: () => ["制单", "经办", "审核", "财务"]
}
});
const emit = defineEmits(["submit"]);
const displayBusinessDate = computed(() => formatChineseDate(props.businessDate));
const metaAriaLabel = computed(() => `${props.metaLabel}基础信息`);
</script>
@@ -0,0 +1,18 @@
<template>
<div class="document-inline-summary" :class="toneClass">
<slot />
</div>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
tone: {
type: String,
default: "green"
}
});
const toneClass = computed(() => `document-inline-summary-${props.tone}`);
</script>
@@ -43,7 +43,11 @@
</div> </div>
</footer> </footer>
<div class="document-paper-action-host" data-warehouse-document-action-host></div> <div
class="document-paper-action-host"
data-document-action-host
data-warehouse-document-action-host
></div>
</article> </article>
</section> </section>
</template> </template>
@@ -62,7 +66,7 @@ const props = defineProps({
}, },
companyName: { companyName: {
type: String, type: String,
default: "嘉恒仓库 ERP" default: "百华仓库 ERP"
}, },
tone: { tone: {
type: String, type: String,

Some files were not shown because too many files have changed in this diff Show More