Compare commits

...

18 Commits
main ... BH_DEV

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
108 changed files with 7796 additions and 625 deletions

View File

@ -6,7 +6,7 @@ product
## Users ## Users
嘉恒五金 ERP 面向采购、销售、仓库、生产、财务、售后和管理人员。用户通常在真实业务现场或办公室中处理订单、入库、出库、生产台账、发货、权限和经营数据,需要快速、准确、少解释地完成工作。 百华五金 ERP 面向采购、销售、仓库、生产、财务、售后和管理人员。用户通常在真实业务现场或办公室中处理订单、入库、出库、生产台账、发货、权限和经营数据,需要快速、准确、少解释地完成工作。
## Product Purpose ## Product Purpose

View File

@ -1,6 +1,6 @@
# ForgeFlow ERP # ForgeFlow ERP
宁波嘉恒智能科技有限公司五金行业 ERP 初始骨架,采用前后端分离架构: 宁波百华智能科技有限公司五金行业 ERP 初始骨架,采用前后端分离架构:
- 前端Vue 3 + Vite - 前端Vue 3 + Vite
- 后端FastAPI - 后端FastAPI

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 "宁波百华智能科技有限公司"
) )

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,

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")

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.",
} }

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",

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"

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"),
)

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__ = (

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)

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

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

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": {},

View File

@ -0,0 +1 @@
"""Operational scripts for ForgeFlow ERP backend."""

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())

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密钥',

View File

@ -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

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密钥',

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

View File

@ -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)

View File

@ -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",
"仓库人员", "仓库人员",

View File

@ -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,

View File

@ -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()

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -1,4 +1,4 @@
# 嘉恒智能五金 ERP 生产使用案例 # 百华智能五金 ERP 生产使用案例
## 案例背景 ## 案例背景

View File

@ -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 确认盘库并导出仓库清单.

View File

@ -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. 点击“开始盘库并锁库”。

View File

@ -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.

View File

@ -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 projects existing local start commands. If the commands are already running, reuse them. Then verify: Run the backend and frontend using the projects 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 退货出库.

View File

@ -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. 废料库入库区有:退货废料入库。

View File

@ -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 生产废料入库、期初入库、委外废料入库.

View File

@ -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:

View File

@ -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.

View File

@ -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. 采购订单详情抽屉

View File

@ -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.

View File

@ -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
半成品入库字段显示“产品工序” 半成品入库字段显示“产品工序”

View File

@ -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 `返工入库`.

View File

@ -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: 生产执行页面**

View File

@ -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 件对应废料重量
-> 勾选结单 -> 勾选结单

View File

@ -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 大库的出库操作区都有“特殊出库”。
- 未填写说明时,不能添加/提交特殊调整明细。 - 未填写说明时,不能添加/提交特殊调整明细。
- 原材料库特殊出库只能填写重量,提交后对应库存批次重量减少。 - 原材料库特殊出库只能填写重量,提交后对应库存批次重量减少。
- 辅料库特殊入库只按数量增加,不写入重量。 - 辅料库特殊入库只按数量增加,不写入重量。

View File

@ -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 已结单.

View File

@ -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后端返回中文错误不保存单据。
``` ```

View File

@ -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", "百华仓库", []),
], ],
), ),
( (

View File

@ -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>

View File

@ -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,

View File

@ -185,7 +185,7 @@ const props = defineProps({
}, },
companyName: { companyName: {
type: String, type: String,
default: "宁波嘉恒智能科技有限公司" default: "宁波百华智能科技有限公司"
}, },
metaLabel: { metaLabel: {
type: String, type: String,
@ -496,7 +496,7 @@ defineProps({
}, },
companyName: { companyName: {
type: String, type: String,
default: "嘉恒仓库" default: "百华仓库"
}, },
signatureLabels: { signatureLabels: {
type: Array, type: Array,

View File

@ -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

View File

@ -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`。

View File

@ -4,14 +4,14 @@
当前系统同时存在两个发货相关入口: 当前系统同时存在两个发货相关入口:
- `嘉恒仓库 -> 成品库 -> 销售出库` - `百华仓库 -> 成品库 -> 销售出库`
- `发货与售后 -> 发货管理` - `发货与售后 -> 发货管理`
两个入口都会影响发货单、成品库存和后续退货追溯,容易让用户不知道应该在哪里执行发货,也容易造成业务口径重复。 两个入口都会影响发货单、成品库存和后续退货追溯,容易让用户不知道应该在哪里执行发货,也容易造成业务口径重复。
## 目标 ## 目标
采用方案 A将发货执行入口统一到 `嘉恒仓库 -> 成品库 -> 销售出库`,将 `发货与售后 -> 发货管理` 调整为发货台账/交付查询。 采用方案 A将发货执行入口统一到 `百华仓库 -> 成品库 -> 销售出库`,将 `发货与售后 -> 发货管理` 调整为发货台账/交付查询。
目标效果: 目标效果:
@ -25,7 +25,7 @@
### 成品库销售出库 ### 成品库销售出库
`嘉恒仓库 -> 成品库 -> 销售出库` 是唯一新增发货入口。 `百华仓库 -> 成品库 -> 销售出库` 是唯一新增发货入口。
该入口支持两种模式: 该入口支持两种模式:

View File

@ -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。
- 点击“流水”按钮。 - 点击“流水”按钮。
- 抽屉标题和当前仓库一致。 - 抽屉标题和当前仓库一致。

View File

@ -12,7 +12,7 @@
## 目标 ## 目标
- 在 `嘉恒仓库 -> 成品库` 的入库区新增 `返工入库` - 在 `百华仓库 -> 成品库` 的入库区新增 `返工入库`
- `返工入库` 必须选择产品,并关联该产品未完成回库的返工出库记录。 - `返工入库` 必须选择产品,并关联该产品未完成回库的返工出库记录。
- 选择产品后,系统带出该产品对应的原材料库存批次号多选框,用 `JH...` 原材料库存批次号做追溯。 - 选择产品后,系统带出该产品对应的原材料库存批次号多选框,用 `JH...` 原材料库存批次号做追溯。
- 成品库仍以数量为主维度,重量只作为辅助信息或追溯信息,不作为主要库存口径。 - 成品库仍以数量为主维度,重量只作为辅助信息或追溯信息,不作为主要库存口径。
@ -21,7 +21,7 @@
## 业务入口 ## 业务入口
`嘉恒仓库 -> 成品库 -> 入库 -> 返工入库` `百华仓库 -> 成品库 -> 入库 -> 返工入库`
该入口建议复用当前普通仓库入库抽屉样式,不另起一个完全不同的页面。字段建议为: 该入口建议复用当前普通仓库入库抽屉样式,不另起一个完全不同的页面。字段建议为:

View File

@ -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. 补齐自动化测试和启动检查。

View File

@ -1,8 +1,8 @@
# 嘉恒仓库全出入库单据纸质化与归档设计 # 百华仓库全出入库单据纸质化与归档设计
## 背景 ## 背景
嘉恒仓库已经有 6 大库:原材料库、半成品库、成品库、辅料库、废料库、退货库。当前出入库业务主要通过抽屉表单完成,保存后形成库存流水。用户已经认可 `原材料库 · 客料入库` 的纸质填单样板,但该样板目前只完成填写 UI没有生成 PDF 归档,也没有预览和下载入口。 百华仓库已经有 6 大库:原材料库、半成品库、成品库、辅料库、废料库、退货库。当前出入库业务主要通过抽屉表单完成,保存后形成库存流水。用户已经认可 `原材料库 · 客料入库` 的纸质填单样板,但该样板目前只完成填写 UI没有生成 PDF 归档,也没有预览和下载入口。
现有归档体系已覆盖销售订单、采购订单、到货入库单、质量校验单、生产领料出库单、生产入库结算单。仓库里大量非生产分支仍未纳入正式单据化:客料、委外、销售、退货、返工、特殊出入库、期初等。为避免后续继续碎片化,本方案把“所有仓库出入库填单纸质化、保存后 PDF 归档、流水入口预览下载、流水 Excel 导出”作为统一仓库单据化工程。 现有归档体系已覆盖销售订单、采购订单、到货入库单、质量校验单、生产领料出库单、生产入库结算单。仓库里大量非生产分支仍未纳入正式单据化:客料、委外、销售、退货、返工、特殊出入库、期初等。为避免后续继续碎片化,本方案把“所有仓库出入库填单纸质化、保存后 PDF 归档、流水入口预览下载、流水 Excel 导出”作为统一仓库单据化工程。
@ -167,7 +167,7 @@
所有仓库出入库抽屉采用统一纸质单据底座: 所有仓库出入库抽屉采用统一纸质单据底座:
- 抬头:嘉恒仓库、单据标题、单据编号或提交后生成提示。 - 抬头:百华仓库、单据标题、单据编号或提交后生成提示。
- 基础信息区:仓库、库位、业务类型、来源/去向、经办人、时间。 - 基础信息区:仓库、库位、业务类型、来源/去向、经办人、时间。
- 明细区:物料/产品/半成品/废料/退货品、库存批次号、来源库存批次号、数量、重量、单价、金额。 - 明细区:物料/产品/半成品/废料/退货品、库存批次号、来源库存批次号、数量、重量、单价、金额。
- 辅助信息区:运单号、运费、辅助照片、用途、包装备注、退货/返工/委外方、说明。 - 辅助信息区:运单号、运费、辅助照片、用途、包装备注、退货/返工/委外方、说明。
@ -227,7 +227,7 @@ PDF 不得出现:
### 各库流水抽屉 ### 各库流水抽屉
`嘉恒仓库 -> 工具 -> 流水` 中新增 `单据留档` 列。 `百华仓库 -> 工具 -> 流水` 中新增 `单据留档` 列。
每条流水根据后端返回的归档字段展示: 每条流水根据后端返回的归档字段展示:

View File

@ -2,7 +2,7 @@
## 背景 ## 背景
系统已经完成销售订单、采购订单、到货入库单、质量校验单的正式单据化与 PDF 归档。嘉恒仓库仍以业务抽屉和库存流水为主,用户在办理生产出库、生产入库、生产余料入库、生产废料入库时,缺少一张可预览、可下载、可留档的正式业务单据。 系统已经完成销售订单、采购订单、到货入库单、质量校验单的正式单据化与 PDF 归档。百华仓库仍以业务抽屉和库存流水为主,用户在办理生产出库、生产入库、生产余料入库、生产废料入库时,缺少一张可预览、可下载、可留档的正式业务单据。
仓库业务入口很多6 大库中存在采购、生产、委外、退货、销售、特殊调整等多条链路。如果一次性把所有出入库按钮都接入 PDF容易形成重复模板和重复归档逻辑。本次先以生产主链路作为第一期样板抽出仓库作业单通用底座后续再扩展到委外、退货、销售出库、特殊出入库。 仓库业务入口很多6 大库中存在采购、生产、委外、退货、销售、特殊调整等多条链路。如果一次性把所有出入库按钮都接入 PDF容易形成重复模板和重复归档逻辑。本次先以生产主链路作为第一期样板抽出仓库作业单通用底座后续再扩展到委外、退货、销售出库、特殊出入库。
@ -190,7 +190,7 @@
### 操作弹窗 ### 操作弹窗
生产主链路现有入口保留,不改变用户从嘉恒仓库进入业务的习惯。 生产主链路现有入口保留,不改变用户从百华仓库进入业务的习惯。
本期只做两类升级: 本期只做两类升级:

View File

@ -44,13 +44,13 @@
| 模块 | 页面 | 单据 | 当前问题 | 优先级 | | 模块 | 页面 | 单据 | 当前问题 | 优先级 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| 嘉恒仓库 | `InventoryLedgerView.vue` | 特殊入库单 / 特殊出库单 | 明细编辑区仍像现代卡片控件,和纸质单据割裂 | P0 | | 百华仓库 | `InventoryLedgerView.vue` | 特殊入库单 / 特殊出库单 | 明细编辑区仍像现代卡片控件,和纸质单据割裂 | P0 |
| 嘉恒仓库 | `InventoryLedgerView.vue` | 生产出库单 | 已使用纸质外壳,但来源批次选择、产品/BOM信息仍需统一纸质表格语言 | P0 | | 百华仓库 | `InventoryLedgerView.vue` | 生产出库单 | 已使用纸质外壳,但来源批次选择、产品/BOM信息仍需统一纸质表格语言 | P0 |
| 嘉恒仓库 | `InventoryLedgerView.vue` | 生产台账入库结算单 | 已有纸质外壳和摘要格,但三库联动字段、异常说明区域仍需与纸质单据模板对齐 | P0 | | 百华仓库 | `InventoryLedgerView.vue` | 生产台账入库结算单 | 已有纸质外壳和摘要格,但三库联动字段、异常说明区域仍需与纸质单据模板对齐 | P0 |
| 嘉恒仓库 | `InventoryLedgerView.vue` | 生产入库单 / 返工入库单 | 结单区和摘要区仍有旧卡片感 | P1 | | 百华仓库 | `InventoryLedgerView.vue` | 生产入库单 / 返工入库单 | 结单区和摘要区仍有旧卡片感 | P1 |
| 嘉恒仓库 | `InventoryLedgerView.vue` | 销售出库单 | 已套纸质外壳,但销售订单关联、包装备注、发货明细区域需统一 | P1 | | 百华仓库 | `InventoryLedgerView.vue` | 销售出库单 | 已套纸质外壳,但销售订单关联、包装备注、发货明细区域需统一 | P1 |
| 嘉恒仓库 | `InventoryLedgerView.vue` | 原材料退货出库单、退货入库单、退货废料入库单、返工出库单 | 已套纸质外壳,但字段区仍有普通表单/表格痕迹 | P1 | | 百华仓库 | `InventoryLedgerView.vue` | 原材料退货出库单、退货入库单、退货废料入库单、返工出库单 | 已套纸质外壳,但字段区仍有普通表单/表格痕迹 | P1 |
| 嘉恒仓库 | `InventoryLedgerView.vue` | 期初入库、委外入库/出库、报废出库、售卖出库等通用分支 | 通用分支第一阶段只统一了生产余料/废料一部分,仍需逐个验收 | P1 | | 百华仓库 | `InventoryLedgerView.vue` | 期初入库、委外入库/出库、报废出库、售卖出库等通用分支 | 通用分支第一阶段只统一了生产余料/废料一部分,仍需逐个验收 | P1 |
### 需要补充纸质化或确认是否作为单据 ### 需要补充纸质化或确认是否作为单据
@ -60,7 +60,7 @@
| 发货概览 | `ReturnManagementView.vue` | 退货/返工/废料处置 | 属于业务凭证,需要与退货库单据闭环统一 | P1 | | 发货概览 | `ReturnManagementView.vue` | 退货/返工/废料处置 | 属于业务凭证,需要与退货库单据闭环统一 | P1 |
| 发货概览 | `ScrapReworkView.vue` | 报废返工处理 | 若仍在使用,应按退货/返工处置单统一;若已被仓库/退货库替代,应清理或降级为台账 | P2 | | 发货概览 | `ScrapReworkView.vue` | 报废返工处理 | 若仍在使用,应按退货/返工处置单统一;若已被仓库/退货库替代,应清理或降级为台账 | P2 |
| 生产执行 | `CompletionReceiptView.vue` | 成品入库 | 旧入口如果仍可用,需要和仓库生产入库/生产台账入库统一;如果已被新流程替代,应标记为旧入口 | P2 | | 生产执行 | `CompletionReceiptView.vue` | 成品入库 | 旧入口如果仍可用,需要和仓库生产入库/生产台账入库统一;如果已被新流程替代,应标记为旧入口 | P2 |
| 嘉恒仓库 | `StocktakeDialog.vue` | 盘库单 | 盘库会改库存,应纳入纸质盘库单和 PDF 归档,但可排在特殊出入库之后 | P2 | | 百华仓库 | `StocktakeDialog.vue` | 盘库单 | 盘库会改库存,应纳入纸质盘库单和 PDF 归档,但可排在特殊出入库之后 | P2 |
## 推荐方案 ## 推荐方案

View File

@ -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 实体文件。默认不删除。

View File

@ -1,4 +1,4 @@
# 宁波嘉恒智能科技有限公司 ERP 设计方案 # 宁波百华智能科技有限公司 ERP 设计方案
## 1. 项目定位 ## 1. 项目定位

View File

@ -1,6 +1,6 @@
# 采购使用说明手册 # 采购使用说明手册
适用模块:`采购与库存 -> 采购订单`、`采购与库存 -> 到货入库`、`采购与库存 -> 质量检验`、`采购与库存 -> 嘉恒仓库`。 适用模块:`采购与库存 -> 采购订单`、`采购与库存 -> 到货入库`、`采购与库存 -> 质量检验`、`采购与库存 -> 百华仓库`。
本手册面向采购专员、仓库管理人员和管理人员,用于说明从采购下单、锁单、到货、质检、入库、退货到合同生成的完整操作方式。 本手册面向采购专员、仓库管理人员和管理人员,用于说明从采购下单、锁单、到货、质检、入库、退货到合同生成的完整操作方式。
@ -251,14 +251,14 @@ flowchart TD
| 已拒收 | 不进入可用库存,采购和仓库需要协同处理。 | | 已拒收 | 不进入可用库存,采购和仓库需要协同处理。 |
| 待质检 | 暂时不可用,不能当作可生产库存。 | | 待质检 | 暂时不可用,不能当作可生产库存。 |
如果材料入仓后才发现质量或性能问题,应在嘉恒仓库的 `原材料库 -> 退货出库` 中办理退货。 如果材料入仓后才发现质量或性能问题,应在百华仓库的 `原材料库 -> 退货出库` 中办理退货。
## 9. 原材料退货出库 ## 9. 原材料退货出库
入口: 入口:
```text ```text
采购与库存 -> 嘉恒仓库 -> 原材料库 -> 出库 -> 退货出库 采购与库存 -> 百华仓库 -> 原材料库 -> 出库 -> 退货出库
``` ```
适用场景: 适用场景:
@ -284,9 +284,9 @@ flowchart TD
| 某张采购订单是否锁单 | 采购与库存 -> 采购订单 | | 某张采购订单是否锁单 | 采购与库存 -> 采购订单 |
| 某采购订单到了多少 | 采购与库存 -> 到货入库 | | 某采购订单到了多少 | 采购与库存 -> 到货入库 |
| 到货是否质检放行 | 采购与库存 -> 质量检验 | | 到货是否质检放行 | 采购与库存 -> 质量检验 |
| 材料是否形成库存批次 | 采购与库存 -> 嘉恒仓库 -> 原材料库 | | 材料是否形成库存批次 | 采购与库存 -> 百华仓库 -> 原材料库 |
| 某采购订单是否存在退货 | 采购与库存 -> 采购订单,查看警示标记 | | 某采购订单是否存在退货 | 采购与库存 -> 采购订单,查看警示标记 |
| 某材料批次来源采购单 | 嘉恒仓库 -> 原材料库 -> 批次明细 | | 某材料批次来源采购单 | 百华仓库 -> 原材料库 -> 批次明细 |
## 11. 常见问题 ## 11. 常见问题

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. 常见问题

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
嘉恒仓库 -> 选择仓库 -> 盘库 百华仓库 -> 选择仓库 -> 盘库
``` ```
盘库规则: 盘库规则:

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>

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

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

View File

@ -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");

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\)/);
});
});

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: "已收到。"
}); });
} }

View File

@ -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/);
});
});

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";

View File

@ -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, /主管:未设置/);
});
});

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;

View File

@ -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">

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 ? "处理中..." : "确认盘库并导出仓库清单" }}

View File

@ -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/);
});
});

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();

View File

@ -8,10 +8,14 @@
:signature-labels="signatureLabels" :signature-labels="signatureLabels"
> >
<div class="document-form-meta" :aria-label="metaAriaLabel"> <div class="document-form-meta" :aria-label="metaAriaLabel">
<span>{{ metaLabel }}</span> <span class="document-form-meta-pair">
<strong>{{ companyName }}</strong> <span>{{ metaLabel }}</span>
<span>{{ dateLabel }}</span> <strong>{{ companyName }}</strong>
<strong>{{ displayBusinessDate }}</strong> </span>
<span class="document-form-meta-pair document-form-meta-date">
<span>{{ dateLabel }}</span>
<strong>{{ displayBusinessDate }}</strong>
</span>
</div> </div>
<main class="document-form-body"> <main class="document-form-body">
@ -46,7 +50,7 @@ const props = defineProps({
}, },
companyName: { companyName: {
type: String, type: String,
default: "宁波嘉恒智能科技有限公司" default: "宁波百华智能科技有限公司"
}, },
metaLabel: { metaLabel: {
type: String, type: String,

View File

@ -66,7 +66,7 @@ const props = defineProps({
}, },
companyName: { companyName: {
type: String, type: String,
default: "嘉恒仓库 ERP" default: "百华仓库 ERP"
}, },
tone: { tone: {
type: String, type: String,

View File

@ -36,7 +36,7 @@ defineProps({
}, },
companyName: { companyName: {
type: String, type: String,
default: "嘉恒仓库" default: "百华仓库"
}, },
signatureLabels: { signatureLabels: {
type: Array, type: Array,

View File

@ -49,4 +49,35 @@ describe("document form foundation", () => {
assert.match(mainCss, /\.document-settle-panel/); assert.match(mainCss, /\.document-settle-panel/);
assert.match(mainCss, /\.warehouse-paper-control-grid/); assert.match(mainCss, /\.warehouse-paper-control-grid/);
}); });
it("keeps document header labels close to their values instead of stretching business date apart", () => {
const shell = readComponent("DocumentFormShell.vue");
assert.match(shell, /class="document-form-meta-pair"/);
assert.match(shell, /class="document-form-meta-pair document-form-meta-date"/);
assert.match(mainCss, /\.document-form-meta\s*\{[\s\S]*display:\s*flex;/);
assert.match(mainCss, /\.document-form-meta-date\s*\{[\s\S]*margin-left:\s*auto;/);
assert.doesNotMatch(
mainCss.match(/\.document-form-meta\s*\{[\s\S]*?\}/)?.[0] || "",
/grid-template-columns:[^;]*1fr/,
"document form meta should not use a flexible spacer that separates 业务日期 from its date value"
);
});
it("keeps the final paper layout contract after legacy warehouse grid rules", () => {
const legacyWarehouseGrid = mainCss.lastIndexOf(".warehouse-document-section-grid {");
const finalContract = mainCss.lastIndexOf("Final paper document layout contract");
const warehousePaperGrid = mainCss.lastIndexOf("body .warehouse-document-section-grid > .warehouse-paper-grid");
const genericWrappedWarehousePaperGrid = mainCss.lastIndexOf("body .document-section-grid > .warehouse-paper-grid");
const genericWrappedWarehouseMeta = mainCss.lastIndexOf("body .document-section-grid > .warehouse-document-meta-strip");
const genericDocumentGrid = mainCss.lastIndexOf("body .document-section-grid > .document-grid");
assert.ok(legacyWarehouseGrid > 0, "expected the legacy warehouse grid rule to exist");
assert.ok(finalContract > legacyWarehouseGrid, "expected final paper contract after legacy grid rules");
assert.ok(warehousePaperGrid > legacyWarehouseGrid, "expected warehouse paper grid full-width rule after legacy grid");
assert.ok(genericWrappedWarehousePaperGrid > legacyWarehouseGrid, "expected generic section wrapped warehouse paper grid rule after legacy grid");
assert.ok(genericWrappedWarehouseMeta > legacyWarehouseGrid, "expected generic section wrapped warehouse meta strip rule after legacy grid");
assert.ok(genericDocumentGrid > 0, "expected generic document grid full-width rule in final contract");
assert.match(mainCss.slice(finalContract), /grid-column: 1 \/ -1 !important;/);
});
}); });

View File

@ -1,10 +1,12 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000/api"; const API_BASE_URL = import.meta.env?.VITE_API_BASE_URL || "http://localhost:8000/api";
const SESSION_KEY = "forgeflow.erp.session"; const SESSION_KEY = "forgeflow.erp.session";
async function parseResponse(response) { export async function parseResponse(response) {
const contentType = response.headers.get("content-type") || ""; const contentType = response.headers.get("content-type") || "";
const isJson = contentType.includes("application/json"); const isJson = contentType.includes("application/json");
const data = isJson ? await response.json() : await response.text(); const hasEmptyBody = response.status === 204 || response.status === 205;
const rawText = hasEmptyBody ? "" : await response.text();
const data = isJson && rawText.trim() ? JSON.parse(rawText) : rawText || null;
if (!response.ok) { if (!response.ok) {
const detail = typeof data === "object" && data?.detail ? data.detail : `Request failed: ${response.status}`; const detail = typeof data === "object" && data?.detail ? data.detail : `Request failed: ${response.status}`;

View File

@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { parseResponse } from "./api.js";
describe("api parseResponse", () => {
it("treats no-content JSON responses as empty success instead of parsing an empty body", async () => {
const response = new Response(null, {
status: 204,
headers: {
"content-type": "application/json"
}
});
assert.equal(await parseResponse(response), null);
});
it("parses normal JSON response bodies", async () => {
const response = new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
"content-type": "application/json"
}
});
assert.deepEqual(await parseResponse(response), { ok: true });
});
it("keeps backend error details when JSON error responses include detail", async () => {
const response = new Response(JSON.stringify({ detail: "组织节点不存在" }), {
status: 404,
headers: {
"content-type": "application/json"
}
});
await assert.rejects(() => parseResponse(response), /组织节点不存在/);
});
});

View File

@ -8,7 +8,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const styles = readFileSync(join(__dirname, "main.css"), "utf8"); const styles = readFileSync(join(__dirname, "main.css"), "utf8");
const viewSourceByName = new Map( const viewSourceByName = new Map(
[ [
"InventoryLedgerView.vue", "ProductSpecLiteView.vue",
"PurchaseOrderView.vue", "PurchaseOrderView.vue",
"SalesPlanningView.vue", "SalesPlanningView.vue",
"SupplierMaterialLiteView.vue", "SupplierMaterialLiteView.vue",
@ -36,6 +36,15 @@ describe("table action button layout and tones", () => {
assert.match(wideActionHeaderRule, /text-align:\s*center !important;/); assert.match(wideActionHeaderRule, /text-align:\s*center !important;/);
}); });
it("makes wide mode expose an explicit horizontal scrollbar for every table wrapper", () => {
const wideModeRule = cssRuleBlock("body .table-view-wide");
assert.match(wideModeRule, /overflow-x:\s*scroll !important;/);
assert.match(wideModeRule, /overflow-y:\s*hidden !important;/);
assert.match(wideModeRule, /scrollbar-gutter:\s*stable/);
assert.match(styles, /body \.table-view-wide::-webkit-scrollbar/);
});
it("centers compact overview action headers over icon-only action buttons", () => { it("centers compact overview action headers over icon-only action buttons", () => {
const overviewActionHeaderRule = cssRuleBlock("body .table-view-overview .data-table:has(td.action-row) thead th:last-child"); const overviewActionHeaderRule = cssRuleBlock("body .table-view-overview .data-table:has(td.action-row) thead th:last-child");

View File

@ -11585,7 +11585,182 @@ body .table-cell-hover-tooltip-visible {
} }
body .table-view-wide { body .table-view-wide {
overflow-x: auto !important; overflow-x: scroll !important;
overflow-y: hidden !important;
scrollbar-gutter: stable both-edges;
scrollbar-width: none;
}
body .table-view-wide::-webkit-scrollbar {
width: 0;
height: 0;
}
body .table-view-wide::-webkit-scrollbar-track {
border-radius: 999px;
background: rgba(226, 232, 240, 0.72);
}
body .table-view-wide::-webkit-scrollbar-thumb {
border: 3px solid rgba(226, 232, 240, 0.72);
border-radius: 999px;
background: rgba(100, 116, 139, 0.58);
}
body .table-view-wide::-webkit-scrollbar-thumb:hover {
background: rgba(71, 85, 105, 0.72);
}
body .table-view-wide + .table-scroll-dock,
body .table-scroll-dock {
position: relative;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 34px;
gap: 10px;
align-items: center;
min-height: 56px;
margin-top: -1px;
padding: 10px 16px;
border: 1px solid rgba(205, 218, 233, 0.92);
border-radius: 0 0 18px 18px;
background:
linear-gradient(180deg, #fbfdff 0%, #f4f8fd 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.92),
0 14px 26px rgba(15, 23, 42, 0.06);
box-sizing: border-box;
}
body .table-view-wide + .table-scroll-dock::before,
body .table-view-wide + .table-scroll-dock::after,
body .table-scroll-dock::before,
body .table-scroll-dock::after {
position: absolute;
top: 1px;
bottom: 1px;
z-index: 1;
width: 58px;
pointer-events: none;
content: "";
}
body .table-view-wide + .table-scroll-dock::before,
body .table-scroll-dock::before {
left: 0;
background: linear-gradient(90deg, rgba(248, 251, 255, 0.98), rgba(248, 251, 255, 0));
}
body .table-view-wide + .table-scroll-dock::after,
body .table-scroll-dock::after {
right: 0;
background: linear-gradient(270deg, rgba(248, 251, 255, 0.98), rgba(248, 251, 255, 0));
}
body .table-scroll-dock-arrow {
position: relative;
z-index: 2;
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: 1px solid rgba(191, 206, 224, 0.9);
border-radius: 12px;
background:
linear-gradient(180deg, #ffffff 0%, #eef5fc 100%);
color: #64748b;
font: inherit;
font-size: 30px;
font-weight: 500;
line-height: 1;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.96),
0 6px 14px rgba(15, 23, 42, 0.06);
cursor: pointer;
transition:
border-color 0.16s ease,
color 0.16s ease,
transform 0.16s ease,
box-shadow 0.16s ease;
}
body .table-scroll-dock-arrow span {
transform: translateY(-2px);
}
body .table-scroll-dock-arrow:hover {
border-color: rgba(64, 158, 255, 0.48);
color: #1677ff;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.96),
0 8px 18px rgba(22, 119, 255, 0.14);
transform: translateY(-1px);
}
body .table-scroll-dock-track {
position: relative;
z-index: 2;
height: 22px;
overflow: hidden;
border-radius: 999px;
background:
linear-gradient(180deg, rgba(226, 234, 243, 0.98), rgba(211, 222, 235, 0.98));
box-shadow:
inset 0 1px 2px rgba(15, 23, 42, 0.12),
inset 0 -1px 0 rgba(255, 255, 255, 0.82);
cursor: pointer;
}
body .table-scroll-dock-thumb {
position: absolute;
top: 3px;
left: 0;
display: flex;
align-items: center;
justify-content: center;
min-width: 72px;
height: 16px;
border: 0;
border-radius: 999px;
background:
linear-gradient(90deg, #8fc4ff 0%, #409eff 35%, #1677ff 100%);
box-shadow:
0 7px 16px rgba(22, 119, 255, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.34);
cursor: grab;
touch-action: none;
transition:
box-shadow 0.16s ease,
filter 0.16s ease;
}
body .table-scroll-dock-thumb span {
width: 28px;
height: 10px;
background:
linear-gradient(90deg, transparent 0 5px, rgba(255, 255, 255, 0.68) 5px 7px, transparent 7px 11px, rgba(255, 255, 255, 0.68) 11px 13px, transparent 13px 17px, rgba(255, 255, 255, 0.68) 17px 19px, transparent 19px);
}
body .table-scroll-dock-thumb:hover,
body .table-scroll-dock-dragging .table-scroll-dock-thumb {
box-shadow:
0 10px 24px rgba(22, 119, 255, 0.32),
inset 0 1px 0 rgba(255, 255, 255, 0.42);
filter: saturate(1.08);
}
body .table-scroll-dock-dragging .table-scroll-dock-thumb {
cursor: grabbing;
}
body .table-scroll-dock-disabled {
display: none;
}
@media (prefers-reduced-motion: reduce) {
body .table-scroll-dock-arrow,
body .table-scroll-dock-thumb {
transition: none;
}
} }
body .table-view-wide .data-table, body .table-view-wide .data-table,
@ -12062,32 +12237,37 @@ body .system-permission-page .tree-layout-card .org-employee-table td {
body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(1), body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(1),
body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(1) { body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(1) {
width: 13% !important; width: 10% !important;
} }
body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(2), body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(2),
body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(2) { body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(2) {
width: 20% !important; width: 30% !important;
} }
body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(3), body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(3),
body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(3) { body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(3) {
width: 14% !important; width: 13% !important;
} }
body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(4), body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(4),
body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(4) { body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(4) {
width: 17% !important; width: 10% !important;
} }
body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(5), body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(5),
body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(5) { body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(5) {
width: 12% !important; width: 11% !important;
} }
body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(6), body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(6),
body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(6) { body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(6) {
width: 24% !important; width: 7% !important;
}
body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(7),
body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(7) {
width: 19% !important;
} }
body .system-permission-page .tree-layout-card .org-employee-table .action-row { body .system-permission-page .tree-layout-card .org-employee-table .action-row {
@ -12096,9 +12276,17 @@ body .system-permission-page .tree-layout-card .org-employee-table .action-row {
white-space: nowrap !important; white-space: nowrap !important;
} }
body .system-permission-page .tree-layout-card .org-employee-table .employee-actions {
display: inline-flex !important;
align-items: center !important;
justify-content: flex-start !important;
gap: 8px !important;
min-width: max-content !important;
}
body .system-permission-page .tree-layout-card .org-employee-table .action-row button { body .system-permission-page .tree-layout-card .org-employee-table .action-row button {
min-height: 30px !important; min-height: 30px !important;
padding: 0 8px !important; padding: 0 10px !important;
font-size: 12px !important; font-size: 12px !important;
} }
@ -14539,11 +14727,11 @@ body .login-line-brand {
position: relative; position: relative;
z-index: 2; z-index: 2;
display: inline-grid; display: inline-grid;
grid-template-columns: 48px minmax(0, max-content); grid-template-columns: minmax(116px, 150px) minmax(0, max-content);
gap: 12px; gap: 12px;
align-items: center; align-items: center;
justify-self: start; justify-self: start;
max-width: min(280px, 100%); max-width: min(380px, 100%);
padding: 10px 15px 10px 10px; padding: 10px 15px 10px 10px;
border: 1px solid rgba(148, 163, 184, 0.2); border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 20px; border-radius: 20px;
@ -14558,12 +14746,21 @@ body .login-line-brand {
body .login-line-brand .login-logo, body .login-line-brand .login-logo,
body .login-production-layout .login-logo { body .login-production-layout .login-logo {
width: 48px; width: 150px;
height: 48px; height: 48px;
border-radius: 16px; border-radius: 14px;
object-fit: contain; object-fit: contain;
background: rgba(255, 255, 255, 0.94); object-position: center;
box-shadow: 0 12px 32px rgba(2, 6, 23, 0.28); background:
radial-gradient(circle at 18% 18%, rgba(232, 247, 255, 0.54), transparent 34%),
linear-gradient(135deg, rgba(181, 220, 239, 0.76), rgba(78, 128, 157, 0.46) 52%, rgba(12, 35, 55, 0.64)),
rgba(15, 38, 58, 0.58);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.42),
inset 0 -12px 26px rgba(3, 13, 24, 0.2),
0 12px 30px rgba(2, 6, 23, 0.28),
0 0 0 1px rgba(77, 174, 221, 0.22);
backdrop-filter: blur(10px) saturate(138%);
} }
body .login-line-brand span { body .login-line-brand span {
@ -15785,13 +15982,13 @@ body .login-production-layout .login-security-foot p {
} }
body .login-line-brand { body .login-line-brand {
grid-template-columns: 42px minmax(0, max-content); grid-template-columns: minmax(96px, 128px) minmax(0, max-content);
border-radius: 17px; border-radius: 17px;
} }
body .login-line-brand .login-logo, body .login-line-brand .login-logo,
body .login-production-layout .login-logo { body .login-production-layout .login-logo {
width: 42px; width: 128px;
height: 42px; height: 42px;
} }
@ -16085,6 +16282,14 @@ body .login-production-layout .login-security-foot p {
background: rgba(255, 250, 240, 0.5); background: rgba(255, 250, 240, 0.5);
} }
.document-grid-cell-wide {
grid-column: span 2;
}
.document-grid-cell-full {
grid-column: 1 / -1;
}
.document-grid-label, .document-grid-label,
.document-grid-value { .document-grid-value {
margin: 0; margin: 0;
@ -16515,10 +16720,11 @@ body .login-production-layout .login-security-foot p {
} }
.document-form-meta { .document-form-meta {
display: grid; display: flex;
grid-template-columns: auto auto minmax(0, 1fr) auto; flex-wrap: wrap;
gap: 10px; gap: 10px 18px;
align-items: center; align-items: center;
justify-content: flex-start;
padding: 9px 12px; padding: 9px 12px;
color: rgba(23, 18, 10, 0.72); color: rgba(23, 18, 10, 0.72);
border: 2px solid rgba(20, 14, 6, 0.82); border: 2px solid rgba(20, 14, 6, 0.82);
@ -16531,11 +16737,29 @@ body .login-production-layout .login-security-foot p {
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.document-form-meta-pair {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.document-form-meta-date {
margin-left: auto;
}
.document-form-meta-pair > span {
flex: 0 0 auto;
white-space: nowrap;
}
.document-form-meta strong { .document-form-meta strong {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
flex: 0 0 auto;
min-height: 26px; min-height: 26px;
padding: 0 10px; padding: 0 10px;
white-space: nowrap;
color: #145c39; color: #145c39;
border: 1.5px solid rgba(31, 122, 74, 0.64); border: 1.5px solid rgba(31, 122, 74, 0.64);
background: rgba(232, 246, 232, 0.62); background: rgba(232, 246, 232, 0.62);
@ -16592,11 +16816,21 @@ body .login-production-layout .login-security-foot p {
padding: 14px; padding: 14px;
} }
.document-section-grid > .document-grid,
.document-section-grid > .document-line-table,
.document-section-grid > .document-control-grid, .document-section-grid > .document-control-grid,
.document-section-grid > .document-selector-panel, .document-section-grid > .document-selector-panel,
.document-section-grid > .document-source-table, .document-section-grid > .document-source-table,
.document-section-grid > .document-inline-summary, .document-section-grid > .document-inline-summary,
.document-section-grid > .document-settle-panel, .document-section-grid > .document-settle-panel,
.document-section-grid > .warehouse-document-meta-strip,
.document-section-grid > .warehouse-paper-grid,
.document-section-grid > .warehouse-paper-control-grid,
.document-section-grid > .warehouse-paper-source-lot-panel,
.document-section-grid > .warehouse-paper-source-lot-table,
.document-section-grid > .warehouse-paper-inline-summary,
.document-section-grid > .warehouse-paper-settle-panel,
.document-section-grid > .warehouse-paper-summary-grid,
.document-section-grid > .document-paper-field { .document-section-grid > .document-paper-field {
grid-column: 1 / -1; grid-column: 1 / -1;
width: 100%; width: 100%;
@ -16811,7 +17045,6 @@ body .login-production-layout .login-security-foot p {
} }
@media (max-width: 960px) { @media (max-width: 960px) {
.document-form-meta,
.document-section-grid, .document-section-grid,
.document-control-grid { .document-control-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@ -16827,18 +17060,32 @@ body .login-production-layout .login-security-foot p {
padding-inline: 0; padding-inline: 0;
} }
.document-form-meta,
.document-section-grid, .document-section-grid,
.document-control-grid { .document-control-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.document-form-meta {
align-items: stretch;
}
.document-form-meta-pair,
.document-form-meta-date {
width: 100%;
margin-left: 0;
}
.document-paper-field, .document-paper-field,
.document-paper-field-wide, .document-paper-field-wide,
.document-paper-field-full { .document-paper-field-full {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.document-grid-cell-wide,
.document-grid-cell-full {
grid-column: 1 / -1;
}
.document-action-bar, .document-action-bar,
.document-action-bar--with-state, .document-action-bar--with-state,
.document-action-state, .document-action-state,
@ -19053,7 +19300,56 @@ body .warehouse-document-action-bar {
transform: none !important; transform: none !important;
} }
/* Final paper document layout contract: later legacy drawer grids must not squeeze formal document blocks. */
body .document-section-grid > .document-grid,
body .document-section-grid > .document-line-table,
body .document-section-grid > .document-control-grid,
body .document-section-grid > .document-selector-panel,
body .document-section-grid > .document-source-table,
body .document-section-grid > .document-inline-summary,
body .document-section-grid > .document-settle-panel,
body .document-section-grid > .document-paper-field,
body .document-section-grid > .warehouse-document-meta-strip,
body .document-section-grid > .warehouse-paper-grid,
body .document-section-grid > .warehouse-paper-control-grid,
body .document-section-grid > .warehouse-paper-source-lot-panel,
body .document-section-grid > .warehouse-paper-source-lot-table,
body .document-section-grid > .warehouse-paper-inline-summary,
body .document-section-grid > .warehouse-paper-settle-panel,
body .document-section-grid > .warehouse-paper-summary-grid,
body .warehouse-document-section-grid > .warehouse-document-meta-strip,
body .warehouse-document-section-grid > .warehouse-paper-grid,
body .warehouse-document-section-grid > .warehouse-paper-control-grid,
body .warehouse-document-section-grid > .warehouse-paper-source-lot-panel,
body .warehouse-document-section-grid > .warehouse-paper-source-lot-table,
body .warehouse-document-section-grid > .warehouse-paper-inline-summary,
body .warehouse-document-section-grid > .warehouse-paper-settle-panel,
body .warehouse-document-section-grid > .warehouse-paper-summary-grid {
grid-column: 1 / -1 !important;
width: 100% !important;
min-width: 0 !important;
}
body .workflow-drawer.form-drawer:has(.document-form-shell) {
width: min(1280px, calc(100vw - 24px)) !important;
}
body .workflow-drawer.form-drawer:has(.document-form-shell) .form-drawer-body {
overflow-x: hidden !important;
}
body .document-action-bar {
position: relative !important;
right: auto !important;
bottom: auto !important;
z-index: 1 !important;
margin: 0 0 0 auto !important;
transform: none !important;
}
@media (max-width: 620px) { @media (max-width: 620px) {
body .document-action-bar,
body .document-action-bar--with-state,
body .warehouse-document-action-bar, body .warehouse-document-action-bar,
body .warehouse-document-action-bar--shared, body .warehouse-document-action-bar--shared,
body .warehouse-document-action-bar--shared.warehouse-document-action-bar--with-state { body .warehouse-document-action-bar--shared.warehouse-document-action-bar--with-state {

View File

@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, extname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const srcRoot = join(__dirname, "..");
const bannedVisiblePhrases = [
"lite 模式",
"当前为内置占位",
"真实 LLM",
"当前聊天窗口已就绪",
"后续在",
"占位模式",
"系统会自动生成内部默认工艺",
"先下载当前仓库类型",
"导入会生成期初批次",
"每条明细都会写入库存流水",
"脑图和组织树共用同一套组织数据",
"角色用于配置菜单权限",
"为该人员创建或更新系统账号",
"新增人员是把基础资料人员挂到当前脑图节点",
"用于协助其他用户忘记密码",
"系统会自动保存成新版本",
"后续小程序报工按",
"系统会按退货库",
"理论值只是辅助参考",
"当前没有可选原材料",
"每一条明细都会写入库存流水",
"查看影响范围",
"点击后系统会锁定当前仓库"
];
function collectSourceFiles(dir) {
return readdirSync(dir)
.flatMap((entry) => {
const fullPath = join(dir, entry);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
return collectSourceFiles(fullPath);
}
if (![".vue", ".js"].includes(extname(fullPath)) || fullPath.endsWith(".test.js")) {
return [];
}
return [fullPath];
});
}
describe("static visible copy audit", () => {
it("keeps demo-like always-visible helper copy out of the product UI", () => {
const violations = [];
for (const filePath of collectSourceFiles(srcRoot)) {
const source = readFileSync(filePath, "utf8");
for (const phrase of bannedVisiblePhrases) {
if (source.includes(phrase)) {
violations.push(`${filePath.replace(`${srcRoot}/`, "")}: ${phrase}`);
}
}
}
assert.deepEqual(violations, []);
});
});

View File

@ -0,0 +1,76 @@
export function collectOrganizationEmployees(node) {
const employees = [];
const seenKeys = new Set();
collectFromNode(node, node, employees, seenKeys, []);
return employees;
}
export function countOrganizationEmployees(node) {
return collectOrganizationEmployees(node).length;
}
function collectFromNode(node, parentNode, employees, seenKeys, pathParts) {
if (!node) {
return;
}
if (isEmployeeNode(node)) {
appendEmployee(node, parentNode, employees, seenKeys, pathParts);
return;
}
const currentPathParts = [...pathParts, nodeLabel(node)].filter(Boolean);
for (const employee of Array.isArray(node.employees) ? node.employees : []) {
appendEmployee(employee, node, employees, seenKeys, currentPathParts);
}
for (const child of Array.isArray(node.children) ? node.children : []) {
if (isOrganizationNode(child)) {
collectFromNode(child, child, employees, seenKeys, currentPathParts);
} else if (isEmployeeNode(child)) {
appendEmployee(child, node, employees, seenKeys, currentPathParts);
}
}
}
function appendEmployee(employee, parentNode, employees, seenKeys, pathParts) {
const key = employeeKey(employee);
if (!key || seenKeys.has(key)) {
return;
}
const organizationPathParts = Array.isArray(employee.organization_path_parts) && employee.organization_path_parts.length
? employee.organization_path_parts
: pathParts;
seenKeys.add(key);
employees.push({
...employee,
parent_node_id: employee.parent_node_id ?? nodeId(parentNode),
parent_node_label: employee.parent_node_label ?? nodeLabel(parentNode),
organization_path_parts: organizationPathParts,
organization_path: employee.organization_path || organizationPathParts.join(" / ")
});
}
function isOrganizationNode(node) {
return Boolean(node) && node.node_type !== "EMPLOYEE" && !node.employee_id;
}
function isEmployeeNode(node) {
return Boolean(node) && (node.node_type === "EMPLOYEE" || Boolean(node.employee_id));
}
function employeeKey(employee) {
return String(employee?.employee_id ?? employee?.user_id ?? employee?.username ?? employee?.mobile ?? employee?.phone ?? "");
}
function nodeId(node) {
return node?.node_id ?? node?.id ?? node?.value ?? "";
}
function nodeLabel(node) {
return node?.node_label || node?.label || node?.name || "";
}

View File

@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { collectOrganizationEmployees, countOrganizationEmployees } from "./orgTreeEmployees.js";
describe("organization tree employee aggregation", () => {
it("collects employees from the selected organization and all descendant organizations", () => {
const branch = {
node_id: 10,
node_type: "BRANCH",
node_label: "旭升车间",
employees: [
{ employee_id: 1, employee_name: "直属主管", mobile: "13000000001" }
],
children: [
{
node_id: 11,
node_type: "DEPARTMENT",
node_label: "旭升加工部",
employees: [
{ employee_id: 2, employee_name: "张三", mobile: "13000000002" },
{ employee_id: 3, employee_name: "李四", mobile: "13000000003" }
],
children: [
{
node_id: 12,
node_type: "GROUP",
node_label: "冲压小组",
employees: [
{ employee_id: 4, employee_name: "王五", mobile: "13000000004" }
]
}
]
}
]
};
const employees = collectOrganizationEmployees(branch);
assert.deepEqual(employees.map((employee) => employee.employee_name), ["直属主管", "张三", "李四", "王五"]);
assert.equal(employees[1].parent_node_id, 11);
assert.equal(employees[1].parent_node_label, "旭升加工部");
assert.deepEqual(employees[1].organization_path_parts, ["旭升车间", "旭升加工部"]);
assert.equal(employees[1].organization_path, "旭升车间 / 旭升加工部");
assert.equal(employees[3].organization_path, "旭升车间 / 旭升加工部 / 冲压小组");
assert.equal(countOrganizationEmployees(branch), 4);
});
it("deduplicates employees that appear in multiple descendant branches", () => {
const employee = { employee_id: 9, employee_name: "重复人员", mobile: "13000000009" };
const branch = {
node_id: 20,
node_type: "BRANCH",
node_label: "嘉恒车间",
employees: [employee],
children: [
{
node_id: 21,
node_type: "DEPARTMENT",
node_label: "嘉恒生产部",
employees: [employee]
}
]
};
const employees = collectOrganizationEmployees(branch);
assert.equal(employees.length, 1);
assert.equal(countOrganizationEmployees(branch), 1);
});
});

View File

@ -2,7 +2,7 @@
<ProcessWorkspace <ProcessWorkspace
eyebrow="发货概览" eyebrow="发货概览"
title="成品销售出库交付工作台" title="成品销售出库交付工作台"
description="发货概览模块只保留成品发运;客户退货、退货废料和返工出库统一到嘉恒仓库办理。" description="发货概览模块只保留成品发运;客户退货、退货废料和返工出库统一到百华仓库办理。"
panel-tag="交付主流程" panel-tag="交付主流程"
stage-code="06" stage-code="06"
:sections="sections" :sections="sections"

View File

@ -1,7 +1,7 @@
<template> <template>
<section class="page-grid"> <section class="page-grid">
<section class="panel inventory-unified-panel"> <section class="panel inventory-unified-panel">
<div class="warehouse-switchboard" role="tablist" aria-label="嘉恒仓库库存类型"> <div class="warehouse-switchboard" role="tablist" aria-label="百华仓库库存类型">
<button <button
v-for="tab in inventoryTabs" v-for="tab in inventoryTabs"
:key="tab.key" :key="tab.key"
@ -868,8 +868,8 @@
<button class="ghost-button" type="button" :disabled="!specialAdjustmentForm.reason.trim()" @click="addSpecialAdjustmentLine"> <button class="ghost-button" type="button" :disabled="!specialAdjustmentForm.reason.trim()" @click="addSpecialAdjustmentLine">
添加调整明细 添加调整明细
</button> </button>
<span v-if="!specialAdjustmentForm.reason.trim()" class="special-adjustment-tip">请先填写调整说明</span> <span v-if="!specialAdjustmentForm.reason.trim()" class="special-adjustment-tip">填写调整说明后添加明细</span>
<span v-else>每条明细都会写入库存流水调整说明会同步进入变更记录</span> <span v-else>可添加调整明细</span>
</div> </div>
<div class="special-adjustment-paper-lines special-adjustment-lines" :class="{ 'is-empty': !specialAdjustmentForm.lines.length }"> <div class="special-adjustment-paper-lines special-adjustment-lines" :class="{ 'is-empty': !specialAdjustmentForm.lines.length }">
@ -994,8 +994,7 @@
</article> </article>
<div v-if="!specialAdjustmentForm.lines.length" class="special-adjustment-empty"> <div v-if="!specialAdjustmentForm.lines.length" class="special-adjustment-empty">
<strong>还没有调整明细</strong> <strong>暂无调整明细</strong>
<span>先填写调整说明再点击添加调整明细每一条明细都会写入库存流水</span>
</div> </div>
</div> </div>
@ -1331,7 +1330,7 @@
<FormDrawer <FormDrawer
:open="openingImportDrawerOpen" :open="openingImportDrawerOpen"
eyebrow="嘉恒仓库 · 期初导入" eyebrow="百华仓库 · 期初导入"
:title="`${activeInventoryLabel}期初库存导入`" :title="`${activeInventoryLabel}期初库存导入`"
tag="库存初始化" tag="库存初始化"
wide wide
@ -1344,7 +1343,6 @@
<div class="import-layout"> <div class="import-layout">
<section class="import-card"> <section class="import-card">
<strong>导出模版</strong> <strong>导出模版</strong>
<p>先下载当前仓库类型的期初导入模版按实际清点库存填写后再导入</p>
<button class="primary-button" type="button" :disabled="openingImportBusy" @click="exportOpeningTemplate"> <button class="primary-button" type="button" :disabled="openingImportBusy" @click="exportOpeningTemplate">
{{ openingImportBusy ? "处理中..." : `导出${activeInventoryLabel}期初模版` }} {{ openingImportBusy ? "处理中..." : `导出${activeInventoryLabel}期初模版` }}
</button> </button>
@ -1355,7 +1353,6 @@
<input ref="openingImportInputRef" class="hidden-file-input" type="file" accept=".xlsx,.xlsm" @change="importOpeningInventory" /> <input ref="openingImportInputRef" class="hidden-file-input" type="file" accept=".xlsx,.xlsm" @change="importOpeningInventory" />
<span class="import-icon">XLSX</span> <span class="import-icon">XLSX</span>
<strong>{{ openingImportBusy ? "导入中..." : "上传期初库存 Excel" }}</strong> <strong>{{ openingImportBusy ? "导入中..." : "上传期初库存 Excel" }}</strong>
<p>导入会生成期初批次库存余额和库存流水批次号重复会被拦截</p>
</label> </label>
</section> </section>
</div> </div>
@ -1366,7 +1363,6 @@
:open="productionDrawerOpen" :open="productionDrawerOpen"
eyebrow="原材料库 · 生产出库" eyebrow="原材料库 · 生产出库"
title="生产出库" title="生产出库"
description="选择原材料库存批次出库,后续小程序报工按库存批次号推进。"
tag="生产出库" tag="生产出库"
wide wide
@close="productionDrawerOpen = false" @close="productionDrawerOpen = false"
@ -1383,10 +1379,6 @@
<div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div> <div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div>
<div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div> <div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>
<WarehouseDocumentSection index="01" title="生产出库信息"> <WarehouseDocumentSection index="01" title="生产出库信息">
<div class="document-inline-summary production-out-paper-tip">
选择原材料库存批次出库后续小程序报工按库存批次号推进一次生产出库只能选择一个材料库存批次
</div>
<div class="document-control-grid production-out-paper-grid"> <div class="document-control-grid production-out-paper-grid">
<label class="document-paper-field document-paper-field-wide"> <label class="document-paper-field document-paper-field-wide">
<span>产品</span> <span>产品</span>
@ -1464,10 +1456,6 @@
</label> </label>
</div> </div>
<div class="feedback-banner">
单件毛重{{ selectedBomGrossWeight ? formatWeight(selectedBomGrossWeight) : "未配置" }}参考生产数量 = 本次生产出库重量 ÷ 单件毛重
</div>
<div class="document-control-grid production-out-paper-grid production-out-paper-remark"> <div class="document-control-grid production-out-paper-grid production-out-paper-remark">
<label class="document-paper-field document-paper-field-full"> <label class="document-paper-field document-paper-field-full">
<span>备注</span> <span>备注</span>
@ -1865,8 +1853,8 @@
</label> </label>
</div> </div>
<div class="document-inline-summary return-rework-trace-summary"> <div v-if="selectedReturnReworkItem" class="document-inline-summary return-rework-trace-summary">
{{ selectedReturnReworkItem ? formatReturnTraceSummary(selectedReturnReworkItem) : "请选择退货明细,系统会按退货库锁定批次办理返工出库。" }} {{ formatReturnTraceSummary(selectedReturnReworkItem) }}
</div> </div>
<div class="warehouse-paper-control-grid return-rework-paper-grid"> <div class="warehouse-paper-control-grid return-rework-paper-grid">
@ -1899,7 +1887,7 @@
<FormDrawer <FormDrawer
:open="warehouseLedgerDrawerOpen" :open="warehouseLedgerDrawerOpen"
eyebrow="嘉恒仓库 · 库级流水" eyebrow="百华仓库 · 库级流水"
:title="`${activeInventoryLabel} · 出入库流水`" :title="`${activeInventoryLabel} · 出入库流水`"
tag="库存流水" tag="库存流水"
wide wide
@ -2055,7 +2043,7 @@
<FormDrawer <FormDrawer
:open="productionWorkOrderInboundDrawerOpen" :open="productionWorkOrderInboundDrawerOpen"
eyebrow="嘉恒仓库 · 生产台账入库" eyebrow="百华仓库 · 生产台账入库"
title="生产台账入库" title="生产台账入库"
description="按在生产的材料库存批次号统一办理成品入库、生产余料入库和生产废料入库。" description="按在生产的材料库存批次号统一办理成品入库、生产余料入库和生产废料入库。"
tag="材料批次闭环" tag="材料批次闭环"
@ -2072,10 +2060,6 @@
<div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div> <div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div>
<div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div> <div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>
<WarehouseDocumentSection index="01" title="生产台账入库结算信息"> <WarehouseDocumentSection index="01" title="生产台账入库结算信息">
<div class="document-inline-summary production-ledger-inbound-tip">
选择生产台账后系统按领料重量单件毛重和已入库成品数量填入理论余料理论废料理论值只是辅助参考实际称重可修改偏差超过上下15%时需要填写说明
</div>
<div class="document-control-grid production-ledger-inbound-paper-grid"> <div class="document-control-grid production-ledger-inbound-paper-grid">
<label class="document-paper-field document-paper-field-wide"> <label class="document-paper-field document-paper-field-wide">
<span>生产台账</span> <span>生产台账</span>
@ -2546,7 +2530,7 @@ const operationMatrix = computed(() => ({
makeOperation("raw-special-in", "in", "SPECIAL_IN", "特殊入库", "强制填写说明后,直接调整原材料库库存明细。", "specialAdjustment") makeOperation("raw-special-in", "in", "SPECIAL_IN", "特殊入库", "强制填写说明后,直接调整原材料库库存明细。", "specialAdjustment")
], ],
outbound: [ outbound: [
makeOperation("raw-production-out", "out", "PRODUCTION_OUT", "生产出库", "选择原材料库存批次出库,后续小程序报工按库存批次号推进。", "production"), makeOperation("raw-production-out", "out", "PRODUCTION_OUT", "生产出库", "生产出库", "production"),
makeOperation("raw-outsourcing-out", "out", "OUTSOURCING_OUT", "委外出库", "原材料出库给第三方加工。"), makeOperation("raw-outsourcing-out", "out", "OUTSOURCING_OUT", "委外出库", "原材料出库给第三方加工。"),
makeOperation("raw-return-out", "out", "PURCHASE_RETURN_OUT", "退货出库", "入仓后发现质量或性能不合格的原材料退回供应商。", "rawReturn"), makeOperation("raw-return-out", "out", "PURCHASE_RETURN_OUT", "退货出库", "入仓后发现质量或性能不合格的原材料退回供应商。", "rawReturn"),
makeOperation("raw-scrap-out", "out", "SCRAP_OUT", "报废出库", "原材料报废出库。"), makeOperation("raw-scrap-out", "out", "SCRAP_OUT", "报废出库", "原材料报废出库。"),

View File

@ -13,11 +13,11 @@
</div> </div>
<main class="login-production-cockpit" @click.stop> <main class="login-production-cockpit" @click.stop>
<section class="login-line-area" aria-label="嘉恒五金冲压产线"> <section class="login-line-area" aria-label="百华五金冲压产线">
<div class="login-line-brand"> <div class="login-line-brand">
<img class="login-logo" src="/logo.svg" alt="宁波嘉恒智能科技有限公司 logo" /> <img class="login-logo login-logo-wordmark" src="/logo.png" alt="宁波百华智能科技有限公司 logo" />
<div> <div>
<span>嘉恒智能</span> <span>百华智能</span>
</div> </div>
</div> </div>
@ -98,7 +98,7 @@
<div class="login-console-frame"> <div class="login-console-frame">
<div class="login-control-console"> <div class="login-control-console">
<div class="login-panel-head"> <div class="login-panel-head">
<h2>嘉恒智能五金 ERP</h2> <h2>百华智能五金 ERP</h2>
</div> </div>
<form class="login-form" @submit.prevent="handleLogin"> <form class="login-form" @submit.prevent="handleLogin">

View File

@ -1,8 +1,8 @@
<template> <template>
<ProcessWorkspace <ProcessWorkspace
eyebrow="采购与库存" eyebrow="采购与库存"
title="采购与嘉恒仓库工作台" title="采购与百华仓库工作台"
description="覆盖采购下单、自家料到货、质检和嘉恒仓库出入库台账。" description="覆盖采购下单、自家料到货、质检和百华仓库出入库台账。"
panel-tag="仓库主流程" panel-tag="仓库主流程"
stage-code="04" stage-code="04"
:sections="sections" :sections="sections"
@ -44,7 +44,7 @@ const sections = [
{ {
key: "inventory-ledger", key: "inventory-ledger",
step: "04-4", step: "04-4",
label: "嘉恒仓库", label: "百华仓库",
desc: "统一处理原材料、半成品、成品、辅料的出入库和批次流水。", desc: "统一处理原材料、半成品、成品、辅料的出入库和批次流水。",
permission: "MENU_INVENTORY_LEDGER", permission: "MENU_INVENTORY_LEDGER",
component: InventoryLedgerView component: InventoryLedgerView

View File

@ -0,0 +1,46 @@
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, "ProductSpecLiteView.vue"), "utf8");
describe("ProductSpecLiteView operation foundation loading", () => {
it("loads process and work-center master data sequentially so backend default seeding can settle", () => {
assert.match(source, /const processRows = await fetchResource\("\/master-data\/processes", \[\]\);/);
assert.match(source, /const centerRows = await fetchResource\("\/master-data\/work-centers", \[\]\);/);
assert.ok(
source.indexOf('fetchResource("/master-data/processes", [])') <
source.indexOf('fetchResource("/master-data/work-centers", [])')
);
});
it("prepares route operations before creating a new product so validation cannot leave partial records", () => {
assert.match(source, /async function ensureOperationFoundationReady\(\)/);
assert.match(source, /await ensureOperationFoundationReady\(\);/);
assert.match(source, /const preparedRouteOperations = buildRouteOperationsPayload\(processRowsForSave\);/);
assert.ok(
source.indexOf("const preparedRouteOperations = buildRouteOperationsPayload(processRowsForSave);") <
source.indexOf('await postResource("/master-data/products", productPayload)')
);
});
it("cleans newly created product-spec records if a later save step fails", () => {
assert.match(source, /let createdProductItemId = null;/);
assert.match(source, /const createdMiniappPayloads = \[\];/);
assert.match(source, /createdProductItemId = productItemId;/);
assert.match(source, /createdMiniappPayloads\.push\(payload\);/);
assert.match(source, /await deleteResource\(buildMiniappDeletePath\(payload\)\)\.catch\(\(\) => null\);/);
assert.match(source, /await deleteResource\(`\/master-data\/product-specs\/\$\{createdProductItemId\}`\)\.catch\(\(\) => null\);/);
});
it("does not override wide mode with a product-spec-only one-screen table layout", () => {
assert.doesNotMatch(source, /product-spec-table-wrap/);
assert.doesNotMatch(source, /product-spec-table/);
assert.doesNotMatch(source, /<colgroup>[\s\S]*product-spec-col-action[\s\S]*<\/colgroup>/);
assert.doesNotMatch(source, /overflow-x:\s*hidden !important/);
assert.doesNotMatch(source, /table-layout:\s*fixed !important;[\s\S]*product-spec/);
});
});

View File

@ -73,6 +73,7 @@
<td class="action-row"> <td class="action-row">
<button <button
class="ghost-button" class="ghost-button"
data-semantic-label="编辑"
type="button" type="button"
:disabled="!canModifySpec(row)" :disabled="!canModifySpec(row)"
:title="canModifySpec(row) ? '编辑' : '已停用的产品需规只读,不允许修改'" :title="canModifySpec(row) ? '编辑' : '已停用的产品需规只读,不允许修改'"
@ -82,6 +83,7 @@
</button> </button>
<button <button
class="ghost-button danger-ghost" class="ghost-button danger-ghost"
data-semantic-label="删除"
type="button" type="button"
:disabled="!canModifySpec(row)" :disabled="!canModifySpec(row)"
:title="canModifySpec(row) ? '删除' : '已停用的产品需规只读,不允许删除'" :title="canModifySpec(row) ? '删除' : '已停用的产品需规只读,不允许删除'"
@ -104,7 +106,6 @@
:open="drawerOpen" :open="drawerOpen"
eyebrow="产品需规" eyebrow="产品需规"
:title="editingRow ? '编辑产品需规' : '新增产品需规'" :title="editingRow ? '编辑产品需规' : '新增产品需规'"
description="lite 模式下,一个抽屉完成产品资料、默认用料和小程序报工字段。系统会按小程序工序自动生成内部默认工艺,供生产工单使用。"
tag="产品需规清单" tag="产品需规清单"
wide wide
@close="drawerOpen = false" @close="drawerOpen = false"
@ -856,19 +857,23 @@ function buildAutoRouteOperation(processRow, index) {
}; };
} }
function buildRoutePayload(product, processRowsForSave) { function buildRouteOperationsPayload(processRowsForSave) {
const productItemId = product.item_id;
const processRows = buildProcessGroupForRoute(processRowsForSave); const processRows = buildProcessGroupForRoute(processRowsForSave);
if (!processRows.length) { if (!processRows.length) {
throw new Error("请至少维护一道小程序工序"); throw new Error("请至少维护一道小程序工序");
} }
return processRows.map((processRow, index) => buildAutoRouteOperation(processRow, index));
}
function buildRoutePayload(product, processRowsForSave, preparedOperations = null) {
const productItemId = product.item_id;
return { return {
route_name: `${form.item_name} 工艺路线`, route_name: `${form.item_name} 工艺路线`,
product_item_id: productItemId, product_item_id: productItemId,
version_no: form.version_no, version_no: form.version_no,
is_default: true, is_default: true,
status: "ACTIVE", status: "ACTIVE",
operations: processRows.map((processRow, index) => buildAutoRouteOperation(processRow, index)) operations: preparedOperations || buildRouteOperationsPayload(processRowsForSave)
}; };
} }
@ -931,16 +936,16 @@ watch(calculatedLossRate, (lossRate) => {
}, { immediate: true }); }, { immediate: true });
async function fetchMasterData() { async function fetchMasterData() {
const [productRows, bomRows, routeRows, materialRows, miniappProductRows, pointRows, processRows, centerRows] = await Promise.all([ const [productRows, bomRows, routeRows, materialRows, miniappProductRows, pointRows] = await Promise.all([
fetchResource("/master-data/products?limit=200", []), fetchResource("/master-data/products?limit=200", []),
fetchResource("/master-data/boms?limit=500", []), fetchResource("/master-data/boms?limit=500", []),
fetchResource("/master-data/process-routes?limit=500", []), fetchResource("/master-data/process-routes?limit=500", []),
fetchResource("/master-data/materials?limit=200", []), fetchResource("/master-data/materials?limit=200", []),
fetchResource("/master-data/miniapp-products?limit=1000", []), fetchResource("/master-data/miniapp-products?limit=1000", []),
fetchResource("/master-data/miniapp-attendance-points", []), fetchResource("/master-data/miniapp-attendance-points", [])
fetchResource("/master-data/processes", []),
fetchResource("/master-data/work-centers", [])
]); ]);
const processRows = await fetchResource("/master-data/processes", []);
const centerRows = await fetchResource("/master-data/work-centers", []);
products.value = productRows; products.value = productRows;
boms.value = bomRows; boms.value = bomRows;
routes.value = routeRows; routes.value = routeRows;
@ -951,6 +956,18 @@ async function fetchMasterData() {
workCenters.value = centerRows; workCenters.value = centerRows;
} }
async function ensureOperationFoundationReady() {
if (!processes.value.length) {
processes.value = await fetchResource("/master-data/processes", []);
}
if (!workCenters.value.length) {
workCenters.value = await fetchResource("/master-data/work-centers", []);
}
if (!processes.value.length || !workCenters.value.length) {
throw new Error("系统无法准备默认工艺基础资料,请稍后重试或联系管理员");
}
}
async function syncMiniappInBackground() { async function syncMiniappInBackground() {
if (miniappSyncing.value) { if (miniappSyncing.value) {
return; return;
@ -977,8 +994,12 @@ async function submitSpec() {
feedbackMessage.value = ""; feedbackMessage.value = "";
errorMessage.value = ""; errorMessage.value = "";
submitting.value = true; submitting.value = true;
let createdProductItemId = null;
const createdMiniappPayloads = [];
try { try {
const processRowsForSave = normalizeProcessFormRowsForSave(); const processRowsForSave = normalizeProcessFormRowsForSave();
await ensureOperationFoundationReady();
const preparedRouteOperations = buildRouteOperationsPayload(processRowsForSave);
const productPayload = { const productPayload = {
item_name: form.item_name, item_name: form.item_name,
specification: editingRow.value?.specification || null, specification: editingRow.value?.specification || null,
@ -997,6 +1018,9 @@ async function submitSpec() {
? await putResource(`/master-data/products/${editingRow.value.item_id}`, productPayload) ? await putResource(`/master-data/products/${editingRow.value.item_id}`, productPayload)
: await postResource("/master-data/products", productPayload); : await postResource("/master-data/products", productPayload);
const productItemId = product.item_id; const productItemId = product.item_id;
if (!shouldUpdateErpProduct) {
createdProductItemId = productItemId;
}
const canUpdateExistingSpec = editingRow.value && Number(editingRow.value.item_id) === Number(productItemId); const canUpdateExistingSpec = editingRow.value && Number(editingRow.value.item_id) === Number(productItemId);
if (canUpdateExistingSpec && editingRow.value.bom?.bom_id) { if (canUpdateExistingSpec && editingRow.value.bom?.bom_id) {
await putResource(`/master-data/boms/${editingRow.value.bom.bom_id}`, buildBomPayload(productItemId)); await putResource(`/master-data/boms/${editingRow.value.bom.bom_id}`, buildBomPayload(productItemId));
@ -1004,19 +1028,27 @@ async function submitSpec() {
await postResource("/master-data/boms", buildBomPayload(productItemId)); await postResource("/master-data/boms", buildBomPayload(productItemId));
} }
if (canUpdateExistingSpec && editingRow.value.route?.route_id) { if (canUpdateExistingSpec && editingRow.value.route?.route_id) {
await putResource(`/master-data/process-routes/${editingRow.value.route.route_id}`, buildRoutePayload(product, processRowsForSave)); await putResource(`/master-data/process-routes/${editingRow.value.route.route_id}`, buildRoutePayload(product, processRowsForSave, preparedRouteOperations));
} else { } else {
await postResource("/master-data/process-routes", buildRoutePayload(product, processRowsForSave)); await postResource("/master-data/process-routes", buildRoutePayload(product, processRowsForSave, preparedRouteOperations));
} }
const miniappPayloads = buildMiniappProductPayloads(product, processRowsForSave); const miniappPayloads = buildMiniappProductPayloads(product, processRowsForSave);
for (const payload of miniappPayloads) { for (const payload of miniappPayloads) {
await postResource("/master-data/miniapp-products?sync=false", payload); await postResource("/master-data/miniapp-products?sync=false", payload);
createdMiniappPayloads.push(payload);
} }
await postResource("/master-data/miniapp-materials/sync?force=true", {}); await postResource("/master-data/miniapp-materials/sync?force=true", {});
feedbackMessage.value = `产品需规 ${product.item_code} 已保存`; feedbackMessage.value = `产品需规 ${product.item_code} 已保存`;
drawerOpen.value = false; drawerOpen.value = false;
await loadAll(); await loadAll();
} catch (error) { } catch (error) {
if (createdProductItemId) {
for (const payload of createdMiniappPayloads) {
await deleteResource(buildMiniappDeletePath(payload)).catch(() => null);
}
await deleteResource(`/master-data/product-specs/${createdProductItemId}`).catch(() => null);
await fetchMasterData().catch(() => null);
}
errorMessage.value = error.message || "产品需规保存失败"; errorMessage.value = error.message || "产品需规保存失败";
} finally { } finally {
submitting.value = false; submitting.value = false;

View File

@ -13,15 +13,6 @@
</div> </div>
</div> </div>
<div
v-if="!smartOperationReportEnabled"
class="production-compact-tip production-mode-tip"
title="当前未对接智能报工小程序生产进度按成品入库数量和BOM毛重/净重反推;工序明细在此模式下隐藏。"
>
<strong>未对接智能报工小程序</strong>
<span>生产进度按成品入库数量和BOM毛重/净重反推工序明细隐藏</span>
<FieldHint text="当前未对接智能报工小程序生产进度按成品入库数量和BOM毛重/净重反推;工序明细在此模式下隐藏。" />
</div>
<div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div> <div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div>
<div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div> <div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>
@ -169,23 +160,6 @@
</div> </div>
</template> </template>
</DrawerDataTable> </DrawerDataTable>
<div
v-if="smartOperationReportEnabled"
class="production-compact-tip production-drawer-tip"
title="工序明细由智能报工小程序按材料库存批次号回传,并在生产台账中用于辅助判断生产进度。"
>
<strong>小程序工序明细</strong>
<span>按材料库存批次号回传用于辅助判断生产进度</span>
</div>
<div
v-else
class="production-compact-tip production-drawer-tip"
title="当前未对接智能报工小程序工序明细隐藏系统按ERP入库数据反推生产进度。"
>
<strong>工序明细隐藏</strong>
<span>当前未对接智能报工小程序系统按ERP入库数据反推生产进度</span>
</div>
</aside> </aside>
</div> </div>
</div> </div>
@ -198,13 +172,11 @@ import { computed, onMounted, ref } from "vue";
import DocumentArchiveActions from "../components/documentForms/DocumentArchiveActions.vue"; import DocumentArchiveActions from "../components/documentForms/DocumentArchiveActions.vue";
import DrawerDataTable from "../components/DrawerDataTable.vue"; import DrawerDataTable from "../components/DrawerDataTable.vue";
import FieldHint from "../components/FieldHint.vue";
import PaginationBar from "../components/PaginationBar.vue"; import PaginationBar from "../components/PaginationBar.vue";
import StatusBadge from "../components/StatusBadge.vue"; import StatusBadge from "../components/StatusBadge.vue";
import TableControls from "../components/TableControls.vue"; import TableControls from "../components/TableControls.vue";
import { useBodyScrollLock } from "../composables/useBodyScrollLock"; import { useBodyScrollLock } from "../composables/useBodyScrollLock";
import { downloadResource, fetchResource, openResource, postResource } from "../services/api"; import { downloadResource, fetchResource, openResource, postResource } from "../services/api";
import { loadSmartOperationReportConfig } from "../services/systemFeatures";
import { formatDateTime, formatQty, formatWeight } from "../utils/formatters"; import { formatDateTime, formatQty, formatWeight } from "../utils/formatters";
import { usePagination } from "../utils/pagination"; import { usePagination } from "../utils/pagination";
import { useTableControls } from "../utils/tableControls"; import { useTableControls } from "../utils/tableControls";
@ -216,7 +188,6 @@ const expandedTxnRows = ref([]);
const feedbackMessage = ref(""); const feedbackMessage = ref("");
const errorMessage = ref(""); const errorMessage = ref("");
const lockingLedger = ref(false); const lockingLedger = ref(false);
const smartOperationReportEnabled = ref(true);
const activeLedger = computed(() => ledgers.value.find((item) => Number(item.production_ledger_id) === Number(selectedLedgerId.value)) || null); const activeLedger = computed(() => ledgers.value.find((item) => Number(item.production_ledger_id) === Number(selectedLedgerId.value)) || null);
const activeLedgerTxns = computed(() => activeLedger.value?.txns || []); const activeLedgerTxns = computed(() => activeLedger.value?.txns || []);
@ -358,12 +329,8 @@ async function regenerateDocumentArchive(typeKey, businessId) {
async function loadAll() { async function loadAll() {
errorMessage.value = ""; errorMessage.value = "";
const [rows, config] = await Promise.all([ const rows = await fetchResource("/production/production-ledger?limit=500", []);
fetchResource("/production/production-ledger?limit=500", []),
loadSmartOperationReportConfig()
]);
ledgers.value = Array.isArray(rows) ? rows : []; ledgers.value = Array.isArray(rows) ? rows : [];
smartOperationReportEnabled.value = config.enabled !== false;
} }
onMounted(async () => { onMounted(async () => {

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