Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5dbb5fa70 | ||
|
|
705ecf1e5a | ||
|
|
b024b061e9 | ||
|
|
867fc3beee | ||
|
|
9b60aa3e0d | ||
|
|
30fb7af159 | ||
|
|
6d87a2e1cd | ||
|
|
707db3a482 | ||
|
|
c424633030 | ||
|
|
eb0f2eff95 | ||
|
|
5ec8a98125 |
@ -6,7 +6,7 @@ product
|
||||
|
||||
## Users
|
||||
|
||||
百华五金 ERP 面向采购、销售、仓库、生产、财务、售后和管理人员。用户通常在真实业务现场或办公室中处理订单、入库、出库、生产台账、发货、权限和经营数据,需要快速、准确、少解释地完成工作。
|
||||
嘉恒五金 ERP 面向采购、销售、仓库、生产、财务、售后和管理人员。用户通常在真实业务现场或办公室中处理订单、入库、出库、生产台账、发货、权限和经营数据,需要快速、准确、少解释地完成工作。
|
||||
|
||||
## Product Purpose
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# ForgeFlow ERP
|
||||
|
||||
宁波百华智能科技有限公司五金行业 ERP 初始骨架,采用前后端分离架构:
|
||||
宁波嘉恒智能科技有限公司五金行业 ERP 初始骨架,采用前后端分离架构:
|
||||
|
||||
- 前端:Vue 3 + Vite
|
||||
- 后端:FastAPI
|
||||
|
||||
@ -1949,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="Baihua Hardware ERP API", alias="APP_NAME")
|
||||
app_name: str = Field(default="Jiaheng 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": "Baihua Hardware ERP API is running.",
|
||||
"message": "Jiaheng Hardware ERP API is running.",
|
||||
}
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
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.master_data import Bom, BomItem, Item, 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
|
||||
@ -14,7 +12,6 @@ __all__ = [
|
||||
"Bom",
|
||||
"BomItem",
|
||||
"Item",
|
||||
"ItemCategory",
|
||||
"Material",
|
||||
"MaterialDemand",
|
||||
"Permission",
|
||||
|
||||
@ -17,20 +17,6 @@ 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 JSON, BigInteger, Boolean, Date, DateTime, DECIMAL, ForeignKey, Index, Integer, String, Text, text
|
||||
from sqlalchemy import BigInteger, Boolean, Date, DateTime, DECIMAL, ForeignKey, Integer, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
@ -13,16 +13,6 @@ 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)
|
||||
@ -59,7 +49,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)
|
||||
@ -74,96 +64,18 @@ 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, 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)
|
||||
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)
|
||||
employee_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"))
|
||||
report_date: Mapped[object] = mapped_column(Date)
|
||||
start_at: Mapped[object] = mapped_column(DateTime)
|
||||
@ -182,32 +94,16 @@ 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, autoincrement=True)
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=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))
|
||||
@ -216,8 +112,6 @@ 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"))
|
||||
@ -225,95 +119,13 @@ 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, autoincrement=True)
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=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"))
|
||||
@ -329,22 +141,3 @@ 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,39 +559,6 @@ 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",
|
||||
|
||||
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", "嘉恒仓库", []),
|
||||
],
|
||||
),
|
||||
(
|
||||
|
||||
@ -1 +0,0 @@
|
||||
"""Operational scripts for ForgeFlow ERP backend."""
|
||||
@ -1,89 +0,0 @@
|
||||
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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,152 +0,0 @@
|
||||
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()
|
||||
@ -1,97 +0,0 @@
|
||||
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)
|
||||
@ -1,329 +0,0 @@
|
||||
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()
|
||||
@ -1,177 +0,0 @@
|
||||
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()
|
||||
@ -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=Baihua Hardware ERP API
|
||||
APP_NAME=Jiaheng 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,
|
||||
|
||||
@ -1,231 +0,0 @@
|
||||
# 系统初始化执行手册
|
||||
|
||||
本手册用于在后端服务器上初始化 ForgeFlow ERP 客户数据库。请注意:重置模式会清理目标数据库中的业务数据,正式执行前必须再次确认目标库、备份路径和是否清理实体文件。
|
||||
|
||||
## 适用场景
|
||||
|
||||
全新初始化模式 `fresh` 适用于全新客户数据库或空数据库结构。它会创建数据库、创建 ERP 与小程序相关全部表,并写入最小系统骨架。
|
||||
|
||||
重置初始化模式 `reset` 适用于已经部署过、但需要正式启用前清理测试数据的数据库。它会先执行 MySQL 备份,再清空业务数据、主数据、人员账号、小程序数据,并重建最小系统骨架。
|
||||
|
||||
默认不会删除 PDF、照片等实体文件。只有显式增加 `--delete-files` 时,才会清理系统管理范围内的文件目录内容。
|
||||
|
||||
## 执行前检查
|
||||
|
||||
执行前先进入后端目录并激活虚拟环境:
|
||||
|
||||
```bash
|
||||
cd /home/souplearn/ERP/ForgeFlow-ERP/backend
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
确认 `.env` 中数据库配置指向正确目标:
|
||||
|
||||
```bash
|
||||
grep -E "MYSQL_HOST|MYSQL_PORT|MYSQL_DATABASE|MYSQL_USER" .env
|
||||
```
|
||||
|
||||
确认后端能连接数据库:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8000/api/system/health | python3 -m json.tool
|
||||
```
|
||||
|
||||
## 全新数据库初始化
|
||||
|
||||
新客户全新部署时使用全新初始化模式 `fresh`:
|
||||
|
||||
```bash
|
||||
python scripts/system_initialize.py fresh \
|
||||
--company-name 百华 \
|
||||
--admin-name 超级管理员 \
|
||||
--admin-phone 13800000000 \
|
||||
--admin-password '正式密码' \
|
||||
--smart-operation-report on
|
||||
```
|
||||
|
||||
执行成功后,命令行会输出:
|
||||
|
||||
```text
|
||||
mode=fresh
|
||||
database=<数据库地址>:<端口>/<数据库名>
|
||||
backup_path=
|
||||
summary_path=
|
||||
admin_username=13800000000
|
||||
```
|
||||
|
||||
## 已有测试库重置
|
||||
|
||||
已有测试数据、需要正式启用前清理时使用重置初始化模式 `reset`。该模式必须显式传入确认参数 `--confirm-reset`:
|
||||
|
||||
```bash
|
||||
python scripts/system_initialize.py reset \
|
||||
--company-name 百华 \
|
||||
--admin-name 超级管理员 \
|
||||
--admin-phone 13800000000 \
|
||||
--admin-password '正式密码' \
|
||||
--smart-operation-report on \
|
||||
--confirm-reset
|
||||
```
|
||||
|
||||
执行成功后,命令行会输出:
|
||||
|
||||
```text
|
||||
mode=reset
|
||||
database=<数据库地址>:<端口>/<数据库名>
|
||||
backup_path=/path/to/system_init_backup_<timestamp>.sql
|
||||
summary_path=/path/to/system_init_summary_<timestamp>.json
|
||||
admin_username=13800000000
|
||||
```
|
||||
|
||||
备份和执行摘要默认写入:
|
||||
|
||||
```text
|
||||
outputs/cleanup_backups/
|
||||
```
|
||||
|
||||
## 可选文件清理
|
||||
|
||||
默认重置初始化只清数据库记录,不删除实体文件。
|
||||
|
||||
如确实需要同时清理系统托管的历史文件,才增加 `--delete-files`:
|
||||
|
||||
```bash
|
||||
python scripts/system_initialize.py reset \
|
||||
--company-name 百华 \
|
||||
--admin-name 超级管理员 \
|
||||
--admin-phone 13800000000 \
|
||||
--admin-password '正式密码' \
|
||||
--smart-operation-report on \
|
||||
--confirm-reset \
|
||||
--delete-files
|
||||
```
|
||||
|
||||
`--delete-files` 只会删除以下目录中的子文件和子目录,不会删除目录本身:
|
||||
|
||||
```text
|
||||
outputs/document_archives
|
||||
outputs/document_archive_batches
|
||||
backend/uploads/logistics
|
||||
```
|
||||
|
||||
## 初始化后验证
|
||||
|
||||
先验证 API 健康状态:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8000/api/system/health | python3 -m json.tool
|
||||
```
|
||||
|
||||
再验证核心数据状态。为了避免密码出现在命令行参数中,使用临时 MySQL 配置文件:
|
||||
|
||||
```bash
|
||||
MYSQL_DEFAULTS_FILE="$(mktemp)"
|
||||
chmod 600 "$MYSQL_DEFAULTS_FILE"
|
||||
cat > "$MYSQL_DEFAULTS_FILE" <<EOF
|
||||
[client]
|
||||
host=$MYSQL_HOST
|
||||
port=$MYSQL_PORT
|
||||
user=$MYSQL_USER
|
||||
password=$MYSQL_PASSWORD
|
||||
default-character-set=utf8mb4
|
||||
EOF
|
||||
|
||||
mysql --defaults-extra-file="$MYSQL_DEFAULTS_FILE" "$MYSQL_DATABASE" -e "
|
||||
SELECT COUNT(*) AS customer_count FROM md_customer;
|
||||
SELECT COUNT(*) AS supplier_count FROM md_supplier;
|
||||
SELECT COUNT(*) AS stock_lot_count FROM wh_stock_lot;
|
||||
SELECT COUNT(*) AS sales_order_count FROM so_sales_order;
|
||||
SELECT COUNT(*) AS admin_count FROM sys_user WHERE username='13800000000' AND is_super_admin=1;
|
||||
"
|
||||
|
||||
rm -f "$MYSQL_DEFAULTS_FILE"
|
||||
```
|
||||
|
||||
预期结果:
|
||||
|
||||
```text
|
||||
customer_count = 0
|
||||
supplier_count = 0
|
||||
stock_lot_count = 0
|
||||
sales_order_count = 0
|
||||
admin_count = 1
|
||||
```
|
||||
|
||||
然后用初始化账号登录 ERP:
|
||||
|
||||
```text
|
||||
账号:初始化时填写的管理员手机号
|
||||
密码:初始化时填写的管理员密码
|
||||
```
|
||||
|
||||
## 执行摘要检查
|
||||
|
||||
重置初始化执行后检查 JSON 格式的执行摘要:
|
||||
|
||||
```bash
|
||||
python3 -m json.tool outputs/cleanup_backups/system_init_summary_<timestamp>.json
|
||||
```
|
||||
|
||||
重点确认:
|
||||
|
||||
```text
|
||||
mode
|
||||
database
|
||||
backup_path
|
||||
summary_path
|
||||
counts_before
|
||||
counts_after
|
||||
seeded.admin_username
|
||||
```
|
||||
|
||||
如果使用了 `--delete-files`,还应看到:
|
||||
|
||||
```text
|
||||
seeded.deleted_file_count
|
||||
seeded.deleted_dir_count
|
||||
```
|
||||
|
||||
## 回滚方式
|
||||
|
||||
如果重置初始化后发现目标数据库选错,先停止相关服务,避免继续写入:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop forgeflow-backend
|
||||
```
|
||||
|
||||
然后使用本次生成的备份 SQL 恢复:
|
||||
|
||||
```bash
|
||||
MYSQL_DEFAULTS_FILE="$(mktemp)"
|
||||
chmod 600 "$MYSQL_DEFAULTS_FILE"
|
||||
cat > "$MYSQL_DEFAULTS_FILE" <<EOF
|
||||
[client]
|
||||
host=$MYSQL_HOST
|
||||
port=$MYSQL_PORT
|
||||
user=$MYSQL_USER
|
||||
password=$MYSQL_PASSWORD
|
||||
default-character-set=utf8mb4
|
||||
EOF
|
||||
|
||||
mysql --defaults-extra-file="$MYSQL_DEFAULTS_FILE" "$MYSQL_DATABASE" < outputs/cleanup_backups/system_init_backup_<timestamp>.sql
|
||||
|
||||
rm -f "$MYSQL_DEFAULTS_FILE"
|
||||
```
|
||||
|
||||
恢复完成后再启动服务:
|
||||
|
||||
```bash
|
||||
sudo systemctl start forgeflow-backend
|
||||
```
|
||||
|
||||
## 安全提醒
|
||||
|
||||
正式环境执行重置初始化前必须确认:
|
||||
|
||||
1. `.env` 中的 `MYSQL_HOST`、`MYSQL_PORT`、`MYSQL_DATABASE` 是目标测试库或待初始化客户库。
|
||||
2. 已经明确允许清理该库中的现有数据。
|
||||
3. `outputs/cleanup_backups` 有足够磁盘空间保存备份。
|
||||
4. 没有显式传入 `--delete-files` 时,不会删除 PDF、照片等实体文件。
|
||||
5. 只有确认要清理实体文件时,才允许传入 `--delete-files`。
|
||||
6. 执行后必须先保存备份 SQL,再做后续验收。
|
||||
File diff suppressed because it is too large
Load Diff
@ -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 |
|
||||
|
||||
## 推荐方案
|
||||
|
||||
|
||||
@ -1,388 +0,0 @@
|
||||
# 系统标准初始化工具设计
|
||||
|
||||
## 背景
|
||||
|
||||
当前系统已经从嘉恒测试环境切到百华客制化分支。数据库中存在大量嘉恒/测试业务数据,百华正式启用前需要初始化为干净系统。
|
||||
|
||||
这次初始化不能只做一次性清库脚本。后续给其他公司部署时,也需要一套标准工具,能够在全新的 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/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>
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
54
frontend/public/favicon.svg
Normal file
54
frontend/public/favicon.svg
Normal file
@ -0,0 +1,54 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 509 KiB |
54
frontend/public/logo.svg
Normal file
54
frontend/public/logo.svg
Normal file
@ -0,0 +1,54 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@ -4,9 +4,9 @@
|
||||
<div v-else :class="shellClass">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<img class="brand-logo" src="/logo-mark.png" alt="宁波百华智能科技有限公司 logo" />
|
||||
<img class="brand-logo" src="/logo.svg" 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>
|
||||
@ -186,7 +186,8 @@
|
||||
</span>
|
||||
<div>
|
||||
<p class="eyebrow">AI 助手</p>
|
||||
<h3>百华工艺助手</h3>
|
||||
<h3>嘉恒工艺助手</h3>
|
||||
<p>当前为内置占位对话,真实 LLM 可在 08 系统拓展中配置。</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="ghost-button" type="button" @click="closeAssistantDrawer">关闭</button>
|
||||
@ -250,6 +251,7 @@
|
||||
<div>
|
||||
<p class="eyebrow">管理员操作</p>
|
||||
<h3>重置他人密码</h3>
|
||||
<p>用于协助其他用户忘记密码时重置登录密码,仅系统管理员可用。</p>
|
||||
</div>
|
||||
<button class="ghost-button" type="button" @click="closePasswordModals">关闭</button>
|
||||
</header>
|
||||
@ -325,7 +327,7 @@ const chatMessages = ref([
|
||||
{
|
||||
id: 1,
|
||||
role: "assistant",
|
||||
content: "你好,我是百华工艺助手。你可以问我系统操作路径、字段含义、业务流程或异常排查。"
|
||||
content: "你好,我是嘉恒工艺助手。你可以问我系统操作路径、字段含义、业务流程或异常排查。"
|
||||
}
|
||||
]);
|
||||
const changePasswordForm = ref({
|
||||
@ -522,7 +524,7 @@ const navStages = [
|
||||
key: "procurement",
|
||||
step: "04",
|
||||
label: "采购与库存",
|
||||
desc: "采购、自家料入库、质量检验和百华仓库。",
|
||||
desc: "采购、自家料入库、质量检验和嘉恒仓库。",
|
||||
tail: "采购",
|
||||
icon: "warehouse",
|
||||
iconPaths: [
|
||||
@ -588,7 +590,7 @@ const navStages = [
|
||||
},
|
||||
{
|
||||
key: "inventory-ledger",
|
||||
label: "百华仓库",
|
||||
label: "嘉恒仓库",
|
||||
icon: "warehouse",
|
||||
to: { name: "inventory-ledger" },
|
||||
routeNames: ["inventory-ledger"],
|
||||
@ -764,7 +766,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,
|
||||
@ -1200,7 +1202,7 @@ function sendAssistantMessage() {
|
||||
chatMessages.value.push({
|
||||
id: Date.now() + 1,
|
||||
role: "assistant",
|
||||
content: "已收到。"
|
||||
content: "我已收到。当前聊天窗口已就绪,后续在 08 系统拓展配置 LLM API 后,可以把这里切换为真实模型回复。现在我可以先作为操作入口,帮助你记录和梳理 ERP 问题。"
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
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,13 +1,14 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="workflow-drawer-mask" @click.self="emit('close')">
|
||||
<aside class="workflow-drawer form-drawer" :class="drawerClass">
|
||||
<aside class="workflow-drawer form-drawer" :class="drawerClass" :aria-describedby="description ? descriptionId : undefined">
|
||||
<header class="workflow-drawer-head">
|
||||
<div>
|
||||
<p class="eyebrow">{{ eyebrow }}</p>
|
||||
<div class="drawer-title-row">
|
||||
<div class="drawer-title-row" :title="description || undefined">
|
||||
<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>
|
||||
@ -26,7 +27,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, toRef } from "vue";
|
||||
import { computed, toRef, useId } from "vue";
|
||||
|
||||
import { useBodyScrollLock } from "../composables/useBodyScrollLock";
|
||||
import AppIcon from "./AppIcon.vue";
|
||||
@ -63,6 +64,7 @@ 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";
|
||||
|
||||
@ -36,7 +36,7 @@
|
||||
<p class="eyebrow">组织人员</p>
|
||||
<h3>{{ selectedNodeLabel }}</h3>
|
||||
<p class="node-manager-line" :title="selectedNodeManagerText">{{ selectedNodeManagerText }}</p>
|
||||
<span class="status-pill employee-count-pill">人员 {{ selectedEmployees.length }}</span>
|
||||
<p class="permission-note">当前节点人员共 {{ selectedEmployees.length }} 人</p>
|
||||
</div>
|
||||
<div class="employee-head-actions">
|
||||
<button
|
||||
@ -583,16 +583,13 @@ 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;
|
||||
|
||||
@ -48,7 +48,10 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">步骤一</p>
|
||||
<span class="title-with-help">
|
||||
<span
|
||||
class="title-with-help"
|
||||
title="同一产品编码允许维护多个版本。编辑时如果修改版本号,系统会自动保存成新版本。"
|
||||
>
|
||||
<h3>产品资料</h3>
|
||||
</span>
|
||||
</div>
|
||||
@ -171,7 +174,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">
|
||||
@ -273,7 +276,9 @@
|
||||
</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">
|
||||
@ -393,7 +398,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">
|
||||
@ -542,14 +547,14 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">步骤五</p>
|
||||
<span class="title-with-help">
|
||||
<span class="title-with-help" title="工艺路线只绑定当前产品版本,参考节拍用于后续和真实报工节拍对比。">
|
||||
<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,6 +36,7 @@
|
||||
<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 ? "处理中..." : "确认盘库并导出仓库清单" }}
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
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,13 +78,6 @@ 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 || "")
|
||||
@ -138,212 +131,6 @@ 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();
|
||||
@ -533,11 +320,6 @@ function applyTableViewMode(table = findTableAfterControl()) {
|
||||
refreshCellTooltips(table);
|
||||
bindCellTooltip(table);
|
||||
observeTableForViewMode(table);
|
||||
if (isWide) {
|
||||
ensureWideTableScrollDock(wrap);
|
||||
} else {
|
||||
removeWideTableScrollDock();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleApplyTableViewMode() {
|
||||
@ -791,7 +573,6 @@ onBeforeUnmount(() => {
|
||||
modeFrame = 0;
|
||||
}
|
||||
clearTableViewClasses(modeTable);
|
||||
removeWideTableScrollDock();
|
||||
disconnectModeObserver();
|
||||
clearTooltipListeners();
|
||||
clearEnhancedHeaders();
|
||||
|
||||
@ -50,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,
|
||||
|
||||
@ -8,7 +8,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const styles = readFileSync(join(__dirname, "main.css"), "utf8");
|
||||
const viewSourceByName = new Map(
|
||||
[
|
||||
"ProductSpecLiteView.vue",
|
||||
"InventoryLedgerView.vue",
|
||||
"PurchaseOrderView.vue",
|
||||
"SalesPlanningView.vue",
|
||||
"SupplierMaterialLiteView.vue",
|
||||
@ -36,15 +36,6 @@ 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,182 +11585,7 @@ body .table-cell-hover-tooltip-visible {
|
||||
}
|
||||
|
||||
body .table-view-wide {
|
||||
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;
|
||||
}
|
||||
overflow-x: auto !important;
|
||||
}
|
||||
|
||||
body .table-view-wide .data-table,
|
||||
@ -14727,11 +14552,11 @@ body .login-line-brand {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: inline-grid;
|
||||
grid-template-columns: minmax(116px, 150px) minmax(0, max-content);
|
||||
grid-template-columns: 48px minmax(0, max-content);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-self: start;
|
||||
max-width: min(380px, 100%);
|
||||
max-width: min(280px, 100%);
|
||||
padding: 10px 15px 10px 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 20px;
|
||||
@ -14746,21 +14571,12 @@ body .login-line-brand {
|
||||
|
||||
body .login-line-brand .login-logo,
|
||||
body .login-production-layout .login-logo {
|
||||
width: 150px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
border-radius: 16px;
|
||||
object-fit: contain;
|
||||
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%);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0 12px 32px rgba(2, 6, 23, 0.28);
|
||||
}
|
||||
|
||||
body .login-line-brand span {
|
||||
@ -15982,13 +15798,13 @@ body .login-production-layout .login-security-foot p {
|
||||
}
|
||||
|
||||
body .login-line-brand {
|
||||
grid-template-columns: minmax(96px, 128px) minmax(0, max-content);
|
||||
grid-template-columns: 42px minmax(0, max-content);
|
||||
border-radius: 17px;
|
||||
}
|
||||
|
||||
body .login-line-brand .login-logo,
|
||||
body .login-production-layout .login-logo {
|
||||
width: 128px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
|
||||
@ -1,65 +0,0 @@
|
||||
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, []);
|
||||
});
|
||||
});
|
||||
@ -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,7 +994,8 @@
|
||||
</article>
|
||||
|
||||
<div v-if="!specialAdjustmentForm.lines.length" class="special-adjustment-empty">
|
||||
<strong>暂无调整明细</strong>
|
||||
<strong>还没有调整明细</strong>
|
||||
<span>先填写调整说明,再点击“添加调整明细”。每一条明细都会写入库存流水。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1330,7 +1331,7 @@
|
||||
|
||||
<FormDrawer
|
||||
:open="openingImportDrawerOpen"
|
||||
eyebrow="百华仓库 · 期初导入"
|
||||
eyebrow="嘉恒仓库 · 期初导入"
|
||||
:title="`${activeInventoryLabel}期初库存导入`"
|
||||
tag="库存初始化"
|
||||
wide
|
||||
@ -1343,6 +1344,7 @@
|
||||
<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>
|
||||
@ -1353,6 +1355,7 @@
|
||||
<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>
|
||||
@ -1363,6 +1366,7 @@
|
||||
:open="productionDrawerOpen"
|
||||
eyebrow="原材料库 · 生产出库"
|
||||
title="生产出库"
|
||||
description="选择原材料库存批次出库,后续小程序报工按库存批次号推进。"
|
||||
tag="生产出库"
|
||||
wide
|
||||
@close="productionDrawerOpen = false"
|
||||
@ -1379,6 +1383,10 @@
|
||||
<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>
|
||||
@ -1456,6 +1464,10 @@
|
||||
</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>
|
||||
@ -1853,8 +1865,8 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedReturnReworkItem" class="document-inline-summary return-rework-trace-summary">
|
||||
{{ formatReturnTraceSummary(selectedReturnReworkItem) }}
|
||||
<div class="document-inline-summary return-rework-trace-summary">
|
||||
{{ selectedReturnReworkItem ? formatReturnTraceSummary(selectedReturnReworkItem) : "请选择退货明细,系统会按退货库锁定批次办理返工出库。" }}
|
||||
</div>
|
||||
|
||||
<div class="warehouse-paper-control-grid return-rework-paper-grid">
|
||||
@ -1887,7 +1899,7 @@
|
||||
|
||||
<FormDrawer
|
||||
:open="warehouseLedgerDrawerOpen"
|
||||
eyebrow="百华仓库 · 库级流水"
|
||||
eyebrow="嘉恒仓库 · 库级流水"
|
||||
:title="`${activeInventoryLabel} · 出入库流水`"
|
||||
tag="库存流水"
|
||||
wide
|
||||
@ -2043,7 +2055,7 @@
|
||||
|
||||
<FormDrawer
|
||||
:open="productionWorkOrderInboundDrawerOpen"
|
||||
eyebrow="百华仓库 · 生产台账入库"
|
||||
eyebrow="嘉恒仓库 · 生产台账入库"
|
||||
title="生产台账入库"
|
||||
description="按在生产的材料库存批次号统一办理成品入库、生产余料入库和生产废料入库。"
|
||||
tag="材料批次闭环"
|
||||
@ -2060,6 +2072,10 @@
|
||||
<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>
|
||||
@ -2530,7 +2546,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 login-logo-wordmark" src="/logo.png" alt="宁波百华智能科技有限公司 logo" />
|
||||
<img class="login-logo" src="/logo.svg" 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
|
||||
|
||||
@ -36,11 +36,12 @@ describe("ProductSpecLiteView operation foundation loading", () => {
|
||||
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/);
|
||||
it("uses a one-screen table layout so the action and pagination areas do not get pushed into the edge", () => {
|
||||
assert.match(source, /class="table-wrap product-spec-table-wrap"/);
|
||||
assert.match(source, /class="data-table compact-table product-spec-table"/);
|
||||
assert.match(source, /<colgroup>[\s\S]*product-spec-col-action[\s\S]*<\/colgroup>/);
|
||||
assert.match(source, /\.product-spec-table-wrap\.table-view-wide,[\s\S]*?overflow-x:\s*hidden !important;/);
|
||||
assert.match(source, /\.product-spec-table[\s\S]*?table-layout:\s*fixed !important;/);
|
||||
assert.match(source, /\.product-spec-table\s+\.action-row[\s\S]*?width:\s*100% !important;/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -23,8 +23,25 @@
|
||||
placeholder="搜索考勤点、产品编码、名称、版本、原材料、小程序工序"
|
||||
/>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="data-table compact-table">
|
||||
<div class="table-wrap product-spec-table-wrap">
|
||||
<table class="data-table compact-table product-spec-table">
|
||||
<colgroup>
|
||||
<col class="product-spec-col-code" />
|
||||
<col class="product-spec-col-attendance" />
|
||||
<col class="product-spec-col-project" />
|
||||
<col class="product-spec-col-product" />
|
||||
<col class="product-spec-col-version" />
|
||||
<col class="product-spec-col-weight" />
|
||||
<col class="product-spec-col-material" />
|
||||
<col class="product-spec-col-gross" />
|
||||
<col class="product-spec-col-rate" />
|
||||
<col class="product-spec-col-rate" />
|
||||
<col class="product-spec-col-price" />
|
||||
<col class="product-spec-col-process" />
|
||||
<col class="product-spec-col-beat" />
|
||||
<col class="product-spec-col-status" />
|
||||
<col class="product-spec-col-action" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>产品编码</th>
|
||||
@ -99,13 +116,14 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="filteredRows.length" />
|
||||
<PaginationBar class="product-spec-pagination" v-model:page="page" v-model:page-size="pageSize" :total="filteredRows.length" />
|
||||
</section>
|
||||
|
||||
<FormDrawer
|
||||
:open="drawerOpen"
|
||||
eyebrow="产品需规"
|
||||
:title="editingRow ? '编辑产品需规' : '新增产品需规'"
|
||||
description="lite 模式下,一个抽屉完成产品资料、默认用料和小程序报工字段。系统会按小程序工序自动生成内部默认工艺,供生产工单使用。"
|
||||
tag="产品需规清单"
|
||||
wide
|
||||
@close="drawerOpen = false"
|
||||
@ -1070,6 +1088,177 @@ onActivated(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-spec-table-wrap,
|
||||
.product-spec-table-wrap.table-view-wide,
|
||||
.product-spec-table-wrap.table-view-overview {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
.product-spec-table,
|
||||
.product-spec-table.data-table-wide,
|
||||
.product-spec-table.data-table-overview,
|
||||
.product-spec-table-wrap.table-view-wide .product-spec-table,
|
||||
.product-spec-table-wrap.table-view-overview .product-spec-table {
|
||||
width: 100% !important;
|
||||
min-width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
table-layout: fixed !important;
|
||||
}
|
||||
|
||||
.product-spec-col-code {
|
||||
width: 8%;
|
||||
}
|
||||
|
||||
.product-spec-col-attendance {
|
||||
width: 5.5%;
|
||||
}
|
||||
|
||||
.product-spec-col-project {
|
||||
width: 8.5%;
|
||||
}
|
||||
|
||||
.product-spec-col-product {
|
||||
width: 10.5%;
|
||||
}
|
||||
|
||||
.product-spec-col-version {
|
||||
width: 4.2%;
|
||||
}
|
||||
|
||||
.product-spec-col-weight {
|
||||
width: 4.6%;
|
||||
}
|
||||
|
||||
.product-spec-col-material {
|
||||
width: 11%;
|
||||
}
|
||||
|
||||
.product-spec-col-gross {
|
||||
width: 5.4%;
|
||||
}
|
||||
|
||||
.product-spec-col-rate {
|
||||
width: 5.7%;
|
||||
}
|
||||
|
||||
.product-spec-col-price {
|
||||
width: 5.4%;
|
||||
}
|
||||
|
||||
.product-spec-col-process {
|
||||
width: 6.2%;
|
||||
}
|
||||
|
||||
.product-spec-col-beat {
|
||||
width: 5.2%;
|
||||
}
|
||||
|
||||
.product-spec-col-status {
|
||||
width: 5%;
|
||||
}
|
||||
|
||||
.product-spec-col-action {
|
||||
width: 8.6%;
|
||||
}
|
||||
|
||||
.product-spec-table th,
|
||||
.product-spec-table td,
|
||||
.product-spec-table.data-table-wide th,
|
||||
.product-spec-table.data-table-overview th,
|
||||
.product-spec-table-wrap.table-view-wide .product-spec-table th,
|
||||
.product-spec-table-wrap.table-view-overview .product-spec-table th {
|
||||
min-width: 0 !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.product-spec-table th,
|
||||
.product-spec-table th :deep(.sortable-th-button),
|
||||
.product-spec-table th :deep(.sortable-th-label) {
|
||||
overflow: visible !important;
|
||||
text-overflow: clip !important;
|
||||
white-space: normal !important;
|
||||
word-break: keep-all !important;
|
||||
overflow-wrap: anywhere !important;
|
||||
line-height: 1.22;
|
||||
}
|
||||
|
||||
.product-spec-table th :deep(.sortable-th-button) {
|
||||
grid-template-columns: minmax(0, 1fr) 16px !important;
|
||||
column-gap: 4px !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.product-spec-table th :deep(.sortable-th-label) {
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.product-spec-table td:not(.action-row),
|
||||
.product-spec-table.data-table-wide td:not(.action-row),
|
||||
.product-spec-table.data-table-overview td:not(.action-row) {
|
||||
min-width: 0 !important;
|
||||
max-width: 100% !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
.product-spec-table .action-row,
|
||||
.product-spec-table.data-table-wide .action-row,
|
||||
.product-spec-table.data-table-overview .action-row {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding-right: 8px !important;
|
||||
padding-left: 8px !important;
|
||||
text-align: center !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
.product-spec-table-wrap.table-view-wide .product-spec-table:has(td.action-row) thead th:last-child,
|
||||
.product-spec-table-wrap.table-view-overview .product-spec-table:has(td.action-row) thead th:last-child,
|
||||
.product-spec-table.data-table-wide:has(td.action-row) thead th:last-child,
|
||||
.product-spec-table.data-table-overview:has(td.action-row) thead th:last-child {
|
||||
width: auto !important;
|
||||
min-width: 0 !important;
|
||||
max-width: 100% !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.product-spec-table .action-row .ghost-button,
|
||||
.product-spec-table .action-row :deep(.semantic-action-button) {
|
||||
width: calc((100% - 6px) / 2) !important;
|
||||
min-width: 0 !important;
|
||||
max-width: none !important;
|
||||
justify-content: center !important;
|
||||
margin-right: 0 !important;
|
||||
padding: 0 6px !important;
|
||||
gap: 5px !important;
|
||||
}
|
||||
|
||||
.product-spec-table .action-row .ghost-button + .ghost-button,
|
||||
.product-spec-table .action-row :deep(.semantic-action-button + .semantic-action-button) {
|
||||
margin-left: 6px !important;
|
||||
}
|
||||
|
||||
.product-spec-table .action-row :deep(.semantic-action-label) {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-spec-pagination {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.product-spec-pagination :deep(.pagination-actions) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.process-field-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
||||
@ -1471,7 +1471,7 @@ function renderPurchaseContractHtml(contractOrders) {
|
||||
<div class="contract-no">合同编号:${escapeHtml(contractNo)}</div>
|
||||
|
||||
<div class="meta-grid">
|
||||
<div><strong>甲方(采购方):</strong>${escapeHtml("百华智能五金")}</div>
|
||||
<div><strong>甲方(采购方):</strong>${escapeHtml("嘉恒智能五金")}</div>
|
||||
<div><strong>乙方(供应方):</strong>${escapeHtml(normalizeContractText(supplier?.supplier_name))}</div>
|
||||
<div><strong>乙方联系人:</strong>${escapeHtml(normalizeContractText(supplier?.contact_name))}</div>
|
||||
<div><strong>乙方电话:</strong>${escapeHtml(normalizeContractText(supplier?.contact_phone))}</div>
|
||||
@ -1530,7 +1530,7 @@ function renderPurchaseContractHtml(contractOrders) {
|
||||
日期:
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted">注:该合同由百华智能五金 ERP 按当前采购订单数据自动生成。</p>
|
||||
<p class="muted">注:该合同由嘉恒智能五金 ERP 按当前采购订单数据自动生成。</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@ -961,7 +961,7 @@ function renderContractHtml(orders) {
|
||||
|
||||
<div class="meta-grid">
|
||||
<div><strong>甲方(买方):</strong>${escapeHtml(normalizeContractText(customer?.customer_name))}</div>
|
||||
<div><strong>乙方(卖方):</strong>${escapeHtml("百华智能五金")}</div>
|
||||
<div><strong>乙方(卖方):</strong>${escapeHtml("嘉恒智能五金")}</div>
|
||||
<div><strong>甲方联系人:</strong>${escapeHtml(normalizeContractText(customer?.contact_name))}</div>
|
||||
<div><strong>甲方电话:</strong>${escapeHtml(normalizeContractText(customer?.contact_phone))}</div>
|
||||
<div><strong>合同日期:</strong>${escapeHtml(currentDateText())}</div>
|
||||
@ -1033,7 +1033,7 @@ function renderContractHtml(orders) {
|
||||
日期:
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted">注:该合同由百华智能五金 ERP 按当前销售订单数据自动生成。</p>
|
||||
<p class="muted">注:该合同由嘉恒智能五金 ERP 按当前销售订单数据自动生成。</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@ -34,7 +34,7 @@ describe("SystemExtensionView workbench redesign", () => {
|
||||
assert.doesNotMatch(source, /<small>{{ section\.desc }}<\/small>/);
|
||||
assert.doesNotMatch(source, /activeSectionMeta\.eyebrow/);
|
||||
assert.doesNotMatch(source, /extension-impact-list/);
|
||||
assert.doesNotMatch(source, /extension-detail-note/);
|
||||
assert.match(source, /extension-detail-note/);
|
||||
assert.match(source, /extension-preview-disclosure/);
|
||||
assert.match(source, /extension-field-grid/);
|
||||
});
|
||||
|
||||
@ -149,6 +149,11 @@
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<details class="extension-detail-note">
|
||||
<summary>查看影响范围</summary>
|
||||
<p>开启时显示工序报工和小程序证据;关闭时隐藏工序报工,生产按 ERP 入库闭环推进。</p>
|
||||
</details>
|
||||
|
||||
<label class="form-field">
|
||||
<span>说明</span>
|
||||
<input v-model.trim="smartOperationReportForm.remark" type="text" placeholder="例如:客户未购买小程序模块,暂按ERP入库反推" />
|
||||
@ -196,7 +201,7 @@
|
||||
<input v-model="assistantForm.enabled" type="checkbox" />
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span>
|
||||
<strong>启用智能助手</strong>
|
||||
<strong>启用真实 LLM 调用</strong>
|
||||
<small class="extension-inline-state">{{ assistantConfig.api_key_configured ? "密钥已配置" : "未配置密钥" }}</small>
|
||||
</span>
|
||||
</label>
|
||||
@ -414,13 +419,13 @@ function buildEmptyBroadcastForm() {
|
||||
|
||||
function buildEmptyAssistantForm() {
|
||||
return {
|
||||
assistant_name: "百华工艺助手",
|
||||
assistant_name: "嘉恒工艺助手",
|
||||
provider_name: "",
|
||||
api_base_url: "",
|
||||
api_key: "",
|
||||
model_name: "",
|
||||
temperature: 0.3,
|
||||
system_prompt: "你是宁波百华智能五金 ERP 的企业助手,优先解释业务流程、字段含义、数据异常和操作步骤。",
|
||||
system_prompt: "你是宁波嘉恒智能五金 ERP 的企业助手,优先解释业务流程、字段含义、数据异常和操作步骤。",
|
||||
enabled: false
|
||||
};
|
||||
}
|
||||
@ -562,7 +567,7 @@ function restoreSectionSnapshot(sectionKey) {
|
||||
|
||||
if (sectionKey === "assistant") {
|
||||
Object.assign(assistantForm, {
|
||||
assistant_name: snapshot.assistant_name || "百华工艺助手",
|
||||
assistant_name: snapshot.assistant_name || "嘉恒工艺助手",
|
||||
provider_name: snapshot.provider_name || "",
|
||||
api_base_url: snapshot.api_base_url || "",
|
||||
api_key: "",
|
||||
@ -580,7 +585,7 @@ function restoreSectionSnapshot(sectionKey) {
|
||||
function applyAssistantConfig(config) {
|
||||
assistantConfig.value = config || {};
|
||||
Object.assign(assistantForm, {
|
||||
assistant_name: config?.assistant_name || "百华工艺助手",
|
||||
assistant_name: config?.assistant_name || "嘉恒工艺助手",
|
||||
provider_name: config?.provider_name || "",
|
||||
api_base_url: config?.api_base_url || "",
|
||||
api_key: "",
|
||||
@ -620,7 +625,7 @@ const assistantStateLabel = computed(() => {
|
||||
if (assistantConfig.value.api_key_configured) {
|
||||
return "密钥已配置";
|
||||
}
|
||||
return "未启用";
|
||||
return "占位模式";
|
||||
});
|
||||
|
||||
const statusCards = computed(() => [
|
||||
|
||||
@ -39,6 +39,7 @@
|
||||
<div>
|
||||
<p class="eyebrow">组织架构</p>
|
||||
<h3>权限组织架构</h3>
|
||||
<p class="permission-note">脑图和组织树共用同一套组织数据;脑图模式下员工节点默认收拢,人员授权和移除都在组织架构里处理。</p>
|
||||
</div>
|
||||
<div class="mindmap-card-actions">
|
||||
<div class="org-view-switch" role="tablist" aria-label="组织架构展示模式">
|
||||
@ -94,6 +95,7 @@
|
||||
<div>
|
||||
<p class="eyebrow">角色权限</p>
|
||||
<h3>角色维护</h3>
|
||||
<p class="permission-note">角色用于配置菜单权限和业务身份;人员授权请回到组织架构中对人员节点操作。</p>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<button
|
||||
@ -159,6 +161,7 @@
|
||||
<div>
|
||||
<p class="eyebrow">人员授权</p>
|
||||
<h2>{{ authorizingEmployee?.employee_name || "人员授权" }}</h2>
|
||||
<p class="authorize-header-note">为该人员创建或更新系统账号,并分配可访问的系统角色。</p>
|
||||
</div>
|
||||
<button class="ghost-button authorize-close-button" type="button" @click="showAuthorizeDrawer = false">关闭窗口</button>
|
||||
</div>
|
||||
@ -326,6 +329,9 @@
|
||||
</div>
|
||||
|
||||
<div v-if="orgDrawerMode === 'create' || orgDrawerMode === 'rename'">
|
||||
<p v-if="orgDrawerMode === 'create'" class="permission-note">
|
||||
将在「{{ orgForm.parent_label }}」下新增{{ nodeTypeLabel(orgForm.node_type) }},只需要填写名称。
|
||||
</p>
|
||||
<label class="form-field">
|
||||
<span>{{ nodeTypeLabel(orgForm.node_type) }}名称</span>
|
||||
<input v-model="orgForm.node_label" type="text" placeholder="请输入名称" />
|
||||
@ -333,6 +339,7 @@
|
||||
</div>
|
||||
|
||||
<div v-else-if="orgDrawerMode === 'manager'">
|
||||
<p class="permission-note">支持绑定多位主管,直接点击人员卡片即可选中或取消,不需要按键盘组合键。</p>
|
||||
<div class="form-field">
|
||||
<span>多位主管</span>
|
||||
<div class="selected-manager-tags">
|
||||
@ -374,6 +381,7 @@
|
||||
</div>
|
||||
|
||||
<div v-else-if="orgDrawerMode === 'employee'">
|
||||
<p class="permission-note">新增人员是把基础资料人员挂到当前脑图节点,不会修改小程序人员主数据。</p>
|
||||
<label class="form-field">
|
||||
<span>人员</span>
|
||||
<select v-model="orgEmployeeForm.employee_id">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user