修复产品需规保存与表格布局问题

This commit is contained in:
汤学会 2026-06-15 10:51:54 +08:00
parent 867fc3beee
commit b024b061e9
4 changed files with 422 additions and 12 deletions

View File

@ -88,6 +88,87 @@ from app.services.system_permissions import list_employee_options
router = APIRouter(dependencies=[Depends(require_authenticated_user)]) router = APIRouter(dependencies=[Depends(require_authenticated_user)])
DEFAULT_PROCESS_CODE = "PROC-DEFAULT"
DEFAULT_PROCESS_NAME = "系统默认工序"
DEFAULT_WORK_CENTER_CODE = "WC-DEFAULT"
DEFAULT_WORK_CENTER_NAME = "系统默认工作中心"
DEFAULT_ORG_ROOT_CODE = "ORG_ROOT"
def _default_operation_department(db: Session) -> Department:
department = db.scalar(select(Department).where(Department.dept_code == DEFAULT_ORG_ROOT_CODE).limit(1))
if department:
return department
department = db.scalar(select(Department).order_by(Department.id.asc()).limit(1))
if department:
return department
now = datetime.now()
department = Department(
dept_code=DEFAULT_ORG_ROOT_CODE,
dept_name="总公司",
parent_id=None,
org_node_type="COMPANY",
dept_type="ADMIN",
manager_name=None,
manager_employee_id=None,
status="ACTIVE",
sort_no=0,
remark="系统自动创建的默认组织根节点",
created_at=now,
updated_at=now,
)
db.add(department)
db.flush()
return department
def _ensure_default_operation_foundation(db: Session) -> None:
changed = False
now = datetime.now()
process_exists = db.scalar(select(Process.id).limit(1))
if not process_exists:
db.add(
Process(
process_code=DEFAULT_PROCESS_CODE,
process_name=DEFAULT_PROCESS_NAME,
process_type="INTERNAL",
is_report_required=1,
is_quality_gate=0,
status="ACTIVE",
remark="系统自动创建,用于产品需规快速建档时生成默认工艺路线",
created_at=now,
updated_at=now,
)
)
changed = True
work_center_exists = db.scalar(select(WorkCenter.id).limit(1))
if not work_center_exists:
department = _default_operation_department(db)
db.add(
WorkCenter(
center_code=DEFAULT_WORK_CENTER_CODE,
center_name=DEFAULT_WORK_CENTER_NAME,
dept_id=department.id,
center_type="INTERNAL",
shift_pattern="默认班次",
capacity_hours_per_day=Decimal("8.00"),
status="ACTIVE",
remark="系统自动创建,用于产品需规快速建档时生成默认工艺路线",
created_at=now,
updated_at=now,
)
)
changed = True
if changed:
try:
db.commit()
except IntegrityError:
db.rollback()
MATERIAL_EXCEL_HEADERS = ["原材料编码", "原材料名称", "材质", "规格", "废料单价", "性能要求", "状态"] MATERIAL_EXCEL_HEADERS = ["原材料编码", "原材料名称", "材质", "规格", "废料单价", "性能要求", "状态"]
MATERIAL_EXCEL_REQUIRED_HEADERS = ["原材料名称", "材质", "规格"] MATERIAL_EXCEL_REQUIRED_HEADERS = ["原材料名称", "材质", "规格"]
MATERIAL_STATUS_LABELS = { MATERIAL_STATUS_LABELS = {
@ -597,6 +678,7 @@ def list_work_centers(
limit: int = Query(default=100, ge=1, le=500), limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> list[WorkCenterRead]: ) -> list[WorkCenterRead]:
_ensure_default_operation_foundation(db)
stmt = ( stmt = (
select( select(
WorkCenter.id.label("id"), WorkCenter.id.label("id"),
@ -766,6 +848,7 @@ def list_processes(
limit: int = Query(default=100, ge=1, le=500), limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> list[ProcessRead]: ) -> list[ProcessRead]:
_ensure_default_operation_foundation(db)
rows = db.execute( rows = db.execute(
select( select(
Process.id.label("process_id"), Process.id.label("process_id"),

View File

@ -0,0 +1,59 @@
from __future__ import annotations
import unittest
from sqlalchemy import BigInteger, create_engine, func, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
@compiles(BigInteger, "sqlite")
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
_ = type_, compiler, kw
return "INTEGER"
import app.models.master_data # noqa: E402,F401
import app.models.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.api.routes import master_data # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.operations import Process, WorkCenter # noqa: E402
from app.models.org import Department # noqa: E402
class MasterDataDefaultOperationFoundationTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
self.db: Session = self.SessionLocal()
def tearDown(self) -> None:
self.db.close()
def test_list_processes_seeds_default_process_for_empty_system(self) -> None:
self.assertEqual(self.db.scalar(select(func.count(Process.id))), 0)
rows = master_data.list_processes(limit=100, db=self.db)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].process_code, "PROC-DEFAULT")
self.assertEqual(rows[0].process_name, "系统默认工序")
self.assertEqual(self.db.scalar(select(func.count(Process.id))), 1)
def test_list_work_centers_seeds_default_work_center_for_empty_system(self) -> None:
self.assertEqual(self.db.scalar(select(func.count(Department.id))), 0)
self.assertEqual(self.db.scalar(select(func.count(WorkCenter.id))), 0)
rows = master_data.list_work_centers(limit=100, db=self.db)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].center_code, "WC-DEFAULT")
self.assertEqual(rows[0].center_name, "系统默认工作中心")
self.assertEqual(rows[0].dept_name, "总公司")
self.assertEqual(self.db.scalar(select(func.count(Department.id))), 1)
self.assertEqual(self.db.scalar(select(func.count(WorkCenter.id))), 1)

View File

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

View File

@ -23,8 +23,25 @@
placeholder="搜索考勤点、产品编码、名称、版本、原材料、小程序工序" placeholder="搜索考勤点、产品编码、名称、版本、原材料、小程序工序"
/> />
<div class="table-wrap"> <div class="table-wrap product-spec-table-wrap">
<table class="data-table compact-table"> <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> <thead>
<tr> <tr>
<th>产品编码</th> <th>产品编码</th>
@ -73,6 +90,7 @@
<td class="action-row"> <td class="action-row">
<button <button
class="ghost-button" class="ghost-button"
data-semantic-label="编辑"
type="button" type="button"
:disabled="!canModifySpec(row)" :disabled="!canModifySpec(row)"
:title="canModifySpec(row) ? '编辑' : '已停用的产品需规只读,不允许修改'" :title="canModifySpec(row) ? '编辑' : '已停用的产品需规只读,不允许修改'"
@ -82,6 +100,7 @@
</button> </button>
<button <button
class="ghost-button danger-ghost" class="ghost-button danger-ghost"
data-semantic-label="删除"
type="button" type="button"
:disabled="!canModifySpec(row)" :disabled="!canModifySpec(row)"
:title="canModifySpec(row) ? '删除' : '已停用的产品需规只读,不允许删除'" :title="canModifySpec(row) ? '删除' : '已停用的产品需规只读,不允许删除'"
@ -97,7 +116,7 @@
</tbody> </tbody>
</table> </table>
</div> </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> </section>
<FormDrawer <FormDrawer
@ -856,19 +875,23 @@ function buildAutoRouteOperation(processRow, index) {
}; };
} }
function buildRoutePayload(product, processRowsForSave) { function buildRouteOperationsPayload(processRowsForSave) {
const productItemId = product.item_id;
const processRows = buildProcessGroupForRoute(processRowsForSave); const processRows = buildProcessGroupForRoute(processRowsForSave);
if (!processRows.length) { if (!processRows.length) {
throw new Error("请至少维护一道小程序工序"); throw new Error("请至少维护一道小程序工序");
} }
return processRows.map((processRow, index) => buildAutoRouteOperation(processRow, index));
}
function buildRoutePayload(product, processRowsForSave, preparedOperations = null) {
const productItemId = product.item_id;
return { return {
route_name: `${form.item_name} 工艺路线`, route_name: `${form.item_name} 工艺路线`,
product_item_id: productItemId, product_item_id: productItemId,
version_no: form.version_no, version_no: form.version_no,
is_default: true, is_default: true,
status: "ACTIVE", status: "ACTIVE",
operations: processRows.map((processRow, index) => buildAutoRouteOperation(processRow, index)) operations: preparedOperations || buildRouteOperationsPayload(processRowsForSave)
}; };
} }
@ -931,16 +954,16 @@ watch(calculatedLossRate, (lossRate) => {
}, { immediate: true }); }, { immediate: true });
async function fetchMasterData() { async function fetchMasterData() {
const [productRows, bomRows, routeRows, materialRows, miniappProductRows, pointRows, processRows, centerRows] = await Promise.all([ const [productRows, bomRows, routeRows, materialRows, miniappProductRows, pointRows] = await Promise.all([
fetchResource("/master-data/products?limit=200", []), fetchResource("/master-data/products?limit=200", []),
fetchResource("/master-data/boms?limit=500", []), fetchResource("/master-data/boms?limit=500", []),
fetchResource("/master-data/process-routes?limit=500", []), fetchResource("/master-data/process-routes?limit=500", []),
fetchResource("/master-data/materials?limit=200", []), fetchResource("/master-data/materials?limit=200", []),
fetchResource("/master-data/miniapp-products?limit=1000", []), fetchResource("/master-data/miniapp-products?limit=1000", []),
fetchResource("/master-data/miniapp-attendance-points", []), fetchResource("/master-data/miniapp-attendance-points", [])
fetchResource("/master-data/processes", []),
fetchResource("/master-data/work-centers", [])
]); ]);
const processRows = await fetchResource("/master-data/processes", []);
const centerRows = await fetchResource("/master-data/work-centers", []);
products.value = productRows; products.value = productRows;
boms.value = bomRows; boms.value = bomRows;
routes.value = routeRows; routes.value = routeRows;
@ -951,6 +974,18 @@ async function fetchMasterData() {
workCenters.value = centerRows; workCenters.value = centerRows;
} }
async function ensureOperationFoundationReady() {
if (!processes.value.length) {
processes.value = await fetchResource("/master-data/processes", []);
}
if (!workCenters.value.length) {
workCenters.value = await fetchResource("/master-data/work-centers", []);
}
if (!processes.value.length || !workCenters.value.length) {
throw new Error("系统无法准备默认工艺基础资料,请稍后重试或联系管理员");
}
}
async function syncMiniappInBackground() { async function syncMiniappInBackground() {
if (miniappSyncing.value) { if (miniappSyncing.value) {
return; return;
@ -977,8 +1012,12 @@ async function submitSpec() {
feedbackMessage.value = ""; feedbackMessage.value = "";
errorMessage.value = ""; errorMessage.value = "";
submitting.value = true; submitting.value = true;
let createdProductItemId = null;
const createdMiniappPayloads = [];
try { try {
const processRowsForSave = normalizeProcessFormRowsForSave(); const processRowsForSave = normalizeProcessFormRowsForSave();
await ensureOperationFoundationReady();
const preparedRouteOperations = buildRouteOperationsPayload(processRowsForSave);
const productPayload = { const productPayload = {
item_name: form.item_name, item_name: form.item_name,
specification: editingRow.value?.specification || null, specification: editingRow.value?.specification || null,
@ -997,6 +1036,9 @@ async function submitSpec() {
? await putResource(`/master-data/products/${editingRow.value.item_id}`, productPayload) ? await putResource(`/master-data/products/${editingRow.value.item_id}`, productPayload)
: await postResource("/master-data/products", productPayload); : await postResource("/master-data/products", productPayload);
const productItemId = product.item_id; const productItemId = product.item_id;
if (!shouldUpdateErpProduct) {
createdProductItemId = productItemId;
}
const canUpdateExistingSpec = editingRow.value && Number(editingRow.value.item_id) === Number(productItemId); const canUpdateExistingSpec = editingRow.value && Number(editingRow.value.item_id) === Number(productItemId);
if (canUpdateExistingSpec && editingRow.value.bom?.bom_id) { if (canUpdateExistingSpec && editingRow.value.bom?.bom_id) {
await putResource(`/master-data/boms/${editingRow.value.bom.bom_id}`, buildBomPayload(productItemId)); await putResource(`/master-data/boms/${editingRow.value.bom.bom_id}`, buildBomPayload(productItemId));
@ -1004,19 +1046,27 @@ async function submitSpec() {
await postResource("/master-data/boms", buildBomPayload(productItemId)); await postResource("/master-data/boms", buildBomPayload(productItemId));
} }
if (canUpdateExistingSpec && editingRow.value.route?.route_id) { if (canUpdateExistingSpec && editingRow.value.route?.route_id) {
await putResource(`/master-data/process-routes/${editingRow.value.route.route_id}`, buildRoutePayload(product, processRowsForSave)); await putResource(`/master-data/process-routes/${editingRow.value.route.route_id}`, buildRoutePayload(product, processRowsForSave, preparedRouteOperations));
} else { } else {
await postResource("/master-data/process-routes", buildRoutePayload(product, processRowsForSave)); await postResource("/master-data/process-routes", buildRoutePayload(product, processRowsForSave, preparedRouteOperations));
} }
const miniappPayloads = buildMiniappProductPayloads(product, processRowsForSave); const miniappPayloads = buildMiniappProductPayloads(product, processRowsForSave);
for (const payload of miniappPayloads) { for (const payload of miniappPayloads) {
await postResource("/master-data/miniapp-products?sync=false", payload); await postResource("/master-data/miniapp-products?sync=false", payload);
createdMiniappPayloads.push(payload);
} }
await postResource("/master-data/miniapp-materials/sync?force=true", {}); await postResource("/master-data/miniapp-materials/sync?force=true", {});
feedbackMessage.value = `产品需规 ${product.item_code} 已保存`; feedbackMessage.value = `产品需规 ${product.item_code} 已保存`;
drawerOpen.value = false; drawerOpen.value = false;
await loadAll(); await loadAll();
} catch (error) { } catch (error) {
if (createdProductItemId) {
for (const payload of createdMiniappPayloads) {
await deleteResource(buildMiniappDeletePath(payload)).catch(() => null);
}
await deleteResource(`/master-data/product-specs/${createdProductItemId}`).catch(() => null);
await fetchMasterData().catch(() => null);
}
errorMessage.value = error.message || "产品需规保存失败"; errorMessage.value = error.message || "产品需规保存失败";
} finally { } finally {
submitting.value = false; submitting.value = false;
@ -1038,6 +1088,177 @@ onActivated(async () => {
</script> </script>
<style scoped> <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 { .process-field-list {
display: grid; display: grid;
gap: 10px; gap: 10px;