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:
Hermes CI Fix
2026-09-05 09:10:35 +08:00
parent 46096e8f84
commit 696f8bc934
5 changed files with 451 additions and 8 deletions
+55 -6
View File
@@ -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", ""),
+7
View File
@@ -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()
+146
View File
@@ -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作业成本测试"""
+45 -2
View File
@@ -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('加载实际成本失败') }