From 38b603f263824bfd58fc643c6e85c5f3a695ec19 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Tue, 4 Aug 2026 15:04:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A8=8E=E5=8A=A1=E5=90=88=E8=A7=84?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E4=BD=93=E2=80=94=E7=A8=8E=E8=B4=9F=E7=9B=91?= =?UTF-8?q?=E6=8E=A7+=E5=8F=91=E7=A5=A8=E6=A0=A1=E9=AA=8C+=E7=A4=BE?= =?UTF-8?q?=E4=BF=9D=E6=AF=94=E5=AF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/tax_compliance.py | 884 +++++++++++++++++++++++++++ backend/app/main.py | 3 +- backend/app/models/__init__.py | 63 ++ frontend/src/api/index.ts | 28 + frontend/src/permission.ts | 9 +- frontend/src/router/index.ts | 1 + frontend/src/views/TaxCompliance.vue | 787 ++++++++++++++++++++++++ 7 files changed, 1770 insertions(+), 5 deletions(-) create mode 100644 backend/app/api/tax_compliance.py create mode 100644 frontend/src/views/TaxCompliance.vue diff --git a/backend/app/api/tax_compliance.py b/backend/app/api/tax_compliance.py new file mode 100644 index 00000000..e4d9d8a4 --- /dev/null +++ b/backend/app/api/tax_compliance.py @@ -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} diff --git a/backend/app/main.py b/backend/app/main.py index 1e44fb1e..05999c2a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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): diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c4afb267..6e14f4d8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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()) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 0de570d1..607b9ce1 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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 diff --git a/frontend/src/permission.ts b/frontend/src/permission.ts index ce7236a2..b18dff7e 100644 --- a/frontend/src/permission.ts +++ b/frontend/src/permission.ts @@ -9,10 +9,10 @@ interface MenuItem { // ── 角色路由映射 ── export const ROLE_ROUTES: Record = { - 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 = { @@ -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 执行与控制' }, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 489ff91c..8384f40d 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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'] } }, ] }, ] diff --git a/frontend/src/views/TaxCompliance.vue b/frontend/src/views/TaxCompliance.vue new file mode 100644 index 00000000..107091e3 --- /dev/null +++ b/frontend/src/views/TaxCompliance.vue @@ -0,0 +1,787 @@ + + + + +