feat(cost): 成本模型加研发归集维度 research_flag/project_type (cma-rd-dimension-20260905)
- StandardCost/ActualCost 增加 research_flag(int,idx)/project_type(varchar30,idx) project_type: platform_dev平台研发/client_delivery客户交付/internal内部管理 - 生产库两表 ALTER 完成, 存量 29+134 行默认 internal(保守防误归研发) - API: create/update 支持写入维度, 未传默认 client_delivery(录入默认), research_flag 未显式传入按 project_type 联动(platform_dev=1); create/update/delete 绑定 entity_id(token优先, 防跨账套) - API: standard/actual 列表支持 project_type/research_flag 过滤 - 前端 CostDashboard: 表单加归集类型下拉(默认客户交付+研发提示), 列表加归集类型列(标签), 工具栏按归集类型过滤 - tests: test_cost.py +6 用例(默认值/联动/过滤/更新) - scripts: verify_cost_research_dimension_e2e.py 生产E2E(标记+清理+回查)
This commit is contained in:
+55
-6
@@ -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", ""),
|
||||
|
||||
@@ -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="产品/服务名称")
|
||||
|
||||
@@ -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()
|
||||
@@ -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作业成本测试"""
|
||||
|
||||
@@ -75,10 +75,13 @@
|
||||
<!-- Tab 2: 标准成本管理 -->
|
||||
<el-tab-pane label="标准成本" name="standard">
|
||||
<div style="margin-bottom:12px;">
|
||||
<el-button type="primary" @click="showStdForm = true; stdForm = {}">+ 新建标准成本</el-button>
|
||||
<el-button type="primary" @click="resetStdForm()">+ 新建标准成本</el-button>
|
||||
<el-select v-model="stdProductFilter" placeholder="产品" clearable style="width:150px;margin-left:8px;" @change="loadStandardCosts">
|
||||
<el-option v-for="p in productOptions" :key="p" :label="p" :value="p" />
|
||||
</el-select>
|
||||
<el-select v-model="stdTypeFilter" placeholder="归集类型" clearable style="width:130px;margin-left:8px;" @change="loadStandardCosts">
|
||||
<el-option v-for="(label, val) in projectTypeOptions" :key="val" :label="label" :value="val" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table :data="standardCosts" v-loading="stdLoading" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="product_code" label="产品编码" width="100" />
|
||||
@@ -96,6 +99,9 @@
|
||||
<template #default="{ row }"><span style="font-weight:500;">{{ formatMoney(row.standard_cost) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="version" label="版本" width="60" />
|
||||
<el-table-column prop="project_type" label="归集类型" width="95">
|
||||
<template #default="{ row }"><el-tag size="small" :type="projectTypeTag(row.project_type)">{{ projectTypeLabel(row.project_type) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editStd(row)">编辑</el-button>
|
||||
@@ -108,10 +114,13 @@
|
||||
<!-- Tab 3: 成本录入 -->
|
||||
<el-tab-pane label="成本录入" name="entry">
|
||||
<div style="margin-bottom:12px;">
|
||||
<el-button type="primary" @click="showActualForm = true; actualForm = {}">+ 录入实际成本</el-button>
|
||||
<el-button type="primary" @click="showActualForm = true; actualForm = { ...actualFormDefaults }">+ 录入实际成本</el-button>
|
||||
<el-select v-model="entryPeriod" placeholder="期间" style="width:110px;margin-left:8px;" @change="loadActualCosts">
|
||||
<el-option v-for="p in periodOptions" :key="p" :label="p" :value="p" />
|
||||
</el-select>
|
||||
<el-select v-model="entryTypeFilter" placeholder="归集类型" clearable style="width:130px;margin-left:8px;" @change="loadActualCosts">
|
||||
<el-option v-for="(label, val) in projectTypeOptions" :key="val" :label="label" :value="val" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table :data="actualCosts" v-loading="actualLoading" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="period" label="期间" width="80" />
|
||||
@@ -129,6 +138,9 @@
|
||||
<template #default="{ row }"><span style="font-weight:500;">{{ formatMoney(row.actual_cost) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="source" label="来源" width="70" />
|
||||
<el-table-column prop="project_type" label="归集类型" width="95">
|
||||
<template #default="{ row }"><el-tag size="small" :type="projectTypeTag(row.project_type)">{{ projectTypeLabel(row.project_type) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -291,6 +303,12 @@
|
||||
<el-form-item label="标准用量"><el-input-number v-model="stdForm.standard_quantity" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="单位"><el-input v-model="stdForm.unit" style="width:150px;" /></el-form-item>
|
||||
<el-form-item label="标准单价"><el-input-number v-model="stdForm.standard_price" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="归集类型">
|
||||
<el-select v-model="stdForm.project_type" style="width:200px;">
|
||||
<el-option v-for="(label, val) in projectTypeOptions" :key="val" :label="label" :value="val" />
|
||||
</el-select>
|
||||
<div v-if="stdForm.project_type === 'platform_dev'" style="font-size:12px;color:#e6a23c;line-height:1.4;">平台研发投入 → 自动标记研发(research_flag=1),可作研发费用加计扣除归集</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showStdForm = false">取消</el-button>
|
||||
@@ -308,6 +326,12 @@
|
||||
<el-form-item label="项目名称"><el-input v-model="actualForm.item_name" /></el-form-item>
|
||||
<el-form-item label="实际用量"><el-input-number v-model="actualForm.actual_quantity" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="实际单价"><el-input-number v-model="actualForm.actual_price" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="归集类型">
|
||||
<el-select v-model="actualForm.project_type" style="width:200px;">
|
||||
<el-option v-for="(label, val) in projectTypeOptions" :key="val" :label="label" :value="val" />
|
||||
</el-select>
|
||||
<div v-if="actualForm.project_type === 'platform_dev'" style="font-size:12px;color:#e6a23c;line-height:1.4;">平台研发投入 → 自动标记研发(research_flag=1),可作研发费用加计扣除归集</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showActualForm = false">取消</el-button>
|
||||
@@ -377,6 +401,14 @@ const costTypeMap: Record<string, string> = { material: '材料', labor: '人工
|
||||
const costTypeTagMap: Record<string, string> = { material: 'primary', labor: 'success', overhead: 'warning' }
|
||||
function costTypeLabel(t: string) { return costTypeMap[t] || t }
|
||||
function costTypeTag(t: string) { return costTypeTagMap[t] || 'info' }
|
||||
|
||||
// ── 研发归集维度(2026-09-05 研发费用加计扣除归集)──
|
||||
const projectTypeOptions: Record<string, string> = {
|
||||
platform_dev: '平台研发', client_delivery: '客户交付', internal: '内部管理',
|
||||
}
|
||||
const projectTypeTagMap: Record<string, string> = { platform_dev: 'danger', client_delivery: 'primary', internal: 'info' }
|
||||
function projectTypeLabel(t: string) { return projectTypeOptions[t] || '未分类' }
|
||||
function projectTypeTag(t: string) { return projectTypeTagMap[t] || 'info' }
|
||||
function formatMoney(v: any) {
|
||||
if (v === null || v === undefined) return '--'
|
||||
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
@@ -423,12 +455,20 @@ const showStdForm = ref(false)
|
||||
const stdForm = ref<any>({})
|
||||
const stdSaving = ref(false)
|
||||
const stdProductFilter = ref('')
|
||||
const stdTypeFilter = ref('')
|
||||
const stdFormDefaults = { project_type: 'client_delivery' }
|
||||
|
||||
function resetStdForm() {
|
||||
stdForm.value = { ...stdFormDefaults }
|
||||
showStdForm.value = true
|
||||
}
|
||||
|
||||
async function loadStandardCosts() {
|
||||
stdLoading.value = true
|
||||
try {
|
||||
const params: any = { entity_id: getEntityId() }
|
||||
if (stdProductFilter.value) params.product_code = stdProductFilter.value
|
||||
if (stdTypeFilter.value) params.project_type = stdTypeFilter.value
|
||||
const r: any = await costApi.listStandardCosts(params)
|
||||
standardCosts.value = r.data || []
|
||||
} catch (e) { ElMessage.error('加载标准成本失败') }
|
||||
@@ -474,11 +514,14 @@ const entryPeriod = ref(overviewPeriod.value)
|
||||
const showActualForm = ref(false)
|
||||
const actualForm = ref<any>({})
|
||||
const actualSaving = ref(false)
|
||||
const entryTypeFilter = ref('')
|
||||
const actualFormDefaults = { period: '', product_code: '', product_name: '', cost_type: 'material', item_name: '', project_type: 'client_delivery' }
|
||||
|
||||
async function loadActualCosts() {
|
||||
actualLoading.value = true
|
||||
try {
|
||||
const params: any = { period: entryPeriod.value, entity_id: getEntityId() }
|
||||
if (entryTypeFilter.value) params.project_type = entryTypeFilter.value
|
||||
const r: any = await costApi.listActualCosts(params)
|
||||
actualCosts.value = r.data || []
|
||||
} catch (e) { ElMessage.error('加载实际成本失败') }
|
||||
|
||||
Reference in New Issue
Block a user