Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f378d4754 | ||
|
|
bca020554c | ||
|
|
eb472ad455 | ||
|
|
ac18942a4b | ||
|
|
6dd9529a16 | ||
|
|
4c06295535 | ||
|
|
c42d190f4b | ||
|
|
bad87029d3 | ||
|
|
b74a4ad6d4 | ||
|
|
914c8ed99f | ||
|
|
dac7844d4c | ||
|
|
3313dc3bbb | ||
|
|
c6a5628bb6 | ||
|
|
26bcd97d15 | ||
|
|
e308a6a7f2 | ||
|
|
f957899500 | ||
|
|
442713b4ef | ||
|
|
e90d296047 |
@ -6,7 +6,7 @@ product
|
||||
|
||||
## Users
|
||||
|
||||
嘉恒五金 ERP 面向采购、销售、仓库、生产、财务、售后和管理人员。用户通常在真实业务现场或办公室中处理订单、入库、出库、生产台账、发货、权限和经营数据,需要快速、准确、少解释地完成工作。
|
||||
百华五金 ERP 面向采购、销售、仓库、生产、财务、售后和管理人员。用户通常在真实业务现场或办公室中处理订单、入库、出库、生产台账、发货、权限和经营数据,需要快速、准确、少解释地完成工作。
|
||||
|
||||
## Product Purpose
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# ForgeFlow ERP
|
||||
|
||||
宁波嘉恒智能科技有限公司五金行业 ERP 初始骨架,采用前后端分离架构:
|
||||
宁波百华智能科技有限公司五金行业 ERP 初始骨架,采用前后端分离架构:
|
||||
|
||||
- 前端:Vue 3 + Vite
|
||||
- 后端:FastAPI
|
||||
|
||||
@ -88,6 +88,87 @@ from app.services.system_permissions import list_employee_options
|
||||
|
||||
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_REQUIRED_HEADERS = ["原材料名称", "材质", "规格"]
|
||||
MATERIAL_STATUS_LABELS = {
|
||||
@ -597,6 +678,7 @@ def list_work_centers(
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[WorkCenterRead]:
|
||||
_ensure_default_operation_foundation(db)
|
||||
stmt = (
|
||||
select(
|
||||
WorkCenter.id.label("id"),
|
||||
@ -766,6 +848,7 @@ def list_processes(
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[ProcessRead]:
|
||||
_ensure_default_operation_foundation(db)
|
||||
rows = db.execute(
|
||||
select(
|
||||
Process.id.label("process_id"),
|
||||
@ -1866,7 +1949,7 @@ def _default_miniapp_product_import_point_name(db: Session) -> str:
|
||||
.order_by(MiniAppAttendancePoint.name.asc())
|
||||
.limit(1)
|
||||
)
|
||||
or "宁波嘉恒智能科技有限公司"
|
||||
or "宁波百华智能科技有限公司"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -119,13 +119,13 @@ def _get_notice_creator_phone(db: Session) -> str:
|
||||
def _assistant_config_read(row: AiAssistantConfig | None) -> AiAssistantConfigRead:
|
||||
if not row:
|
||||
return AiAssistantConfigRead(
|
||||
assistant_name="嘉恒工艺助手",
|
||||
assistant_name="百华工艺助手",
|
||||
provider_name="",
|
||||
api_base_url="",
|
||||
api_key=None,
|
||||
model_name="",
|
||||
temperature=0.3,
|
||||
system_prompt="你是宁波嘉恒智能五金 ERP 的企业助手,优先解释业务流程、字段含义、数据异常和操作步骤。",
|
||||
system_prompt="你是宁波百华智能五金 ERP 的企业助手,优先解释业务流程、字段含义、数据异常和操作步骤。",
|
||||
enabled=False,
|
||||
config_id=None,
|
||||
api_key_configured=False,
|
||||
|
||||
@ -6,7 +6,7 @@ from sqlalchemy.engine import URL
|
||||
|
||||
|
||||
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_host: str = Field(default="0.0.0.0", alias="APP_HOST")
|
||||
app_port: int = Field(default=8000, alias="APP_PORT")
|
||||
|
||||
@ -49,5 +49,5 @@ def root() -> dict[str, str]:
|
||||
return {
|
||||
"name": settings.app_name,
|
||||
"env": settings.app_env,
|
||||
"message": "Jiaheng Hardware ERP API is running.",
|
||||
"message": "Baihua Hardware ERP API is running.",
|
||||
}
|
||||
|
||||
@ -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.org import Department, Employee, Permission, Role, RolePermission, User, UserRole
|
||||
from app.models.planning import MaterialDemand
|
||||
@ -12,6 +14,7 @@ __all__ = [
|
||||
"Bom",
|
||||
"BomItem",
|
||||
"Item",
|
||||
"ItemCategory",
|
||||
"Material",
|
||||
"MaterialDemand",
|
||||
"Permission",
|
||||
|
||||
@ -17,6 +17,20 @@ class Unit(Base):
|
||||
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):
|
||||
__tablename__ = "md_item"
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
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 app.models.base import Base
|
||||
@ -13,6 +13,16 @@ class MiniAppAttendancePoint(Base):
|
||||
latitude: 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"))
|
||||
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)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, server_default=text("1"))
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
@ -49,7 +59,7 @@ class MiniAppPersonAttendancePoint(Base):
|
||||
class MiniAppProduct(Base):
|
||||
__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)
|
||||
product_name: Mapped[str] = mapped_column(String(255), 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)
|
||||
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_workload: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0"))
|
||||
created_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):
|
||||
__tablename__ = "production_reports"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("'宁波嘉恒智能科技有限公司'"))
|
||||
session_id: Mapped[int] = mapped_column(BigInteger)
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("''"))
|
||||
session_id: Mapped[int] = mapped_column(ForeignKey("work_sessions.id"), unique=True)
|
||||
employee_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"))
|
||||
report_date: Mapped[object] = mapped_column(Date)
|
||||
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)
|
||||
reviewed_at: Mapped[object | None] = mapped_column(DateTime, 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)
|
||||
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)
|
||||
|
||||
__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):
|
||||
__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"))
|
||||
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))
|
||||
project_no: Mapped[str] = mapped_column(String(64))
|
||||
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)
|
||||
process_name: 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_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"))
|
||||
@ -119,13 +225,95 @@ class MiniAppProductionReportItem(Base):
|
||||
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"))
|
||||
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)
|
||||
|
||||
__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):
|
||||
__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))
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
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)
|
||||
attendance_point_name: Mapped[str] = mapped_column(ForeignKey("attendance_points.name"), primary_key=True)
|
||||
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"),
|
||||
)
|
||||
|
||||
@ -559,6 +559,39 @@ class WorkOrderMaterialIssue(Base):
|
||||
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):
|
||||
__tablename__ = "pp_production_batch_ledger"
|
||||
__table_args__ = (
|
||||
|
||||
@ -167,7 +167,7 @@ class AiAssistantConfig(Base):
|
||||
__tablename__ = "sys_ai_assistant_config"
|
||||
|
||||
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)
|
||||
api_base_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
api_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
|
||||
@ -30,7 +30,7 @@ class BroadcastMessageRead(BroadcastMessageCreate):
|
||||
|
||||
|
||||
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
|
||||
api_base_url: str | None = None
|
||||
api_key: str | None = None
|
||||
|
||||
@ -6,7 +6,7 @@ from app.schemas.domain import CostInsight, InventoryLotSummary, MiniAppField, O
|
||||
|
||||
def get_dashboard_payload() -> DashboardPayload:
|
||||
return DashboardPayload(
|
||||
company="宁波嘉恒智能科技有限公司",
|
||||
company="宁波百华智能科技有限公司",
|
||||
overview=[
|
||||
OverviewMetric(
|
||||
code="sales_open",
|
||||
|
||||
1041
backend/app/services/system_initializer.py
Normal file
1041
backend/app/services/system_initializer.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -58,7 +58,7 @@ MENU_PERMISSION_TREE = [
|
||||
("MENU_PURCHASE_ORDER", "采购订单", []),
|
||||
("MENU_PURCHASE_RECEIPT", "到货入库", []),
|
||||
("MENU_QUALITY_INSPECTION", "质量检验", []),
|
||||
("MENU_INVENTORY_LEDGER", "嘉恒仓库", []),
|
||||
("MENU_INVENTORY_LEDGER", "百华仓库", []),
|
||||
],
|
||||
),
|
||||
(
|
||||
@ -85,7 +85,10 @@ MENU_PERMISSION_TREE = [
|
||||
ORG_ROOT_CODE = "ORG_ROOT"
|
||||
ORG_ROOT_NAME = "总公司"
|
||||
VALID_CHILD_NODE_TYPES = {
|
||||
"COMPANY": {"BRANCH": "总公司下只能新增分公司"},
|
||||
"COMPANY": {
|
||||
"BRANCH": "总公司下只能新增分公司或直属部门",
|
||||
"DEPARTMENT": "总公司下只能新增分公司或直属部门",
|
||||
},
|
||||
"BRANCH": {"DEPARTMENT": "分公司下只能新增部门"},
|
||||
"DEPARTMENT": {"GROUP": "部门下只能新增小组"},
|
||||
"GROUP": {},
|
||||
|
||||
1
backend/scripts/__init__.py
Normal file
1
backend/scripts/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Operational scripts for ForgeFlow ERP backend."""
|
||||
89
backend/scripts/system_initialize.py
Normal file
89
backend/scripts/system_initialize.py
Normal 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())
|
||||
@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS sys_broadcast_message (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sys_ai_assistant_config (
|
||||
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供应商名称',
|
||||
api_base_url VARCHAR(255) NULL COMMENT 'LLM API基础地址',
|
||||
api_key VARCHAR(512) NULL COMMENT 'LLM API密钥',
|
||||
|
||||
@ -71,7 +71,7 @@ SET role_name = '采购专员', remark = '供应商、采购订单、到货和
|
||||
WHERE role_code = 'PURCHASER';
|
||||
|
||||
UPDATE sys_role
|
||||
SET role_name = '仓库管理人员', remark = '到货、质检、嘉恒仓库、发货台账相关作业'
|
||||
SET role_name = '仓库管理人员', remark = '到货、质检、百华仓库、发货台账相关作业'
|
||||
WHERE role_code = 'WAREHOUSE';
|
||||
|
||||
UPDATE sys_role
|
||||
|
||||
@ -152,7 +152,7 @@ CREATE TABLE IF NOT EXISTS sys_broadcast_message (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sys_ai_assistant_config (
|
||||
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供应商名称',
|
||||
api_base_url VARCHAR(255) NULL COMMENT 'LLM API基础地址',
|
||||
api_key VARCHAR(512) NULL COMMENT 'LLM API密钥',
|
||||
|
||||
@ -308,7 +308,7 @@ SELECT
|
||||
(SELECT id FROM md_unit WHERE unit_code = 'PCS'),
|
||||
(SELECT id FROM md_unit WHERE unit_code = 'PCS'),
|
||||
500.000000, 0.000000,
|
||||
0.0000, 'ACTIVE', '宁波嘉恒智能本体示例成品,用于演示销售、MRP、工单、成本闭环'
|
||||
0.0000, 'ACTIVE', '宁波百华智能本体示例成品,用于演示销售、MRP、工单、成本闭环'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM md_item WHERE item_code = 'FG-BT-001');
|
||||
|
||||
@ -594,7 +594,7 @@ INSERT INTO em_equipment (
|
||||
SELECT
|
||||
'EQ-QA-01', '终检包装工位 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),
|
||||
'IDLE', '终检与包装工位'
|
||||
FROM DUAL
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import BigInteger, create_engine, func, select
|
||||
from sqlalchemy.ext.compiler import compiles
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
|
||||
@compiles(BigInteger, "sqlite")
|
||||
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
|
||||
_ = type_, compiler, kw
|
||||
return "INTEGER"
|
||||
|
||||
|
||||
import app.models.master_data # noqa: E402,F401
|
||||
import app.models.miniapp # noqa: E402,F401
|
||||
import app.models.operations # noqa: E402,F401
|
||||
import app.models.org # noqa: E402,F401
|
||||
import app.models.planning # noqa: E402,F401
|
||||
import app.models.sales # noqa: E402,F401
|
||||
from app.api.routes import master_data # noqa: E402
|
||||
from app.models.base import Base # noqa: E402
|
||||
from app.models.operations import Process, WorkCenter # noqa: E402
|
||||
from app.models.org import Department # noqa: E402
|
||||
|
||||
|
||||
class MasterDataDefaultOperationFoundationTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
|
||||
Base.metadata.create_all(engine)
|
||||
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
self.db: Session = self.SessionLocal()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.db.close()
|
||||
|
||||
def test_list_processes_seeds_default_process_for_empty_system(self) -> None:
|
||||
self.assertEqual(self.db.scalar(select(func.count(Process.id))), 0)
|
||||
|
||||
rows = master_data.list_processes(limit=100, db=self.db)
|
||||
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0].process_code, "PROC-DEFAULT")
|
||||
self.assertEqual(rows[0].process_name, "系统默认工序")
|
||||
self.assertEqual(self.db.scalar(select(func.count(Process.id))), 1)
|
||||
|
||||
def test_list_work_centers_seeds_default_work_center_for_empty_system(self) -> None:
|
||||
self.assertEqual(self.db.scalar(select(func.count(Department.id))), 0)
|
||||
self.assertEqual(self.db.scalar(select(func.count(WorkCenter.id))), 0)
|
||||
|
||||
rows = master_data.list_work_centers(limit=100, db=self.db)
|
||||
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0].center_code, "WC-DEFAULT")
|
||||
self.assertEqual(rows[0].center_name, "系统默认工作中心")
|
||||
self.assertEqual(rows[0].dept_name, "总公司")
|
||||
self.assertEqual(self.db.scalar(select(func.count(Department.id))), 1)
|
||||
self.assertEqual(self.db.scalar(select(func.count(WorkCenter.id))), 1)
|
||||
@ -265,7 +265,7 @@ class SalesOrderDeliveryTraceTest(unittest.TestCase):
|
||||
def _seed_inventory_ledger_employee_permission(self) -> Employee:
|
||||
return self._seed_employee_permission(
|
||||
"MENU_INVENTORY_LEDGER",
|
||||
"嘉恒仓库",
|
||||
"百华仓库",
|
||||
"INVENTORY_LEDGER",
|
||||
"WAREHOUSE",
|
||||
"仓库人员",
|
||||
|
||||
@ -214,7 +214,7 @@ class SelectedStockLotProductionIssueTest(unittest.TestCase):
|
||||
self.db.add(
|
||||
MiniAppProductionReport(
|
||||
id=9000 + item_id,
|
||||
attendance_point_name="宁波嘉恒智能科技有限公司",
|
||||
attendance_point_name="宁波百华智能科技有限公司",
|
||||
session_id=8000 + item_id,
|
||||
employee_phone="13800000000",
|
||||
report_date=timestamp.date(),
|
||||
@ -238,7 +238,7 @@ class SelectedStockLotProductionIssueTest(unittest.TestCase):
|
||||
item = MiniAppProductionReportItem(
|
||||
id=item_id,
|
||||
report_id=9000 + item_id,
|
||||
attendance_point_name="宁波嘉恒智能科技有限公司",
|
||||
attendance_point_name="宁波百华智能科技有限公司",
|
||||
device_no="",
|
||||
project_no=self.product.item_code,
|
||||
product_name=self.product.item_name,
|
||||
|
||||
152
backend/tests/test_system_initializer_cli.py
Normal file
152
backend/tests/test_system_initializer_cli.py
Normal 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()
|
||||
97
backend/tests/test_system_initializer_models.py
Normal file
97
backend/tests/test_system_initializer_models.py
Normal 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)
|
||||
329
backend/tests/test_system_initializer_reset.py
Normal file
329
backend/tests/test_system_initializer_reset.py
Normal 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()
|
||||
177
backend/tests/test_system_initializer_seed.py
Normal file
177
backend/tests/test_system_initializer_seed.py
Normal 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()
|
||||
@ -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].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:
|
||||
from fastapi import HTTPException
|
||||
from app.schemas.system_permissions import OrgNodeCreate
|
||||
@ -408,9 +434,9 @@ class SystemPermissionManagementTest(unittest.TestCase):
|
||||
self.db,
|
||||
OrgNodeCreate(
|
||||
parent_id=self.company.id,
|
||||
node_type="DEPARTMENT",
|
||||
node_label="错误部门",
|
||||
dept_code="BAD-DEPT",
|
||||
node_type="GROUP",
|
||||
node_label="错误小组",
|
||||
dept_code="BAD-GROUP",
|
||||
dept_type="ADMIN",
|
||||
manager_employee_id=None,
|
||||
sort_no=1,
|
||||
@ -419,7 +445,7 @@ class SystemPermissionManagementTest(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
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:
|
||||
from fastapi import HTTPException
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# 嘉恒智能五金 ERP 生产使用案例
|
||||
# 百华智能五金 ERP 生产使用案例
|
||||
|
||||
## 案例背景
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**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.
|
||||
|
||||
@ -1072,7 +1072,7 @@ Expected: Vite exits code 0. Existing chunk warning is acceptable.
|
||||
Use the running ERP UI and verify:
|
||||
|
||||
```text
|
||||
1. Select 原材料库 in 嘉恒仓库 warehouse bar.
|
||||
1. Select 原材料库 in 百华仓库 warehouse bar.
|
||||
2. Click 盘库.
|
||||
3. Dialog shows 当前仓库:原材料库 and no multi-warehouse checkbox cards.
|
||||
4. Click 确认盘库并导出仓库清单.
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**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.
|
||||
|
||||
@ -1955,7 +1955,7 @@ Create `frontend/src/components/StocktakeDialog.vue`:
|
||||
<section class="stocktake-shell">
|
||||
<header class="stocktake-head">
|
||||
<div>
|
||||
<p class="eyebrow">嘉恒仓库 · 盘库</p>
|
||||
<p class="eyebrow">百华仓库 · 盘库</p>
|
||||
<h3>{{ activeStocktake ? activeStocktake.stocktake_no : "新建盘库" }}</h3>
|
||||
</div>
|
||||
<button class="ghost-button" type="button" @click="$emit('close')">关闭</button>
|
||||
@ -2125,7 +2125,7 @@ async function startStocktake() {
|
||||
try {
|
||||
activeStocktake.value = await postResource("/inventory/stocktakes", {
|
||||
warehouse_ids: selectedWarehouseIds.value,
|
||||
remark: "嘉恒仓库盘库"
|
||||
remark: "百华仓库盘库"
|
||||
});
|
||||
activeStep.value = "export";
|
||||
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:
|
||||
|
||||
```text
|
||||
1. 打开 采购与库存 -> 嘉恒仓库。
|
||||
1. 打开 采购与库存 -> 百华仓库。
|
||||
2. 点击仓库 bar 上方“盘库”。
|
||||
3. 默认勾选当前仓库。
|
||||
4. 点击“开始盘库并锁库”。
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**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.
|
||||
|
||||
@ -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:
|
||||
|
||||
```text
|
||||
1. Open 嘉恒仓库 and click 盘库.
|
||||
1. Open 百华仓库 and click 盘库.
|
||||
2. Warehouse cards show 可盘库 or active stocktake status.
|
||||
3. Start a stocktake for one warehouse.
|
||||
4. Dialog moves to 导出清单 and shows the selected warehouse name.
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
|
||||
## 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 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.
|
||||
@ -1956,7 +1956,7 @@ Expected: first command finds the new feature in planned files. Second command m
|
||||
Run the backend and frontend using the project’s existing local start commands. If the commands are already running, reuse them. Then verify:
|
||||
|
||||
```text
|
||||
1. Open 嘉恒仓库.
|
||||
1. Open 百华仓库.
|
||||
2. Select 原材料库.
|
||||
3. Confirm 出库 row shows 生产出库、委外出库、报废出库、退货出库.
|
||||
4. Click 退货出库.
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**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.
|
||||
|
||||
@ -12,11 +12,11 @@
|
||||
|
||||
## Confirmed Business Rules
|
||||
|
||||
- “发货与售后”的“退货处理”入口去掉,退货登记逻辑移动到“嘉恒仓库 -> 退货库 -> 入库 -> 退货入库”。
|
||||
- “发货与售后”的“退货处理”入口去掉,退货登记逻辑移动到“百华仓库 -> 退货库 -> 入库 -> 退货入库”。
|
||||
- “退货库”只存放客户退回后判定可以返工的产品。
|
||||
- 退货库入库后的批次默认“待返工/锁定”,不能作为成品库存销售。
|
||||
- 退货库出库只有“返工出库”,出库后扣减退货库库存并生成返工处置/返工工单。
|
||||
- 客户退回后判定报废的产品不进入退货库,用户在“嘉恒仓库 -> 废料库 -> 入库 -> 退货废料入库”登记。
|
||||
- 客户退回后判定报废的产品不进入退货库,用户在“百华仓库 -> 废料库 -> 入库 -> 退货废料入库”登记。
|
||||
- 退货库需要“期初入库”,用于初始化历史退货待返工库存。
|
||||
- 退货品明细必须展示发货单号或发货批次号,并通过发货明细追溯成品批次、原材料库存批次号、来源材料。
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
- Add `RETURN` warehouse and default location.
|
||||
- Add `delivery_item_id` to `rt_return_item`.
|
||||
- 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`
|
||||
- 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`
|
||||
@ -1418,7 +1418,7 @@ Update the workspace title/description:
|
||||
|
||||
```vue
|
||||
title="成品发货与交付管理"
|
||||
description="发货退回后的库存处理已迁移到嘉恒仓库的退货库和废料库。"
|
||||
description="发货退回后的库存处理已迁移到百华仓库的退货库和废料库。"
|
||||
```
|
||||
|
||||
- [ ] **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:
|
||||
|
||||
```text
|
||||
1. 嘉恒仓库出现第六个仓库:退货库。
|
||||
1. 百华仓库出现第六个仓库:退货库。
|
||||
2. 退货库入库区有:期初入库、退货入库。
|
||||
3. 退货库出库区有:返工出库。
|
||||
4. 废料库入库区有:退货废料入库。
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**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.
|
||||
|
||||
@ -1324,7 +1324,7 @@ npm run dev
|
||||
```
|
||||
|
||||
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.
|
||||
- 废料库 list is weight-only and does not show quantity columns.
|
||||
- 入库 bar shows 生产废料入库、期初入库、委外废料入库.
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**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.
|
||||
|
||||
@ -872,7 +872,7 @@ Expected:
|
||||
Open the ERP frontend and verify:
|
||||
|
||||
```text
|
||||
嘉恒仓库 -> 成品库 -> 销售出库
|
||||
百华仓库 -> 成品库 -> 销售出库
|
||||
```
|
||||
|
||||
Expected UI behavior:
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**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.
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
|
||||
## 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: 原材料库、半成品库、成品库、辅料库、废料库、退货库.
|
||||
- Clicking opens a right-side drawer titled `{当前仓库名} · 出入库流水`.
|
||||
- The drawer shows summary cards, filters, and a paginated table.
|
||||
@ -774,7 +774,7 @@ In the same Vue file, before `<StocktakeDialog`, add:
|
||||
```vue
|
||||
<FormDrawer
|
||||
:open="warehouseLedgerDrawerOpen"
|
||||
eyebrow="嘉恒仓库 · 库级流水"
|
||||
eyebrow="百华仓库 · 库级流水"
|
||||
:title="`${activeInventoryLabel} · 出入库流水`"
|
||||
tag="库存流水"
|
||||
wide
|
||||
@ -1214,7 +1214,7 @@ http://localhost:5173/inventory-ledger
|
||||
|
||||
Manual expected results after login:
|
||||
|
||||
- 嘉恒仓库 page renders.
|
||||
- 百华仓库 page renders.
|
||||
- Select 原材料库, click `流水`, drawer title is `原材料库 · 出入库流水`.
|
||||
- Switch 成品库 while the drawer is open, drawer reloads and title becomes `成品库 · 出入库流水`.
|
||||
- Search box can filter by a known material or batch keyword.
|
||||
|
||||
@ -704,7 +704,7 @@ Expected: all pass.
|
||||
Open the ERP in the browser and check:
|
||||
|
||||
```text
|
||||
1. 嘉恒仓库 -> 成品库 -> 产品明细抽屉
|
||||
1. 百华仓库 -> 成品库 -> 产品明细抽屉
|
||||
2. 库存批次明细表
|
||||
3. 库存流水表
|
||||
4. 采购订单详情抽屉
|
||||
|
||||
@ -104,4 +104,4 @@ Expected: all backend tests pass.
|
||||
|
||||
Run frontend dev server and open `http://127.0.0.1:5173/` in the in-app browser.
|
||||
|
||||
Expected: page loads with title `嘉恒智能五金 ERP` and no console errors.
|
||||
Expected: page loads with title `百华智能五金 ERP` and no console errors.
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**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` 过滤与展示,避免跨工序混扣库存。
|
||||
|
||||
@ -575,7 +575,7 @@ Expected: PASS.
|
||||
|
||||
- [ ] **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
|
||||
半成品入库字段显示“产品工序”
|
||||
|
||||
@ -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.
|
||||
|
||||
**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.
|
||||
|
||||
@ -977,7 +977,7 @@ http://127.0.0.1:5173/
|
||||
Manual checks:
|
||||
|
||||
- Login if needed.
|
||||
- Navigate to `嘉恒仓库`.
|
||||
- Navigate to `百华仓库`.
|
||||
- Select `成品库`.
|
||||
- Confirm the inbound operation row shows `返工入库`.
|
||||
- Click `返工入库`.
|
||||
|
||||
@ -319,7 +319,7 @@ nano /opt/ForgeFlow-ERP/backend/.env
|
||||
推荐内容:
|
||||
|
||||
```env
|
||||
APP_NAME=Jiaheng Hardware ERP API
|
||||
APP_NAME=Baihua Hardware ERP API
|
||||
APP_ENV=staging-dev
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8000
|
||||
@ -802,7 +802,7 @@ API 请求地址是 http://<腾讯云公网IP>:8000/api/...
|
||||
验证:
|
||||
|
||||
```text
|
||||
嘉恒仓库六大库能打开,出入库弹窗能打开
|
||||
百华仓库六大库能打开,出入库弹窗能打开
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 生产执行页面**
|
||||
|
||||
@ -1497,7 +1497,7 @@ ERP 工单台账中返工工单累计良品 8、废品 2。
|
||||
Manual flow:
|
||||
|
||||
```text
|
||||
嘉恒仓库 -> 成品库 -> 返工入库
|
||||
百华仓库 -> 成品库 -> 返工入库
|
||||
-> 选择返工工单 JH0001 对应工单
|
||||
-> 入库 8 件
|
||||
```
|
||||
@ -1515,7 +1515,7 @@ Expected:
|
||||
Manual flow:
|
||||
|
||||
```text
|
||||
嘉恒仓库 -> 废料库 -> 返工废料入库
|
||||
百华仓库 -> 废料库 -> 返工废料入库
|
||||
-> 选择同一返工工单
|
||||
-> 入库 2 件对应废料重量
|
||||
-> 勾选结单
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为嘉恒仓库 6 大库的入库与出库操作区分别增加“特殊入库”“特殊出库”,允许在强制填写说明后,精确到库存明细行进行受控库存调整,并把说明写入所有变更记录。
|
||||
**Goal:** 为百华仓库 6 大库的入库与出库操作区分别增加“特殊入库”“特殊出库”,允许在强制填写说明后,精确到库存明细行进行受控库存调整,并把说明写入所有变更记录。
|
||||
|
||||
**Architecture:** 新增独立的“特殊调整”后端服务与审计主/明细表,库存变动仍复用现有 `wh_stock_lot`、`wh_stock_balance`、`wh_inventory_txn` 三层账本。前端在现有 `InventoryLedgerView.vue` 的仓库出入库操作矩阵中增加特殊操作入口,打开专用抽屉完成说明填写、明细选择、调整前后预览和提交。
|
||||
|
||||
@ -1743,8 +1743,8 @@ http://127.0.0.1:5173/
|
||||
|
||||
Manual checks:
|
||||
|
||||
- 嘉恒仓库 6 大库的入库操作区都有“特殊入库”。
|
||||
- 嘉恒仓库 6 大库的出库操作区都有“特殊出库”。
|
||||
- 百华仓库 6 大库的入库操作区都有“特殊入库”。
|
||||
- 百华仓库 6 大库的出库操作区都有“特殊出库”。
|
||||
- 未填写说明时,不能添加/提交特殊调整明细。
|
||||
- 原材料库特殊出库只能填写重量,提交后对应库存批次重量减少。
|
||||
- 辅料库特殊入库只按数量增加,不写入重量。
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**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.
|
||||
|
||||
@ -1859,7 +1859,7 @@ Add a `FormDrawer` in `InventoryLedgerView.vue`:
|
||||
```html
|
||||
<FormDrawer
|
||||
:open="productionWorkOrderInboundDrawerOpen"
|
||||
eyebrow="嘉恒仓库 · 生产工单入库"
|
||||
eyebrow="百华仓库 · 生产工单入库"
|
||||
title="生产工单入库"
|
||||
tag="工单闭环"
|
||||
wide
|
||||
@ -2144,12 +2144,12 @@ Smoke flow:
|
||||
|
||||
1. Open `http://127.0.0.1:5173`.
|
||||
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.
|
||||
5. Verify 工单台账 has work order `YYYYMMDD-YLxxxx-产品名称`.
|
||||
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.
|
||||
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.
|
||||
10. Check “本次入库后结单”, submit, confirm second prompt, verify work order status becomes 已结单.
|
||||
|
||||
|
||||
@ -42,11 +42,11 @@
|
||||
- Modify `backend/app/api/routes/inventory.py`
|
||||
- Validate warehouse form personnel fields against `MENU_INVENTORY_LEDGER`.
|
||||
- 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`
|
||||
- Validate delivery shipper fields against `MENU_DELIVERY_MANAGEMENT` for 发货概览 delivery endpoints.
|
||||
- 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`
|
||||
- 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. 销售订单新增:能看到“销售人员”,选项只包含有客户名录/销售订单权限的人。
|
||||
4. 采购订单新增:采购员只包含有采购订单权限的人。
|
||||
5. 到货入库:接收人只包含有到货入库权限的人。
|
||||
6. 嘉恒仓库内接收人/经办人/发货人只包含有嘉恒仓库权限的人。
|
||||
6. 百华仓库内接收人/经办人/发货人只包含有百华仓库权限的人。
|
||||
7. 设备管理负责人只包含有设备管理权限的人。
|
||||
8. 尝试用接口提交一个无权限人员 ID,后端返回中文错误,不保存单据。
|
||||
```
|
||||
|
||||
@ -187,7 +187,7 @@ class SystemPermissionManagementTest(unittest.TestCase):
|
||||
now = datetime(2026, 6, 8, 9, 0, 0)
|
||||
self.company = Department(
|
||||
dept_code="JH",
|
||||
dept_name="嘉恒总公司",
|
||||
dept_name="百华总公司",
|
||||
parent_id=None,
|
||||
org_node_type="COMPANY",
|
||||
dept_type="ADMIN",
|
||||
@ -331,7 +331,7 @@ SET role_name = '采购专员', remark = '供应商、采购订单、到货和
|
||||
WHERE role_code = 'PURCHASER';
|
||||
|
||||
UPDATE sys_role
|
||||
SET role_name = '仓库管理人员', remark = '到货、质检、嘉恒仓库、发货台账相关作业'
|
||||
SET role_name = '仓库管理人员', remark = '到货、质检、百华仓库、发货台账相关作业'
|
||||
WHERE role_code = 'WAREHOUSE';
|
||||
|
||||
UPDATE sys_role
|
||||
@ -498,7 +498,7 @@ Append these tests to `SystemPermissionManagementTest` in `backend/tests/test_sy
|
||||
tree = build_org_tree(self.db)
|
||||
|
||||
self.assertEqual(len(tree), 1)
|
||||
self.assertEqual(tree[0].node_label, "嘉恒总公司")
|
||||
self.assertEqual(tree[0].node_label, "百华总公司")
|
||||
purchase = tree[0].children[0]
|
||||
self.assertEqual(purchase.node_label, "采购部")
|
||||
self.assertEqual(purchase.manager_employee_id, self.manager_employee_id)
|
||||
@ -792,7 +792,7 @@ MENU_PERMISSION_TREE = [
|
||||
("MENU_PURCHASE_ORDER", "采购订单", []),
|
||||
("MENU_PURCHASE_RECEIPT", "到货入库", []),
|
||||
("MENU_QUALITY_INSPECTION", "质量检验", []),
|
||||
("MENU_INVENTORY_LEDGER", "嘉恒仓库", []),
|
||||
("MENU_INVENTORY_LEDGER", "百华仓库", []),
|
||||
],
|
||||
),
|
||||
(
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 让嘉恒仓库 6 大库所有出入库填单都采用正式纸质单据风格,保存后生成 PDF 留档,并支持在各库流水中预览、下载、重新生成 PDF,以及按当前筛选时间范围导出流水 Excel 清单。
|
||||
**Goal:** 让百华仓库 6 大库所有出入库填单都采用正式纸质单据风格,保存后生成 PDF 留档,并支持在各库流水中预览、下载、重新生成 PDF,以及按当前筛选时间范围导出流水 Excel 清单。
|
||||
|
||||
**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')">
|
||||
<header class="warehouse-document-head">
|
||||
<div>
|
||||
<span class="warehouse-document-kicker">嘉恒仓库</span>
|
||||
<span class="warehouse-document-kicker">百华仓库</span>
|
||||
<h2>{{ title }}</h2>
|
||||
</div>
|
||||
<dl>
|
||||
|
||||
@ -431,8 +431,8 @@ class DocumentArchiveServiceTest(unittest.TestCase):
|
||||
self.customer = Customer(
|
||||
id=1,
|
||||
customer_code="CUS001",
|
||||
customer_name="嘉恒客户",
|
||||
short_name="嘉恒",
|
||||
customer_name="百华客户",
|
||||
short_name="百华",
|
||||
status="ACTIVE",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
@ -1789,7 +1789,7 @@ const props = defineProps({
|
||||
},
|
||||
companyName: {
|
||||
type: String,
|
||||
default: "嘉恒仓库 ERP"
|
||||
default: "百华仓库 ERP"
|
||||
},
|
||||
tone: {
|
||||
type: String,
|
||||
|
||||
@ -185,7 +185,7 @@ const props = defineProps({
|
||||
},
|
||||
companyName: {
|
||||
type: String,
|
||||
default: "宁波嘉恒智能科技有限公司"
|
||||
default: "宁波百华智能科技有限公司"
|
||||
},
|
||||
metaLabel: {
|
||||
type: String,
|
||||
@ -496,7 +496,7 @@ defineProps({
|
||||
},
|
||||
companyName: {
|
||||
type: String,
|
||||
default: "嘉恒仓库"
|
||||
default: "百华仓库"
|
||||
},
|
||||
signatureLabels: {
|
||||
type: Array,
|
||||
|
||||
@ -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,再做后续验收。
|
||||
2098
docs/superpowers/plans/2026-06-14-system-initialization-bootstrap.md
Normal file
2098
docs/superpowers/plans/2026-06-14-system-initialization-bootstrap.md
Normal file
File diff suppressed because it is too large
Load Diff
493
docs/superpowers/plans/2026-06-15-org-tree-action-parity.md
Normal file
493
docs/superpowers/plans/2026-06-15-org-tree-action-parity.md
Normal 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`。
|
||||
|
||||
@ -4,14 +4,14 @@
|
||||
|
||||
当前系统同时存在两个发货相关入口:
|
||||
|
||||
- `嘉恒仓库 -> 成品库 -> 销售出库`
|
||||
- `百华仓库 -> 成品库 -> 销售出库`
|
||||
- `发货与售后 -> 发货管理`
|
||||
|
||||
两个入口都会影响发货单、成品库存和后续退货追溯,容易让用户不知道应该在哪里执行发货,也容易造成业务口径重复。
|
||||
|
||||
## 目标
|
||||
|
||||
采用方案 A:将发货执行入口统一到 `嘉恒仓库 -> 成品库 -> 销售出库`,将 `发货与售后 -> 发货管理` 调整为发货台账/交付查询。
|
||||
采用方案 A:将发货执行入口统一到 `百华仓库 -> 成品库 -> 销售出库`,将 `发货与售后 -> 发货管理` 调整为发货台账/交付查询。
|
||||
|
||||
目标效果:
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
|
||||
### 成品库销售出库
|
||||
|
||||
`嘉恒仓库 -> 成品库 -> 销售出库` 是唯一新增发货入口。
|
||||
`百华仓库 -> 成品库 -> 销售出库` 是唯一新增发货入口。
|
||||
|
||||
该入口支持两种模式:
|
||||
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
# 嘉恒仓库库级出入库流水设计
|
||||
# 百华仓库库级出入库流水设计
|
||||
|
||||
## 背景
|
||||
|
||||
嘉恒仓库已经承载原材料库、半成品库、成品库、辅料库、废料库、退货库六类库存。当前页面支持点击某个物料查看该物料的批次台账和库存流水,但缺少“站在某一个仓库整体视角查看所有出入库流水”的入口。
|
||||
百华仓库已经承载原材料库、半成品库、成品库、辅料库、废料库、退货库六类库存。当前页面支持点击某个物料查看该物料的批次台账和库存流水,但缺少“站在某一个仓库整体视角查看所有出入库流水”的入口。
|
||||
|
||||
本设计在仓库页新增库级流水入口。入口跟随当前选中的仓库 tab,用于查看当前仓库下所有物料、批次和业务单据产生的出入库流水。
|
||||
|
||||
## 目标
|
||||
|
||||
- 在嘉恒仓库页面的出入库操作区右侧新增一个“流水”图标按钮,位置对应用户截图红框区域。
|
||||
- 在百华仓库页面的出入库操作区右侧新增一个“流水”图标按钮,位置对应用户截图红框区域。
|
||||
- 当前选中哪个仓库 tab,点击按钮就查看该仓库的出入库流水。
|
||||
- 六大仓库都支持库级流水:原材料库、半成品库、成品库、辅料库、废料库、退货库。
|
||||
- 流水数据来自统一库存流水表 `wh_inventory_txn`,不新增另一套流水记录体系。
|
||||
@ -23,7 +23,7 @@
|
||||
|
||||
## 页面入口
|
||||
|
||||
在嘉恒仓库页面的出入库操作区右侧新增一个图标按钮。
|
||||
在百华仓库页面的出入库操作区右侧新增一个图标按钮。
|
||||
|
||||
按钮要求:
|
||||
|
||||
@ -245,7 +245,7 @@ GET /inventory/transaction-ledger
|
||||
|
||||
前端静态测试:
|
||||
|
||||
- 嘉恒仓库页面存在“流水”入口。
|
||||
- 百华仓库页面存在“流水”入口。
|
||||
- 六大仓库 tab 都可打开同一流水抽屉。
|
||||
- 请求使用 `/inventory/transaction-ledger`。
|
||||
- 旧物料级库存流水仍保留。
|
||||
@ -256,7 +256,7 @@ GET /inventory/transaction-ledger
|
||||
|
||||
浏览器冒烟:
|
||||
|
||||
- 登录后进入嘉恒仓库。
|
||||
- 登录后进入百华仓库。
|
||||
- 切换任意仓库 tab。
|
||||
- 点击“流水”按钮。
|
||||
- 抽屉标题和当前仓库一致。
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
|
||||
## 目标
|
||||
|
||||
- 在 `嘉恒仓库 -> 成品库` 的入库区新增 `返工入库`。
|
||||
- 在 `百华仓库 -> 成品库` 的入库区新增 `返工入库`。
|
||||
- `返工入库` 必须选择产品,并关联该产品未完成回库的返工出库记录。
|
||||
- 选择产品后,系统带出该产品对应的原材料库存批次号多选框,用 `JH...` 原材料库存批次号做追溯。
|
||||
- 成品库仍以数量为主维度,重量只作为辅助信息或追溯信息,不作为主要库存口径。
|
||||
@ -21,7 +21,7 @@
|
||||
|
||||
## 业务入口
|
||||
|
||||
`嘉恒仓库 -> 成品库 -> 入库 -> 返工入库`
|
||||
`百华仓库 -> 成品库 -> 入库 -> 返工入库`
|
||||
|
||||
该入口建议复用当前普通仓库入库抽屉样式,不另起一个完全不同的页面。字段建议为:
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
- 生产工单号改为 `YYYYMMDD-库存批次号-产品名称`,例如 `20260607-YL0001-钢制碗`。
|
||||
- 同一天、同一个库存批次号、同一个产品的生产出库合并为同一张未结单工单。
|
||||
- 小程序原“工单号”业务语义调整为“材料库存批次号”,员工按材料库存批次号报工。
|
||||
- 嘉恒仓库“流水”按钮旁新增“生产工单入库”入口,统一处理成品入库、生产余料入库、生产废料入库。
|
||||
- 百华仓库“流水”按钮旁新增“生产工单入库”入口,统一处理成品入库、生产余料入库、生产废料入库。
|
||||
- “生产工单入库”中系统计算理论成品、理论余料、理论废料作为辅助默认值,用户可修改。
|
||||
- 当用户修改余料或废料后,与理论值偏差超过上下 15%,必须弹窗填写说明,但不禁止入库。
|
||||
- “生产工单入库”不默认结单。只有用户主动勾选“本次入库后结单”才结单,并且提交前必须二次确认。
|
||||
@ -159,13 +159,13 @@ YYYYMMDD-库存批次号-产品名称
|
||||
|
||||
## 生产工单入库入口
|
||||
|
||||
在嘉恒仓库页面,“流水”按钮旁新增:
|
||||
在百华仓库页面,“流水”按钮旁新增:
|
||||
|
||||
```text
|
||||
生产工单入库
|
||||
```
|
||||
|
||||
入口跟随嘉恒仓库整体,不放在某一个具体仓库的入库/出库行里。因为这个操作一次会同时影响成品库、原材料库和废料库。
|
||||
入口跟随百华仓库整体,不放在某一个具体仓库的入库/出库行里。因为这个操作一次会同时影响成品库、原材料库和废料库。
|
||||
|
||||
点击后打开较宽抽屉,避免表格和计算字段拥挤。该入口不使用小弹窗承载主流程。
|
||||
|
||||
@ -443,7 +443,7 @@ max(Q * (G - N) + S * G - W, 0)
|
||||
|
||||
- 原材料库生产出库 UI 只能选择一个材料库存批次号。
|
||||
- 生产出库提交 payload 只包含一个库存批次。
|
||||
- 嘉恒仓库流水按钮旁显示“生产工单入库”。
|
||||
- 百华仓库流水按钮旁显示“生产工单入库”。
|
||||
- 生产工单入库选择工单后自动填理论值。
|
||||
- 修改成品数量后自动重算余料和废料理论值。
|
||||
- 偏差超过 15% 时弹出说明框。
|
||||
@ -465,7 +465,7 @@ max(Q * (G - N) + S * G - W, 0)
|
||||
3. 调整生产出库为单库存批次,并实现同日同批同产品工单合并。
|
||||
4. 调整小程序报工同步逻辑,按材料库存批次号匹配未结单工单。
|
||||
5. 新增生产工单入库后端接口。
|
||||
6. 新增嘉恒仓库“生产工单入库”前端入口和交互。
|
||||
6. 新增百华仓库“生产工单入库”前端入口和交互。
|
||||
7. 联调工单台账、成品入库、余料入库、废料入库、发货追溯。
|
||||
8. 补齐自动化测试和启动检查。
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# 嘉恒仓库全出入库单据纸质化与归档设计
|
||||
# 百华仓库全出入库单据纸质化与归档设计
|
||||
|
||||
## 背景
|
||||
|
||||
嘉恒仓库已经有 6 大库:原材料库、半成品库、成品库、辅料库、废料库、退货库。当前出入库业务主要通过抽屉表单完成,保存后形成库存流水。用户已经认可 `原材料库 · 客料入库` 的纸质填单样板,但该样板目前只完成填写 UI,没有生成 PDF 归档,也没有预览和下载入口。
|
||||
百华仓库已经有 6 大库:原材料库、半成品库、成品库、辅料库、废料库、退货库。当前出入库业务主要通过抽屉表单完成,保存后形成库存流水。用户已经认可 `原材料库 · 客料入库` 的纸质填单样板,但该样板目前只完成填写 UI,没有生成 PDF 归档,也没有预览和下载入口。
|
||||
|
||||
现有归档体系已覆盖销售订单、采购订单、到货入库单、质量校验单、生产领料出库单、生产入库结算单。仓库里大量非生产分支仍未纳入正式单据化:客料、委外、销售、退货、返工、特殊出入库、期初等。为避免后续继续碎片化,本方案把“所有仓库出入库填单纸质化、保存后 PDF 归档、流水入口预览下载、流水 Excel 导出”作为统一仓库单据化工程。
|
||||
|
||||
@ -167,7 +167,7 @@
|
||||
|
||||
所有仓库出入库抽屉采用统一纸质单据底座:
|
||||
|
||||
- 抬头:嘉恒仓库、单据标题、单据编号或提交后生成提示。
|
||||
- 抬头:百华仓库、单据标题、单据编号或提交后生成提示。
|
||||
- 基础信息区:仓库、库位、业务类型、来源/去向、经办人、时间。
|
||||
- 明细区:物料/产品/半成品/废料/退货品、库存批次号、来源库存批次号、数量、重量、单价、金额。
|
||||
- 辅助信息区:运单号、运费、辅助照片、用途、包装备注、退货/返工/委外方、说明。
|
||||
@ -227,7 +227,7 @@ PDF 不得出现:
|
||||
|
||||
### 各库流水抽屉
|
||||
|
||||
在 `嘉恒仓库 -> 工具 -> 流水` 中新增 `单据留档` 列。
|
||||
在 `百华仓库 -> 工具 -> 流水` 中新增 `单据留档` 列。
|
||||
|
||||
每条流水根据后端返回的归档字段展示:
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
## 背景
|
||||
|
||||
系统已经完成销售订单、采购订单、到货入库单、质量校验单的正式单据化与 PDF 归档。嘉恒仓库仍以业务抽屉和库存流水为主,用户在办理生产出库、生产入库、生产余料入库、生产废料入库时,缺少一张可预览、可下载、可留档的正式业务单据。
|
||||
系统已经完成销售订单、采购订单、到货入库单、质量校验单的正式单据化与 PDF 归档。百华仓库仍以业务抽屉和库存流水为主,用户在办理生产出库、生产入库、生产余料入库、生产废料入库时,缺少一张可预览、可下载、可留档的正式业务单据。
|
||||
|
||||
仓库业务入口很多,6 大库中存在采购、生产、委外、退货、销售、特殊调整等多条链路。如果一次性把所有出入库按钮都接入 PDF,容易形成重复模板和重复归档逻辑。本次先以生产主链路作为第一期样板,抽出仓库作业单通用底座,后续再扩展到委外、退货、销售出库、特殊出入库。
|
||||
|
||||
@ -190,7 +190,7 @@
|
||||
|
||||
### 操作弹窗
|
||||
|
||||
生产主链路现有入口保留,不改变用户从嘉恒仓库进入业务的习惯。
|
||||
生产主链路现有入口保留,不改变用户从百华仓库进入业务的习惯。
|
||||
|
||||
本期只做两类升级:
|
||||
|
||||
|
||||
@ -44,13 +44,13 @@
|
||||
|
||||
| 模块 | 页面 | 单据 | 当前问题 | 优先级 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 嘉恒仓库 | `InventoryLedgerView.vue` | 特殊入库单 / 特殊出库单 | 明细编辑区仍像现代卡片控件,和纸质单据割裂 | P0 |
|
||||
| 嘉恒仓库 | `InventoryLedgerView.vue` | 生产出库单 | 已使用纸质外壳,但来源批次选择、产品/BOM信息仍需统一纸质表格语言 | P0 |
|
||||
| 嘉恒仓库 | `InventoryLedgerView.vue` | 生产台账入库结算单 | 已有纸质外壳和摘要格,但三库联动字段、异常说明区域仍需与纸质单据模板对齐 | P0 |
|
||||
| 嘉恒仓库 | `InventoryLedgerView.vue` | 生产入库单 / 返工入库单 | 结单区和摘要区仍有旧卡片感 | P1 |
|
||||
| 嘉恒仓库 | `InventoryLedgerView.vue` | 销售出库单 | 已套纸质外壳,但销售订单关联、包装备注、发货明细区域需统一 | P1 |
|
||||
| 嘉恒仓库 | `InventoryLedgerView.vue` | 原材料退货出库单、退货入库单、退货废料入库单、返工出库单 | 已套纸质外壳,但字段区仍有普通表单/表格痕迹 | P1 |
|
||||
| 嘉恒仓库 | `InventoryLedgerView.vue` | 期初入库、委外入库/出库、报废出库、售卖出库等通用分支 | 通用分支第一阶段只统一了生产余料/废料一部分,仍需逐个验收 | P1 |
|
||||
| 百华仓库 | `InventoryLedgerView.vue` | 特殊入库单 / 特殊出库单 | 明细编辑区仍像现代卡片控件,和纸质单据割裂 | P0 |
|
||||
| 百华仓库 | `InventoryLedgerView.vue` | 生产出库单 | 已使用纸质外壳,但来源批次选择、产品/BOM信息仍需统一纸质表格语言 | P0 |
|
||||
| 百华仓库 | `InventoryLedgerView.vue` | 生产台账入库结算单 | 已有纸质外壳和摘要格,但三库联动字段、异常说明区域仍需与纸质单据模板对齐 | P0 |
|
||||
| 百华仓库 | `InventoryLedgerView.vue` | 生产入库单 / 返工入库单 | 结单区和摘要区仍有旧卡片感 | P1 |
|
||||
| 百华仓库 | `InventoryLedgerView.vue` | 销售出库单 | 已套纸质外壳,但销售订单关联、包装备注、发货明细区域需统一 | P1 |
|
||||
| 百华仓库 | `InventoryLedgerView.vue` | 原材料退货出库单、退货入库单、退货废料入库单、返工出库单 | 已套纸质外壳,但字段区仍有普通表单/表格痕迹 | P1 |
|
||||
| 百华仓库 | `InventoryLedgerView.vue` | 期初入库、委外入库/出库、报废出库、售卖出库等通用分支 | 通用分支第一阶段只统一了生产余料/废料一部分,仍需逐个验收 | P1 |
|
||||
|
||||
### 需要补充纸质化或确认是否作为单据
|
||||
|
||||
@ -60,7 +60,7 @@
|
||||
| 发货概览 | `ReturnManagementView.vue` | 退货/返工/废料处置 | 属于业务凭证,需要与退货库单据闭环统一 | P1 |
|
||||
| 发货概览 | `ScrapReworkView.vue` | 报废返工处理 | 若仍在使用,应按退货/返工处置单统一;若已被仓库/退货库替代,应清理或降级为台账 | P2 |
|
||||
| 生产执行 | `CompletionReceiptView.vue` | 成品入库 | 旧入口如果仍可用,需要和仓库生产入库/生产台账入库统一;如果已被新流程替代,应标记为旧入口 | P2 |
|
||||
| 嘉恒仓库 | `StocktakeDialog.vue` | 盘库单 | 盘库会改库存,应纳入纸质盘库单和 PDF 归档,但可排在特殊出入库之后 | P2 |
|
||||
| 百华仓库 | `StocktakeDialog.vue` | 盘库单 | 盘库会改库存,应纳入纸质盘库单和 PDF 归档,但可排在特殊出入库之后 | P2 |
|
||||
|
||||
## 推荐方案
|
||||
|
||||
|
||||
@ -0,0 +1,388 @@
|
||||
# 系统标准初始化工具设计
|
||||
|
||||
## 背景
|
||||
|
||||
当前系统已经从嘉恒测试环境切到百华客制化分支。数据库中存在大量嘉恒/测试业务数据,百华正式启用前需要初始化为干净系统。
|
||||
|
||||
这次初始化不能只做一次性清库脚本。后续给其他公司部署时,也需要一套标准工具,能够在全新的 MySQL 数据库上完成建库、建表和系统骨架初始化,同时也能对已经建好表、已经产生测试数据的系统做正式启用前重置。
|
||||
|
||||
## 目标
|
||||
|
||||
初始化工具提供两个模式:
|
||||
|
||||
- `fresh`:面向全新 MySQL 或全新 schema,创建 ERP 数据库下所有 ERP 表和智能报工小程序相关表,并写入最小系统骨架。
|
||||
- `reset`:面向已有 schema,先备份,再清空业务数据、主数据、人员账号、小程序数据,然后重建最小系统骨架。
|
||||
|
||||
初始化完成后,系统应满足:
|
||||
|
||||
- 可以用一个超级管理员账号登录 ERP。
|
||||
- 系统权限、菜单、角色关系完整。
|
||||
- 六大仓库和默认库位存在,后续可直接做出入库。
|
||||
- 系统配置存在,例如原材料库存批次前缀、是否对接智能报工小程序。
|
||||
- 没有嘉恒或测试业务数据、客户、供应商、物料、产品、库存、订单、报工、发货、财务、单据归档数据残留。
|
||||
- 如果后续客户购买小程序,小程序后端所需表也已经创建好。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不做客户真实主数据导入。客户、供应商、物料、产品、BOM、工艺路线后续用现有导入功能导入。
|
||||
- 默认不删除上传文件和 PDF 实体文件,只清数据库记录。文件删除作为显式参数单独开启。
|
||||
- 不重命名数据库 schema,例如仍可使用当前 `jiaheng_erp` 作为历史技术库名,避免改动部署配置和小程序连接。
|
||||
- 不在初始化工具里修改业务逻辑。
|
||||
|
||||
## 命令形态
|
||||
|
||||
建议新增入口:
|
||||
|
||||
```bash
|
||||
python backend/scripts/system_initialize.py fresh \
|
||||
--company-name 百华 \
|
||||
--admin-name 超级管理员 \
|
||||
--admin-phone 13800000000 \
|
||||
--admin-password '正式密码'
|
||||
|
||||
python backend/scripts/system_initialize.py reset \
|
||||
--company-name 百华 \
|
||||
--admin-name 超级管理员 \
|
||||
--admin-phone 13800000000 \
|
||||
--admin-password '正式密码' \
|
||||
--confirm-reset
|
||||
```
|
||||
|
||||
脚本读取 `backend/.env` 中的 MySQL 连接信息:
|
||||
|
||||
- `MYSQL_HOST`
|
||||
- `MYSQL_PORT`
|
||||
- `MYSQL_DATABASE`
|
||||
- `MYSQL_USER`
|
||||
- `MYSQL_PASSWORD`
|
||||
|
||||
## 模式一:fresh
|
||||
|
||||
`fresh` 用于全新部署,是后续交付其他公司最常用的路径。
|
||||
|
||||
流程:
|
||||
|
||||
1. 读取 `.env` 中的 MySQL 连接信息。
|
||||
2. 连接 MySQL server。
|
||||
3. 如果目标 database 不存在,执行 `CREATE DATABASE`。
|
||||
4. 切换到目标 database。
|
||||
5. 创建 ERP 全量表。
|
||||
6. 创建小程序全量表。
|
||||
7. 执行所有已纳入标准基线的 patch。
|
||||
8. 初始化系统权限、角色、角色权限。
|
||||
9. 初始化系统配置。
|
||||
10. 初始化单位、物料分类等基础字典。
|
||||
11. 初始化六大仓库和默认库位。
|
||||
12. 初始化根组织节点、超级管理员人员、超级管理员系统账号、用户角色绑定。
|
||||
13. 输出初始化报告。
|
||||
|
||||
`fresh` 模式不写入任何业务主数据和业务流水。
|
||||
|
||||
## 模式二:reset
|
||||
|
||||
`reset` 用于已有系统正式启用前清理,例如这次百华切换。
|
||||
|
||||
流程:
|
||||
|
||||
1. 读取 `.env` 中的 MySQL 连接信息。
|
||||
2. 检查必须显式传入 `--confirm-reset`。
|
||||
3. 打印目标数据库连接摘要,包括 host、port、database、当前时间。
|
||||
4. 统计关键表行数。
|
||||
5. 强制导出备份 SQL 到 `outputs/cleanup_backups/system_init_backup_<timestamp>.sql`。
|
||||
6. `SET FOREIGN_KEY_CHECKS=0`。
|
||||
7. 清空业务流水表。
|
||||
8. 清空库存表。
|
||||
9. 清空业务主数据表。
|
||||
10. 清空人员、组织、账号、组织绑定、角色绑定。
|
||||
11. 清空小程序业务数据和小程序主数据。
|
||||
12. 清空系统广播、单据归档记录。
|
||||
13. 重置相关自增 ID。
|
||||
14. 重建系统权限、角色、角色权限、系统配置、单位、分类、仓库、库位、根组织、超级管理员。
|
||||
15. `SET FOREIGN_KEY_CHECKS=1`。
|
||||
16. 再次统计关键表行数。
|
||||
17. 输出 JSON 报告到 `outputs/cleanup_backups/system_init_summary_<timestamp>.json`。
|
||||
|
||||
## 小程序表覆盖范围
|
||||
|
||||
ERP 当前代码已映射部分小程序表,但小程序后端模型更完整。标准初始化必须覆盖两侧共同需要的完整表集合。
|
||||
|
||||
小程序基础主数据:
|
||||
|
||||
- `attendance_points`
|
||||
- `personnel`
|
||||
- `person_roles`
|
||||
- `person_attendance_points`
|
||||
- `products`
|
||||
- `equipment`
|
||||
- `work_schedules`
|
||||
|
||||
小程序业务数据:
|
||||
|
||||
- `work_sessions`
|
||||
- `work_session_devices`
|
||||
- `production_reports`
|
||||
- `production_report_items`
|
||||
- `report_audit_logs`
|
||||
- `mold_lock_feedbacks`
|
||||
- `device_qrcodes`
|
||||
- `device_qrcode_batch_tasks`
|
||||
- `notices`
|
||||
- `notice_points`
|
||||
- `reconciliation_ledger_entries`
|
||||
|
||||
`fresh` 模式需要创建这些表。`reset` 模式需要清空这些表,并只重建必要的考勤点/管理员人员数据。
|
||||
|
||||
## 清理表分层
|
||||
|
||||
### 业务流水表
|
||||
|
||||
销售:
|
||||
|
||||
- `so_sales_order_item`
|
||||
- `so_sales_order`
|
||||
- `so_delivery_item`
|
||||
- `so_delivery`
|
||||
|
||||
采购:
|
||||
|
||||
- `po_purchase_order_sales_order`
|
||||
- `po_purchase_order_item`
|
||||
- `po_purchase_order`
|
||||
- `po_receipt_item`
|
||||
- `po_receipt`
|
||||
- `po_purchase_return_item`
|
||||
- `po_purchase_return`
|
||||
|
||||
库存:
|
||||
|
||||
- `wh_special_adjustment_line`
|
||||
- `wh_special_adjustment`
|
||||
- `wh_stocktake_adjustment`
|
||||
- `wh_stocktake_line`
|
||||
- `wh_stocktake_warehouse`
|
||||
- `wh_stocktake`
|
||||
- `wh_inventory_txn`
|
||||
- `wh_stock_balance`
|
||||
- `wh_stock_lot`
|
||||
|
||||
生产:
|
||||
|
||||
- `pp_production_batch_ledger_txn`
|
||||
- `pp_production_batch_ledger`
|
||||
- `pp_completion_receipt_item`
|
||||
- `pp_completion_receipt`
|
||||
- `pp_scrap_record`
|
||||
- `pp_operation_report`
|
||||
- `pp_material_issue_item`
|
||||
- `pp_material_issue`
|
||||
- `pp_work_order_material_issue`
|
||||
- `pp_work_order_operation`
|
||||
- `pp_work_order_material`
|
||||
- `pp_work_order`
|
||||
|
||||
发货与售后:
|
||||
|
||||
- `rt_return_disposition`
|
||||
- `rt_return_item`
|
||||
- `rt_return_order`
|
||||
|
||||
财务:
|
||||
|
||||
- `fi_statement_snapshot`
|
||||
- `fi_cost_allocation`
|
||||
- `fi_overhead_entry`
|
||||
- `fi_accounting_period`
|
||||
- `reconciliation_ledger_entries`
|
||||
|
||||
单据归档:
|
||||
|
||||
- `document_archives`
|
||||
|
||||
MRP:
|
||||
|
||||
- `mrp_material_demand`
|
||||
|
||||
### 业务主数据表
|
||||
|
||||
- `em_maintenance_order`
|
||||
- `em_maintenance_plan`
|
||||
- `em_equipment`
|
||||
- `md_bom_item`
|
||||
- `md_bom`
|
||||
- `md_process_route_operation`
|
||||
- `md_process_route`
|
||||
- `md_work_center`
|
||||
- `md_process`
|
||||
- `md_product`
|
||||
- `md_material`
|
||||
- `md_item`
|
||||
- `md_customer`
|
||||
- `md_supplier`
|
||||
|
||||
### 组织和账号
|
||||
|
||||
选择标准为“只保留一个超级管理员”。
|
||||
|
||||
需要清空:
|
||||
|
||||
- `sys_user_role`
|
||||
- `sys_user`
|
||||
- `sys_org_manager_binding`
|
||||
- `sys_org_employee_binding`
|
||||
- `hr_employee`
|
||||
- `sys_department`
|
||||
|
||||
需要重建:
|
||||
|
||||
- 根组织节点,例如 `COMPANY_ROOT`
|
||||
- 超级管理员人员
|
||||
- 超级管理员系统账号
|
||||
- 超级管理员角色绑定
|
||||
|
||||
### 系统骨架
|
||||
|
||||
需要保留或重建:
|
||||
|
||||
- `sys_permission`
|
||||
- `sys_role`
|
||||
- `sys_role_permission`
|
||||
- `sys_system_config`
|
||||
- `sys_ai_assistant_config`
|
||||
- `md_unit`
|
||||
- `md_item_category`
|
||||
- `wh_warehouse`
|
||||
- `wh_location`
|
||||
|
||||
需要清空:
|
||||
|
||||
- `sys_broadcast_message`
|
||||
|
||||
## 系统配置初始化
|
||||
|
||||
必须至少初始化:
|
||||
|
||||
- `RAW_MATERIAL_LOT_PREFIX`:默认 `YL`。
|
||||
- `SMART_OPERATION_REPORT_ENABLED`:默认可通过参数设置。百华当前建议按实际购买情况设置。
|
||||
- AI 助手名称:默认 `<company-name>工艺助手`。
|
||||
|
||||
配置项必须使用 upsert,确保 `fresh` 和 `reset` 都可重复执行。
|
||||
|
||||
## 仓库初始化
|
||||
|
||||
初始化六大库和默认库位:
|
||||
|
||||
- 原材料库:`RAW`
|
||||
- 半成品库:`SEMI`
|
||||
- 成品库:`FINISHED`
|
||||
- 辅料库:`AUX`
|
||||
- 废料库:`SCRAP`
|
||||
- 退货库:`RETURN`
|
||||
|
||||
每个仓库创建一个默认库位。命名可保持当前业务通用名称,例如:
|
||||
|
||||
- 原料主库位
|
||||
- 半成品主库位
|
||||
- 成品主库位
|
||||
- 辅料主库位
|
||||
- 废料主库位
|
||||
- 退货主库位
|
||||
|
||||
仓库名可根据公司名生成,例如 `百华原材料库`,也可以保持通用名 `原材料库`。推荐保持通用名,避免系统内部业务文案过长。
|
||||
|
||||
## 备份与报告
|
||||
|
||||
`reset` 模式必须生成备份:
|
||||
|
||||
```text
|
||||
outputs/cleanup_backups/system_init_backup_<timestamp>.sql
|
||||
```
|
||||
|
||||
必须生成报告:
|
||||
|
||||
```text
|
||||
outputs/cleanup_backups/system_init_summary_<timestamp>.json
|
||||
```
|
||||
|
||||
报告内容包括:
|
||||
|
||||
- 执行模式
|
||||
- 执行时间
|
||||
- 数据库 host、port、database
|
||||
- 备份路径
|
||||
- 清理表清单
|
||||
- 每张表清理前行数
|
||||
- 每张表清理后行数
|
||||
- 重建骨架数据摘要
|
||||
- 是否清理文件
|
||||
- 执行结果
|
||||
|
||||
## 文件清理
|
||||
|
||||
默认不删除实体文件,只清数据库引用。
|
||||
|
||||
如显式传入 `--delete-files`,才允许清理:
|
||||
|
||||
- 单据归档 PDF 目录
|
||||
- 批量下载临时 zip 目录
|
||||
- 辅助照片上传目录
|
||||
|
||||
文件清理必须记录到报告中。
|
||||
|
||||
## 安全保护
|
||||
|
||||
`reset` 模式必须满足:
|
||||
|
||||
- 未传 `--confirm-reset` 时禁止执行删除,只输出预检摘要。
|
||||
- 执行前必须成功备份。
|
||||
- 打印目标 database,并要求命令行参数显式确认。
|
||||
- 默认拒绝清理未知 database,除非传入 `--allow-any-database`。
|
||||
- 清理前后检查 `FOREIGN_KEY_CHECKS` 恢复为 `1`。
|
||||
- 清理后必须能查询到一个超级管理员用户。
|
||||
- 清理后业务流水表行数必须为 `0`。
|
||||
|
||||
## 测试策略
|
||||
|
||||
单元测试:
|
||||
|
||||
- `fresh` 在临时 SQLite 或测试 MySQL schema 上可创建所有 SQLAlchemy metadata 表。
|
||||
- `reset` 对带测试数据的测试库执行后,业务表为空。
|
||||
- `reset` 后系统账号、角色、权限、仓库、库位、系统配置存在。
|
||||
- `reset` 未传 `--confirm-reset` 时不会删除数据。
|
||||
- 小程序关键表存在,并可写入最小人员、考勤点、产品清单、报工记录。
|
||||
|
||||
集成测试:
|
||||
|
||||
- 初始化后调用 `/api/system/health` 返回数据库连接正常。
|
||||
- 初始化后用超级管理员登录成功。
|
||||
- 初始化后打开仓库页面不会因为缺仓库/库位报错。
|
||||
- 初始化后打开系统权限管理页面能看到根组织节点和超级管理员。
|
||||
|
||||
人工验收:
|
||||
|
||||
- 前端登录成功。
|
||||
- 左侧菜单正常。
|
||||
- 基础资料中客户、供应商、产品、物料为空。
|
||||
- 六大仓库库存为空,但仓库入口可正常进入。
|
||||
- 销售、采购、生产、发货、财务台账为空。
|
||||
- 系统管理中只有初始化创建的超级管理员账号。
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. 补齐小程序完整 SQLAlchemy 模型或标准 SQL,确保初始化可创建小程序全部表。
|
||||
2. 新增初始化脚本 `backend/scripts/system_initialize.py`。
|
||||
3. 新增 SQL seed/cleanup 分组文件。
|
||||
4. 编写测试覆盖 `fresh` 和 `reset`。
|
||||
5. 在本地测试库执行 `fresh`。
|
||||
6. 在本地测试库导入当前测试数据后执行 `reset`。
|
||||
7. 确认报告和备份文件。
|
||||
8. 用户审核后,在百华服务器数据库执行 `reset`。
|
||||
|
||||
## 开放决策
|
||||
|
||||
本设计已固定采用“只保留一个超级管理员”的账号策略。
|
||||
|
||||
执行前仍需确定:
|
||||
|
||||
- 百华超级管理员姓名、手机号、初始密码。
|
||||
- 百华是否默认开启智能报工小程序对接。
|
||||
- 是否在本次初始化时删除服务器上的历史上传图片和 PDF 实体文件。默认不删除。
|
||||
@ -1,4 +1,4 @@
|
||||
# 宁波嘉恒智能科技有限公司 ERP 设计方案
|
||||
# 宁波百华智能科技有限公司 ERP 设计方案
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 采购使用说明手册
|
||||
|
||||
适用模块:`采购与库存 -> 采购订单`、`采购与库存 -> 到货入库`、`采购与库存 -> 质量检验`、`采购与库存 -> 嘉恒仓库`。
|
||||
适用模块:`采购与库存 -> 采购订单`、`采购与库存 -> 到货入库`、`采购与库存 -> 质量检验`、`采购与库存 -> 百华仓库`。
|
||||
|
||||
本手册面向采购专员、仓库管理人员和管理人员,用于说明从采购下单、锁单、到货、质检、入库、退货到合同生成的完整操作方式。
|
||||
|
||||
@ -251,14 +251,14 @@ flowchart TD
|
||||
| 已拒收 | 不进入可用库存,采购和仓库需要协同处理。 |
|
||||
| 待质检 | 暂时不可用,不能当作可生产库存。 |
|
||||
|
||||
如果材料入仓后才发现质量或性能问题,应在嘉恒仓库的 `原材料库 -> 退货出库` 中办理退货。
|
||||
如果材料入仓后才发现质量或性能问题,应在百华仓库的 `原材料库 -> 退货出库` 中办理退货。
|
||||
|
||||
## 9. 原材料退货出库
|
||||
|
||||
入口:
|
||||
|
||||
```text
|
||||
采购与库存 -> 嘉恒仓库 -> 原材料库 -> 出库 -> 退货出库
|
||||
采购与库存 -> 百华仓库 -> 原材料库 -> 出库 -> 退货出库
|
||||
```
|
||||
|
||||
适用场景:
|
||||
@ -284,9 +284,9 @@ flowchart TD
|
||||
| 某张采购订单是否锁单 | 采购与库存 -> 采购订单 |
|
||||
| 某采购订单到了多少 | 采购与库存 -> 到货入库 |
|
||||
| 到货是否质检放行 | 采购与库存 -> 质量检验 |
|
||||
| 材料是否形成库存批次 | 采购与库存 -> 嘉恒仓库 -> 原材料库 |
|
||||
| 材料是否形成库存批次 | 采购与库存 -> 百华仓库 -> 原材料库 |
|
||||
| 某采购订单是否存在退货 | 采购与库存 -> 采购订单,查看警示标记 |
|
||||
| 某材料批次来源采购单 | 嘉恒仓库 -> 原材料库 -> 批次明细 |
|
||||
| 某材料批次来源采购单 | 百华仓库 -> 原材料库 -> 批次明细 |
|
||||
|
||||
## 11. 常见问题
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 销售使用说明手册
|
||||
|
||||
适用模块:`基础资料 -> 客户名录`、`订单与计划 -> 销售订单列表`、`采购与库存 -> 采购订单`、`采购与库存 -> 嘉恒仓库 -> 成品库 -> 销售出库`、`发货概览 -> 发货台账`。
|
||||
适用模块:`基础资料 -> 客户名录`、`订单与计划 -> 销售订单列表`、`采购与库存 -> 采购订单`、`采购与库存 -> 百华仓库 -> 成品库 -> 销售出库`、`发货概览 -> 发货台账`。
|
||||
|
||||
本手册面向销售人员、管理人员和仓库发货人员,用于说明客户维护、销售订单创建、合同生成、采购联动、成品发货、发货追溯和客户退货处理。
|
||||
|
||||
@ -174,19 +174,19 @@ flowchart TD
|
||||
|
||||
## 8. 销售出库
|
||||
|
||||
销售出库目前在嘉恒仓库的成品库中办理。
|
||||
销售出库目前在百华仓库的成品库中办理。
|
||||
|
||||
入口:
|
||||
|
||||
```text
|
||||
采购与库存 -> 嘉恒仓库 -> 成品库 -> 出库 -> 销售出库
|
||||
采购与库存 -> 百华仓库 -> 成品库 -> 出库 -> 销售出库
|
||||
```
|
||||
|
||||
销售出库用于把成品库存发给客户,并回写销售订单已发数量。
|
||||
|
||||
操作步骤:
|
||||
|
||||
1. 进入嘉恒仓库。
|
||||
1. 进入百华仓库。
|
||||
2. 选择 `成品库`。
|
||||
3. 点击出库区的 `销售出库`。
|
||||
4. 选择是否关联销售订单。
|
||||
@ -230,12 +230,12 @@ flowchart TD
|
||||
|
||||
## 10. 客户退货处理
|
||||
|
||||
客户退货不再放在旧的 `发货与售后 -> 退货处理` 中,而是在嘉恒仓库中按退回品去向处理。
|
||||
客户退货不再放在旧的 `发货与售后 -> 退货处理` 中,而是在百华仓库中按退回品去向处理。
|
||||
|
||||
| 客户退货判断 | 操作入口 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 可返工 | 嘉恒仓库 -> 退货库 -> 入库 -> 退货入库 | 退货品进入退货库,后续可返工出库。 |
|
||||
| 不可返工,直接报废 | 嘉恒仓库 -> 废料库 -> 入库 -> 退货废料入库 | 退货品直接进入废料库。 |
|
||||
| 可返工 | 百华仓库 -> 退货库 -> 入库 -> 退货入库 | 退货品进入退货库,后续可返工出库。 |
|
||||
| 不可返工,直接报废 | 百华仓库 -> 废料库 -> 入库 -> 退货废料入库 | 退货品直接进入废料库。 |
|
||||
|
||||
退货入库时,应选择原发货批次或发货明细,系统会追溯到原销售订单、产品和原材料库存批次。
|
||||
|
||||
@ -262,8 +262,8 @@ flowchart TD
|
||||
| 生成销售合同 | 订单与计划 -> 销售订单列表 |
|
||||
| 看销售订单是否采购联动 | 订单与计划 -> 销售订单列表,或采购与库存 -> 采购订单 |
|
||||
| 看销售订单是否发货 | 发货概览 -> 发货台账 |
|
||||
| 办理销售发货 | 采购与库存 -> 嘉恒仓库 -> 成品库 -> 销售出库 |
|
||||
| 办理客户退货 | 采购与库存 -> 嘉恒仓库 -> 退货库或废料库 |
|
||||
| 办理销售发货 | 采购与库存 -> 百华仓库 -> 成品库 -> 销售出库 |
|
||||
| 办理客户退货 | 采购与库存 -> 百华仓库 -> 退货库或废料库 |
|
||||
|
||||
## 12. 常见问题
|
||||
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
# 仓库使用说明手册
|
||||
|
||||
适用模块:`采购与库存 -> 嘉恒仓库`、`生产执行 -> 工单台账`、`采购与库存 -> 到货入库`、`采购与库存 -> 质量检验`、`发货概览 -> 发货台账`。
|
||||
适用模块:`采购与库存 -> 百华仓库`、`生产执行 -> 工单台账`、`采购与库存 -> 到货入库`、`采购与库存 -> 质量检验`、`发货概览 -> 发货台账`。
|
||||
|
||||
本手册面向仓库管理人员、生产管理人员和管理人员,用于说明嘉恒仓库六大库的库存口径、出入库操作、库存批次、流水、盘库和追溯方式。
|
||||
本手册面向仓库管理人员、生产管理人员和管理人员,用于说明百华仓库六大库的库存口径、出入库操作、库存批次、流水、盘库和追溯方式。
|
||||
|
||||
## 1. 嘉恒仓库总览
|
||||
## 1. 百华仓库总览
|
||||
|
||||
嘉恒仓库目前包含六大库:
|
||||
百华仓库目前包含六大库:
|
||||
|
||||
| 仓库 | 主要对象 | 主计量口径 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
@ -29,7 +29,7 @@
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["进入嘉恒仓库"] --> B["选择仓库"]
|
||||
A["进入百华仓库"] --> B["选择仓库"]
|
||||
B --> C["查看库存总览"]
|
||||
B --> D["选择入库操作"]
|
||||
B --> E["选择出库操作"]
|
||||
@ -313,7 +313,7 @@ flowchart TD
|
||||
入口:
|
||||
|
||||
```text
|
||||
嘉恒仓库 -> 选择仓库 -> 点击流水图标
|
||||
百华仓库 -> 选择仓库 -> 点击流水图标
|
||||
```
|
||||
|
||||
流水用于回答:
|
||||
@ -332,7 +332,7 @@ flowchart TD
|
||||
入口:
|
||||
|
||||
```text
|
||||
嘉恒仓库 -> 选择仓库 -> 盘库
|
||||
百华仓库 -> 选择仓库 -> 盘库
|
||||
```
|
||||
|
||||
盘库规则:
|
||||
|
||||
@ -3,10 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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="alternate icon" type="image/png" href="/logo.png" />
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<title>嘉恒智能五金 ERP</title>
|
||||
<link rel="icon" type="image/png" href="/logo-mark.png" />
|
||||
<link rel="alternate icon" type="image/png" href="/logo-mark.png" />
|
||||
<link rel="apple-touch-icon" href="/logo-mark.png" />
|
||||
<title>百华智能五金 ERP</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@ -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 |
BIN
frontend/public/logo-mark.png
Normal file
BIN
frontend/public/logo-mark.png
Normal file
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 |
@ -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 |
@ -17,7 +17,8 @@ assert.match(router, /loadSmartOperationReportConfig/, "router should guard oper
|
||||
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(systemExtension, /对接智能报工小程序/, "system extension should show switch");
|
||||
assert.match(productionLedger, /工序明细/, "production ledger page should show operation detail wording when enabled");
|
||||
assert.match(productionLedger, /当前未对接智能报工小程序/, "production ledger page should explain disabled mode");
|
||||
assert.doesNotMatch(productionLedger, /当前未对接智能报工小程序/, "production ledger page should not expose disabled integration wording");
|
||||
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");
|
||||
|
||||
@ -19,3 +19,13 @@ describe("App reset password account picker", () => {
|
||||
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\)/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
<div v-else :class="shellClass">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<img class="brand-logo" src="/logo.svg" alt="宁波嘉恒智能科技有限公司 logo" />
|
||||
<img class="brand-logo" src="/logo-mark.png" alt="宁波百华智能科技有限公司 logo" />
|
||||
<div>
|
||||
<h1>嘉恒智能</h1>
|
||||
<h1>百华智能</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -60,7 +60,7 @@
|
||||
<div class="sidebar-footer">
|
||||
<div class="status-chip">
|
||||
<span class="status-dot"></span>
|
||||
采购、嘉恒仓库、生产、发货、财务、售后、系统拓展已接入真实业务流
|
||||
采购、百华仓库、生产、发货、财务、售后、系统拓展已接入真实业务流
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@ -110,7 +110,7 @@
|
||||
<span class="assistant-bot-eye"></span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="user-menu-shell">
|
||||
<div ref="userMenuShellRef" class="user-menu-shell">
|
||||
<button class="user-avatar-button" type="button" @click="toggleUserMenu">
|
||||
<span class="user-avatar">{{ avatarInitial }}</span>
|
||||
<span class="user-avatar-meta">
|
||||
@ -186,8 +186,7 @@
|
||||
</span>
|
||||
<div>
|
||||
<p class="eyebrow">AI 助手</p>
|
||||
<h3>嘉恒工艺助手</h3>
|
||||
<p>当前为内置占位对话,真实 LLM 可在 08 系统拓展中配置。</p>
|
||||
<h3>百华工艺助手</h3>
|
||||
</div>
|
||||
</div>
|
||||
<button class="ghost-button" type="button" @click="closeAssistantDrawer">关闭</button>
|
||||
@ -251,7 +250,6 @@
|
||||
<div>
|
||||
<p class="eyebrow">管理员操作</p>
|
||||
<h3>重置他人密码</h3>
|
||||
<p>用于协助其他用户忘记密码时重置登录密码,仅系统管理员可用。</p>
|
||||
</div>
|
||||
<button class="ghost-button" type="button" @click="closePasswordModals">关闭</button>
|
||||
</header>
|
||||
@ -307,6 +305,7 @@ const activeBroadcasts = ref([]);
|
||||
const activeBroadcastIndex = ref(0);
|
||||
const assistantDrawerOpen = ref(false);
|
||||
const userMenuOpen = ref(false);
|
||||
const userMenuShellRef = ref(null);
|
||||
const changePasswordOpen = ref(false);
|
||||
const resetPasswordOpen = ref(false);
|
||||
const passwordSubmitting = ref(false);
|
||||
@ -326,7 +325,7 @@ const chatMessages = ref([
|
||||
{
|
||||
id: 1,
|
||||
role: "assistant",
|
||||
content: "你好,我是嘉恒工艺助手。你可以问我系统操作路径、字段含义、业务流程或异常排查。"
|
||||
content: "你好,我是百华工艺助手。你可以问我系统操作路径、字段含义、业务流程或异常排查。"
|
||||
}
|
||||
]);
|
||||
const changePasswordForm = ref({
|
||||
@ -523,7 +522,7 @@ const navStages = [
|
||||
key: "procurement",
|
||||
step: "04",
|
||||
label: "采购与库存",
|
||||
desc: "采购、自家料入库、质量检验和嘉恒仓库。",
|
||||
desc: "采购、自家料入库、质量检验和百华仓库。",
|
||||
tail: "采购",
|
||||
icon: "warehouse",
|
||||
iconPaths: [
|
||||
@ -589,7 +588,7 @@ const navStages = [
|
||||
},
|
||||
{
|
||||
key: "inventory-ledger",
|
||||
label: "嘉恒仓库",
|
||||
label: "百华仓库",
|
||||
icon: "warehouse",
|
||||
to: { name: "inventory-ledger" },
|
||||
routeNames: ["inventory-ledger"],
|
||||
@ -765,7 +764,7 @@ const navStages = [
|
||||
];
|
||||
|
||||
const isAuthLayout = computed(() => route.meta.layout === "auth");
|
||||
const currentTitle = computed(() => route.meta.title || "嘉恒智能五金 ERP");
|
||||
const currentTitle = computed(() => route.meta.title || "百华智能五金 ERP");
|
||||
const shellClass = computed(() => ({
|
||||
shell: true,
|
||||
"admin-shell": true,
|
||||
@ -1011,9 +1010,20 @@ function handleTagContextAction(action) {
|
||||
function handleGlobalKeydown(event) {
|
||||
if (event.key === "Escape") {
|
||||
closeTagContextMenu();
|
||||
userMenuOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleUserMenuOutsidePointerDown(event) {
|
||||
if (!userMenuOpen.value) {
|
||||
return;
|
||||
}
|
||||
if (userMenuShellRef.value?.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
userMenuOpen.value = false;
|
||||
}
|
||||
|
||||
function updateShellViewportState() {
|
||||
narrowShell.value = window.innerWidth < 1024;
|
||||
if (narrowShell.value) {
|
||||
@ -1040,6 +1050,7 @@ onMounted(() => {
|
||||
window.addEventListener("resize", updateShellViewportState);
|
||||
window.addEventListener("click", closeTagContextMenu);
|
||||
window.addEventListener("keydown", handleGlobalKeydown);
|
||||
document.addEventListener("pointerdown", handleUserMenuOutsidePointerDown);
|
||||
void loadActiveBroadcast();
|
||||
void loadSmartOperationFeature();
|
||||
broadcastTimer = window.setInterval(() => {
|
||||
@ -1054,6 +1065,7 @@ onUnmounted(() => {
|
||||
window.removeEventListener("resize", updateShellViewportState);
|
||||
window.removeEventListener("click", closeTagContextMenu);
|
||||
window.removeEventListener("keydown", handleGlobalKeydown);
|
||||
document.removeEventListener("pointerdown", handleUserMenuOutsidePointerDown);
|
||||
if (broadcastTimer) {
|
||||
window.clearInterval(broadcastTimer);
|
||||
broadcastTimer = null;
|
||||
@ -1188,7 +1200,7 @@ function sendAssistantMessage() {
|
||||
chatMessages.value.push({
|
||||
id: Date.now() + 1,
|
||||
role: "assistant",
|
||||
content: "我已收到。当前聊天窗口已就绪,后续在 08 系统拓展配置 LLM API 后,可以把这里切换为真实模型回复。现在我可以先作为操作入口,帮助你记录和梳理 ERP 问题。"
|
||||
content: "已收到。"
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
15
frontend/src/components/FormDrawer.test.js
Normal file
15
frontend/src/components/FormDrawer.test.js
Normal 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/);
|
||||
});
|
||||
});
|
||||
@ -1,14 +1,13 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<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">
|
||||
<div>
|
||||
<p class="eyebrow">{{ eyebrow }}</p>
|
||||
<div class="drawer-title-row" :title="description || undefined">
|
||||
<div class="drawer-title-row">
|
||||
<h3>{{ title }}</h3>
|
||||
</div>
|
||||
<p v-if="description" :id="descriptionId" class="form-drawer-description">{{ description }}</p>
|
||||
</div>
|
||||
<div class="workflow-drawer-head-actions">
|
||||
<span v-if="tag" class="panel-tag">{{ tag }}</span>
|
||||
@ -27,7 +26,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, toRef, useId } from "vue";
|
||||
import { computed, toRef } from "vue";
|
||||
|
||||
import { useBodyScrollLock } from "../composables/useBodyScrollLock";
|
||||
import AppIcon from "./AppIcon.vue";
|
||||
@ -64,7 +63,6 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
const descriptionId = `form-drawer-description-${useId()}`;
|
||||
|
||||
const drawerClass = computed(() => {
|
||||
const normalizedSize = ["normal", "wide", "xl", "fullscreen"].includes(props.size) ? props.size : "normal";
|
||||
|
||||
73
frontend/src/components/OrgPermissionTree.test.js
Normal file
73
frontend/src/components/OrgPermissionTree.test.js
Normal 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, /主管:未设置/);
|
||||
});
|
||||
});
|
||||
@ -35,16 +35,27 @@
|
||||
<div>
|
||||
<p class="eyebrow">组织人员</p>
|
||||
<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>
|
||||
<button
|
||||
class="primary-button"
|
||||
type="button"
|
||||
:disabled="!canAddEmployee"
|
||||
@click="addEmployee"
|
||||
>
|
||||
新增人员
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="employee-search-field">
|
||||
@ -54,9 +65,19 @@
|
||||
|
||||
<div class="table-wrap">
|
||||
<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>
|
||||
<tr>
|
||||
<th>姓名</th>
|
||||
<th>组织路径</th>
|
||||
<th>电话号码 / 账号</th>
|
||||
<th>岗位</th>
|
||||
<th>角色</th>
|
||||
@ -67,6 +88,10 @@
|
||||
<tbody>
|
||||
<tr v-for="employee in paginatedEmployees" :key="employeeKey(employee)">
|
||||
<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>{{ employeePosition(employee) }}</td>
|
||||
<td>{{ employeeRoles(employee) }}</td>
|
||||
@ -74,12 +99,14 @@
|
||||
<StatusBadge :value="employee?.status" :text="employeeStatus(employee)" />
|
||||
</td>
|
||||
<td class="action-row">
|
||||
<button class="ghost-button" type="button" @click="authorizeEmployee(employee)">授权</button>
|
||||
<button class="ghost-button danger" type="button" @click="removeEmployee(employee)">移除</button>
|
||||
<div class="employee-actions">
|
||||
<button class="ghost-button" type="button" @click="authorizeEmployee(employee)">人员授权</button>
|
||||
<button class="ghost-button danger" type="button" @click="removeEmployee(employee)">移除记录</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!filteredEmployees.length">
|
||||
<td colspan="6" class="empty-row">{{ selectedEmployees.length ? "没有符合条件的人员" : "当前节点暂无人员" }}</td>
|
||||
<td colspan="7" class="empty-row">{{ selectedEmployees.length ? "没有符合条件的人员" : "当前节点暂无人员" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@ -95,6 +122,7 @@ import { computed, defineComponent, h, ref, watch } from "vue";
|
||||
|
||||
import PaginationBar from "./PaginationBar.vue";
|
||||
import StatusBadge from "./StatusBadge.vue";
|
||||
import { collectOrganizationEmployees, countOrganizationEmployees } from "../utils/orgTreeEmployees";
|
||||
import { usePagination } from "../utils/pagination";
|
||||
|
||||
const props = defineProps({
|
||||
@ -164,13 +192,16 @@ const OrgTreeBranch = defineComponent({
|
||||
}
|
||||
|
||||
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(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: ["org-tree-node", { active: isSelected.value }],
|
||||
style: { "--tree-level": branchProps.level },
|
||||
style: treeLevelStyle(branchProps.level),
|
||||
onClick: selectCurrent,
|
||||
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-main" }, [
|
||||
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 effectiveSelectedNodeId = computed(() => getNodeId(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 filteredEmployees = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase();
|
||||
@ -233,7 +266,7 @@ const filteredEmployees = computed(() => {
|
||||
return selectedEmployees.value;
|
||||
}
|
||||
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)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
@ -263,6 +296,16 @@ function organizationChildren(node) {
|
||||
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) {
|
||||
const normalizedKeyword = String(keyword || "").trim().toLowerCase();
|
||||
if (!normalizedKeyword) {
|
||||
@ -344,26 +387,23 @@ function nodeTypeShortLabel(type) {
|
||||
}[type] || "组";
|
||||
}
|
||||
|
||||
function employeeCount(node) {
|
||||
const employeeKeys = new Set();
|
||||
collectEmployeeKeys(node, employeeKeys);
|
||||
return employeeKeys.size;
|
||||
function nodeManagerNames(node) {
|
||||
if (Array.isArray(node?.manager_names) && node.manager_names.length) {
|
||||
return node.manager_names.filter(Boolean);
|
||||
}
|
||||
if (node?.manager_name) {
|
||||
return [node.manager_name];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function collectEmployeeKeys(node, employeeKeys) {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
for (const employee of Array.isArray(node.employees) ? node.employees : []) {
|
||||
employeeKeys.add(String(employeeKey(employee)));
|
||||
}
|
||||
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 nodeManagerText(node) {
|
||||
const names = nodeManagerNames(node);
|
||||
return names.length ? `主管:${names.join("、")}` : "主管:未设置";
|
||||
}
|
||||
|
||||
function employeeCount(node) {
|
||||
return countOrganizationEmployees(node);
|
||||
}
|
||||
|
||||
function toggleNode(node) {
|
||||
@ -385,6 +425,18 @@ function openNodeMenu(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) {
|
||||
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 || "未设置岗位";
|
||||
}
|
||||
|
||||
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) {
|
||||
if (Array.isArray(employee?.role_names) && employee.role_names.length) {
|
||||
return employee.role_names.join("、");
|
||||
@ -424,8 +485,8 @@ function employeeKey(employee) {
|
||||
function employeeWithParent(employee) {
|
||||
return {
|
||||
...employee,
|
||||
parent_node_id: getNodeId(selectedNode.value),
|
||||
parent_node_label: nodeLabel(selectedNode.value)
|
||||
parent_node_id: employee?.parent_node_id ?? getNodeId(selectedNode.value),
|
||||
parent_node_label: employee?.parent_node_label ?? nodeLabel(selectedNode.value)
|
||||
};
|
||||
}
|
||||
|
||||
@ -489,6 +550,24 @@ function removeEmployee(employee) {
|
||||
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 {
|
||||
margin: 0 0 6px;
|
||||
color: #2563eb;
|
||||
@ -504,13 +583,16 @@ function removeEmployee(employee) {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.permission-note,
|
||||
.empty-state {
|
||||
margin: 6px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.employee-count-pill {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -567,6 +649,10 @@ function removeEmployee(employee) {
|
||||
}
|
||||
|
||||
.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;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
@ -579,12 +665,30 @@ function removeEmployee(employee) {
|
||||
}
|
||||
|
||||
.org-tree-branch {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 18px 24px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
@ -600,6 +704,17 @@ function removeEmployee(employee) {
|
||||
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.active {
|
||||
border-color: transparent;
|
||||
@ -670,6 +785,7 @@ function removeEmployee(employee) {
|
||||
}
|
||||
|
||||
.org-tree-children {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
@ -734,9 +850,23 @@ function removeEmployee(employee) {
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
overflow: visible;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.employee-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
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 {
|
||||
@ -749,13 +879,92 @@ function removeEmployee(employee) {
|
||||
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) {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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) {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 18px 24px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
@ -772,6 +981,17 @@ function removeEmployee(employee) {
|
||||
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.active) {
|
||||
border-color: transparent;
|
||||
@ -842,6 +1062,7 @@ function removeEmployee(employee) {
|
||||
}
|
||||
|
||||
:deep(.org-tree-children) {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
@ -48,10 +48,7 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">步骤一</p>
|
||||
<span
|
||||
class="title-with-help"
|
||||
title="同一产品编码允许维护多个版本。编辑时如果修改版本号,系统会自动保存成新版本。"
|
||||
>
|
||||
<span class="title-with-help">
|
||||
<h3>产品资料</h3>
|
||||
</span>
|
||||
</div>
|
||||
@ -174,7 +171,7 @@
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div v-if="!suppliers.length" class="workflow-empty-card">还没有供应商,请先新增供应商。</div>
|
||||
<div v-if="!suppliers.length" class="workflow-empty-card">暂无供应商</div>
|
||||
</div>
|
||||
|
||||
<form v-if="showSupplierCreateForm" class="stack-form workflow-inline-form" @submit.prevent="createSupplier">
|
||||
@ -276,9 +273,7 @@
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div v-if="!filteredMaterials.length" class="workflow-empty-card">
|
||||
当前没有可选原材料,请先新建原材料或先勾选供应商。
|
||||
</div>
|
||||
<div v-if="!filteredMaterials.length" class="workflow-empty-card">暂无可选原材料</div>
|
||||
</div>
|
||||
|
||||
<form v-if="showMaterialCreateForm" class="stack-form workflow-inline-form" @submit.prevent="createMaterial">
|
||||
@ -398,7 +393,7 @@
|
||||
</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>
|
||||
<div class="table-wrap">
|
||||
@ -547,14 +542,14 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">步骤五</p>
|
||||
<span class="title-with-help" title="工艺路线只绑定当前产品版本,参考节拍用于后续和真实报工节拍对比。">
|
||||
<span class="title-with-help">
|
||||
<h3>工艺路线</h3>
|
||||
</span>
|
||||
</div>
|
||||
<button class="ghost-button" type="button" :disabled="!currentProductId" @click="resetRouteForm">新建工艺路线</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!currentProductId" class="workflow-empty-card">请先在步骤一保存产品资料,再维护该产品的工艺路线。</div>
|
||||
<div v-if="!currentProductId" class="workflow-empty-card">保存产品后维护工艺路线</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-wrap">
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<section class="stocktake-shell">
|
||||
<header class="stocktake-head">
|
||||
<div>
|
||||
<p class="eyebrow">嘉恒仓库 · 盘库</p>
|
||||
<p class="eyebrow">百华仓库 · 盘库</p>
|
||||
<h3>{{ activeStocktake ? activeStocktake.stocktake_no : "新建盘库" }}</h3>
|
||||
</div>
|
||||
<button class="ghost-button" type="button" @click="$emit('close')">关闭</button>
|
||||
@ -36,7 +36,6 @@
|
||||
<div class="stocktake-action-card stocktake-action-card-wide">
|
||||
<span class="stocktake-stage-tag">当前仓库:{{ currentWarehouseName }}</span>
|
||||
<strong>01 确认盘库并导出仓库清单</strong>
|
||||
<p>点击后系统会锁定当前仓库,并导出本次盘库 Excel 清单。盘库结束前该仓库不能出入库。</p>
|
||||
<div class="stocktake-inline-actions">
|
||||
<button class="primary-button" type="button" :disabled="busy || !currentWarehouseId" @click="startAndExportStocktake">
|
||||
{{ busy ? "处理中..." : "确认盘库并导出仓库清单" }}
|
||||
|
||||
43
frontend/src/components/TableControls.test.js
Normal file
43
frontend/src/components/TableControls.test.js
Normal 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/);
|
||||
});
|
||||
});
|
||||
@ -78,6 +78,13 @@ let headerObserver = null;
|
||||
let enhanceFrame = 0;
|
||||
let modeFrame = 0;
|
||||
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) {
|
||||
return String(value || "")
|
||||
@ -131,6 +138,212 @@ function clearTableViewClasses(table) {
|
||||
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() {
|
||||
if (modeTableObserver) {
|
||||
modeTableObserver.disconnect();
|
||||
@ -320,6 +533,11 @@ function applyTableViewMode(table = findTableAfterControl()) {
|
||||
refreshCellTooltips(table);
|
||||
bindCellTooltip(table);
|
||||
observeTableForViewMode(table);
|
||||
if (isWide) {
|
||||
ensureWideTableScrollDock(wrap);
|
||||
} else {
|
||||
removeWideTableScrollDock();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleApplyTableViewMode() {
|
||||
@ -573,6 +791,7 @@ onBeforeUnmount(() => {
|
||||
modeFrame = 0;
|
||||
}
|
||||
clearTableViewClasses(modeTable);
|
||||
removeWideTableScrollDock();
|
||||
disconnectModeObserver();
|
||||
clearTooltipListeners();
|
||||
clearEnhancedHeaders();
|
||||
|
||||
@ -8,10 +8,14 @@
|
||||
:signature-labels="signatureLabels"
|
||||
>
|
||||
<div class="document-form-meta" :aria-label="metaAriaLabel">
|
||||
<span>{{ metaLabel }}</span>
|
||||
<strong>{{ companyName }}</strong>
|
||||
<span>{{ dateLabel }}</span>
|
||||
<strong>{{ displayBusinessDate }}</strong>
|
||||
<span class="document-form-meta-pair">
|
||||
<span>{{ metaLabel }}</span>
|
||||
<strong>{{ companyName }}</strong>
|
||||
</span>
|
||||
<span class="document-form-meta-pair document-form-meta-date">
|
||||
<span>{{ dateLabel }}</span>
|
||||
<strong>{{ displayBusinessDate }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<main class="document-form-body">
|
||||
@ -46,7 +50,7 @@ const props = defineProps({
|
||||
},
|
||||
companyName: {
|
||||
type: String,
|
||||
default: "宁波嘉恒智能科技有限公司"
|
||||
default: "宁波百华智能科技有限公司"
|
||||
},
|
||||
metaLabel: {
|
||||
type: String,
|
||||
|
||||
@ -66,7 +66,7 @@ const props = defineProps({
|
||||
},
|
||||
companyName: {
|
||||
type: String,
|
||||
default: "嘉恒仓库 ERP"
|
||||
default: "百华仓库 ERP"
|
||||
},
|
||||
tone: {
|
||||
type: String,
|
||||
|
||||
@ -36,7 +36,7 @@ defineProps({
|
||||
},
|
||||
companyName: {
|
||||
type: String,
|
||||
default: "嘉恒仓库"
|
||||
default: "百华仓库"
|
||||
},
|
||||
signatureLabels: {
|
||||
type: Array,
|
||||
|
||||
@ -49,4 +49,35 @@ describe("document form foundation", () => {
|
||||
assert.match(mainCss, /\.document-settle-panel/);
|
||||
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;/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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";
|
||||
|
||||
async function parseResponse(response) {
|
||||
export async function parseResponse(response) {
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
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) {
|
||||
const detail = typeof data === "object" && data?.detail ? data.detail : `Request failed: ${response.status}`;
|
||||
|
||||
39
frontend/src/services/api.test.js
Normal file
39
frontend/src/services/api.test.js
Normal 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), /组织节点不存在/);
|
||||
});
|
||||
});
|
||||
@ -8,7 +8,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const styles = readFileSync(join(__dirname, "main.css"), "utf8");
|
||||
const viewSourceByName = new Map(
|
||||
[
|
||||
"InventoryLedgerView.vue",
|
||||
"ProductSpecLiteView.vue",
|
||||
"PurchaseOrderView.vue",
|
||||
"SalesPlanningView.vue",
|
||||
"SupplierMaterialLiteView.vue",
|
||||
@ -36,6 +36,15 @@ describe("table action button layout and tones", () => {
|
||||
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", () => {
|
||||
const overviewActionHeaderRule = cssRuleBlock("body .table-view-overview .data-table:has(td.action-row) thead th:last-child");
|
||||
|
||||
|
||||
@ -11585,7 +11585,182 @@ body .table-cell-hover-tooltip-visible {
|
||||
}
|
||||
|
||||
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,
|
||||
@ -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 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 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 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 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 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 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 {
|
||||
@ -12096,9 +12276,17 @@ body .system-permission-page .tree-layout-card .org-employee-table .action-row {
|
||||
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 {
|
||||
min-height: 30px !important;
|
||||
padding: 0 8px !important;
|
||||
padding: 0 10px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
@ -14539,11 +14727,11 @@ body .login-line-brand {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: inline-grid;
|
||||
grid-template-columns: 48px minmax(0, max-content);
|
||||
grid-template-columns: minmax(116px, 150px) minmax(0, max-content);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-self: start;
|
||||
max-width: min(280px, 100%);
|
||||
max-width: min(380px, 100%);
|
||||
padding: 10px 15px 10px 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 20px;
|
||||
@ -14558,12 +14746,21 @@ body .login-line-brand {
|
||||
|
||||
body .login-line-brand .login-logo,
|
||||
body .login-production-layout .login-logo {
|
||||
width: 48px;
|
||||
width: 150px;
|
||||
height: 48px;
|
||||
border-radius: 16px;
|
||||
border-radius: 14px;
|
||||
object-fit: contain;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0 12px 32px rgba(2, 6, 23, 0.28);
|
||||
object-position: center;
|
||||
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 {
|
||||
@ -15785,13 +15982,13 @@ body .login-production-layout .login-security-foot p {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
body .login-line-brand .login-logo,
|
||||
body .login-production-layout .login-logo {
|
||||
width: 42px;
|
||||
width: 128px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
@ -16085,6 +16282,14 @@ body .login-production-layout .login-security-foot p {
|
||||
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-value {
|
||||
margin: 0;
|
||||
@ -16515,10 +16720,11 @@ body .login-production-layout .login-security-foot p {
|
||||
}
|
||||
|
||||
.document-form-meta {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 18px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 9px 12px;
|
||||
color: rgba(23, 18, 10, 0.72);
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
min-height: 26px;
|
||||
padding: 0 10px;
|
||||
white-space: nowrap;
|
||||
color: #145c39;
|
||||
border: 1.5px solid rgba(31, 122, 74, 0.64);
|
||||
background: rgba(232, 246, 232, 0.62);
|
||||
@ -16592,11 +16816,21 @@ body .login-production-layout .login-security-foot p {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.document-section-grid > .document-grid,
|
||||
.document-section-grid > .document-line-table,
|
||||
.document-section-grid > .document-control-grid,
|
||||
.document-section-grid > .document-selector-panel,
|
||||
.document-section-grid > .document-source-table,
|
||||
.document-section-grid > .document-inline-summary,
|
||||
.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 {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
@ -16811,7 +17045,6 @@ body .login-production-layout .login-security-foot p {
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.document-form-meta,
|
||||
.document-section-grid,
|
||||
.document-control-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@ -16827,18 +17060,32 @@ body .login-production-layout .login-security-foot p {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.document-form-meta,
|
||||
.document-section-grid,
|
||||
.document-control-grid {
|
||||
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-wide,
|
||||
.document-paper-field-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.document-grid-cell-wide,
|
||||
.document-grid-cell-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.document-action-bar,
|
||||
.document-action-bar--with-state,
|
||||
.document-action-state,
|
||||
@ -19053,7 +19300,56 @@ body .warehouse-document-action-bar {
|
||||
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) {
|
||||
body .document-action-bar,
|
||||
body .document-action-bar--with-state,
|
||||
body .warehouse-document-action-bar,
|
||||
body .warehouse-document-action-bar--shared,
|
||||
body .warehouse-document-action-bar--shared.warehouse-document-action-bar--with-state {
|
||||
|
||||
65
frontend/src/styles/staticCopyAudit.test.js
Normal file
65
frontend/src/styles/staticCopyAudit.test.js
Normal 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, []);
|
||||
});
|
||||
});
|
||||
76
frontend/src/utils/orgTreeEmployees.js
Normal file
76
frontend/src/utils/orgTreeEmployees.js
Normal 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 || "";
|
||||
}
|
||||
71
frontend/src/utils/orgTreeEmployees.test.js
Normal file
71
frontend/src/utils/orgTreeEmployees.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -2,7 +2,7 @@
|
||||
<ProcessWorkspace
|
||||
eyebrow="发货概览"
|
||||
title="成品销售出库交付工作台"
|
||||
description="发货概览模块只保留成品发运;客户退货、退货废料和返工出库统一到嘉恒仓库办理。"
|
||||
description="发货概览模块只保留成品发运;客户退货、退货废料和返工出库统一到百华仓库办理。"
|
||||
panel-tag="交付主流程"
|
||||
stage-code="06"
|
||||
:sections="sections"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<section class="page-grid">
|
||||
<section class="panel inventory-unified-panel">
|
||||
<div class="warehouse-switchboard" role="tablist" aria-label="嘉恒仓库库存类型">
|
||||
<div class="warehouse-switchboard" role="tablist" aria-label="百华仓库库存类型">
|
||||
<button
|
||||
v-for="tab in inventoryTabs"
|
||||
:key="tab.key"
|
||||
@ -868,8 +868,8 @@
|
||||
<button class="ghost-button" type="button" :disabled="!specialAdjustmentForm.reason.trim()" @click="addSpecialAdjustmentLine">
|
||||
添加调整明细
|
||||
</button>
|
||||
<span v-if="!specialAdjustmentForm.reason.trim()" class="special-adjustment-tip">请先填写调整说明</span>
|
||||
<span v-else>每条明细都会写入库存流水,调整说明会同步进入变更记录。</span>
|
||||
<span v-if="!specialAdjustmentForm.reason.trim()" class="special-adjustment-tip">填写调整说明后添加明细</span>
|
||||
<span v-else>可添加调整明细</span>
|
||||
</div>
|
||||
|
||||
<div class="special-adjustment-paper-lines special-adjustment-lines" :class="{ 'is-empty': !specialAdjustmentForm.lines.length }">
|
||||
@ -994,8 +994,7 @@
|
||||
</article>
|
||||
|
||||
<div v-if="!specialAdjustmentForm.lines.length" class="special-adjustment-empty">
|
||||
<strong>还没有调整明细</strong>
|
||||
<span>先填写调整说明,再点击“添加调整明细”。每一条明细都会写入库存流水。</span>
|
||||
<strong>暂无调整明细</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1331,7 +1330,7 @@
|
||||
|
||||
<FormDrawer
|
||||
:open="openingImportDrawerOpen"
|
||||
eyebrow="嘉恒仓库 · 期初导入"
|
||||
eyebrow="百华仓库 · 期初导入"
|
||||
:title="`${activeInventoryLabel}期初库存导入`"
|
||||
tag="库存初始化"
|
||||
wide
|
||||
@ -1344,7 +1343,6 @@
|
||||
<div class="import-layout">
|
||||
<section class="import-card">
|
||||
<strong>导出模版</strong>
|
||||
<p>先下载当前仓库类型的期初导入模版,按实际清点库存填写后再导入。</p>
|
||||
<button class="primary-button" type="button" :disabled="openingImportBusy" @click="exportOpeningTemplate">
|
||||
{{ openingImportBusy ? "处理中..." : `导出${activeInventoryLabel}期初模版` }}
|
||||
</button>
|
||||
@ -1355,7 +1353,6 @@
|
||||
<input ref="openingImportInputRef" class="hidden-file-input" type="file" accept=".xlsx,.xlsm" @change="importOpeningInventory" />
|
||||
<span class="import-icon">XLSX</span>
|
||||
<strong>{{ openingImportBusy ? "导入中..." : "上传期初库存 Excel" }}</strong>
|
||||
<p>导入会生成期初批次、库存余额和库存流水;批次号重复会被拦截。</p>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
@ -1366,7 +1363,6 @@
|
||||
:open="productionDrawerOpen"
|
||||
eyebrow="原材料库 · 生产出库"
|
||||
title="生产出库"
|
||||
description="选择原材料库存批次出库,后续小程序报工按库存批次号推进。"
|
||||
tag="生产出库"
|
||||
wide
|
||||
@close="productionDrawerOpen = false"
|
||||
@ -1383,10 +1379,6 @@
|
||||
<div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div>
|
||||
<div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>
|
||||
<WarehouseDocumentSection index="01" title="生产出库信息">
|
||||
<div class="document-inline-summary production-out-paper-tip">
|
||||
选择原材料库存批次出库,后续小程序报工按库存批次号推进。一次生产出库只能选择一个材料库存批次。
|
||||
</div>
|
||||
|
||||
<div class="document-control-grid production-out-paper-grid">
|
||||
<label class="document-paper-field document-paper-field-wide">
|
||||
<span>产品</span>
|
||||
@ -1464,10 +1456,6 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="feedback-banner">
|
||||
单件毛重:{{ selectedBomGrossWeight ? formatWeight(selectedBomGrossWeight) : "未配置" }};参考生产数量 = 本次生产出库重量 ÷ 单件毛重。
|
||||
</div>
|
||||
|
||||
<div class="document-control-grid production-out-paper-grid production-out-paper-remark">
|
||||
<label class="document-paper-field document-paper-field-full">
|
||||
<span>备注</span>
|
||||
@ -1865,8 +1853,8 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="document-inline-summary return-rework-trace-summary">
|
||||
{{ selectedReturnReworkItem ? formatReturnTraceSummary(selectedReturnReworkItem) : "请选择退货明细,系统会按退货库锁定批次办理返工出库。" }}
|
||||
<div v-if="selectedReturnReworkItem" class="document-inline-summary return-rework-trace-summary">
|
||||
{{ formatReturnTraceSummary(selectedReturnReworkItem) }}
|
||||
</div>
|
||||
|
||||
<div class="warehouse-paper-control-grid return-rework-paper-grid">
|
||||
@ -1899,7 +1887,7 @@
|
||||
|
||||
<FormDrawer
|
||||
:open="warehouseLedgerDrawerOpen"
|
||||
eyebrow="嘉恒仓库 · 库级流水"
|
||||
eyebrow="百华仓库 · 库级流水"
|
||||
:title="`${activeInventoryLabel} · 出入库流水`"
|
||||
tag="库存流水"
|
||||
wide
|
||||
@ -2055,7 +2043,7 @@
|
||||
|
||||
<FormDrawer
|
||||
:open="productionWorkOrderInboundDrawerOpen"
|
||||
eyebrow="嘉恒仓库 · 生产台账入库"
|
||||
eyebrow="百华仓库 · 生产台账入库"
|
||||
title="生产台账入库"
|
||||
description="按在生产的材料库存批次号统一办理成品入库、生产余料入库和生产废料入库。"
|
||||
tag="材料批次闭环"
|
||||
@ -2072,10 +2060,6 @@
|
||||
<div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div>
|
||||
<div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>
|
||||
<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">
|
||||
<label class="document-paper-field document-paper-field-wide">
|
||||
<span>生产台账</span>
|
||||
@ -2546,7 +2530,7 @@ const operationMatrix = computed(() => ({
|
||||
makeOperation("raw-special-in", "in", "SPECIAL_IN", "特殊入库", "强制填写说明后,直接调整原材料库库存明细。", "specialAdjustment")
|
||||
],
|
||||
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-return-out", "out", "PURCHASE_RETURN_OUT", "退货出库", "入仓后发现质量或性能不合格的原材料退回供应商。", "rawReturn"),
|
||||
makeOperation("raw-scrap-out", "out", "SCRAP_OUT", "报废出库", "原材料报废出库。"),
|
||||
|
||||
@ -13,11 +13,11 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<img class="login-logo" src="/logo.svg" alt="宁波嘉恒智能科技有限公司 logo" />
|
||||
<img class="login-logo login-logo-wordmark" src="/logo.png" alt="宁波百华智能科技有限公司 logo" />
|
||||
<div>
|
||||
<span>嘉恒智能</span>
|
||||
<span>百华智能</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -98,7 +98,7 @@
|
||||
<div class="login-console-frame">
|
||||
<div class="login-control-console">
|
||||
<div class="login-panel-head">
|
||||
<h2>嘉恒智能五金 ERP</h2>
|
||||
<h2>百华智能五金 ERP</h2>
|
||||
</div>
|
||||
|
||||
<form class="login-form" @submit.prevent="handleLogin">
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<ProcessWorkspace
|
||||
eyebrow="采购与库存"
|
||||
title="采购与嘉恒仓库工作台"
|
||||
description="覆盖采购下单、自家料到货、质检和嘉恒仓库出入库台账。"
|
||||
title="采购与百华仓库工作台"
|
||||
description="覆盖采购下单、自家料到货、质检和百华仓库出入库台账。"
|
||||
panel-tag="仓库主流程"
|
||||
stage-code="04"
|
||||
:sections="sections"
|
||||
@ -44,7 +44,7 @@ const sections = [
|
||||
{
|
||||
key: "inventory-ledger",
|
||||
step: "04-4",
|
||||
label: "嘉恒仓库",
|
||||
label: "百华仓库",
|
||||
desc: "统一处理原材料、半成品、成品、辅料的出入库和批次流水。",
|
||||
permission: "MENU_INVENTORY_LEDGER",
|
||||
component: InventoryLedgerView
|
||||
|
||||
46
frontend/src/views/ProductSpecLiteView.test.js
Normal file
46
frontend/src/views/ProductSpecLiteView.test.js
Normal 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/);
|
||||
});
|
||||
});
|
||||
@ -73,6 +73,7 @@
|
||||
<td class="action-row">
|
||||
<button
|
||||
class="ghost-button"
|
||||
data-semantic-label="编辑"
|
||||
type="button"
|
||||
:disabled="!canModifySpec(row)"
|
||||
:title="canModifySpec(row) ? '编辑' : '已停用的产品需规只读,不允许修改'"
|
||||
@ -82,6 +83,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="ghost-button danger-ghost"
|
||||
data-semantic-label="删除"
|
||||
type="button"
|
||||
:disabled="!canModifySpec(row)"
|
||||
:title="canModifySpec(row) ? '删除' : '已停用的产品需规只读,不允许删除'"
|
||||
@ -104,7 +106,6 @@
|
||||
:open="drawerOpen"
|
||||
eyebrow="产品需规"
|
||||
:title="editingRow ? '编辑产品需规' : '新增产品需规'"
|
||||
description="lite 模式下,一个抽屉完成产品资料、默认用料和小程序报工字段。系统会按小程序工序自动生成内部默认工艺,供生产工单使用。"
|
||||
tag="产品需规清单"
|
||||
wide
|
||||
@close="drawerOpen = false"
|
||||
@ -856,19 +857,23 @@ function buildAutoRouteOperation(processRow, index) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildRoutePayload(product, processRowsForSave) {
|
||||
const productItemId = product.item_id;
|
||||
function buildRouteOperationsPayload(processRowsForSave) {
|
||||
const processRows = buildProcessGroupForRoute(processRowsForSave);
|
||||
if (!processRows.length) {
|
||||
throw new Error("请至少维护一道小程序工序");
|
||||
}
|
||||
return processRows.map((processRow, index) => buildAutoRouteOperation(processRow, index));
|
||||
}
|
||||
|
||||
function buildRoutePayload(product, processRowsForSave, preparedOperations = null) {
|
||||
const productItemId = product.item_id;
|
||||
return {
|
||||
route_name: `${form.item_name} 工艺路线`,
|
||||
product_item_id: productItemId,
|
||||
version_no: form.version_no,
|
||||
is_default: true,
|
||||
status: "ACTIVE",
|
||||
operations: processRows.map((processRow, index) => buildAutoRouteOperation(processRow, index))
|
||||
operations: preparedOperations || buildRouteOperationsPayload(processRowsForSave)
|
||||
};
|
||||
}
|
||||
|
||||
@ -931,16 +936,16 @@ watch(calculatedLossRate, (lossRate) => {
|
||||
}, { immediate: true });
|
||||
|
||||
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/boms?limit=500", []),
|
||||
fetchResource("/master-data/process-routes?limit=500", []),
|
||||
fetchResource("/master-data/materials?limit=200", []),
|
||||
fetchResource("/master-data/miniapp-products?limit=1000", []),
|
||||
fetchResource("/master-data/miniapp-attendance-points", []),
|
||||
fetchResource("/master-data/processes", []),
|
||||
fetchResource("/master-data/work-centers", [])
|
||||
fetchResource("/master-data/miniapp-attendance-points", [])
|
||||
]);
|
||||
const processRows = await fetchResource("/master-data/processes", []);
|
||||
const centerRows = await fetchResource("/master-data/work-centers", []);
|
||||
products.value = productRows;
|
||||
boms.value = bomRows;
|
||||
routes.value = routeRows;
|
||||
@ -951,6 +956,18 @@ async function fetchMasterData() {
|
||||
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() {
|
||||
if (miniappSyncing.value) {
|
||||
return;
|
||||
@ -977,8 +994,12 @@ async function submitSpec() {
|
||||
feedbackMessage.value = "";
|
||||
errorMessage.value = "";
|
||||
submitting.value = true;
|
||||
let createdProductItemId = null;
|
||||
const createdMiniappPayloads = [];
|
||||
try {
|
||||
const processRowsForSave = normalizeProcessFormRowsForSave();
|
||||
await ensureOperationFoundationReady();
|
||||
const preparedRouteOperations = buildRouteOperationsPayload(processRowsForSave);
|
||||
const productPayload = {
|
||||
item_name: form.item_name,
|
||||
specification: editingRow.value?.specification || null,
|
||||
@ -997,6 +1018,9 @@ async function submitSpec() {
|
||||
? await putResource(`/master-data/products/${editingRow.value.item_id}`, productPayload)
|
||||
: await postResource("/master-data/products", productPayload);
|
||||
const productItemId = product.item_id;
|
||||
if (!shouldUpdateErpProduct) {
|
||||
createdProductItemId = productItemId;
|
||||
}
|
||||
const canUpdateExistingSpec = editingRow.value && Number(editingRow.value.item_id) === Number(productItemId);
|
||||
if (canUpdateExistingSpec && editingRow.value.bom?.bom_id) {
|
||||
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));
|
||||
}
|
||||
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 {
|
||||
await postResource("/master-data/process-routes", buildRoutePayload(product, processRowsForSave));
|
||||
await postResource("/master-data/process-routes", buildRoutePayload(product, processRowsForSave, preparedRouteOperations));
|
||||
}
|
||||
const miniappPayloads = buildMiniappProductPayloads(product, processRowsForSave);
|
||||
for (const payload of miniappPayloads) {
|
||||
await postResource("/master-data/miniapp-products?sync=false", payload);
|
||||
createdMiniappPayloads.push(payload);
|
||||
}
|
||||
await postResource("/master-data/miniapp-materials/sync?force=true", {});
|
||||
feedbackMessage.value = `产品需规 ${product.item_code} 已保存`;
|
||||
drawerOpen.value = false;
|
||||
await loadAll();
|
||||
} 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 || "产品需规保存失败";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
|
||||
@ -13,15 +13,6 @@
|
||||
</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="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>
|
||||
|
||||
@ -169,23 +160,6 @@
|
||||
</div>
|
||||
</template>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@ -198,13 +172,11 @@ import { computed, onMounted, ref } from "vue";
|
||||
|
||||
import DocumentArchiveActions from "../components/documentForms/DocumentArchiveActions.vue";
|
||||
import DrawerDataTable from "../components/DrawerDataTable.vue";
|
||||
import FieldHint from "../components/FieldHint.vue";
|
||||
import PaginationBar from "../components/PaginationBar.vue";
|
||||
import StatusBadge from "../components/StatusBadge.vue";
|
||||
import TableControls from "../components/TableControls.vue";
|
||||
import { useBodyScrollLock } from "../composables/useBodyScrollLock";
|
||||
import { downloadResource, fetchResource, openResource, postResource } from "../services/api";
|
||||
import { loadSmartOperationReportConfig } from "../services/systemFeatures";
|
||||
import { formatDateTime, formatQty, formatWeight } from "../utils/formatters";
|
||||
import { usePagination } from "../utils/pagination";
|
||||
import { useTableControls } from "../utils/tableControls";
|
||||
@ -216,7 +188,6 @@ const expandedTxnRows = ref([]);
|
||||
const feedbackMessage = ref("");
|
||||
const errorMessage = ref("");
|
||||
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 activeLedgerTxns = computed(() => activeLedger.value?.txns || []);
|
||||
@ -358,12 +329,8 @@ async function regenerateDocumentArchive(typeKey, businessId) {
|
||||
|
||||
async function loadAll() {
|
||||
errorMessage.value = "";
|
||||
const [rows, config] = await Promise.all([
|
||||
fetchResource("/production/production-ledger?limit=500", []),
|
||||
loadSmartOperationReportConfig()
|
||||
]);
|
||||
const rows = await fetchResource("/production/production-ledger?limit=500", []);
|
||||
ledgers.value = Array.isArray(rows) ? rows : [];
|
||||
smartOperationReportEnabled.value = config.enabled !== false;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user