feat: 税务合规智能体—税负监控+发票校验+社保比对

This commit is contained in:
Hermes CI Fix
2026-08-04 15:04:39 +08:00
parent 8e277de3de
commit 38b603f263
7 changed files with 1770 additions and 5 deletions
+884
View File
@@ -0,0 +1,884 @@
"""税务合规智能体 API — 管理会计OS
① 税负监控: 税务记录CRUD + 税负率计算(实缴/收入×100%) + 行业基准预警(增值税3.5%/所得税2.5%, 超±20%预警)
② 发票校验: 发票录入 + 批量校验(发票号33位数字 / 金额与报销单匹配 / 供应商与合同匹配) + 异常查询
③ 社保比对: 缴费记录CRUD + 比对(基数与工资匹配60%~300% / 单位缴纳比例24.5% / 漏缴提醒) + 异常查询
看板: /tax/dashboard 聚合税负趋势+行业对比 + 发票异常列表 + 社保异常列表
"""
import json
from datetime import datetime, date
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import TaxRecord, InvoiceCheck, SocialSecurity, ExpenseReimbursement
router = APIRouter(prefix="/api/cma/tax", tags=["税务合规"])
# ── 行业平均税负率参考值 (%) ──
TAX_BENCHMARKS = {
"vat": 3.5, # 增值税平均税负率
"income": 2.5, # 企业所得税平均税负率
"surtax": 0.5, # 附加税平均税负率
}
TAX_TYPE_LABELS = {"vat": "增值税", "income": "所得税", "surtax": "附加税"}
INVOICE_TYPE_LABELS = {"vat": "增值税专用发票", "vat_normal": "增值税普通发票", "electronic": "电子发票", "other": "其他"}
SS_COMPANY_RATE = 24.5 # 单位缴纳比例: 养老16% + 医疗8% + 失业0.5%
SS_RATE_TOLERANCE = 1.0 # 比例允许偏差 ±1%
SS_BASE_LOW = 0.6 # 基数下限 = 工资60%
SS_BASE_HIGH = 3.0 # 基数上限 = 工资300%
INVOICE_NO_LEN = 33 # 发票号位数(全数字)
# ── 序列化 ──
def _tax_to_dict(t: TaxRecord) -> dict:
return {
"id": t.id,
"entity_id": t.entity_id,
"period": t.period,
"tax_type": t.tax_type,
"tax_type_label": TAX_TYPE_LABELS.get(t.tax_type, t.tax_type),
"tax_payable": t.tax_payable,
"tax_paid": t.tax_paid,
"tax_rate": t.tax_rate,
"income": t.income,
"tax_burden_rate": t.tax_burden_rate,
"benchmark": TAX_BENCHMARKS.get(t.tax_type),
"burden_status": t.burden_status,
"warning_msg": t.warning_msg,
"remark": t.remark,
"created_at": t.created_at.isoformat() if t.created_at else None,
}
def _invoice_to_dict(i: InvoiceCheck) -> dict:
return {
"id": i.id,
"entity_id": i.entity_id,
"invoice_no": i.invoice_no,
"amount": i.amount,
"invoice_type": i.invoice_type,
"invoice_type_label": INVOICE_TYPE_LABELS.get(i.invoice_type, i.invoice_type),
"invoice_date": i.invoice_date.isoformat() if i.invoice_date else None,
"supplier": i.supplier,
"reimb_no": i.reimb_no,
"contract_no": i.contract_no,
"check_status": i.check_status,
"check_result": i.check_result,
"check_reason": i.check_reason,
"checked_at": i.checked_at.isoformat() if i.checked_at else None,
"created_at": i.created_at.isoformat() if i.created_at else None,
}
def _ss_to_dict(s: SocialSecurity) -> dict:
return {
"id": s.id,
"entity_id": s.entity_id,
"employee": s.employee,
"period": s.period,
"base_amount": s.base_amount,
"salary": s.salary,
"company_amount": s.company_amount,
"personal_amount": s.personal_amount,
"company_rate": s.company_rate,
"check_status": s.check_status,
"warning_msg": s.warning_msg,
"remark": s.remark,
"created_at": s.created_at.isoformat() if s.created_at else None,
}
def _parse_date(v) -> datetime | None:
if not v:
return None
try:
if isinstance(v, datetime):
return v
if isinstance(v, date):
return datetime(v.year, v.month, v.day)
return datetime.fromisoformat(str(v)[:10])
except Exception:
return None
# ============================================================
# ① 税负监控 — 税务记录 CRUD + 税负率计算 + 行业基准预警
# ============================================================
def _calc_burden(t: TaxRecord):
"""计算单条税务记录的税负率 + 行业基准预警"""
rate = None
if t.income and t.income > 0:
rate = round(t.tax_paid / t.income * 100, 2)
t.tax_burden_rate = rate
bench = TAX_BENCHMARKS.get(t.tax_type)
t.burden_status = "normal"
t.warning_msg = None
if rate is None or bench is None:
return
low, high = bench * 0.8, bench * 1.2
if rate < low or rate > high:
t.burden_status = "alert"
t.warning_msg = (
f"税负率{rate}%超出行业均值{bench}%的±20%区间({low}%~{high}%)"
f"{'偏高需核查进项/优惠' if rate > high else '偏低需核查申报完整性'}"
)
def _apply_burden_to_all(db: Session, entity_id: int):
"""重算某企业全部税务记录的税负率与预警"""
records = db.query(TaxRecord).filter(TaxRecord.entity_id == entity_id).all()
for t in records:
_calc_burden(t)
db.commit()
return records
@router.get("/records")
def list_tax_records(
period: str = Query(None),
tax_type: str = Query(None),
entity_id: int = Query(None),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""查询税务记录列表"""
q = db.query(TaxRecord)
if entity_id:
q = q.filter(TaxRecord.entity_id == entity_id)
if period:
q = q.filter(TaxRecord.period == period)
if tax_type:
q = q.filter(TaxRecord.tax_type == tax_type)
records = q.order_by(TaxRecord.period.desc(), TaxRecord.id.asc()).all()
return {"data": [_tax_to_dict(t) for t in records], "total": len(records)}
@router.post("/records")
def create_tax_record(
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""新增税务记录"""
period = data.get("period")
tax_type = data.get("tax_type")
if not period or not tax_type:
raise HTTPException(400, "缺少必要参数: period, tax_type")
if tax_type not in TAX_TYPE_LABELS:
raise HTTPException(400, f"无效税种: {tax_type},可选 vat/income/surtax")
t = TaxRecord(
entity_id=data.get("entity_id") or 1,
period=period,
tax_type=tax_type,
tax_payable=data.get("tax_payable") or 0,
tax_paid=data.get("tax_paid") or 0,
tax_rate=data.get("tax_rate"),
income=data.get("income") or 0,
remark=data.get("remark"),
)
_calc_burden(t)
db.add(t)
db.commit()
db.refresh(t)
return {"message": "税务记录已创建", "data": _tax_to_dict(t)}
@router.put("/records/{record_id}")
def update_tax_record(
record_id: int,
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""更新税务记录(自动重算税负率与预警)"""
t = db.query(TaxRecord).filter(TaxRecord.id == record_id).first()
if not t:
raise HTTPException(404, "税务记录不存在")
if "period" in data:
t.period = data["period"]
if "tax_type" in data:
if data["tax_type"] not in TAX_TYPE_LABELS:
raise HTTPException(400, f"无效税种: {data['tax_type']}")
t.tax_type = data["tax_type"]
if "tax_payable" in data:
t.tax_payable = data["tax_payable"] or 0
if "tax_paid" in data:
t.tax_paid = data["tax_paid"] or 0
if "tax_rate" in data:
t.tax_rate = data.get("tax_rate")
if "income" in data:
t.income = data["income"] or 0
if "remark" in data:
t.remark = data.get("remark")
_calc_burden(t)
db.commit()
db.refresh(t)
return {"message": "税务记录已更新", "data": _tax_to_dict(t)}
@router.delete("/records/{record_id}")
def delete_tax_record(
record_id: int,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""删除税务记录"""
t = db.query(TaxRecord).filter(TaxRecord.id == record_id).first()
if not t:
raise HTTPException(404, "税务记录不存在")
db.delete(t)
db.commit()
return {"message": "税务记录已删除"}
@router.get("/burden")
def burden_analysis(
entity_id: int = Query(None),
tax_type: str = Query(None),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""税负率计算 + 行业对比 + 预警列表(前端趋势图数据源)"""
q = db.query(TaxRecord)
if entity_id:
q = q.filter(TaxRecord.entity_id == entity_id)
if tax_type:
q = q.filter(TaxRecord.tax_type == tax_type)
records = q.order_by(TaxRecord.period.asc(), TaxRecord.id.asc()).all()
for t in records:
_calc_burden(t)
db.commit()
# 按期间聚合税负率(每种税一个序列)
trend_map: dict[str, dict] = {}
for t in records:
if t.tax_burden_rate is None:
continue
entry = trend_map.setdefault(t.period, {"period": t.period})
entry[f"{t.tax_type}_rate"] = t.tax_burden_rate
entry[f"{t.tax_type}_benchmark"] = TAX_BENCHMARKS.get(t.tax_type)
trend = sorted(trend_map.values(), key=lambda x: x["period"])
alerts = [t for t in records if t.burden_status == "alert"]
return {
"trend": trend,
"benchmarks": TAX_BENCHMARKS,
"records": [_tax_to_dict(t) for t in records],
"alerts": [_tax_to_dict(t) for t in alerts],
"alert_count": len(alerts),
}
@router.post("/check")
def run_tax_check(
entity_id: int = Query(None),
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""手动触发税负率重算与预警检查"""
records = _apply_burden_to_all(db, entity_id or 1)
alerts = [t for t in records if t.burden_status == "alert"]
return {"message": f"税负检查完成,共{len(records)}条记录,{len(alerts)}条预警", "alert_count": len(alerts)}
# ============================================================
# ② 发票校验 — 录入 + 批量校验 + 异常查询
# ============================================================
def _check_invoice(db: Session, inv: InvoiceCheck):
"""发票校验规则:号码格式 / 金额与报销单匹配 / 供应商与合同匹配"""
results: list[dict] = []
status = "valid"
# 规则1: 发票号格式(33位数字)
no = (inv.invoice_no or "").strip()
if len(no) != INVOICE_NO_LEN or not no.isdigit():
status = "invalid"
results.append({
"rule": "发票号格式",
"passed": False,
"message": f"发票号格式错误:应为{INVOICE_NO_LEN}位纯数字,当前{len(no)}",
})
else:
results.append({"rule": "发票号格式", "passed": True, "message": "33位数字格式正确"})
# 规则2: 金额与报销单匹配
if inv.reimb_no:
reimb = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.reimb_no == inv.reimb_no).first()
if not reimb:
status = "invalid"
results.append({"rule": "报销单匹配", "passed": False, "message": f"报销单{inv.reimb_no}不存在"})
elif abs((inv.amount or 0) - (reimb.amount or 0)) > 0.01:
status = "invalid"
results.append({
"rule": "报销单匹配",
"passed": False,
"message": f"发票金额{inv.amount}元与报销单{inv.reimb_no}金额{reimb.amount}元不符",
})
else:
results.append({"rule": "报销单匹配", "passed": True, "message": f"与报销单{inv.reimb_no}金额一致"})
# 规则3: 供应商与合同匹配
if inv.contract_no:
if not (inv.supplier or "").strip():
status = "invalid"
results.append({"rule": "供应商匹配", "passed": False, "message": f"合同{inv.contract_no}未关联供应商"})
else:
# 同合同下其他发票的供应商一致性
others = (
db.query(InvoiceCheck)
.filter(
InvoiceCheck.contract_no == inv.contract_no,
InvoiceCheck.id != inv.id,
InvoiceCheck.supplier.isnot(None),
)
.all()
)
mismatch = [o.supplier for o in others if o.supplier != inv.supplier]
if mismatch:
status = "invalid"
results.append({
"rule": "供应商匹配",
"passed": False,
"message": f"供应商{inv.supplier}与合同{inv.contract_no}下其他发票供应商{mismatch[0]}不一致",
})
else:
results.append({"rule": "供应商匹配", "passed": True, "message": f"供应商与合同{inv.contract_no}匹配"})
inv.check_result = results
inv.check_status = status
inv.check_reason = "".join(r["message"] for r in results if not r["passed"]) or None
inv.checked_at = datetime.now()
@router.get("/invoices")
def list_invoices(
status: str = Query(None),
keyword: str = Query(None),
entity_id: int = Query(None),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""发票列表"""
q = db.query(InvoiceCheck)
if entity_id:
q = q.filter(InvoiceCheck.entity_id == entity_id)
if status:
q = q.filter(InvoiceCheck.check_status == status)
if keyword:
kw = f"%{keyword}%"
q = q.filter(
(InvoiceCheck.invoice_no.like(kw))
| (InvoiceCheck.supplier.like(kw))
| (InvoiceCheck.reimb_no.like(kw))
)
invoices = q.order_by(InvoiceCheck.id.desc()).all()
return {"data": [_invoice_to_dict(i) for i in invoices], "total": len(invoices)}
@router.post("/invoices")
def create_invoice(
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""录入发票(自动执行校验)"""
invoice_no = data.get("invoice_no")
if not invoice_no:
raise HTTPException(400, "缺少必要参数: invoice_no")
if data.get("amount") is None:
raise HTTPException(400, "缺少必要参数: amount")
inv = InvoiceCheck(
entity_id=data.get("entity_id") or 1,
invoice_no=str(invoice_no).strip(),
amount=data.get("amount"),
invoice_type=data.get("invoice_type") or "vat",
invoice_date=_parse_date(data.get("invoice_date")),
supplier=data.get("supplier"),
reimb_no=data.get("reimb_no"),
contract_no=data.get("contract_no"),
)
_check_invoice(db, inv)
db.add(inv)
db.commit()
db.refresh(inv)
return {"message": "发票已录入并校验", "data": _invoice_to_dict(inv)}
@router.put("/invoices/{invoice_id}")
def update_invoice(
invoice_id: int,
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""更新发票(自动重新校验)"""
inv = db.query(InvoiceCheck).filter(InvoiceCheck.id == invoice_id).first()
if not inv:
raise HTTPException(404, "发票不存在")
if "invoice_no" in data:
inv.invoice_no = str(data["invoice_no"]).strip()
if "amount" in data:
inv.amount = data["amount"]
if "invoice_type" in data:
inv.invoice_type = data["invoice_type"]
if "invoice_date" in data:
inv.invoice_date = _parse_date(data.get("invoice_date"))
if "supplier" in data:
inv.supplier = data.get("supplier")
if "reimb_no" in data:
inv.reimb_no = data.get("reimb_no")
if "contract_no" in data:
inv.contract_no = data.get("contract_no")
_check_invoice(db, inv)
db.commit()
db.refresh(inv)
return {"message": "发票已更新并重新校验", "data": _invoice_to_dict(inv)}
@router.delete("/invoices/{invoice_id}")
def delete_invoice(
invoice_id: int,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""删除发票"""
inv = db.query(InvoiceCheck).filter(InvoiceCheck.id == invoice_id).first()
if not inv:
raise HTTPException(404, "发票不存在")
db.delete(inv)
db.commit()
return {"message": "发票已删除"}
@router.post("/invoices/check")
def batch_check_invoices(
invoice_id: int = Query(None),
entity_id: int = Query(None),
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""批量校验发票(默认校验全部 pending,可指定单张)"""
q = db.query(InvoiceCheck)
if invoice_id:
q = q.filter(InvoiceCheck.id == invoice_id)
elif entity_id:
q = q.filter(InvoiceCheck.entity_id == entity_id)
invoices = q.all()
for inv in invoices:
_check_invoice(db, inv)
db.commit()
abnormal = [i for i in invoices if i.check_status in ("invalid", "warning")]
return {
"message": f"批量校验完成:{len(invoices)}张,异常{len(abnormal)}",
"total": len(invoices),
"abnormal_count": len(abnormal),
}
@router.get("/invoices/abnormal")
def list_abnormal_invoices(
entity_id: int = Query(None),
limit: int = Query(50),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""查询异常发票(invalid/warning"""
q = db.query(InvoiceCheck).filter(InvoiceCheck.check_status.in_(["invalid", "warning"]))
if entity_id:
q = q.filter(InvoiceCheck.entity_id == entity_id)
invoices = q.order_by(InvoiceCheck.id.desc()).limit(limit).all()
return {"data": [_invoice_to_dict(i) for i in invoices], "total": len(invoices)}
# ============================================================
# ③ 社保比对 — 缴费记录 CRUD + 比对 + 异常查询
# ============================================================
def _check_ss(db: Session, s: SocialSecurity, all_records: list | None = None):
"""社保比对规则:基数与工资匹配 / 单位缴纳比例 / 漏缴(含月份断层检测)"""
warnings: list[str] = []
alerts: list[str] = []
# 规则1: 缴费基数与工资匹配(60%~300%区间)
if s.salary and s.salary > 0 and s.base_amount and s.base_amount > 0:
low, high = s.salary * SS_BASE_LOW, s.salary * SS_BASE_HIGH
if s.base_amount < low or s.base_amount > high:
warnings.append(
f"缴费基数{s.base_amount}元超出工资{s.salary}元的{int(SS_BASE_LOW*100)}%~{int(SS_BASE_HIGH*100)}%区间({low:.0f}~{high:.0f})"
)
# 规则2: 单位缴纳比例(养老16%+医疗8%+失业0.5%≈24.5%
rate = None
if s.base_amount and s.base_amount > 0 and s.company_amount is not None:
rate = round(s.company_amount / s.base_amount * 100, 2)
s.company_rate = rate
if rate is not None and abs(rate - SS_COMPANY_RATE) > SS_RATE_TOLERANCE:
warnings.append(f"单位缴纳比例{rate}%与标准{SS_COMPANY_RATE}{SS_RATE_TOLERANCE}%不符")
# 规则3: 漏缴检测
if not s.base_amount or s.base_amount <= 0 or (not s.company_amount and not s.personal_amount):
alerts.append(f"{s.employee}本期缴费基数为0或未缴费(疑似漏缴)")
# 规则3b: 月份断层检测(同人相邻记录期间间隔>1个月 → 漏缴)
if all_records is not None:
periods = sorted(
r.period for r in all_records
if r.employee == s.employee and r.period != s.period
)
prev = None
for p in periods:
if prev is not None:
try:
py, pm = map(int, prev.split("-"))
cy, cm = map(int, p.split("-"))
gap = (cy - py) * 12 + (cm - pm)
if gap > 1:
alerts.append(f"{s.employee}{prev}{p}之间漏缴{max(0, gap-1)}个月")
except Exception:
pass
prev = p
if alerts:
s.check_status = "alert"
elif warnings:
s.check_status = "warning"
else:
s.check_status = "normal"
s.warning_msg = "".join(alerts + warnings) or None
@router.get("/ss")
def list_ss_records(
period: str = Query(None),
employee: str = Query(None),
status: str = Query(None),
entity_id: int = Query(None),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""社保缴费记录列表"""
q = db.query(SocialSecurity)
if entity_id:
q = q.filter(SocialSecurity.entity_id == entity_id)
if period:
q = q.filter(SocialSecurity.period == period)
if employee:
q = q.filter(SocialSecurity.employee.like(f"%{employee}%"))
if status:
q = q.filter(SocialSecurity.check_status == status)
records = q.order_by(SocialSecurity.period.desc(), SocialSecurity.id.asc()).all()
return {"data": [_ss_to_dict(s) for s in records], "total": len(records)}
@router.post("/ss")
def create_ss_record(
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""新增社保缴费记录(自动比对)"""
employee = data.get("employee")
period = data.get("period")
if not employee or not period:
raise HTTPException(400, "缺少必要参数: employee, period")
all_records = db.query(SocialSecurity).filter(SocialSecurity.entity_id == (data.get("entity_id") or 1)).all()
s = SocialSecurity(
entity_id=data.get("entity_id") or 1,
employee=employee,
period=period,
base_amount=data.get("base_amount") or 0,
salary=data.get("salary"),
company_amount=data.get("company_amount") or 0,
personal_amount=data.get("personal_amount") or 0,
remark=data.get("remark"),
)
_check_ss(db, s, all_records)
db.add(s)
db.commit()
db.refresh(s)
return {"message": "社保记录已创建", "data": _ss_to_dict(s)}
@router.put("/ss/{ss_id}")
def update_ss_record(
ss_id: int,
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""更新社保记录(自动重新比对)"""
s = db.query(SocialSecurity).filter(SocialSecurity.id == ss_id).first()
if not s:
raise HTTPException(404, "社保记录不存在")
if "employee" in data:
s.employee = data["employee"]
if "period" in data:
s.period = data["period"]
if "base_amount" in data:
s.base_amount = data["base_amount"] or 0
if "salary" in data:
s.salary = data.get("salary")
if "company_amount" in data:
s.company_amount = data["company_amount"] or 0
if "personal_amount" in data:
s.personal_amount = data["personal_amount"] or 0
if "remark" in data:
s.remark = data.get("remark")
all_records = db.query(SocialSecurity).filter(SocialSecurity.entity_id == s.entity_id).all()
_check_ss(db, s, all_records)
db.commit()
db.refresh(s)
return {"message": "社保记录已更新", "data": _ss_to_dict(s)}
@router.delete("/ss/{ss_id}")
def delete_ss_record(
ss_id: int,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""删除社保记录"""
s = db.query(SocialSecurity).filter(SocialSecurity.id == ss_id).first()
if not s:
raise HTTPException(404, "社保记录不存在")
db.delete(s)
db.commit()
return {"message": "社保记录已删除"}
@router.post("/ss/check")
def batch_check_ss(
entity_id: int = Query(None),
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""批量社保比对"""
q = db.query(SocialSecurity)
if entity_id:
q = q.filter(SocialSecurity.entity_id == entity_id)
records = q.all()
for s in records:
_check_ss(db, s, records)
db.commit()
abnormal = [s for s in records if s.check_status in ("alert", "warning")]
return {
"message": f"社保比对完成:{len(records)}条,异常{len(abnormal)}",
"total": len(records),
"abnormal_count": len(abnormal),
}
@router.get("/ss/abnormal")
def list_abnormal_ss(
entity_id: int = Query(None),
limit: int = Query(50),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""查询社保异常(alert/warning"""
q = db.query(SocialSecurity).filter(SocialSecurity.check_status.in_(["alert", "warning"]))
if entity_id:
q = q.filter(SocialSecurity.entity_id == entity_id)
records = q.order_by(SocialSecurity.period.desc(), SocialSecurity.id.desc()).limit(limit).all()
return {"data": [_ss_to_dict(s) for s in records], "total": len(records)}
# ============================================================
# ④ 税务看板聚合
# ============================================================
@router.get("/dashboard")
def tax_dashboard(
entity_id: int = Query(None),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""税务合规看板聚合:税负趋势+行业对比 / 发票异常 / 社保异常"""
eid = entity_id or 1
# 税负监控
records = db.query(TaxRecord).filter(TaxRecord.entity_id == eid).order_by(TaxRecord.period.asc()).all()
for t in records:
_calc_burden(t)
db.commit()
trend_map: dict[str, dict] = {}
for t in records:
if t.tax_burden_rate is None:
continue
entry = trend_map.setdefault(t.period, {"period": t.period})
entry[f"{t.tax_type}_rate"] = t.tax_burden_rate
entry[f"{t.tax_type}_benchmark"] = TAX_BENCHMARKS.get(t.tax_type)
burden_trend = sorted(trend_map.values(), key=lambda x: x["period"])
latest_period = max((t.period for t in records), default=None)
latest = next((x for x in burden_trend if x["period"] == latest_period), None)
tax_alerts = [t for t in records if t.burden_status == "alert"]
# 发票
invoices = db.query(InvoiceCheck).filter(InvoiceCheck.entity_id == eid).all()
inv_status_count = {"pending": 0, "valid": 0, "invalid": 0, "warning": 0}
for i in invoices:
inv_status_count[i.check_status] = inv_status_count.get(i.check_status, 0) + 1
inv_abnormal = (
db.query(InvoiceCheck)
.filter(InvoiceCheck.entity_id == eid, InvoiceCheck.check_status.in_(["invalid", "warning"]))
.order_by(InvoiceCheck.id.desc())
.limit(10)
.all()
)
# 社保
ss_records = db.query(SocialSecurity).filter(SocialSecurity.entity_id == eid).all()
ss_status_count = {"normal": 0, "warning": 0, "alert": 0}
for s in ss_records:
ss_status_count[s.check_status] = ss_status_count.get(s.check_status, 0) + 1
ss_abnormal = (
db.query(SocialSecurity)
.filter(SocialSecurity.entity_id == eid, SocialSecurity.check_status.in_(["alert", "warning"]))
.order_by(SocialSecurity.period.desc(), SocialSecurity.id.desc())
.limit(10)
.all()
)
return {
"entity_id": eid,
"burden": {
"trend": burden_trend,
"latest": latest,
"benchmarks": TAX_BENCHMARKS,
"alert_count": len(tax_alerts),
"alerts": [_tax_to_dict(t) for t in tax_alerts[:10]],
},
"invoice": {
"total": len(invoices),
"status_count": inv_status_count,
"abnormal": [_invoice_to_dict(i) for i in inv_abnormal],
},
"ss": {
"total": len(ss_records),
"status_count": ss_status_count,
"abnormal": [_ss_to_dict(s) for s in ss_abnormal],
},
}
# ============================================================
# 演示数据(幂等)
# ============================================================
@router.post("/demo-data")
def seed_demo_data(
entity_id: int = Query(1),
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""生成税务合规演示数据(幂等:已存在期间+税种则跳过)"""
eid = entity_id or 1
created = {"tax": 0, "invoice": 0, "ss": 0}
# 税负记录: 2026-01 ~ 2026-07,其中02月增值税故意偏高触发预警
tax_demo = [
("2026-01", "vat", 52000, 48800, 1380000, 13),
("2026-02", "vat", 69000, 66000, 1460000, 13),
("2026-03", "vat", 41000, 39800, 1280000, 13),
("2026-04", "vat", 47500, 46200, 1350000, 13),
("2026-05", "vat", 53000, 51000, 1420000, 13),
("2026-06", "vat", 49800, 48200, 1400000, 13),
("2026-01", "income", 36000, 33500, 1380000, 25),
("2026-02", "income", 38000, 35500, 1460000, 25),
("2026-03", "income", 33000, 31000, 1280000, 25),
("2026-04", "income", 35000, 32800, 1350000, 25),
("2026-05", "income", 37000, 34800, 1420000, 25),
("2026-06", "income", 36500, 34000, 1400000, 25),
("2026-01", "surtax", 6240, 5900, 1380000, 12),
("2026-02", "surtax", 8280, 7900, 1460000, 12),
("2026-03", "surtax", 4920, 4700, 1280000, 12),
("2026-04", "surtax", 5700, 5500, 1350000, 12),
("2026-05", "surtax", 6360, 6100, 1420000, 12),
("2026-06", "surtax", 5976, 5800, 1400000, 12),
]
for period, ttype, payable, paid, income, rate in tax_demo:
exists = (
db.query(TaxRecord)
.filter(TaxRecord.entity_id == eid, TaxRecord.period == period, TaxRecord.tax_type == ttype)
.first()
)
if exists:
continue
t = TaxRecord(entity_id=eid, period=period, tax_type=ttype, tax_payable=payable,
tax_paid=paid, tax_rate=rate, income=income)
_calc_burden(t)
db.add(t)
created["tax"] += 1
# 报销单(供发票"金额与报销单匹配"规则使用)
reimb_demo = [
{"reimb_no": "BX202606150001", "applicant": "张伟", "department": "采购部", "expense_type": "office",
"title": "6月办公用品采购", "amount": 5600, "status": "approved"},
{"reimb_no": "BX202606200002", "applicant": "李娜", "department": "市场部", "expense_type": "management",
"title": "6月市场信息服务费", "amount": 3200, "status": "approved"},
{"reimb_no": "BX202607050003", "applicant": "王强", "department": "供应链部", "expense_type": "office",
"title": "7月供应链物流服务", "amount": 14000, "status": "pending"},
]
for d in reimb_demo:
exists = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.reimb_no == d["reimb_no"]).first()
if exists:
continue
db.add(ExpenseReimbursement(**d))
# 发票: 正常2张(金额匹配) + 格式错误1张 + 金额不符1张 + 供应商与合同不符1张
invoice_demo = [
{"invoice_no": "9" * 33, "amount": 5600, "invoice_type": "vat", "invoice_date": "2026-06-15",
"supplier": "北京云启科技有限公司", "reimb_no": "BX202606150001", "contract_no": "HT-2026-018"},
{"invoice_no": "8" * 33, "amount": 3200, "invoice_type": "electronic", "invoice_date": "2026-06-20",
"supplier": "上海数联信息服务有限公司", "reimb_no": "BX202606200002", "contract_no": "HT-2026-021"},
{"invoice_no": "12345ABC", "amount": 1800, "invoice_type": "vat", "invoice_date": "2026-07-02",
"supplier": "广州锐思咨询有限公司", "reimb_no": None, "contract_no": None},
{"invoice_no": "7" * 33, "amount": 15000, "invoice_type": "vat", "invoice_date": "2026-07-05",
"supplier": "深圳恒达供应链有限公司", "reimb_no": "BX202607050003", "contract_no": "HT-2026-030"},
{"invoice_no": "6" * 33, "amount": 9800, "invoice_type": "vat", "invoice_date": "2026-07-08",
"supplier": None, "reimb_no": None, "contract_no": "HT-2026-033"},
]
for d in invoice_demo:
exists = (
db.query(InvoiceCheck)
.filter(InvoiceCheck.entity_id == eid, InvoiceCheck.invoice_no == d["invoice_no"])
.first()
)
if exists:
continue
inv = InvoiceCheck(entity_id=eid, **d)
_check_invoice(db, inv)
db.add(inv)
created["invoice"] += 1
# 社保: 正常3人×2月 + 基数不符1条 + 漏缴1条
ss_demo = [
{"employee": "张伟", "period": "2026-06", "base_amount": 12000, "salary": 12000, "company_amount": 2940, "personal_amount": 1248},
{"employee": "张伟", "period": "2026-07", "base_amount": 12000, "salary": 12000, "company_amount": 2940, "personal_amount": 1248},
{"employee": "李娜", "period": "2026-06", "base_amount": 18000, "salary": 18000, "company_amount": 4410, "personal_amount": 1872},
{"employee": "李娜", "period": "2026-07", "base_amount": 18000, "salary": 18000, "company_amount": 4410, "personal_amount": 1872},
{"employee": "王强", "period": "2026-06", "base_amount": 9000, "salary": 9000, "company_amount": 2205, "personal_amount": 936},
{"employee": "王强", "period": "2026-07", "base_amount": 9000, "salary": 9000, "company_amount": 2205, "personal_amount": 936},
{"employee": "赵敏", "period": "2026-06", "base_amount": 5000, "salary": 20000, "company_amount": 1225, "personal_amount": 520},
{"employee": "孙磊", "period": "2026-05", "base_amount": 0, "salary": 15000, "company_amount": 0, "personal_amount": 0},
]
for d in ss_demo:
exists = (
db.query(SocialSecurity)
.filter(SocialSecurity.entity_id == eid, SocialSecurity.employee == d["employee"],
SocialSecurity.period == d["period"])
.first()
)
if exists:
continue
s = SocialSecurity(entity_id=eid, **d)
all_records = db.query(SocialSecurity).filter(SocialSecurity.entity_id == eid).all()
_check_ss(db, s, all_records)
db.add(s)
created["ss"] += 1
db.commit()
return {"message": "演示数据生成完成", "created": created}
+2 -1
View File
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from dotenv import load_dotenv
from app.database import init_db
from app.api import auth, kpis, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, bot_iron_law, analysis_results, expenses, cash
from app.api import auth, kpis, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, bot_iron_law, analysis_results, expenses, cash, tax_compliance
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
from scripts.erp_sync import run_sync as run_erp_sync
from app.auth_middleware import require_auth
@@ -75,6 +75,7 @@ app.include_router(bot_iron_law.router)
app.include_router(analysis_results.router)
app.include_router(expenses.router)
app.include_router(cash.router)
app.include_router(tax_compliance.router)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
+63
View File
@@ -592,3 +592,66 @@ class ExpenseReimbursement(Base):
created_by = Column(String(100), nullable=True, comment="提交人")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# ============================================================
# 税务合规智能体 (2026-08)
# ① 税负监控 ② 发票校验 ③ 社保比对
# ============================================================
class TaxRecord(Base):
"""税务记录 — 税负监控:应纳税额/实缴额/税负率 vs 行业基准"""
__tablename__ = "tax_records"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID")
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
tax_type = Column(String(30), nullable=False, comment="税种: vat增值税/income所得税/surtax附加税")
tax_payable = Column(Float, default=0, comment="应纳税额")
tax_paid = Column(Float, default=0, comment="实缴税额")
tax_rate = Column(Float, nullable=True, comment="适用税率 %")
income = Column(Float, default=0, comment="当期收入/计税收入(税负率分母)")
tax_burden_rate = Column(Float, nullable=True, comment="税负率 % = 实缴税额/收入×100")
burden_status = Column(String(20), default="normal", comment="normal正常/warning超基准±20%内/alert超基准±20%")
warning_msg = Column(String(500), nullable=True, comment="预警信息")
remark = Column(String(500), nullable=True, comment="备注")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class InvoiceCheck(Base):
"""发票校验 — 录入后按规则自动校验:号码格式/金额匹配报销单/供应商匹配合同"""
__tablename__ = "invoice_check"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID")
invoice_no = Column(String(50), nullable=False, comment="发票号码")
amount = Column(Float, nullable=False, comment="发票金额")
invoice_type = Column(String(30), default="vat", comment="发票类型: vat专用/vat普通/electronic电子/other其他")
invoice_date = Column(DateTime, nullable=True, comment="开票日期")
supplier = Column(String(200), nullable=True, comment="供应商名称")
reimb_no = Column(String(50), nullable=True, comment="关联报销单号")
contract_no = Column(String(50), nullable=True, comment="关联合同编号")
check_status = Column(String(20), default="pending", comment="校验状态: pending待校验/valid通过/invalid异常/warning提醒")
check_result = Column(JSON, nullable=True, comment="校验明细: [{rule, passed, message}]")
check_reason = Column(String(1000), nullable=True, comment="异常原因汇总")
checked_at = Column(DateTime, nullable=True, comment="校验时间")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class SocialSecurity(Base):
"""社保缴费记录 — 社保比对:基数与工资匹配/单位缴纳比例/漏缴提醒"""
__tablename__ = "social_security"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID")
employee = Column(String(100), nullable=False, comment="人员姓名")
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
base_amount = Column(Float, default=0, comment="缴费基数")
salary = Column(Float, nullable=True, comment="申报工资")
company_amount = Column(Float, default=0, comment="单位缴纳金额")
personal_amount = Column(Float, default=0, comment="个人缴纳金额")
company_rate = Column(Float, nullable=True, comment="单位缴纳比例 % (养老16%+医疗8%+失业0.5%≈24.5%)")
check_status = Column(String(20), default="normal", comment="normal正常/warning基数或比例异常/alert漏缴")
warning_msg = Column(String(500), nullable=True, comment="异常提醒: 漏缴/基数不符/比例异常")
remark = Column(String(500), nullable=True, comment="备注")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+28
View File
@@ -346,4 +346,32 @@ export const cashApi = {
alertStatus: (params?: any) => api.get('/cash/alerts/status', { params }),
}
// ── 税务合规智能体:税负监控 + 发票校验 + 社保比对 ──
export const taxApi = {
// ① 税负监控
listTaxRecords: (params?: any) => api.get('/tax/records', { params }),
createTaxRecord: (data: any) => api.post('/tax/records', data),
updateTaxRecord: (id: number, data: any) => api.put(`/tax/records/${id}`, data),
deleteTaxRecord: (id: number) => api.delete(`/tax/records/${id}`),
burdenAnalysis: (params?: any) => api.get('/tax/burden', { params }),
runTaxCheck: (params?: any) => api.post('/tax/check', null, { params }),
// ② 发票校验
listInvoices: (params?: any) => api.get('/tax/invoices', { params }),
createInvoice: (data: any) => api.post('/tax/invoices', data),
updateInvoice: (id: number, data: any) => api.put(`/tax/invoices/${id}`, data),
deleteInvoice: (id: number) => api.delete(`/tax/invoices/${id}`),
batchCheckInvoices: (params?: any) => api.post('/tax/invoices/check', null, { params }),
abnormalInvoices: (params?: any) => api.get('/tax/invoices/abnormal', { params }),
// ③ 社保比对
listSsRecords: (params?: any) => api.get('/tax/ss', { params }),
createSsRecord: (data: any) => api.post('/tax/ss', data),
updateSsRecord: (id: number, data: any) => api.put(`/tax/ss/${id}`, data),
deleteSsRecord: (id: number) => api.delete(`/tax/ss/${id}`),
batchCheckSs: (params?: any) => api.post('/tax/ss/check', null, { params }),
abnormalSs: (params?: any) => api.get('/tax/ss/abnormal', { params }),
// 看板聚合 + 演示数据
dashboard: (params?: any) => api.get('/tax/dashboard', { params }),
seedDemo: (params?: any) => api.post('/tax/demo-data', null, { params }),
}
export default api
+5 -4
View File
@@ -9,10 +9,10 @@ interface MenuItem {
// ── 角色路由映射 ──
export const ROLE_ROUTES: Record<string, string[]> = {
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan'],
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan'],
business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan'],
it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan'],
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/tax-compliance'],
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/tax-compliance'],
business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/tax-compliance'],
it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/tax-compliance'],
}
export const ROLE_ACTIONS: Record<string, string[]> = {
@@ -33,6 +33,7 @@ export const MENU_ITEMS: MenuItem[] = [
// ── GROUP 2: 执行与控制(Do)──
{ path: '/cost', label: '成本分析', icon: 'Money', roles: ['ceo', 'finance', 'it'], group: '🟢 D 执行与控制' },
{ path: '/expenses', label: '费用审核', icon: 'Money', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
{ path: '/tax-compliance', label: '税务合规', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
{ path: '/cash-plan', label: '收付款计划', icon: 'Money', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
{ path: '/deviations', label: '差异分析', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
+1
View File
@@ -43,6 +43,7 @@ const routes = [
{ path: 'analysis-confidence', name: 'AnalysisConfidence', component: () => import('@/views/AnalysisConfidence.vue'), meta: { title: '分析置信度', roles: ['ceo', 'finance', 'it'] } },
{ path: 'expenses', name: 'ExpenseManage', component: () => import('@/views/ExpenseManage.vue'), meta: { title: '费用审核', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'cash-plan', name: 'CashPlan', component: () => import('@/views/CashPlan.vue'), meta: { title: '收付款计划', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'tax-compliance', name: 'TaxCompliance', component: () => import('@/views/TaxCompliance.vue'), meta: { title: '税务合规', roles: ['ceo', 'finance', 'business', 'it'] } },
]
},
]
+787
View File
@@ -0,0 +1,787 @@
<template>
<div class="tax-compliance">
<!-- 顶部统计卡片 -->
<div class="stat-cards">
<div class="stat-card" @click="switchTab('burden')">
<div class="stat-num alert">{{ stats.burden?.alert_count ?? 0 }}</div>
<div class="stat-label">税负预警</div>
</div>
<div class="stat-card" @click="switchTab('invoice')">
<div class="stat-num danger">{{ (stats.invoice?.status_count?.invalid || 0) + (stats.invoice?.status_count?.warning || 0) }}</div>
<div class="stat-label">发票异常</div>
</div>
<div class="stat-card" @click="switchTab('ss')">
<div class="stat-num warning">{{ (stats.ss?.status_count?.alert || 0) + (stats.ss?.status_count?.warning || 0) }}</div>
<div class="stat-label">社保异常</div>
</div>
<div class="stat-card" @click="switchTab('invoice')">
<div class="stat-num normal">{{ stats.invoice?.total ?? 0 }}</div>
<div class="stat-label">发票总量</div>
</div>
<div class="stat-card" @click="switchTab('ss')">
<div class="stat-num normal">{{ stats.ss?.total ?? 0 }}</div>
<div class="stat-label">社保记录</div>
</div>
<div class="stat-actions">
<el-button type="primary" plain size="small" :loading="seeding" @click="seedDemo">生成演示数据</el-button>
<el-button type="success" plain size="small" :loading="checkingAll" @click="runAllChecks">执行全部检查</el-button>
</div>
</div>
<!-- 标签页 -->
<el-tabs v-model="activeTab" class="tax-tabs">
<!-- 看板 -->
<el-tab-pane label="税务看板" name="dashboard">
<div class="panel">
<div class="panel-title">税负率趋势 vs 行业均值 <span class="panel-sub">增值税3.5% / 所得税2.5% / 附加税0.5%±20%预警</span></div>
<div ref="trendChartRef" class="chart-lg"></div>
<div v-if="!hasTrend" class="empty-tip">暂无税负数据请先在税负监控录入或点击右上角生成演示数据</div>
</div>
<el-row :gutter="16">
<el-col :span="12">
<div class="panel">
<div class="panel-title">发票异常列表 <el-button text type="primary" size="small" @click="switchTab('invoice')">查看全部 </el-button></div>
<el-table :data="stats.invoice?.abnormal || []" size="small" max-height="320" empty-text="暂无异常发票">
<el-table-column prop="invoice_no" label="发票号" min-width="140" show-overflow-tooltip />
<el-table-column label="金额" width="110">
<template #default="{ row }">¥{{ fmt(row.amount) }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }"><el-tag :type="invStatusTag(row.check_status)" size="small">{{ invStatusLabel(row.check_status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="check_reason" label="异常原因" min-width="200" show-overflow-tooltip />
</el-table>
</div>
</el-col>
<el-col :span="12">
<div class="panel">
<div class="panel-title">社保比对异常列表 <el-button text type="primary" size="small" @click="switchTab('ss')">查看全部 </el-button></div>
<el-table :data="stats.ss?.abnormal || []" size="small" max-height="320" empty-text="暂无社保异常">
<el-table-column prop="employee" label="人员" width="90" />
<el-table-column prop="period" label="期间" width="90" />
<el-table-column label="状态" width="90">
<template #default="{ row }"><el-tag :type="ssStatusTag(row.check_status)" size="small">{{ ssStatusLabel(row.check_status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="warning_msg" label="异常提醒" min-width="220" show-overflow-tooltip />
</el-table>
</div>
</el-col>
</el-row>
</el-tab-pane>
<!-- 税负监控 -->
<el-tab-pane label="税负监控" name="burden">
<div class="toolbar">
<el-select v-model="taxFilters.tax_type" placeholder="税种" clearable size="small" style="width:130px" @change="loadTaxRecords">
<el-option label="增值税" value="vat" /><el-option label="所得税" value="income" /><el-option label="附加税" value="surtax" />
</el-select>
<el-input v-model="taxFilters.period" placeholder="期间 YYYY-MM" clearable size="small" style="width:150px" @keyup.enter="loadTaxRecords" @clear="loadTaxRecords" />
<el-button size="small" type="primary" @click="loadTaxRecords">查询</el-button>
<el-button size="small" type="warning" plain :loading="checkingTax" @click="runTaxCheck">重算税负率+预警</el-button>
<el-button size="small" type="primary" plain @click="openTaxDialog()">新增税务记录</el-button>
</div>
<div class="panel">
<div class="panel-title">税负率趋势</div>
<div ref="burdenChartRef" class="chart-md"></div>
</div>
<div class="panel">
<el-table :data="taxRecords" size="small" v-loading="loadingTax" empty-text="暂无税务记录">
<el-table-column prop="period" label="期间" width="90" />
<el-table-column label="税种" width="90">
<template #default="{ row }"><el-tag size="small" :type="taxTypeTag(row.tax_type)">{{ row.tax_type_label }}</el-tag></template>
</el-table-column>
<el-table-column label="应纳税额" width="110"><template #default="{ row }">¥{{ fmt(row.tax_payable) }}</template></el-table-column>
<el-table-column label="实缴税额" width="110"><template #default="{ row }">¥{{ fmt(row.tax_paid) }}</template></el-table-column>
<el-table-column label="收入" width="120"><template #default="{ row }">¥{{ fmt(row.income) }}</template></el-table-column>
<el-table-column label="税率" width="80"><template #default="{ row }">{{ row.tax_rate ?? '-' }}%</template></el-table-column>
<el-table-column label="税负率" width="100">
<template #default="{ row }">
<span :class="{ 'text-alert': row.burden_status === 'alert' }">{{ row.tax_burden_rate != null ? row.tax_burden_rate + '%' : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="行业均值" width="90"><template #default="{ row }">{{ row.benchmark != null ? row.benchmark + '%' : '-' }}</template></el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }"><el-tag size="small" :type="burdenTag(row.burden_status)">{{ burdenLabel(row.burden_status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="warning_msg" label="预警信息" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="openTaxDialog(row)">编辑</el-button>
<el-button text type="danger" size="small" @click="deleteTax(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<!-- 发票校验 -->
<el-tab-pane label="发票校验" name="invoice">
<div class="toolbar">
<el-select v-model="invFilters.status" placeholder="校验状态" clearable size="small" style="width:130px" @change="loadInvoices">
<el-option label="待校验" value="pending" /><el-option label="通过" value="valid" /><el-option label="异常" value="invalid" /><el-option label="提醒" value="warning" />
</el-select>
<el-input v-model="invFilters.keyword" placeholder="发票号/供应商/报销单号" clearable size="small" style="width:220px" @keyup.enter="loadInvoices" @clear="loadInvoices" />
<el-button size="small" type="primary" @click="loadInvoices">查询</el-button>
<el-button size="small" type="warning" plain :loading="checkingInv" @click="batchCheckInvoices">批量校验</el-button>
<el-button size="small" type="danger" plain @click="loadAbnormalInvoices">只看异常</el-button>
<el-button size="small" type="primary" plain @click="openInvoiceDialog()">录入发票</el-button>
</div>
<div class="panel">
<el-table :data="invoices" size="small" v-loading="loadingInv" empty-text="暂无发票">
<el-table-column prop="invoice_no" label="发票号" min-width="150" show-overflow-tooltip />
<el-table-column label="金额" width="110"><template #default="{ row }">¥{{ fmt(row.amount) }}</template></el-table-column>
<el-table-column label="类型" width="110"><template #default="{ row }">{{ row.invoice_type_label }}</template></el-table-column>
<el-table-column label="开票日期" width="105"><template #default="{ row }">{{ (row.invoice_date || '').slice(0, 10) }}</template></el-table-column>
<el-table-column prop="supplier" label="供应商" min-width="140" show-overflow-tooltip />
<el-table-column prop="reimb_no" label="报销单号" width="130" show-overflow-tooltip />
<el-table-column prop="contract_no" label="合同号" width="110" show-overflow-tooltip />
<el-table-column label="状态" width="90">
<template #default="{ row }"><el-tag size="small" :type="invStatusTag(row.check_status)">{{ invStatusLabel(row.check_status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="check_reason" label="异常原因" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="openInvoiceDialog(row)">编辑</el-button>
<el-button text type="danger" size="small" @click="deleteInvoice(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<!-- 社保比对 -->
<el-tab-pane label="社保比对" name="ss">
<div class="toolbar">
<el-input v-model="ssFilters.employee" placeholder="人员姓名" clearable size="small" style="width:150px" @keyup.enter="loadSs" @clear="loadSs" />
<el-input v-model="ssFilters.period" placeholder="期间 YYYY-MM" clearable size="small" style="width:150px" @keyup.enter="loadSs" @clear="loadSs" />
<el-button size="small" type="primary" @click="loadSs">查询</el-button>
<el-button size="small" type="warning" plain :loading="checkingSs" @click="batchCheckSs">批量比对</el-button>
<el-button size="small" type="danger" plain @click="loadAbnormalSs">只看异常</el-button>
<el-button size="small" type="primary" plain @click="openSsDialog()">新增缴费记录</el-button>
</div>
<div class="panel">
<el-table :data="ssRecords" size="small" v-loading="loadingSs" empty-text="暂无社保记录">
<el-table-column prop="employee" label="人员" width="100" />
<el-table-column prop="period" label="期间" width="90" />
<el-table-column label="缴费基数" width="110"><template #default="{ row }">¥{{ fmt(row.base_amount) }}</template></el-table-column>
<el-table-column label="申报工资" width="110"><template #default="{ row }">¥{{ fmt(row.salary) }}</template></el-table-column>
<el-table-column label="单位缴纳" width="110"><template #default="{ row }">¥{{ fmt(row.company_amount) }}</template></el-table-column>
<el-table-column label="个人缴纳" width="110"><template #default="{ row }">¥{{ fmt(row.personal_amount) }}</template></el-table-column>
<el-table-column label="单位比例" width="90"><template #default="{ row }">{{ row.company_rate != null ? row.company_rate + '%' : '-' }}</template></el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }"><el-tag size="small" :type="ssStatusTag(row.check_status)">{{ ssStatusLabel(row.check_status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="warning_msg" label="异常提醒" min-width="220" show-overflow-tooltip />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="openSsDialog(row)">编辑</el-button>
<el-button text type="danger" size="small" @click="deleteSs(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
<!-- 税务记录对话框 -->
<MyDialog v-model="taxDialogVisible" title="税务记录" :width="520">
<el-form label-width="90px" size="small">
<el-form-item label="期间" required>
<el-input v-model="taxForm.period" placeholder="YYYY-MM,如 2026-07" />
</el-form-item>
<el-form-item label="税种" required>
<el-select v-model="taxForm.tax_type" style="width:100%">
<el-option label="增值税" value="vat" /><el-option label="所得税" value="income" /><el-option label="附加税" value="surtax" />
</el-select>
</el-form-item>
<el-form-item label="应纳税额"><el-input-number v-model="taxForm.tax_payable" :min="0" :precision="2" style="width:100%" /></el-form-item>
<el-form-item label="实缴税额"><el-input-number v-model="taxForm.tax_paid" :min="0" :precision="2" style="width:100%" /></el-form-item>
<el-form-item label="当期收入"><el-input-number v-model="taxForm.income" :min="0" :precision="2" style="width:100%" /></el-form-item>
<el-form-item label="适用税率"><el-input-number v-model="taxForm.tax_rate" :min="0" :precision="1" style="width:100%" /></el-form-item>
<el-form-item label="备注"><el-input v-model="taxForm.remark" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer>
<el-button size="small" @click="taxDialogVisible = false">取消</el-button>
<el-button size="small" type="primary" :loading="savingTax" @click="saveTax">保存自动计算税负率</el-button>
</template>
</MyDialog>
<!-- 发票对话框 -->
<MyDialog v-model="invDialogVisible" title="录入/编辑发票" :width="520">
<el-form label-width="100px" size="small">
<el-form-item label="发票号" required>
<el-input v-model="invForm.invoice_no" placeholder="33位数字发票号码" />
</el-form-item>
<el-form-item label="金额" required>
<el-input-number v-model="invForm.amount" :min="0" :precision="2" style="width:100%" />
</el-form-item>
<el-form-item label="发票类型">
<el-select v-model="invForm.invoice_type" style="width:100%">
<el-option label="增值税专用发票" value="vat" /><el-option label="增值税普通发票" value="vat_normal" />
<el-option label="电子发票" value="electronic" /><el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item label="开票日期"><el-input v-model="invForm.invoice_date" placeholder="YYYY-MM-DD" /></el-form-item>
<el-form-item label="供应商"><el-input v-model="invForm.supplier" /></el-form-item>
<el-form-item label="报销单号"><el-input v-model="invForm.reimb_no" placeholder="如 BX202607050003" /></el-form-item>
<el-form-item label="合同编号"><el-input v-model="invForm.contract_no" placeholder="如 HT-2026-018" /></el-form-item>
</el-form>
<template #footer>
<el-button size="small" @click="invDialogVisible = false">取消</el-button>
<el-button size="small" type="primary" :loading="savingInv" @click="saveInvoice">保存自动校验</el-button>
</template>
</MyDialog>
<!-- 社保对话框 -->
<MyDialog v-model="ssDialogVisible" title="社保缴费记录" :width="520">
<el-form label-width="100px" size="small">
<el-form-item label="人员" required><el-input v-model="ssForm.employee" /></el-form-item>
<el-form-item label="期间" required><el-input v-model="ssForm.period" placeholder="YYYY-MM" /></el-form-item>
<el-form-item label="缴费基数"><el-input-number v-model="ssForm.base_amount" :min="0" :precision="2" style="width:100%" /></el-form-item>
<el-form-item label="申报工资"><el-input-number v-model="ssForm.salary" :min="0" :precision="2" style="width:100%" /></el-form-item>
<el-form-item label="单位缴纳"><el-input-number v-model="ssForm.company_amount" :min="0" :precision="2" style="width:100%" /></el-form-item>
<el-form-item label="个人缴纳"><el-input-number v-model="ssForm.personal_amount" :min="0" :precision="2" style="width:100%" /></el-form-item>
<el-form-item label="备注"><el-input v-model="ssForm.remark" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer>
<el-button size="small" @click="ssDialogVisible = false">取消</el-button>
<el-button size="small" type="primary" :loading="savingSs" @click="saveSs">保存自动比对</el-button>
</template>
</MyDialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { taxApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue'
function getEntityId() {
return Number(localStorage.getItem('cma_entity_id') || 1)
}
//
const activeTab = ref('dashboard')
const stats = ref<any>({})
const seeding = ref(false)
const checkingAll = ref(false)
//
const taxRecords = ref<any[]>([])
const loadingTax = ref(false)
const checkingTax = ref(false)
const taxFilters = reactive({ tax_type: '', period: '' })
const trendChartRef = ref<HTMLElement | null>(null)
const burdenChartRef = ref<HTMLElement | null>(null)
//
const invoices = ref<any[]>([])
const loadingInv = ref(false)
const checkingInv = ref(false)
const invFilters = reactive({ status: '', keyword: '' })
//
const ssRecords = ref<any[]>([])
const loadingSs = ref(false)
const checkingSs = ref(false)
const ssFilters = reactive({ employee: '', period: '' })
//
const taxDialogVisible = ref(false)
const savingTax = ref(false)
const taxForm = reactive<any>({ id: 0, period: '', tax_type: 'vat', tax_payable: 0, tax_paid: 0, income: 0, tax_rate: null, remark: '' })
const invDialogVisible = ref(false)
const savingInv = ref(false)
const invForm = reactive<any>({ id: 0, invoice_no: '', amount: 0, invoice_type: 'vat', invoice_date: '', supplier: '', reimb_no: '', contract_no: '' })
const ssDialogVisible = ref(false)
const savingSs = ref(false)
const ssForm = reactive<any>({ id: 0, employee: '', period: '', base_amount: 0, salary: null, company_amount: 0, personal_amount: 0, remark: '' })
//
function fmt(v: any): string {
if (v === null || v === undefined) return '0'
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function taxTypeTag(t: string): string {
const m: Record<string, string> = { vat: 'primary', income: 'success', surtax: 'warning' }
return m[t] || 'info'
}
function burdenLabel(s: string): string {
const m: Record<string, string> = { normal: '正常', alert: '超限' }
return m[s] || s
}
function burdenTag(s: string): string {
const m: Record<string, string> = { normal: 'success', alert: 'danger' }
return m[s] || 'info'
}
function invStatusLabel(s: string): string {
const m: Record<string, string> = { pending: '待校验', valid: '通过', invalid: '异常', warning: '提醒' }
return m[s] || s
}
function invStatusTag(s: string): string {
const m: Record<string, string> = { pending: 'info', valid: 'success', invalid: 'danger', warning: 'warning' }
return m[s] || 'info'
}
function ssStatusLabel(s: string): string {
const m: Record<string, string> = { normal: '正常', warning: '异常', alert: '漏缴' }
return m[s] || s
}
function ssStatusTag(s: string): string {
const m: Record<string, string> = { normal: 'success', warning: 'warning', alert: 'danger' }
return m[s] || 'info'
}
const hasTrend = ref(false)
function refreshHasTrend() {
const trend = stats.value?.burden?.trend || []
hasTrend.value = trend.length > 0
}
function switchTab(name: string) {
activeTab.value = name
}
//
async function loadDashboard() {
try {
const r: any = await taxApi.dashboard({ entity_id: getEntityId() })
stats.value = r || {}
refreshHasTrend()
renderTrendChart()
} catch (e: any) {
ElMessage.error('看板加载失败: ' + (e?.response?.data?.detail || e.message))
}
}
async function seedDemo() {
seeding.value = true
try {
const r: any = await taxApi.seedDemo({ entity_id: getEntityId() })
ElMessage.success(r?.message || '演示数据已生成')
await Promise.all([loadDashboard(), loadTaxRecords(), loadInvoices(), loadSs()])
} catch (e: any) {
ElMessage.error('生成失败: ' + (e?.response?.data?.detail || e.message))
} finally {
seeding.value = false
}
}
async function runAllChecks() {
checkingAll.value = true
try {
await taxApi.runTaxCheck({ entity_id: getEntityId() })
await taxApi.batchCheckInvoices({ entity_id: getEntityId() })
await taxApi.batchCheckSs({ entity_id: getEntityId() })
ElMessage.success('税负/发票/社保全部检查完成')
await Promise.all([loadDashboard(), loadTaxRecords(), loadInvoices(), loadSs()])
} catch (e: any) {
ElMessage.error('检查失败: ' + (e?.response?.data?.detail || e.message))
} finally {
checkingAll.value = false
}
}
//
async function loadTaxRecords() {
loadingTax.value = true
try {
const r: any = await taxApi.listTaxRecords({
entity_id: getEntityId(),
tax_type: taxFilters.tax_type || undefined,
period: taxFilters.period || undefined,
})
taxRecords.value = r.data || []
renderBurdenChart()
} catch (e: any) {
ElMessage.error('税负记录加载失败: ' + (e?.response?.data?.detail || e.message))
} finally {
loadingTax.value = false
}
}
async function runTaxCheck() {
checkingTax.value = true
try {
const r: any = await taxApi.runTaxCheck({ entity_id: getEntityId() })
ElMessage.success(r?.message || '税负检查完成')
await Promise.all([loadDashboard(), loadTaxRecords()])
} catch (e: any) {
ElMessage.error('检查失败: ' + (e?.response?.data?.detail || e.message))
} finally {
checkingTax.value = false
}
}
function openTaxDialog(row?: any) {
if (row) {
Object.assign(taxForm, { id: row.id, period: row.period, tax_type: row.tax_type, tax_payable: row.tax_payable, tax_paid: row.tax_paid, income: row.income, tax_rate: row.tax_rate, remark: row.remark || '' })
} else {
Object.assign(taxForm, { id: 0, period: '', tax_type: 'vat', tax_payable: 0, tax_paid: 0, income: 0, tax_rate: null, remark: '' })
}
taxDialogVisible.value = true
}
async function saveTax() {
if (!taxForm.period || !taxForm.tax_type) {
ElMessage.warning('请填写期间和税种')
return
}
savingTax.value = true
try {
const payload = { ...taxForm }
delete payload.id
if (taxForm.id) {
await taxApi.updateTaxRecord(taxForm.id, payload)
ElMessage.success('税务记录已更新')
} else {
await taxApi.createTaxRecord(payload)
ElMessage.success('税务记录已创建')
}
taxDialogVisible.value = false
await Promise.all([loadDashboard(), loadTaxRecords()])
} catch (e: any) {
ElMessage.error('保存失败: ' + (e?.response?.data?.detail || e.message))
} finally {
savingTax.value = false
}
}
async function deleteTax(row: any) {
try {
await ElMessageBox.confirm(`确认删除 ${row.period} ${row.tax_type_label} 记录?`, '删除确认', { type: 'warning' })
await taxApi.deleteTaxRecord(row.id)
ElMessage.success('已删除')
await Promise.all([loadDashboard(), loadTaxRecords()])
} catch (_) {}
}
//
async function loadInvoices() {
loadingInv.value = true
try {
const r: any = await taxApi.listInvoices({
entity_id: getEntityId(),
status: invFilters.status || undefined,
keyword: invFilters.keyword || undefined,
})
invoices.value = r.data || []
} catch (e: any) {
ElMessage.error('发票加载失败: ' + (e?.response?.data?.detail || e.message))
} finally {
loadingInv.value = false
}
}
async function loadAbnormalInvoices() {
loadingInv.value = true
try {
const r: any = await taxApi.abnormalInvoices({ entity_id: getEntityId(), limit: 100 })
invoices.value = r.data || []
invFilters.status = ''
invFilters.keyword = ''
if (!invoices.value.length) ElMessage.info('暂无异常发票')
} catch (e: any) {
ElMessage.error('加载失败: ' + (e?.response?.data?.detail || e.message))
} finally {
loadingInv.value = false
}
}
async function batchCheckInvoices() {
checkingInv.value = true
try {
const r: any = await taxApi.batchCheckInvoices({ entity_id: getEntityId() })
ElMessage.success(r?.message || '批量校验完成')
await Promise.all([loadDashboard(), loadInvoices()])
} catch (e: any) {
ElMessage.error('校验失败: ' + (e?.response?.data?.detail || e.message))
} finally {
checkingInv.value = false
}
}
function openInvoiceDialog(row?: any) {
if (row) {
Object.assign(invForm, { id: row.id, invoice_no: row.invoice_no, amount: row.amount, invoice_type: row.invoice_type, invoice_date: (row.invoice_date || '').slice(0, 10), supplier: row.supplier || '', reimb_no: row.reimb_no || '', contract_no: row.contract_no || '' })
} else {
Object.assign(invForm, { id: 0, invoice_no: '', amount: 0, invoice_type: 'vat', invoice_date: '', supplier: '', reimb_no: '', contract_no: '' })
}
invDialogVisible.value = true
}
async function saveInvoice() {
if (!invForm.invoice_no || invForm.amount == null) {
ElMessage.warning('请填写发票号和金额')
return
}
savingInv.value = true
try {
const payload: any = { ...invForm }
delete payload.id
if (!payload.invoice_date) delete payload.invoice_date
if (!payload.supplier) delete payload.supplier
if (!payload.reimb_no) delete payload.reimb_no
if (!payload.contract_no) delete payload.contract_no
if (invForm.id) {
await taxApi.updateInvoice(invForm.id, payload)
ElMessage.success('发票已更新并重新校验')
} else {
await taxApi.createInvoice(payload)
ElMessage.success('发票已录入并校验')
}
invDialogVisible.value = false
await Promise.all([loadDashboard(), loadInvoices()])
} catch (e: any) {
ElMessage.error('保存失败: ' + (e?.response?.data?.detail || e.message))
} finally {
savingInv.value = false
}
}
async function deleteInvoice(row: any) {
try {
await ElMessageBox.confirm(`确认删除发票 ${row.invoice_no}`, '删除确认', { type: 'warning' })
await taxApi.deleteInvoice(row.id)
ElMessage.success('已删除')
await Promise.all([loadDashboard(), loadInvoices()])
} catch (_) {}
}
//
async function loadSs() {
loadingSs.value = true
try {
const r: any = await taxApi.listSsRecords({
entity_id: getEntityId(),
employee: ssFilters.employee || undefined,
period: ssFilters.period || undefined,
})
ssRecords.value = r.data || []
} catch (e: any) {
ElMessage.error('社保记录加载失败: ' + (e?.response?.data?.detail || e.message))
} finally {
loadingSs.value = false
}
}
async function loadAbnormalSs() {
loadingSs.value = true
try {
const r: any = await taxApi.abnormalSs({ entity_id: getEntityId(), limit: 100 })
ssRecords.value = r.data || []
ssFilters.employee = ''
ssFilters.period = ''
if (!ssRecords.value.length) ElMessage.info('暂无社保异常')
} catch (e: any) {
ElMessage.error('加载失败: ' + (e?.response?.data?.detail || e.message))
} finally {
loadingSs.value = false
}
}
async function batchCheckSs() {
checkingSs.value = true
try {
const r: any = await taxApi.batchCheckSs({ entity_id: getEntityId() })
ElMessage.success(r?.message || '社保比对完成')
await Promise.all([loadDashboard(), loadSs()])
} catch (e: any) {
ElMessage.error('比对失败: ' + (e?.response?.data?.detail || e.message))
} finally {
checkingSs.value = false
}
}
function openSsDialog(row?: any) {
if (row) {
Object.assign(ssForm, { id: row.id, employee: row.employee, period: row.period, base_amount: row.base_amount, salary: row.salary, company_amount: row.company_amount, personal_amount: row.personal_amount, remark: row.remark || '' })
} else {
Object.assign(ssForm, { id: 0, employee: '', period: '', base_amount: 0, salary: null, company_amount: 0, personal_amount: 0, remark: '' })
}
ssDialogVisible.value = true
}
async function saveSs() {
if (!ssForm.employee || !ssForm.period) {
ElMessage.warning('请填写人员和期间')
return
}
savingSs.value = true
try {
const payload: any = { ...ssForm }
delete payload.id
if (payload.salary == null) delete payload.salary
if (ssForm.id) {
await taxApi.updateSsRecord(ssForm.id, payload)
ElMessage.success('社保记录已更新')
} else {
await taxApi.createSsRecord(payload)
ElMessage.success('社保记录已创建')
}
ssDialogVisible.value = false
await Promise.all([loadDashboard(), loadSs()])
} catch (e: any) {
ElMessage.error('保存失败: ' + (e?.response?.data?.detail || e.message))
} finally {
savingSs.value = false
}
}
async function deleteSs(row: any) {
try {
await ElMessageBox.confirm(`确认删除 ${row.employee} ${row.period} 缴费记录?`, '删除确认', { type: 'warning' })
await taxApi.deleteSsRecord(row.id)
ElMessage.success('已删除')
await Promise.all([loadDashboard(), loadSs()])
} catch (_) {}
}
//
async function renderTrendChart() {
await nextTick()
if (!trendChartRef.value) return
const echarts: any = await import('echarts')
const el = trendChartRef.value
const chart = echarts.getInstanceByDom(el) || echarts.init(el)
const trend = stats.value?.burden?.trend || []
const periods = trend.map((x: any) => x.period)
const series: any[] = []
const types: [string, string, string][] = [
['vat_rate', '增值税税负率', '#F56C6C'],
['income_rate', '所得税税负率', '#E6A23C'],
['surtax_rate', '附加税税负率', '#909399'],
]
for (const [key, name, color] of types) {
series.push({ name, type: 'line', smooth: true, data: trend.map((x: any) => x[key] ?? null), itemStyle: { color }, lineStyle: { width: 2.5 } })
}
const benchSeries: any[] = []
const benchDefs: [string, string, string][] = [
['vat_benchmark', '增值税行业均值 3.5%', '#F56C6C'],
['income_benchmark', '所得税行业均值 2.5%', '#E6A23C'],
]
for (const [key, name, color] of benchDefs) {
benchSeries.push({ name, type: 'line', data: trend.map((x: any) => x[key] ?? null), lineStyle: { type: 'dashed', width: 1.5, color }, itemStyle: { color }, symbol: 'none' })
}
chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: [...types.map(t => t[1]), ...benchDefs.map(b => b[1])], top: 0 },
grid: { left: 60, right: 30, top: 40, bottom: 30 },
xAxis: { type: 'category', data: periods },
yAxis: { type: 'value', name: '税负率 %' },
series: [...series, ...benchSeries],
})
}
async function renderBurdenChart() {
await nextTick()
if (!burdenChartRef.value) return
const echarts: any = await import('echarts')
const el = burdenChartRef.value
const chart = echarts.getInstanceByDom(el) || echarts.init(el)
const periods = [...new Set(taxRecords.value.map((r: any) => r.period))].sort()
const byType: Record<string, Record<string, number>> = {}
for (const r of taxRecords.value) {
if (r.tax_burden_rate == null) continue
;(byType[r.tax_type] = byType[r.tax_type] || {})[r.period] = r.tax_burden_rate
}
const series = Object.entries(byType).map(([type, m]: any) => ({
name: TAX_LABELS[type] || type,
type: 'line' as const,
smooth: true,
data: periods.map(p => m[p] ?? null),
}))
chart.setOption({
tooltip: { trigger: 'axis' },
legend: { top: 0 },
grid: { left: 60, right: 30, top: 40, bottom: 30 },
xAxis: { type: 'category', data: periods },
yAxis: { type: 'value', name: '税负率 %' },
series,
})
}
const TAX_LABELS: Record<string, string> = { vat: '增值税', income: '所得税', surtax: '附加税' }
//
watch(activeTab, async (tab) => {
if (tab === 'dashboard') {
await nextTick()
renderTrendChart()
}
if (tab === 'burden') {
await nextTick()
renderBurdenChart()
}
})
onMounted(() => {
loadDashboard()
loadTaxRecords()
loadInvoices()
loadSs()
})
</script>
<style scoped>
.tax-compliance {
padding: 4px;
}
.stat-cards {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.stat-card {
background: #fff;
border-radius: 8px;
padding: 12px 18px;
min-width: 96px;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
text-align: center;
transition: transform 0.15s;
}
.stat-card:hover { transform: translateY(-2px); }
.stat-num { font-size: 24px; font-weight: 700; line-height: 1.2; }
.stat-num.alert { color: #F56C6C; }
.stat-num.danger { color: #E6A23C; }
.stat-num.warning { color: #F56C6C; }
.stat-num.normal { color: #409EFF; }
.stat-label { font-size: 12px; color: #909399; margin-top: 2px; }
.stat-actions { margin-left: auto; display: flex; gap: 8px; }
.tax-tabs :deep(.el-tabs__item) { font-weight: 500; }
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
align-items: center;
}
.panel {
background: #fff;
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 14px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.panel-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-sub { font-size: 12px; color: #909399; font-weight: 400; }
.chart-lg { width: 100%; height: 320px; }
.chart-md { width: 100%; height: 240px; }
.empty-tip { color: #909399; font-size: 13px; padding: 20px 0; text-align: center; }
.text-alert { color: #F56C6C; font-weight: 600; }
</style>