diff --git a/backend/app/api/cost.py b/backend/app/api/cost.py index 4c49acbd..c8159b9d 100644 --- a/backend/app/api/cost.py +++ b/backend/app/api/cost.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import Session from app.database import get_db from app.deps import get_entity_id from app.models import StandardCost, ActualCost, AbcActivity, AbcAllocation +from app.models.cost_model import RESEARCH_PROJECT_TYPES from app.utils.cost_engine import ( calc_product_variance, get_cost_overview, get_cost_breakdown, calc_driver_rate, allocate_cost @@ -16,6 +17,25 @@ logger = logging.getLogger("cma.cost") router = APIRouter(prefix="/api/cma/cost", tags=["成本分析"]) +def _resolve_research_dims(data: dict, current=None) -> tuple: + """解析研发归集维度(research_flag/project_type)。 + - project_type 缺省:新建=client_delivery(录入默认),编辑=保留现值 + - research_flag 未显式传入 → 按 project_type 联动(platform_dev=研发1,其余=0) + - 显式传入 research_flag 尊重原值(可覆盖联动) + """ + if current is not None: + project_type = data.get("project_type") or current.project_type or "client_delivery" + else: + project_type = data.get("project_type") or "client_delivery" + if project_type not in RESEARCH_PROJECT_TYPES: + raise HTTPException(400, f"project_type 非法: {project_type},可选 {', '.join(RESEARCH_PROJECT_TYPES)}") + if data.get("research_flag") is not None: + research_flag = int(bool(data.get("research_flag"))) + else: + research_flag = 1 if project_type == "platform_dev" else 0 + return project_type, research_flag + + # ============================================================ # 标准成本卡片 CRUD # ============================================================ @@ -23,6 +43,8 @@ router = APIRouter(prefix="/api/cma/cost", tags=["成本分析"]) @router.get("/standard-costs") def list_standard_costs(product_code: Optional[str] = Query(None), cost_type: Optional[str] = Query(None), + project_type: Optional[str] = Query(None), + research_flag: Optional[int] = Query(None), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """查询标准成本卡片""" @@ -31,14 +53,23 @@ def list_standard_costs(product_code: Optional[str] = Query(None), query = query.filter(StandardCost.product_code == product_code) if cost_type: query = query.filter(StandardCost.cost_type == cost_type) + if project_type: + query = query.filter(StandardCost.project_type == project_type) + if research_flag is not None: + query = query.filter(StandardCost.research_flag == research_flag) items = query.order_by(StandardCost.product_code, StandardCost.cost_type).all() return {"data": items} @router.post("/standard-costs") -def create_standard_cost(data: dict, db: Session = Depends(get_db)): +def create_standard_cost(data: dict, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id)): """创建标准成本卡片""" + project_type, research_flag = _resolve_research_dims(data) sc = StandardCost( + entity_id=entity_id, + project_type=project_type, + research_flag=research_flag, product_code=data["product_code"], product_name=data.get("product_name", ""), cost_type=data["cost_type"], @@ -56,24 +87,31 @@ def create_standard_cost(data: dict, db: Session = Depends(get_db)): @router.put("/standard-costs/{cost_id}") -def update_standard_cost(cost_id: int, data: dict, db: Session = Depends(get_db)): +def update_standard_cost(cost_id: int, data: dict, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id)): """修改标准成本卡片""" - sc = db.query(StandardCost).filter(StandardCost.id == cost_id).first() + sc = db.query(StandardCost).filter(StandardCost.id == cost_id, + StandardCost.entity_id == entity_id).first() if not sc: raise HTTPException(404, "标准成本记录不存在") for k in ("product_code", "product_name", "cost_type", "item_name", "standard_quantity", "unit", "standard_price", "version", "remark"): if k in data: setattr(sc, k, data[k]) + project_type, research_flag = _resolve_research_dims(data, current=sc) + sc.project_type = project_type + sc.research_flag = research_flag sc.standard_cost = round(sc.standard_quantity * sc.standard_price, 2) db.commit() return {"message": "已更新"} @router.delete("/standard-costs/{cost_id}") -def delete_standard_cost(cost_id: int, db: Session = Depends(get_db)): +def delete_standard_cost(cost_id: int, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id)): """删除标准成本卡片""" - sc = db.query(StandardCost).filter(StandardCost.id == cost_id).first() + sc = db.query(StandardCost).filter(StandardCost.id == cost_id, + StandardCost.entity_id == entity_id).first() if not sc: raise HTTPException(404, "标准成本记录不存在") sc.status = "archived" @@ -88,6 +126,8 @@ def delete_standard_cost(cost_id: int, db: Session = Depends(get_db)): @router.get("/actual-costs") def list_actual_costs(period: Optional[str] = Query(None), product_code: Optional[str] = Query(None), + project_type: Optional[str] = Query(None), + research_flag: Optional[int] = Query(None), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """查询实际成本""" @@ -96,14 +136,23 @@ def list_actual_costs(period: Optional[str] = Query(None), query = query.filter(ActualCost.period == period) if product_code: query = query.filter(ActualCost.product_code == product_code) + if project_type: + query = query.filter(ActualCost.project_type == project_type) + if research_flag is not None: + query = query.filter(ActualCost.research_flag == research_flag) items = query.order_by(ActualCost.period.desc(), ActualCost.product_code).all() return {"data": items} @router.post("/actual-costs") -def create_actual_cost(data: dict, db: Session = Depends(get_db)): +def create_actual_cost(data: dict, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id)): """录入实际成本""" + project_type, research_flag = _resolve_research_dims(data) ac = ActualCost( + entity_id=entity_id, + project_type=project_type, + research_flag=research_flag, period=data["period"], product_code=data["product_code"], product_name=data.get("product_name", ""), diff --git a/backend/app/models/cost_model.py b/backend/app/models/cost_model.py index 6da82eb5..e7180f4c 100644 --- a/backend/app/models/cost_model.py +++ b/backend/app/models/cost_model.py @@ -5,12 +5,17 @@ from sqlalchemy import Column, Integer, String, Float, DateTime, Text, ForeignKe from app.database import Base +RESEARCH_PROJECT_TYPES = ("platform_dev", "client_delivery", "internal") + + class StandardCost(Base): """标准成本卡片 — 每项产品或服务的标准成本构成""" __tablename__ = "standard_costs" id = Column(Integer, primary_key=True, index=True) entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)") + research_flag = Column(Integer, default=0, index=True, comment="是否研发投入 0/1(研发费用加计扣除归集 2026-09-05)") + project_type = Column(String(30), default="internal", index=True, comment="归集类型: platform_dev平台研发/client_delivery客户交付/internal内部管理(默认internal保守防误归研发)") product_code = Column(String(50), nullable=False, comment="产品/服务编码") product_name = Column(String(200), nullable=False, comment="产品/服务名称") cost_type = Column(String(20), nullable=False, comment="成本类型: material/labor/overhead") @@ -32,6 +37,8 @@ class ActualCost(Base): id = Column(Integer, primary_key=True, index=True) entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)") + research_flag = Column(Integer, default=0, index=True, comment="是否研发投入 0/1(研发费用加计扣除归集 2026-09-05)") + project_type = Column(String(30), default="internal", index=True, comment="归集类型: platform_dev平台研发/client_delivery客户交付/internal内部管理(默认internal保守防误归研发)") period = Column(String(20), nullable=False, comment="期间 2026-05") product_code = Column(String(50), nullable=False, comment="产品/服务编码") product_name = Column(String(200), nullable=False, comment="产品/服务名称") diff --git a/backend/scripts/verify_cost_research_dimension_e2e.py b/backend/scripts/verify_cost_research_dimension_e2e.py new file mode 100644 index 00000000..b64cea1a --- /dev/null +++ b/backend/scripts/verify_cost_research_dimension_e2e.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""生产E2E实证: 成本模型研发归集维度 (cma-rd-dimension-20260905) + +链路验证: + A. 标准成本 POST project_type=platform_dev → research_flag 自动=1 (研发归集) + B. 标准成本 POST 不传归集维度 → 默认 client_delivery/research_flag=0 (录入默认) + C. 标准成本列表 ?project_type=platform_dev 过滤 → 只含研发行, 不含客户交付 + D. 标准成本列表 ?research_flag=1 过滤 → 只含研发行 + E. 实际成本 POST project_type=platform_dev → 落库 + 列表过滤命中 + F. 存量迁移保守默认: 两表 internal 行数 > 0 (ALTER 后存量全 internal) + G. 回归: 既有列表接口(标准/实际)返回 200 且 data 为列表 + H. ORM schema: inspect 两表含 research_flag/project_type 列 + 索引 + +数据纪律: 临时行 product_code 带 E2E-RD- 前缀 + remark 带标记, +验证完成后 DB 精确清理并回查 COUNT=0。 + +用法: cd /root/cma-management/backend && venv/bin/python3 scripts/verify_cost_research_dimension_e2e.py +""" +import json +import os +import sys +import urllib.request +import urllib.error +from datetime import datetime + +BASE = "http://127.0.0.1:8010" +MARK = "【系统验证临时数据-验证后删除】" +PREFIX = "E2E-RD-" +STAMP = datetime.now().strftime("%H%M%S") + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault("CMA_DB_HOST", "127.0.0.1") +from app.database import get_db, get_engine # noqa: E402 +from sqlalchemy import text # noqa: E402 +from sqlalchemy import inspect as sa_inspect # noqa: E402 + + +def http(method, path, body=None, headers=None): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(BASE + path, data=data, method=method) + req.add_header("Content-Type", "application/json") + for k, v in (headers or {}).items(): + req.add_header(k, v) + try: + with urllib.request.urlopen(req, timeout=30) as r: + return r.status, json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + raw = e.read().decode() + try: + return e.code, json.loads(raw) + except Exception: + return e.code, {"detail": raw} + + +def main(): + failures = [] + + def check(name, cond, detail=""): + tag = "PASS" if cond else "FAIL" + print(f" [{tag}] {name}" + (f" | {detail}" if detail else "")) + if not cond: + failures.append(name) + + # ── 0. 登录(admin entity=1) ── + st, r = http("POST", "/api/cma/auth/login", + {"username": "admin", "password": "admin123", "entity_id": 1}) + check("登录 admin entity=1", st == 200, f"http={st}") + token = r.get("token") if st == 200 else "" + auth = {"Authorization": f"Bearer {token}"} + + rd_code = f"{PREFIX}RD{STAMP}" + cd_code = f"{PREFIX}CD{STAMP}" + ac_code = f"{PREFIX}AC{STAMP}" + + try: + # ── A. 标准成本: 平台研发 ── + st, r = http("POST", "/api/cma/cost/standard-costs", { + "product_code": rd_code, "product_name": "CMA平台化开发E2E", + "cost_type": "labor", "item_name": "研发工时E2E", + "standard_quantity": 10.0, "standard_price": 80.0, "unit": "小时", + "project_type": "platform_dev", "remark": MARK, + }, auth) + check("POST standard platform_dev 200", st == 200, f"http={st} body={r}") + + # ── B. 标准成本: 不传归集维度 → 默认 client_delivery ── + st, r = http("POST", "/api/cma/cost/standard-costs", { + "product_code": cd_code, "product_name": "客户交付E2E", + "cost_type": "labor", "item_name": "实施工时E2E", + "standard_quantity": 5.0, "standard_price": 60.0, "unit": "小时", + "remark": MARK, + }, auth) + check("POST standard 默认client_delivery 200", st == 200, f"http={st} body={r}") + + # ── C. 列表过滤: project_type=platform_dev ── + st, r = http("GET", f"/api/cma/cost/standard-costs?project_type=platform_dev", None, auth) + codes = [i["product_code"] for i in r.get("data", [])] + check("GET standard filter=platform_dev 200", st == 200, f"http={st}") + check("研发过滤含研发行", rd_code in codes, f"{rd_code} in {codes}") + check("研发过滤不含客户交付行", cd_code not in codes, f"{cd_code} not in {codes}") + + # ── D. 列表过滤: research_flag=1 ── + st, r = http("GET", "/api/cma/cost/standard-costs?research_flag=1", None, auth) + codes_rf = [i["product_code"] for i in r.get("data", [])] + check("GET standard filter=research_flag=1 含研发行", rd_code in codes_rf, + f"{rd_code} in {codes_rf}") + + # ── E. 实际成本: platform_dev ── + st, r = http("POST", "/api/cma/cost/actual-costs", { + "period": "2026-09", "product_code": ac_code, "product_name": "CMA平台化开发E2E", + "cost_type": "labor", "item_name": "研发工时E2E", + "actual_quantity": 40.0, "actual_price": 100.0, + "project_type": "platform_dev", "remark": MARK, + }, auth) + check("POST actual platform_dev 200", st == 200, f"http={st} body={r}") + st, r = http("GET", "/api/cma/cost/actual-costs?project_type=platform_dev&period=2026-09", None, auth) + ac_codes = [i["product_code"] for i in r.get("data", [])] + check("GET actual filter=platform_dev 含研发行", ac_code in ac_codes, f"{ac_code} in {ac_codes}") + + # ── DB 落库校验 ── + db = next(get_db()) + try: + row_s = db.execute(text( + "SELECT project_type, research_flag, entity_id FROM standard_costs WHERE product_code = :c" + ), {"c": rd_code}).fetchone() + check("DB standard 落库 project_type=platform_dev", row_s and row_s[0] == "platform_dev", + f"row={row_s}") + check("DB standard research_flag 联动=1", row_s and row_s[1] == 1, f"row={row_s}") + check("DB standard entity_id=1(token绑定)", row_s and row_s[2] == 1, f"row={row_s}") + + row_c = db.execute(text( + "SELECT project_type, research_flag FROM standard_costs WHERE product_code = :c" + ), {"c": cd_code}).fetchone() + check("DB standard 默认 project_type=client_delivery", row_c and row_c[0] == "client_delivery", + f"row={row_c}") + check("DB standard 默认 research_flag=0", row_c and row_c[1] == 0, f"row={row_c}") + + row_a = db.execute(text( + "SELECT project_type, research_flag FROM actual_costs WHERE product_code = :c" + ), {"c": ac_code}).fetchone() + check("DB actual 落库 platform_dev+flag=1", row_a and row_a[0] == "platform_dev" and row_a[1] == 1, + f"row={row_a}") + + # ── F. 存量保守默认: internal 行仍在(ALTER后存量 internal, 未误归研发) ── + n_std_internal = db.execute(text( + "SELECT COUNT(*) FROM standard_costs WHERE project_type = 'internal' AND remark IS NULL" + )).scalar() + n_act_internal = db.execute(text( + "SELECT COUNT(*) FROM actual_costs WHERE project_type = 'internal' AND remark IS NULL" + )).scalar() + check("存量 standard internal 保留(29)", n_std_internal >= 29, f"count={n_std_internal}") + check("存量 actual internal 保留(134)", n_act_internal >= 134, f"count={n_act_internal}") + + # ── H. ORM schema: 列+索引存在 ── + insp = sa_inspect(get_engine()) + for t in ("standard_costs", "actual_costs"): + cols = {c["name"] for c in insp.get_columns(t)} + idxs = {ix["name"] for ix in insp.get_indexes(t)} + check(f"{t} 含 research_flag+project_type 列", + {"research_flag", "project_type"} <= cols, f"cols={sorted(cols)}") + check(f"{t} 含两索引", + {f"ix_{t}_research_flag", f"ix_{t}_project_type"} <= idxs, f"idx={sorted(idxs)}") + finally: + db.rollback() + db.close() + + # ── G. 回归: 原列表接口正常 ── + st, r = http("GET", "/api/cma/cost/standard-costs", None, auth) + check("回归 GET standard-costs(全量) 200+list", st == 200 and isinstance(r.get("data"), list), + f"http={st} n={len(r.get('data', []))}") + st, r = http("GET", "/api/cma/cost/actual-costs?period=2026-09", None, auth) + check("回归 GET actual-costs 200+list", st == 200 and isinstance(r.get("data"), list), + f"http={st} n={len(r.get('data', []))}") + + finally: + # ── 清理临时数据(DB直删标记行) + 回查 ── + db = next(get_db()) + try: + for t in ("standard_costs", "actual_costs"): + db.execute(text(f"DELETE FROM {t} WHERE product_code LIKE :p"), {"p": f"{PREFIX}%"}) + db.commit() + for t in ("standard_costs", "actual_costs"): + n = db.execute(text(f"SELECT COUNT(*) FROM {t} WHERE product_code LIKE :p"), + {"p": f"{PREFIX}%"}).scalar() + check(f"清理回查 {t} 标记行=0", n == 0, f"count={n}") + finally: + db.rollback() + db.close() + + print() + if failures: + print(f"RESULT: FAIL ({len(failures)}): {failures}") + sys.exit(1) + print("RESULT: ALL PASS") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_cost.py b/backend/tests/test_cost.py index 28f8aebc..85f82eb9 100644 --- a/backend/tests/test_cost.py +++ b/backend/tests/test_cost.py @@ -45,6 +45,99 @@ class TestStandardCost: list_resp = client.get(f"{self.BASE}/standard-costs", headers=auth_header(token)) assert len(list_resp.json()["data"]) == 1 + def test_create_standard_cost_default_client_delivery(self, client: TestClient, db: Session): + """未传归集维度 → 默认 client_delivery/research_flag=0(录入默认,客户交付)""" + create_test_user(db) + token = get_token_for_user(client) + + resp = client.post( + f"{self.BASE}/standard-costs", + headers=auth_header(token), + json={ + "product_code": "PROD_DEF", + "product_name": "默认产品", + "cost_type": "labor", + "item_name": "人工", + "standard_quantity": 1.0, + "standard_price": 100.0, + }, + ) + assert resp.status_code == 200 + row = db.query(StandardCost).filter(StandardCost.product_code == "PROD_DEF").first() + assert row is not None + assert row.project_type == "client_delivery" + assert row.research_flag == 0 + + def test_create_standard_cost_platform_dev_research_flag_linked(self, client: TestClient, db: Session): + """project_type=platform_dev → research_flag 自动联动为 1(研发归集)""" + create_test_user(db) + token = get_token_for_user(client) + + resp = client.post( + f"{self.BASE}/standard-costs", + headers=auth_header(token), + json={ + "product_code": "PROD_RD", + "product_name": "CMA平台开发", + "cost_type": "labor", + "item_name": "研发工时", + "standard_quantity": 10.0, + "standard_price": 80.0, + "project_type": "platform_dev", + }, + ) + assert resp.status_code == 200 + row = db.query(StandardCost).filter(StandardCost.product_code == "PROD_RD").first() + assert row.project_type == "platform_dev" + assert row.research_flag == 1 + + def test_list_standard_costs_filter_by_project_type(self, client: TestClient, db: Session): + """列表按 project_type 过滤:研发只返回平台研发,不混入客户交付""" + create_test_user(db) + token = get_token_for_user(client) + for code, pt in (("RD_X", "platform_dev"), ("CD_X", "client_delivery"), ("IM_X", "internal")): + db.add(StandardCost( + product_code=code, product_name=code, cost_type="overhead", + item_name=code, standard_quantity=1, standard_price=1.0, + standard_cost=1.0, + project_type=pt, research_flag=1 if pt == "platform_dev" else 0, + )) + db.commit() + + resp = client.get(f"{self.BASE}/standard-costs?project_type=platform_dev", headers=auth_header(token)) + assert resp.status_code == 200 + codes = [i["product_code"] for i in resp.json()["data"]] + assert codes == ["RD_X"] + + resp2 = client.get(f"{self.BASE}/standard-costs?research_flag=1", headers=auth_header(token)) + assert resp2.status_code == 200 + codes2 = [i["product_code"] for i in resp2.json()["data"]] + assert codes2 == ["RD_X"] + + def test_update_standard_cost_project_type(self, client: TestClient, db: Session): + """更新归集类型 → research_flag 联动(client_delivery 改为 platform_dev)""" + create_test_user(db) + token = get_token_for_user(client) + + sc = StandardCost( + product_code="PROD_UPD", product_name="产品U", cost_type="labor", + item_name="人工", standard_quantity=100, unit="小时", + standard_price=50.0, standard_cost=5000.0, + project_type="client_delivery", research_flag=0, + ) + db.add(sc) + db.commit() + + resp = client.put( + f"{self.BASE}/standard-costs/{sc.id}", + headers=auth_header(token), + json={"project_type": "platform_dev"}, + ) + assert resp.status_code == 200 + db.refresh(sc) + assert sc.project_type == "platform_dev" + assert sc.research_flag == 1 + def test_update_standard_cost(self, client: TestClient, db: Session): """修改标准成本卡片""" create_test_user(db) @@ -130,6 +223,59 @@ class TestActualCost: assert resp.status_code == 200 assert len(resp.json()["data"]) == 1 + def test_create_actual_cost_research_dims(self, client: TestClient, db: Session): + """录入实际成本带归集维度(平台研发 → research_flag=1)""" + create_test_user(db) + token = get_token_for_user(client) + + resp = client.post( + f"{self.BASE}/actual-costs", + headers=auth_header(token), + json={ + "period": "2026-09", + "product_code": "RD_SVC", + "product_name": "CMA平台化开发", + "cost_type": "labor", + "item_name": "平台研发工时", + "actual_quantity": 40.0, + "actual_price": 100.0, + "project_type": "platform_dev", + }, + ) + assert resp.status_code == 200 + row = db.query(ActualCost).filter(ActualCost.product_code == "RD_SVC").first() + assert row is not None + assert row.entity_id == 1 + assert row.project_type == "platform_dev" + assert row.research_flag == 1 + + def test_list_actual_costs_filter_by_research(self, client: TestClient, db: Session): + """实际成本列表按归集类型/研发标记过滤""" + create_test_user(db) + token = get_token_for_user(client) + db.add(ActualCost( + period="2026-09", product_code="RD_SVC", product_name="平台开发", + cost_type="labor", item_name="研发工时", actual_quantity=1, actual_price=1.0, + actual_cost=1.0, + project_type="platform_dev", research_flag=1, + )) + db.add(ActualCost( + period="2026-09", product_code="CD_SVC", product_name="客户交付", + cost_type="labor", item_name="实施工时", actual_quantity=1, actual_price=1.0, + actual_cost=1.0, + project_type="client_delivery", research_flag=0, + )) + db.commit() + + resp = client.get(f"{self.BASE}/actual-costs?project_type=platform_dev&period=2026-09", headers=auth_header(token)) + assert resp.status_code == 200 + codes = [i["product_code"] for i in resp.json()["data"]] + assert codes == ["RD_SVC"] + + resp2 = client.get(f"{self.BASE}/actual-costs?research_flag=0&period=2026-09", headers=auth_header(token)) + codes2 = [i["product_code"] for i in resp2.json()["data"]] + assert codes2 == ["CD_SVC"] + class TestAbcCost: """ABC作业成本测试""" diff --git a/frontend/src/views/CostDashboard.vue b/frontend/src/views/CostDashboard.vue index aa1d7fe9..3e41f19d 100644 --- a/frontend/src/views/CostDashboard.vue +++ b/frontend/src/views/CostDashboard.vue @@ -75,10 +75,13 @@
- + 新建标准成本 + + 新建标准成本 + + +
@@ -96,6 +99,9 @@ + + +