diff --git a/backend/app/api/routes/master_data.py b/backend/app/api/routes/master_data.py index 875e9b9..970079a 100644 --- a/backend/app/api/routes/master_data.py +++ b/backend/app/api/routes/master_data.py @@ -88,6 +88,87 @@ from app.services.system_permissions import list_employee_options router = APIRouter(dependencies=[Depends(require_authenticated_user)]) +DEFAULT_PROCESS_CODE = "PROC-DEFAULT" +DEFAULT_PROCESS_NAME = "系统默认工序" +DEFAULT_WORK_CENTER_CODE = "WC-DEFAULT" +DEFAULT_WORK_CENTER_NAME = "系统默认工作中心" +DEFAULT_ORG_ROOT_CODE = "ORG_ROOT" + + +def _default_operation_department(db: Session) -> Department: + department = db.scalar(select(Department).where(Department.dept_code == DEFAULT_ORG_ROOT_CODE).limit(1)) + if department: + return department + + department = db.scalar(select(Department).order_by(Department.id.asc()).limit(1)) + if department: + return department + + now = datetime.now() + department = Department( + dept_code=DEFAULT_ORG_ROOT_CODE, + dept_name="总公司", + parent_id=None, + org_node_type="COMPANY", + dept_type="ADMIN", + manager_name=None, + manager_employee_id=None, + status="ACTIVE", + sort_no=0, + remark="系统自动创建的默认组织根节点", + created_at=now, + updated_at=now, + ) + db.add(department) + db.flush() + return department + + +def _ensure_default_operation_foundation(db: Session) -> None: + changed = False + now = datetime.now() + process_exists = db.scalar(select(Process.id).limit(1)) + if not process_exists: + db.add( + Process( + process_code=DEFAULT_PROCESS_CODE, + process_name=DEFAULT_PROCESS_NAME, + process_type="INTERNAL", + is_report_required=1, + is_quality_gate=0, + status="ACTIVE", + remark="系统自动创建,用于产品需规快速建档时生成默认工艺路线", + created_at=now, + updated_at=now, + ) + ) + changed = True + + work_center_exists = db.scalar(select(WorkCenter.id).limit(1)) + if not work_center_exists: + department = _default_operation_department(db) + db.add( + WorkCenter( + center_code=DEFAULT_WORK_CENTER_CODE, + center_name=DEFAULT_WORK_CENTER_NAME, + dept_id=department.id, + center_type="INTERNAL", + shift_pattern="默认班次", + capacity_hours_per_day=Decimal("8.00"), + status="ACTIVE", + remark="系统自动创建,用于产品需规快速建档时生成默认工艺路线", + created_at=now, + updated_at=now, + ) + ) + changed = True + + if changed: + try: + db.commit() + except IntegrityError: + db.rollback() + MATERIAL_EXCEL_HEADERS = ["原材料编码", "原材料名称", "材质", "规格", "废料单价", "性能要求", "状态"] MATERIAL_EXCEL_REQUIRED_HEADERS = ["原材料名称", "材质", "规格"] MATERIAL_STATUS_LABELS = { @@ -597,6 +678,7 @@ def list_work_centers( limit: int = Query(default=100, ge=1, le=500), db: Session = Depends(get_db), ) -> list[WorkCenterRead]: + _ensure_default_operation_foundation(db) stmt = ( select( WorkCenter.id.label("id"), @@ -766,6 +848,7 @@ def list_processes( limit: int = Query(default=100, ge=1, le=500), db: Session = Depends(get_db), ) -> list[ProcessRead]: + _ensure_default_operation_foundation(db) rows = db.execute( select( Process.id.label("process_id"), diff --git a/backend/tests/test_master_data_default_operation_foundation.py b/backend/tests/test_master_data_default_operation_foundation.py new file mode 100644 index 0000000..e376a3f --- /dev/null +++ b/backend/tests/test_master_data_default_operation_foundation.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import unittest + +from sqlalchemy import BigInteger, create_engine, func, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.api.routes import master_data # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.operations import Process, WorkCenter # noqa: E402 +from app.models.org import Department # noqa: E402 + + +class MasterDataDefaultOperationFoundationTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + + def tearDown(self) -> None: + self.db.close() + + def test_list_processes_seeds_default_process_for_empty_system(self) -> None: + self.assertEqual(self.db.scalar(select(func.count(Process.id))), 0) + + rows = master_data.list_processes(limit=100, db=self.db) + + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].process_code, "PROC-DEFAULT") + self.assertEqual(rows[0].process_name, "系统默认工序") + self.assertEqual(self.db.scalar(select(func.count(Process.id))), 1) + + def test_list_work_centers_seeds_default_work_center_for_empty_system(self) -> None: + self.assertEqual(self.db.scalar(select(func.count(Department.id))), 0) + self.assertEqual(self.db.scalar(select(func.count(WorkCenter.id))), 0) + + rows = master_data.list_work_centers(limit=100, db=self.db) + + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].center_code, "WC-DEFAULT") + self.assertEqual(rows[0].center_name, "系统默认工作中心") + self.assertEqual(rows[0].dept_name, "总公司") + self.assertEqual(self.db.scalar(select(func.count(Department.id))), 1) + self.assertEqual(self.db.scalar(select(func.count(WorkCenter.id))), 1) diff --git a/frontend/src/views/ProductSpecLiteView.test.js b/frontend/src/views/ProductSpecLiteView.test.js new file mode 100644 index 0000000..8bf2dcc --- /dev/null +++ b/frontend/src/views/ProductSpecLiteView.test.js @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const source = readFileSync(join(__dirname, "ProductSpecLiteView.vue"), "utf8"); + +describe("ProductSpecLiteView operation foundation loading", () => { + it("loads process and work-center master data sequentially so backend default seeding can settle", () => { + assert.match(source, /const processRows = await fetchResource\("\/master-data\/processes", \[\]\);/); + assert.match(source, /const centerRows = await fetchResource\("\/master-data\/work-centers", \[\]\);/); + assert.ok( + source.indexOf('fetchResource("/master-data/processes", [])') < + source.indexOf('fetchResource("/master-data/work-centers", [])') + ); + }); + + it("prepares route operations before creating a new product so validation cannot leave partial records", () => { + assert.match(source, /async function ensureOperationFoundationReady\(\)/); + assert.match(source, /await ensureOperationFoundationReady\(\);/); + assert.match(source, /const preparedRouteOperations = buildRouteOperationsPayload\(processRowsForSave\);/); + assert.ok( + source.indexOf("const preparedRouteOperations = buildRouteOperationsPayload(processRowsForSave);") < + source.indexOf('await postResource("/master-data/products", productPayload)') + ); + }); + + it("cleans newly created product-spec records if a later save step fails", () => { + assert.match(source, /let createdProductItemId = null;/); + assert.match(source, /const createdMiniappPayloads = \[\];/); + assert.match(source, /createdProductItemId = productItemId;/); + assert.match(source, /createdMiniappPayloads\.push\(payload\);/); + assert.match(source, /await deleteResource\(buildMiniappDeletePath\(payload\)\)\.catch\(\(\) => null\);/); + assert.match(source, /await deleteResource\(`\/master-data\/product-specs\/\$\{createdProductItemId\}`\)\.catch\(\(\) => null\);/); + }); + + it("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, /
| 产品编码 | @@ -73,6 +90,7 @@
|---|