Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 77 additions & 15 deletions backend/cncflow_core/inquiries/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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):
Expand Down
10 changes: 10 additions & 0 deletions backend/cncflow_core/quoting/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
20 changes: 18 additions & 2 deletions backend/tests/test_hole_recognition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
10 changes: 8 additions & 2 deletions backend/tests/test_llm_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"},
Expand All @@ -148,6 +153,7 @@ def test_sanitize_keeps_only_handbook_types_with_live_quote_mappings():
"thread-0",
"surface-0",
"step-0",
"od-0",
]


Expand Down
24 changes: 18 additions & 6 deletions backend/tests/test_pm_api_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions backend/tests/test_quote_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
["面粗", "钻孔", "倒角"],
Expand Down
55 changes: 53 additions & 2 deletions frontend/src/components/FeatureReview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@ const CANVAS_GL = {
localClippingEnabled: true,
}

/** 手册已覆盖且现网已有报价映射的审查特征。 */
/** 可审查特征;外圆仅展示待手册公式骨架,不参与报价。 */
const REVIEW_TREE_FEATURE_TYPES = new Set([
"hole",
"outer_cylinder",
"face",
"pocket",
"slot",
Expand All @@ -53,6 +54,7 @@ const REVIEW_TREE_FEATURE_TYPES = new Set([

const FEATURE_LABEL: Record<string, string> = {
hole: "孔",
outer_cylinder: "外圆",
face: "面",
pocket: "型腔",
slot: "槽",
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -724,10 +743,19 @@ 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 },
]
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 },
Expand Down Expand Up @@ -879,6 +907,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)
Expand Down Expand Up @@ -1145,6 +1177,25 @@ export function FeatureReview({
</div>
))}
</div>
) : pendingSteps.length ? (
<div className="space-y-3">
{pendingSteps.map((step: any) => (
<div key={step.step_id || step.process} className="rounded border border-amber-200 bg-amber-50 p-2">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="truncate font-medium text-slate-800">{processName(step)}</span>
<span className="shrink-0 text-amber-700">待手册公式</span>
</div>
</div>
))}
<div className="rounded border border-amber-200 bg-white p-2 text-[11px] text-amber-800">
<div className="font-medium">待手册公式 · 不计入报价</div>
{processGaps.length > 0 && (
<ul className="mt-1 list-disc space-y-0.5 pl-4">
{processGaps.map((gap: string) => <li key={gap}>{gap}</li>)}
</ul>
)}
</div>
</div>
) : (
<div className="text-xs text-slate-400">该特征暂无匹配工序</div>
)}
Expand Down
Loading