From c67f99bea43f79e24fb0a1bfa8c22cee7fcf4041 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 07:05:05 +0000 Subject: [PATCH 1/2] Expose outer cylinders as review-only process skeletons Co-authored-by: vbarter --- backend/cncflow_core/inquiries/api.py | 92 +++++++++++++++++---- backend/cncflow_core/quoting/engine.py | 10 +++ backend/tests/test_hole_recognition.py | 20 ++++- backend/tests/test_llm_features.py | 10 ++- backend/tests/test_pm_api_contract.py | 24 ++++-- backend/tests/test_quote_engine.py | 7 ++ frontend/src/components/FeatureReview.tsx | 54 +++++++++++- frontend/tests/featureReviewChrome.test.tsx | 40 +++++++-- 8 files changed, 225 insertions(+), 32 deletions(-) diff --git a/backend/cncflow_core/inquiries/api.py b/backend/cncflow_core/inquiries/api.py index 6d77054..a4026db 100644 --- a/backend/cncflow_core/inquiries/api.py +++ b/backend/cncflow_core/inquiries/api.py @@ -220,6 +220,7 @@ def _surface_for_pipeline(feat, fid): _FEATURE_DIMENSION_FIELDS = { "hole": {"diameter_mm", "depth_mm"}, + "outer_cylinder": {"diameter_mm", "depth_mm", "length"}, "thread": {"diameter_mm", "thread_length"}, "slot": {"length", "width", "depth"}, "pocket": {"length", "width", "depth"}, @@ -335,12 +336,20 @@ def _apply_feature_overrides(features, overrides): feature.update(values) feature_type = str(feature.get("type") or "").lower() pose = dict(feature.get("pose") or {}) - if pose and feature_type in {"hole", "thread"}: + if pose and feature_type in {"hole", "outer_cylinder", "thread"}: if "diameter_mm" in values: pose["diameter_mm"] = values["diameter_mm"] - length_key = "depth_mm" if feature_type == "hole" else "thread_length" - if length_key in values: - pose["length_mm"] = values[length_key] + length_keys = { + "hole": ("depth_mm",), + "outer_cylinder": ("depth_mm", "length"), + "thread": ("thread_length",), + }[feature_type] + length_value = next( + (values[key] for key in length_keys if key in values), + None, + ) + if length_value is not None: + pose["length_mm"] = length_value feature["pose"] = pose if feature_type == "hole" and ( "diameter_mm" in values or "depth_mm" in values @@ -369,6 +378,8 @@ def _review_and_quote_features(parsed_feats, selected_ids, L, W, H=0): if selected is None and feat.get("selected") is False: on = False item = {**feat, "feature_id": fid, "selected": on} + if item.get("type") == "outer_cylinder": + item = _outer_cylinder_review_feature(item) review.append(item) if not on: continue @@ -402,6 +413,7 @@ def _review_and_quote_features(parsed_feats, selected_ids, L, W, H=0): _REVIEW_FEATURE_TYPES = { "hole", + "outer_cylinder", "face", "pocket", "slot", @@ -411,18 +423,68 @@ def _review_and_quote_features(parsed_feats, selected_ids, L, W, H=0): } +_OUTER_CYLINDER_GAPS = [ + "缺少车削 Vc/f/ap 参数表", + "缺少径向余量表", +] + + +def _outer_cylinder_review_feature(feature): + """外圆只暴露已冻结工艺意图;缺表时不生成工时或金额。""" + item = dict(feature) + fid = str(item.get("feature_id") or item.get("id") or "outer-cylinder") + item.update({ + "quote_status": "待手册公式", + "quote_excluded": True, + "amount_contribution": 0, + "gaps": list(_OUTER_CYLINDER_GAPS), + "process_chain": [ + { + "step_id": f"{fid}:rough_turn_outer_cylinder:1", + "order": 1, + "feature_id": fid, + "process": "rough_turn_outer_cylinder", + "name": "粗车外圆", + "status": "待手册公式", + "minutes": None, + "amount": 0, + "quote_excluded": True, + }, + { + "step_id": f"{fid}:finish_turn_outer_cylinder:1", + "order": 2, + "feature_id": fid, + "process": "finish_turn_outer_cylinder", + "name": "精车外圆", + "status": "待手册公式", + "minutes": None, + "amount": 0, + "quote_excluded": True, + }, + ], + }) + return item + + def _sanitize_review_features(features): - """Only handbook-covered features with live quote mappings enter review payloads.""" - return [ - feature - for feature in features or [] - if isinstance(feature, dict) - and str(feature.get("type") or "").lower() in _REVIEW_FEATURE_TYPES - and feature.get("subtype") not in {"cylindrical_candidate", "planar_region"} - and feature.get("type") != "pocket_or_step" - and not str(feature.get("feature_id") or feature.get("id") or "").startswith("cylinder-") - and not str(feature.get("feature_id") or feature.get("id") or "").startswith("prismatic-region-") - ] + """保留已覆盖特征;外圆只带待公式审查骨架,不进入自动报价。""" + sanitized = [] + for feature in features or []: + if ( + not isinstance(feature, dict) + or str(feature.get("type") or "").lower() not in _REVIEW_FEATURE_TYPES + or feature.get("subtype") in {"cylindrical_candidate", "planar_region"} + or feature.get("type") == "pocket_or_step" + or str(feature.get("feature_id") or feature.get("id") or "").startswith("cylinder-") + or str(feature.get("feature_id") or feature.get("id") or "").startswith("prismatic-region-") + ): + continue + sanitized.append( + _outer_cylinder_review_feature(feature) + if str(feature.get("type") or "").lower() == "outer_cylinder" + else feature + ) + return sanitized def _stored_parse_result(conn, part): diff --git a/backend/cncflow_core/quoting/engine.py b/backend/cncflow_core/quoting/engine.py index e4b0fd3..cff71bd 100644 --- a/backend/cncflow_core/quoting/engine.py +++ b/backend/cncflow_core/quoting/engine.py @@ -380,6 +380,16 @@ def quote(payload: dict, conn, rules_version: str = "") -> dict: settings = factory["settings"] raw_features = payload.get("features") features = list(raw_features) if isinstance(raw_features, list) else [] + # Freeze B:外圆仅有工艺意图骨架。缺 Vc/f/ap 与径向余量表时,必须在 + # 设备、夹具、编程、工时和金额计算前排除,避免“无切削时间但总价漂移”。 + features = [ + feature + for feature in features + if not ( + isinstance(feature, dict) + and str(feature.get("type") or "").lower() == "outer_cylinder" + ) + ] slide = slider.resolve(payload.get("slider") or "标准", material, features) stock = payload.get("blank_type") or payload.get("stock_type") or settings.get("blank_type") or "板料" is_bar = stock in {"棒料", "棒", "bar"} diff --git a/backend/tests/test_hole_recognition.py b/backend/tests/test_hole_recognition.py index 58c5c84..8cdc4d6 100644 --- a/backend/tests/test_hole_recognition.py +++ b/backend/tests/test_hole_recognition.py @@ -86,17 +86,33 @@ def test_map_recognized_hole_to_pipeline_fields(): assert holes[0]["surface"] == "side" -def test_outer_cylinder_not_quoted(): +def test_outer_cylinder_has_review_skeleton_but_is_not_quoted(): feats = [ {"type": "outer_cylinder", "feature_id": "od-1", "selected": False, "diameter_mm": 40, "depth_mm": 12}, {"type": "hole", "feature_id": "hole-0", "selected": True, "diameter_mm": 6, "depth_mm": 12, "hole_type": "through", "position_type": "垂直"}, ] - _, features = _review_and_quote_features(feats, None, 80, 60) + review, features = _review_and_quote_features(feats, None, 80, 60) holes = [f for f in features if f["type"] == "hole"] assert len(holes) == 1 assert holes[0]["cut_depth_mm"] == pytest.approx(12 + 0.3 * 6) + outer = next(f for f in review if f["type"] == "outer_cylinder") + assert outer["selected"] is False + assert outer["quote_status"] == "待手册公式" + assert outer["quote_excluded"] is True + assert outer["amount_contribution"] == 0 + assert [step["name"] for step in outer["process_chain"]] == [ + "粗车外圆", + "精车外圆", + ] + assert all(step["minutes"] is None for step in outer["process_chain"]) + assert all(step["amount"] == 0 for step in outer["process_chain"]) + assert outer["gaps"] == [ + "缺少车削 Vc/f/ap 参数表", + "缺少径向余量表", + ] + assert not any(f["type"] == "outer_cylinder" for f in features) def test_raw_cylinder_candidate_never_reaches_review_or_quote(): diff --git a/backend/tests/test_llm_features.py b/backend/tests/test_llm_features.py index 1e0f6c4..0fd0133 100644 --- a/backend/tests/test_llm_features.py +++ b/backend/tests/test_llm_features.py @@ -71,8 +71,13 @@ def test_map_llm_plate_hole_d8_fixture_fields(): def test_map_llm_fixture_review_and_quote_pins(client): features = map_llm_features(_fixture_payload())["features"] review, quoted = _review_and_quote_features(features, None, 80, 60, 12) - assert {feat["feature_id"] for feat in review} == {"hole-0", "face-0"} + assert {feat["feature_id"] for feat in review} == { + "hole-0", "face-0", "od-0", + } assert [feat["type"] for feat in quoted] == ["hole", "face"] + outer = next(feat for feat in review if feat["type"] == "outer_cylinder") + assert outer["quote_excluded"] is True + assert outer["amount_contribution"] == 0 hole = quoted[0] assert hole["cut_depth_mm"] == pytest.approx(14.4) assert hole["hole_type"] == "through" @@ -124,7 +129,7 @@ def test_map_llm_empty_or_garbage_is_visible_failure(): _json_object("not-json") -def test_sanitize_keeps_only_handbook_types_with_live_quote_mappings(): +def test_sanitize_keeps_quote_types_and_review_only_outer_cylinder(): cleaned = _sanitize_review_features([ {"type": "hole", "feature_id": "hole-0"}, {"type": "face", "feature_id": "face-0"}, @@ -148,6 +153,7 @@ def test_sanitize_keeps_only_handbook_types_with_live_quote_mappings(): "thread-0", "surface-0", "step-0", + "od-0", ] diff --git a/backend/tests/test_pm_api_contract.py b/backend/tests/test_pm_api_contract.py index ecd893b..3efc009 100644 --- a/backend/tests/test_pm_api_contract.py +++ b/backend/tests/test_pm_api_contract.py @@ -88,11 +88,16 @@ def test_pm_new_quote_through_hole_contract(client, seeded_db_path): assert by_id["hole-0"]["position_type"] == "垂直" assert by_id["hole-0"]["diameter_mm"] == 8 assert by_id["hole-0"]["depth_mm"] == 12 - assert not any(f.get("type") == "outer_cylinder" for f in review) - assert not any( - str(f.get("feature_id") or "").startswith("od-") - for f in review - ) + outer = by_id["od-1"] + assert outer["type"] == "outer_cylinder" + assert outer["selected"] is False + assert outer["quote_excluded"] is True + assert outer["amount_contribution"] == 0 + assert [step["name"] for step in outer["process_chain"]] == [ + "粗车外圆", + "精车外圆", + ] + assert outer["gaps"] plans = (part["quote"] or {}).get("features") or [] hole_plans = [p for p in plans if p.get("type") == "hole"] @@ -108,7 +113,14 @@ def test_pm_new_quote_through_hole_contract(client, seeded_db_path): seq = (part["quote"] or {}).get("process_sequence") or [] assert seq - assert not any("od-1" in str(s) for s in seq) + assert not any( + step.get("feature_id") == "od-1" + or step.get("process") in { + "rough_turn_outer_cylinder", + "finish_turn_outer_cylinder", + } + for step in seq + ) patched = client.patch(f"/api/v1/parts/{pid}", json={ "material": "SUS304", "tolerance_it": 7, "roughness_ra": 1.6, diff --git a/backend/tests/test_quote_engine.py b/backend/tests/test_quote_engine.py index d54200c..5f0e752 100644 --- a/backend/tests/test_quote_engine.py +++ b/backend/tests/test_quote_engine.py @@ -33,6 +33,13 @@ def quote(client, payload): "length": 80, "width": 60, }, + { + "type": "outer_cylinder", + "feature_id": "od-0", + "selected": True, + "diameter_mm": 80, + "depth_mm": 12, + }, ], 56_997, ["面粗", "钻孔", "倒角"], diff --git a/frontend/src/components/FeatureReview.tsx b/frontend/src/components/FeatureReview.tsx index 608ea75..c1213f5 100644 --- a/frontend/src/components/FeatureReview.tsx +++ b/frontend/src/components/FeatureReview.tsx @@ -40,9 +40,10 @@ const CANVAS_GL = { localClippingEnabled: true, } -/** 手册已覆盖且现网已有报价映射的审查特征。 */ +/** 可审查特征;外圆仅展示待手册公式骨架,不参与报价。 */ const REVIEW_TREE_FEATURE_TYPES = new Set([ "hole", + "outer_cylinder", "face", "pocket", "slot", @@ -53,6 +54,7 @@ const REVIEW_TREE_FEATURE_TYPES = new Set([ const FEATURE_LABEL: Record = { hole: "孔", + outer_cylinder: "外圆", face: "面", pocket: "型腔", slot: "槽", @@ -92,6 +94,13 @@ export function featureTreeTitle(feature: Feat): string { ].filter(Boolean).join("×") return details(size, holeLabel(feature?.hole_type || dim.hole_type)) } + if (type === "outer_cylinder") { + const size = [ + dimension("Ø", feature?.pose?.diameter_mm, feature?.diameter_mm, dim.diameter_mm), + dimension("H", feature?.pose?.length_mm, feature?.depth_mm, feature?.length, dim.depth_mm, dim.length), + ].filter(Boolean).join("×") + return details(size) + } if (type === "thread") { const size = [ dimension("M", feature?.diameter_mm, feature?.nominal_d, dim.diameter_mm), @@ -186,7 +195,15 @@ function poseOf(f: Feat): Pose | null { if (t === "hole" || t === "thread" || t === "outer_cylinder") { if (!origin) return null const diameter = num(f.pose?.diameter_mm, f.diameter_mm, f.nominal_d, dim.diameter_mm) || 1 - const length = num(f.pose?.length_mm, f.depth_mm, f.thread_length, dim.thread_length, dim.depth_mm) || 1 + const length = num( + f.pose?.length_mm, + f.depth_mm, + f.length, + f.thread_length, + dim.length, + dim.thread_length, + dim.depth_mm, + ) || 1 return { kind: "cyl", origin, @@ -701,6 +718,8 @@ function inspectorFields(f: Feat) { ? f.thread_length ?? dim.thread_length ?? f.depth_mm ?? dim.depth_mm : type === "hole" ? f.depth_mm ?? dim.depth_mm + : type === "outer_cylinder" + ? f.depth_mm ?? dim.depth_mm ?? f.length ?? dim.length : type === "step" ? f.height ?? dim.height ?? f.depth ?? dim.depth ?? f.depth_mm ?? dim.depth_mm : f.depth ?? dim.depth ?? f.height ?? dim.height ?? f.depth_mm ?? dim.depth_mm @@ -728,6 +747,14 @@ function editableDimensions(f: Feat): DimensionField[] { { key: "diameter_mm", label: "D", value: fields.d, prefix: "Ø" }, { key: "depth_mm", label: "H", value: fields.h }, ] + if (type === "outer_cylinder") return [ + { key: "diameter_mm", label: "D", value: fields.d, prefix: "Ø" }, + { + key: f.depth_mm != null || dim.depth_mm != null ? "depth_mm" : "length", + label: "H", + value: fields.h, + }, + ] if (type === "thread") return [ { key: "diameter_mm", label: "D", value: fields.d, prefix: "Ø" }, { key: "thread_length", label: "H", value: fields.h }, @@ -879,6 +906,10 @@ export function FeatureReview({ () => processSequence.filter((step) => step.feature_id === picked), [processSequence, picked], ) + const pendingSteps = selectedSteps.length + ? [] + : (Array.isArray(selected?.process_chain) ? selected.process_chain : []) + const processGaps = Array.isArray(selected?.gaps) ? selected.gaps : [] const onBox = useCallback((b: THREE.Box3) => setBox(b.clone()), []) const requestView = useCallback((nextView: ViewName) => { setView(nextView) @@ -1145,6 +1176,25 @@ export function FeatureReview({ ))} + ) : pendingSteps.length ? ( +
+ {pendingSteps.map((step: any) => ( +
+
+ {processName(step)} + 待手册公式 +
+
+ ))} +
+
待手册公式 · 不计入报价
+ {processGaps.length > 0 && ( +
    + {processGaps.map((gap: string) =>
  • {gap}
  • )} +
+ )} +
+
) : (
该特征暂无匹配工序
)} diff --git a/frontend/tests/featureReviewChrome.test.tsx b/frontend/tests/featureReviewChrome.test.tsx index 87f39f5..67f6ed9 100644 --- a/frontend/tests/featureReviewChrome.test.tsx +++ b/frontend/tests/featureReviewChrome.test.tsx @@ -149,15 +149,24 @@ test("特征树标题以中文类型和关键尺寸为主", () => { width: 18, height: 5, }), "台阶 · L30×W18×H5") + assert.equal(featureTreeTitle({ + type: "outer_cylinder", + diameter_mm: 50, + depth_mm: 24, + }), "外圆 · Ø50×H24") + assert.equal(featureTreeTitle({ + type: "outer_cylinder", + dimensions: { diameter_mm: 32, length: 18 }, + }), "外圆 · Ø32×H18") }) -test("特征树只显示手册已覆盖且已有报价映射的七类特征", () => { - const allowed = ["hole", "face", "pocket", "slot", "thread", "surface", "step"] +test("特征树显示七类报价特征和仅供审查的外圆", () => { + const allowed = ["hole", "outer_cylinder", "face", "pocket", "slot", "thread", "surface", "step"] assert.deepEqual( allowed.filter((type) => isReviewTreeFeature({ type, feature_id: `${type}-0` })), allowed, ) - for (const type of ["outer_cylinder", "chamfer", "fillet", "pocket_or_step", "boss", ""]) { + for (const type of ["chamfer", "fillet", "pocket_or_step", "boss", ""]) { assert.equal(isReviewTreeFeature({ type, feature_id: `${type || "unknown"}-0` }), false) } @@ -184,7 +193,17 @@ test("特征树只显示手册已覆盖且已有报价映射的七类特征", () type: "pocket_or_step", subtype: "planar_region", }, - { feature_id: "od-0", type: "outer_cylinder" }, + { + feature_id: "od-0", + type: "outer_cylinder", + diameter_mm: 50, + depth_mm: 24, + process_chain: [ + { step_id: "od-0:rough", order: 1, process: "rough_turn_outer_cylinder", name: "粗车外圆" }, + { step_id: "od-0:finish", order: 2, process: "finish_turn_outer_cylinder", name: "精车外圆" }, + ], + gaps: ["缺少车削 Vc/f/ap 参数表", "缺少径向余量表"], + }, { feature_id: "chamfer-0", type: "chamfer" }, { feature_id: "fillet-0", type: "fillet" }, { feature_id: "boss-0", type: "boss" }, @@ -204,7 +223,18 @@ test("特征树只显示手册已覆盖且已有报价映射的七类特征", () assert.match(tree.textContent || "", /曲面 · 自由曲面 R20/) assert.match(tree.textContent || "", /slot-0/) assert.match(tree.textContent || "", /surface-0/) + assert.match(tree.textContent || "", /外圆 · Ø50×H24/) + assert.match(tree.textContent || "", /od-0/) assert.doesNotMatch(tree.textContent || "", /pocket_or_step/) assert.doesNotMatch(tree.textContent || "", /prismatic-region/) - assert.doesNotMatch(tree.textContent || "", /od-0|outer_cylinder|chamfer|fillet|boss/) + assert.doesNotMatch(tree.textContent || "", /outer_cylinder|chamfer|fillet|boss/) + + fireEvent.click(screen.getByRole("button", { name: /od-0/ })) + const inspector = screen.getByText("特征详细参数").closest("section") + assert.ok(inspector) + assert.match(inspector.textContent || "", /粗车外圆.*待手册公式.*精车外圆.*待手册公式/s) + assert.match(inspector.textContent || "", /缺少车削 Vc\/f\/ap 参数表/) + assert.match(inspector.textContent || "", /缺少径向余量表/) + assert.match(inspector.textContent || "", /不计入报价/) + assert.doesNotMatch(inspector.textContent || "", /暂无匹配工序/) }) From 5ae9b7655225e84d2e64047a0506366030aea81b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 07:06:47 +0000 Subject: [PATCH 2/2] Fix outer cylinder dimension field lookup Co-authored-by: vbarter --- frontend/src/components/FeatureReview.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/components/FeatureReview.tsx b/frontend/src/components/FeatureReview.tsx index c1213f5..a659a50 100644 --- a/frontend/src/components/FeatureReview.tsx +++ b/frontend/src/components/FeatureReview.tsx @@ -743,6 +743,7 @@ type DimensionField = { function editableDimensions(f: Feat): DimensionField[] { const fields = inspectorFields(f) const type = featType(f) + const dim = f.dimensions || {} if (type === "hole") return [ { key: "diameter_mm", label: "D", value: fields.d, prefix: "Ø" }, { key: "depth_mm", label: "H", value: fields.h },