From 6dd9529a168420d194707f884382c097d4111874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=A4=E5=AD=A6=E4=BC=9A?= Date: Mon, 15 Jun 2026 10:51:54 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=A7=E5=93=81=E9=9C=80?= =?UTF-8?q?=E8=A7=84=E4=BF=9D=E5=AD=98=E4=B8=8E=E8=A1=A8=E6=A0=BC=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/routes/master_data.py | 83 ++++++ ...aster_data_default_operation_foundation.py | 59 +++++ .../src/views/ProductSpecLiteView.test.js | 47 ++++ frontend/src/views/ProductSpecLiteView.vue | 245 +++++++++++++++++- 4 files changed, 422 insertions(+), 12 deletions(-) create mode 100644 backend/tests/test_master_data_default_operation_foundation.py create mode 100644 frontend/src/views/ProductSpecLiteView.test.js 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, /[\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;/); + }); +}); diff --git a/frontend/src/views/ProductSpecLiteView.vue b/frontend/src/views/ProductSpecLiteView.vue index 65bca0b..318b81d 100644 --- a/frontend/src/views/ProductSpecLiteView.vue +++ b/frontend/src/views/ProductSpecLiteView.vue @@ -23,8 +23,25 @@ placeholder="搜索考勤点、产品编码、名称、版本、原材料、小程序工序" /> -
- +
+
+ + + + + + + + + + + + + + + + + @@ -73,6 +90,7 @@
产品编码
- + buildAutoRouteOperation(processRow, index)); +} + +function buildRoutePayload(product, processRowsForSave, preparedOperations = null) { + const productItemId = product.item_id; return { route_name: `${form.item_name} 工艺路线`, product_item_id: productItemId, version_no: form.version_no, is_default: true, status: "ACTIVE", - operations: processRows.map((processRow, index) => buildAutoRouteOperation(processRow, index)) + operations: preparedOperations || buildRouteOperationsPayload(processRowsForSave) }; } @@ -931,16 +954,16 @@ watch(calculatedLossRate, (lossRate) => { }, { immediate: true }); async function fetchMasterData() { - const [productRows, bomRows, routeRows, materialRows, miniappProductRows, pointRows, processRows, centerRows] = await Promise.all([ + const [productRows, bomRows, routeRows, materialRows, miniappProductRows, pointRows] = await Promise.all([ fetchResource("/master-data/products?limit=200", []), fetchResource("/master-data/boms?limit=500", []), fetchResource("/master-data/process-routes?limit=500", []), fetchResource("/master-data/materials?limit=200", []), fetchResource("/master-data/miniapp-products?limit=1000", []), - fetchResource("/master-data/miniapp-attendance-points", []), - fetchResource("/master-data/processes", []), - fetchResource("/master-data/work-centers", []) + fetchResource("/master-data/miniapp-attendance-points", []) ]); + const processRows = await fetchResource("/master-data/processes", []); + const centerRows = await fetchResource("/master-data/work-centers", []); products.value = productRows; boms.value = bomRows; routes.value = routeRows; @@ -951,6 +974,18 @@ async function fetchMasterData() { workCenters.value = centerRows; } +async function ensureOperationFoundationReady() { + if (!processes.value.length) { + processes.value = await fetchResource("/master-data/processes", []); + } + if (!workCenters.value.length) { + workCenters.value = await fetchResource("/master-data/work-centers", []); + } + if (!processes.value.length || !workCenters.value.length) { + throw new Error("系统无法准备默认工艺基础资料,请稍后重试或联系管理员"); + } +} + async function syncMiniappInBackground() { if (miniappSyncing.value) { return; @@ -977,8 +1012,12 @@ async function submitSpec() { feedbackMessage.value = ""; errorMessage.value = ""; submitting.value = true; + let createdProductItemId = null; + const createdMiniappPayloads = []; try { const processRowsForSave = normalizeProcessFormRowsForSave(); + await ensureOperationFoundationReady(); + const preparedRouteOperations = buildRouteOperationsPayload(processRowsForSave); const productPayload = { item_name: form.item_name, specification: editingRow.value?.specification || null, @@ -997,6 +1036,9 @@ async function submitSpec() { ? await putResource(`/master-data/products/${editingRow.value.item_id}`, productPayload) : await postResource("/master-data/products", productPayload); const productItemId = product.item_id; + if (!shouldUpdateErpProduct) { + createdProductItemId = productItemId; + } const canUpdateExistingSpec = editingRow.value && Number(editingRow.value.item_id) === Number(productItemId); if (canUpdateExistingSpec && editingRow.value.bom?.bom_id) { await putResource(`/master-data/boms/${editingRow.value.bom.bom_id}`, buildBomPayload(productItemId)); @@ -1004,19 +1046,27 @@ async function submitSpec() { await postResource("/master-data/boms", buildBomPayload(productItemId)); } if (canUpdateExistingSpec && editingRow.value.route?.route_id) { - await putResource(`/master-data/process-routes/${editingRow.value.route.route_id}`, buildRoutePayload(product, processRowsForSave)); + await putResource(`/master-data/process-routes/${editingRow.value.route.route_id}`, buildRoutePayload(product, processRowsForSave, preparedRouteOperations)); } else { - await postResource("/master-data/process-routes", buildRoutePayload(product, processRowsForSave)); + await postResource("/master-data/process-routes", buildRoutePayload(product, processRowsForSave, preparedRouteOperations)); } const miniappPayloads = buildMiniappProductPayloads(product, processRowsForSave); for (const payload of miniappPayloads) { await postResource("/master-data/miniapp-products?sync=false", payload); + createdMiniappPayloads.push(payload); } await postResource("/master-data/miniapp-materials/sync?force=true", {}); feedbackMessage.value = `产品需规 ${product.item_code} 已保存`; drawerOpen.value = false; await loadAll(); } catch (error) { + if (createdProductItemId) { + for (const payload of createdMiniappPayloads) { + await deleteResource(buildMiniappDeletePath(payload)).catch(() => null); + } + await deleteResource(`/master-data/product-specs/${createdProductItemId}`).catch(() => null); + await fetchMasterData().catch(() => null); + } errorMessage.value = error.message || "产品需规保存失败"; } finally { submitting.value = false; @@ -1038,6 +1088,177 @@ onActivated(async () => {