# -*- coding: utf-8 -*- """ 租户管理后台 — Excel 上传/校验/冲突/写入路由。 端点清单: - POST /api/tenant/excel/upload — 上传解析 + 格式校验 + 人员匹配 + 冲突检测 - POST /api/tenant/excel/confirm — 确认写入(单事务) - GET /api/tenant/excel/logs — 上传记录列表(分页) - GET /api/tenant/excel/template/{type} — 下载空白 Excel 模板 需求: 5.1-5.5, 6.1-6.5, 7.1-7.5, 8.1-8.5 AI_CHANGELOG - 2026-03-23 21:00:00 | Prompt: P20260323-210000(根治 tenant_admin managed_site_ids 限制)| Direct cause:JWT managed_site_ids 静态签发,新建店铺后所有端点受限 | Summary:两个 verify_site_access 改用 admin=admin;list_upload_logs 的 site_filter_clause 改用 admin=admin | Verify:Excel 上传/确认/日志覆盖新建店铺 """ from __future__ import annotations import io import json import logging import re from datetime import date, datetime, timezone from decimal import Decimal, InvalidOperation from typing import Any, Optional from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status from fastapi.responses import StreamingResponse from app.auth.tenant_admins import ( CurrentTenantAdmin, require_tenant_admin, site_filter_clause, verify_site_access, ) from app.database import get_connection, get_etl_readonly_connection from app.schemas.tenant_excel import ( ConfirmRequest, ConflictDiff, FieldDiff, UploadLogItem, ValidationError as VError, ValidationResult, ValidationWarning as VWarning, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/tenant/excel", tags=["租户Excel上传"]) # ── 常量 ────────────────────────────────────────────────── VALID_UPLOAD_TYPES = {"expense", "platform_income", "salary_adj", "recharge_commission"} EXPENSE_CATEGORIES = [ "房租", "水电", "物业", "食品饮料进货", "耗材", "报销", "固定人员工资", "其他费用", ] SALARY_ADJ_TYPES = {"扣款": "deduction", "奖金": "bonus"} # 模板列定义(中文表头 → 内部字段名) TEMPLATE_COLUMNS: dict[str, list[tuple[str, str]]] = { "expense": [ ("月份", "expense_month"), ("支出类别", "category"), ("金额", "amount"), ("备注", "remark"), ], "platform_income": [ ("月份", "income_month"), ("平台名称", "platform_name"), ("金额", "amount"), ("备注", "remark"), ], "salary_adj": [ ("月份", "salary_month"), ("助教姓名", "assistant_name"), ("助教编号", "assistant_number"), ("类型", "adjustment_type"), ("金额", "amount"), ("原因", "reason"), ], "recharge_commission": [ ("充值日期", "recharge_date"), ("会员名称", "member_name"), ("充值金额", "recharge_amount"), ("归属助教", "assigned_assistant"), ("奖励金额", "reward_amount"), ], } # 冲突检测主键规则(不含 site_id,site_id 在查询时自动附加) CONFLICT_KEYS: dict[str, list[str]] = { "expense": ["expense_month", "category"], "platform_income": ["income_month", "platform_name"], "salary_adj": ["salary_month", "assistant_name", "assistant_number", "adjustment_type", "reason"], "recharge_commission": ["recharge_date", "member_name", "assigned_assistant"], } # 目标表映射 TARGET_TABLES: dict[str, str] = { "expense": "biz.stg_finance_expense", "platform_income": "biz.stg_platform_income", "salary_adj": "biz.salary_adjustments", "recharge_commission": "biz.stg_recharge_commission", } # 各表写入字段(不含 id, upload_batch_id, created_at, synced_at 等自动字段) TABLE_WRITE_FIELDS: dict[str, list[str]] = { "expense": ["site_id", "expense_month", "category", "amount", "remark", "upload_batch_id", "created_at"], "platform_income": ["site_id", "income_month", "platform_name", "amount", "remark", "upload_batch_id", "created_at"], "salary_adj": ["site_id", "assistant_id", "assistant_name", "assistant_number", "salary_month", "adjustment_type", "amount", "reason", "upload_batch_id", "created_at", "created_by"], "recharge_commission": ["site_id", "recharge_date", "member_name", "recharge_amount", "assigned_assistant", "reward_amount", "upload_batch_id", "created_at"], } # ── 校验工具函数 ────────────────────────────────────────── _MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$") _DATE_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$") def _validate_month(value: str, current_month: str | None = None) -> str | None: """校验月份格式 YYYY-MM,返回错误描述或 None。""" if not value or not _MONTH_RE.match(str(value).strip()): return "月份格式应为 YYYY-MM" return None def _validate_date(value: str) -> str | None: """校验日期格式 YYYY-MM-DD。""" if not value or not _DATE_RE.match(str(value).strip()): return "日期格式应为 YYYY-MM-DD" # 额外验证日期合法性 try: datetime.strptime(str(value).strip(), "%Y-%m-%d") except ValueError: return "无效的日期" return None def _validate_positive_amount(value: Any) -> str | None: """校验金额 > 0,精度 2 位小数。""" try: d = Decimal(str(value)) except (InvalidOperation, TypeError, ValueError): return "金额必须为有效数字" if d <= 0: return "金额必须大于 0" if d.as_tuple().exponent is not None and abs(d.as_tuple().exponent) > 2: return "金额精度不能超过 2 位小数" return None def _validate_non_negative_amount(value: Any) -> str | None: """校验金额 ≥ 0,精度 2 位小数。""" try: d = Decimal(str(value)) except (InvalidOperation, TypeError, ValueError): return "金额必须为有效数字" if d < 0: return "金额不能为负数" if d.as_tuple().exponent is not None and abs(d.as_tuple().exponent) > 2: return "金额精度不能超过 2 位小数" return None def _validate_not_empty(value: Any, max_len: int | None = None) -> str | None: """校验非空字符串,可选最大长度。""" s = str(value).strip() if value is not None else "" if not s: return "不能为空" if max_len and len(s) > max_len: return f"长度不能超过 {max_len} 字符" return None def validate_rows(upload_type: str, rows: list[dict]) -> tuple[list[VError], list[dict]]: """ 按模板类型校验数据行。 返回 (errors, passed_rows)。 passed_rows 中的字段值已做类型转换(如金额转 float)。 """ errors: list[VError] = [] passed: list[dict] = [] for row in rows: row_idx = row.get("row_index", 0) row_errors: list[VError] = [] if upload_type == "expense": _validate_expense_row(row, row_idx, row_errors) elif upload_type == "platform_income": _validate_platform_income_row(row, row_idx, row_errors) elif upload_type == "salary_adj": _validate_salary_adj_row(row, row_idx, row_errors) elif upload_type == "recharge_commission": _validate_recharge_commission_row(row, row_idx, row_errors) if row_errors: errors.extend(row_errors) else: passed.append(row) return errors, passed def _validate_expense_row(row: dict, row_idx: int, errors: list[VError]): """校验财务支出行。""" # 月份 err = _validate_month(row.get("expense_month", "")) if err: errors.append(VError(row_index=row_idx, column="月份", message=err)) # 支出类别 cat = str(row.get("category", "")).strip() if cat not in EXPENSE_CATEGORIES: errors.append(VError( row_index=row_idx, column="支出类别", message=f"无效的支出类别,可选值:{'、'.join(EXPENSE_CATEGORIES)}", )) # 金额 err = _validate_positive_amount(row.get("amount")) if err: errors.append(VError(row_index=row_idx, column="金额", message=err)) # 备注(可选,最长 500) remark = row.get("remark") if remark is not None and str(remark).strip(): if len(str(remark).strip()) > 500: errors.append(VError(row_index=row_idx, column="备注", message="备注长度不能超过 500 字符")) def _validate_platform_income_row(row: dict, row_idx: int, errors: list[VError]): """校验团购收入行。""" err = _validate_month(row.get("income_month", "")) if err: errors.append(VError(row_index=row_idx, column="月份", message=err)) err = _validate_not_empty(row.get("platform_name")) if err: errors.append(VError(row_index=row_idx, column="平台名称", message=err)) err = _validate_positive_amount(row.get("amount")) if err: errors.append(VError(row_index=row_idx, column="金额", message=err)) remark = row.get("remark") if remark is not None and str(remark).strip(): if len(str(remark).strip()) > 500: errors.append(VError(row_index=row_idx, column="备注", message="备注长度不能超过 500 字符")) def _validate_salary_adj_row(row: dict, row_idx: int, errors: list[VError]): """校验助教奖罚行。""" err = _validate_month(row.get("salary_month", "")) if err: errors.append(VError(row_index=row_idx, column="月份", message=err)) err = _validate_not_empty(row.get("assistant_name")) if err: errors.append(VError(row_index=row_idx, column="助教姓名", message=err)) err = _validate_not_empty(row.get("assistant_number")) if err: errors.append(VError(row_index=row_idx, column="助教编号", message=err)) adj_type = str(row.get("adjustment_type", "")).strip() if adj_type not in SALARY_ADJ_TYPES: errors.append(VError( row_index=row_idx, column="类型", message=f"无效的类型,可选值:{'、'.join(SALARY_ADJ_TYPES.keys())}", )) err = _validate_positive_amount(row.get("amount")) if err: errors.append(VError(row_index=row_idx, column="金额", message=err)) err = _validate_not_empty(row.get("reason"), max_len=200) if err: errors.append(VError(row_index=row_idx, column="原因", message=err)) def _validate_recharge_commission_row(row: dict, row_idx: int, errors: list[VError]): """校验充值业绩归属行。""" err = _validate_date(row.get("recharge_date", "")) if err: errors.append(VError(row_index=row_idx, column="充值日期", message=err)) err = _validate_not_empty(row.get("member_name")) if err: errors.append(VError(row_index=row_idx, column="会员名称", message=err)) err = _validate_positive_amount(row.get("recharge_amount")) if err: errors.append(VError(row_index=row_idx, column="充值金额", message=err)) err = _validate_not_empty(row.get("assigned_assistant")) if err: errors.append(VError(row_index=row_idx, column="归属助教", message=err)) err = _validate_non_negative_amount(row.get("reward_amount")) if err: errors.append(VError(row_index=row_idx, column="奖励金额", message=err)) # ── Excel 解析 ──────────────────────────────────────────── def parse_excel(file_bytes: bytes, upload_type: str) -> list[dict]: """ 解析 Excel 文件,返回行数据列表。 每行为 dict,包含 row_index(从 1 开始)和各字段值。 """ import openpyxl wb = openpyxl.load_workbook(io.BytesIO(file_bytes), read_only=True, data_only=True) ws = wb.active if ws is None: return [] columns = TEMPLATE_COLUMNS.get(upload_type, []) if not columns: return [] rows_data: list[dict] = [] header_row = True for row in ws.iter_rows(values_only=True): if header_row: header_row = False continue # 跳过表头行 # 跳过全空行 if all(cell is None or str(cell).strip() == "" for cell in row): continue row_dict: dict[str, Any] = {"row_index": len(rows_data) + 1} for i, (_, field_name) in enumerate(columns): val = row[i] if i < len(row) else None # 将值转为字符串(保留 None) if val is not None: row_dict[field_name] = str(val).strip() else: row_dict[field_name] = "" rows_data.append(row_dict) wb.close() return rows_data # ── 人员匹配 ───────────────────────────────────────────── def match_personnel( rows: list[dict], site_id: int, upload_type: str, ) -> list[VWarning]: """ 对 salary_adj / recharge_commission 模板执行人员匹配校验。 优先 v_dim_assistant(nickname + assistant_number), 未匹配再查 v_dim_staff + v_dim_staff_ex(name + staff_number)。 匹配成功填充 assistant_id,失败标记 warning 不阻断。 """ if upload_type not in ("salary_adj", "recharge_commission"): return [] warnings: list[VWarning] = [] # 提取需要匹配的姓名+编号对 if upload_type == "salary_adj": name_field = "assistant_name" number_field = "assistant_number" else: name_field = "assigned_assistant" number_field = None # recharge_commission 没有编号字段 # 批量查询 v_dim_assistant assistant_map: dict[str, int] = {} staff_map: dict[str, int] = {} try: etl_conn = get_etl_readonly_connection(site_id) try: with etl_conn.cursor() as cur: cur.execute( "SELECT assistant_id, nickname, number FROM fdw_etl.v_dim_assistant WHERE scd2_is_current = 1", ) for aid, nickname, number in cur.fetchall(): if nickname and number: assistant_map[f"{nickname}|{number}"] = aid if nickname: assistant_map[f"{nickname}|"] = aid finally: etl_conn.close() except Exception: logger.warning("v_dim_assistant 查询失败(site_id=%s)", site_id, exc_info=True) try: etl_conn = get_etl_readonly_connection(site_id) try: with etl_conn.cursor() as cur: cur.execute( "SELECT staff_id, name, number FROM fdw_etl.v_dim_staff", ) for sid, name, number in cur.fetchall(): if name and number: staff_map[f"{name}|{number}"] = sid if name: staff_map[f"{name}|"] = sid finally: etl_conn.close() except Exception: logger.warning("v_dim_staff 查询失败(site_id=%s)", site_id, exc_info=True) for row in rows: name = str(row.get(name_field, "")).strip() number = str(row.get(number_field, "")).strip() if number_field else "" row_idx = row.get("row_index", 0) # 优先 v_dim_assistant 匹配 key_full = f"{name}|{number}" key_name = f"{name}|" matched_id = assistant_map.get(key_full) or assistant_map.get(key_name) if not matched_id: matched_id = staff_map.get(key_full) or staff_map.get(key_name) if matched_id: row["assistant_id"] = matched_id else: row["assistant_id"] = None warnings.append(VWarning( row_index=row_idx, column="助教姓名", message=f"未匹配到助教/员工:{name}" + (f"(编号 {number})" if number else ""), )) return warnings # ── 冲突检测 ────────────────────────────────────────────── def detect_conflicts( upload_type: str, rows: list[dict], site_id: int, ) -> tuple[list[ConflictDiff], list[dict], list[dict]]: """ 按模板主键规则检测冲突。 返回 (conflicts, new_rows, conflict_rows_with_existing)。 - conflicts: 冲突 diff 列表 - new_rows: 无冲突的新增行 - conflict_rows_with_existing: 冲突行(附带已有数据用于 confirm 时 UPDATE) """ keys = CONFLICT_KEYS.get(upload_type, []) table = TARGET_TABLES.get(upload_type, "") if not keys or not table: return [], rows, [] # 查询已有数据 existing_map: dict[tuple, dict] = {} conn = get_connection() try: with conn.cursor() as cur: key_cols = ", ".join(keys) # 获取所有字段用于 diff cur.execute(f"SELECT * FROM {table} WHERE site_id = %s LIMIT 0", (site_id,)) col_names = [desc[0] for desc in cur.description] if cur.description else [] cur.execute( f"SELECT * FROM {table} WHERE site_id = %s", (site_id,), ) for row_data in cur.fetchall(): row_dict = dict(zip(col_names, row_data)) pk = tuple(str(row_dict.get(k, "")).strip() for k in keys) existing_map[pk] = row_dict finally: conn.close() conflicts: list[ConflictDiff] = [] new_rows: list[dict] = [] conflict_rows: list[dict] = [] # 对 salary_adj 类型,需要将中文类型映射为英文 for row in rows: pk_values = [] for k in keys: val = str(row.get(k, "")).strip() # salary_adj 的 adjustment_type 需要映射 if upload_type == "salary_adj" and k == "adjustment_type": val = SALARY_ADJ_TYPES.get(val, val) pk_values.append(val) pk = tuple(pk_values) if pk in existing_map: existing = existing_map[pk] # 生成逐字段 diff field_diffs: list[FieldDiff] = [] # 比较可变字段(排除主键和系统字段) compare_fields = _get_compare_fields(upload_type) for field_name, display_name in compare_fields: old_val = str(existing.get(field_name, "")) if existing.get(field_name) is not None else "" new_val = str(row.get(field_name, "")).strip() # salary_adj 的 adjustment_type 需要映射 if upload_type == "salary_adj" and field_name == "adjustment_type": new_val = SALARY_ADJ_TYPES.get(new_val, new_val) if old_val != new_val: field_diffs.append(FieldDiff( field=display_name, old_value=old_val, new_value=new_val, )) if field_diffs: conflicts.append(ConflictDiff( row_index=row.get("row_index", 0), field_diffs=field_diffs, )) row["_existing_id"] = existing.get("id") conflict_rows.append(row) else: # 主键匹配但所有字段相同,视为无变化,跳过 conflict_rows.append(row) row["_existing_id"] = existing.get("id") else: new_rows.append(row) return conflicts, new_rows, conflict_rows def _get_compare_fields(upload_type: str) -> list[tuple[str, str]]: """获取用于 diff 比较的字段列表 [(db_field, display_name)]。""" if upload_type == "expense": return [("amount", "金额"), ("remark", "备注")] elif upload_type == "platform_income": return [("amount", "金额"), ("remark", "备注")] elif upload_type == "salary_adj": return [("amount", "金额"), ("reason", "原因")] elif upload_type == "recharge_commission": return [("recharge_amount", "充值金额"), ("reward_amount", "奖励金额")] return [] # ── POST /api/tenant/excel/upload ───────────────────────── @router.post("/upload") async def upload_excel( file: UploadFile = File(...), upload_type: str = Form(...), site_id: int = Form(...), admin: CurrentTenantAdmin = Depends(require_tenant_admin), ): """ 上传 Excel 文件:解析 → 格式校验 → 人员匹配 → 冲突检测。 返回 upload_id + 校验结果 + 冲突 diff。 """ # 校验 upload_type if upload_type not in VALID_UPLOAD_TYPES: raise HTTPException(status_code=400, detail=f"无效的模板类型,可选值:{', '.join(VALID_UPLOAD_TYPES)}") # 校验门店权限 verify_site_access(site_id, admin=admin) # 校验文件格式 filename = file.filename or "" if not filename.lower().endswith((".xlsx", ".xls")): raise HTTPException(status_code=400, detail="请上传有效的 Excel 文件(.xlsx/.xls)") # 读取文件内容 file_bytes = await file.read() if not file_bytes: raise HTTPException(status_code=400, detail="文件内容为空") # 解析 Excel try: rows = parse_excel(file_bytes, upload_type) except Exception as e: logger.warning("Excel 解析失败:%s", e, exc_info=True) raise HTTPException(status_code=400, detail="Excel 文件解析失败,请检查文件格式") if not rows: raise HTTPException(status_code=400, detail="Excel 文件中没有数据行") # 格式校验 errors, passed_rows = validate_rows(upload_type, rows) # 如果有格式错误,直接返回(不创建 upload_log) if errors: return ValidationResult( errors=errors, warnings=[], passed_rows=[], upload_id=None, ).model_dump(by_alias=True) # 人员匹配校验(仅 salary_adj / recharge_commission) warnings = match_personnel(passed_rows, site_id, upload_type) # 冲突检测 conflicts, new_rows, conflict_rows = detect_conflicts(upload_type, passed_rows, site_id) # 创建 excel_upload_log 记录 conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ INSERT INTO biz.excel_upload_log (site_id, upload_type, file_name, uploaded_by, row_count, conflict_count, status) VALUES (%s, %s, %s, %s, %s, %s, 'pending') RETURNING id """, (site_id, upload_type, filename, admin.admin_id, len(passed_rows), len(conflicts)), ) upload_id = cur.fetchone()[0] conn.commit() except Exception: conn.rollback() logger.error("创建 upload_log 失败", exc_info=True) raise HTTPException(status_code=500, detail="创建上传记录失败") finally: conn.close() # 将通过的行数据临时存储到 upload_log 的 error_detail 字段(JSON) # 用于 confirm 时读取(避免二次上传) _cache_upload_data(upload_id, { "upload_type": upload_type, "site_id": site_id, "new_rows": new_rows, "conflict_rows": conflict_rows, }) return { **ValidationResult( errors=[], warnings=warnings, passed_rows=passed_rows, upload_id=upload_id, ).model_dump(by_alias=True), "conflicts": [c.model_dump(by_alias=True) for c in conflicts], } def _cache_upload_data(upload_id: int, data: dict): """将上传数据缓存到 upload_log.error_detail(JSON),供 confirm 时使用。""" conn = get_connection() try: with conn.cursor() as cur: # 序列化时处理 Decimal 等特殊类型 json_str = json.dumps(data, ensure_ascii=False, default=str) cur.execute( "UPDATE biz.excel_upload_log SET error_detail = %s::jsonb WHERE id = %s", (json_str, upload_id), ) conn.commit() except Exception: conn.rollback() logger.warning("缓存上传数据失败(upload_id=%s)", upload_id, exc_info=True) finally: conn.close() # ── POST /api/tenant/excel/confirm ──────────────────────── @router.post("/confirm") async def confirm_upload( body: ConfirmRequest, admin: CurrentTenantAdmin = Depends(require_tenant_admin), ): """ 确认写入:单事务写入目标表。 替换行执行 UPDATE,新增行执行 INSERT。 写入失败回滚整批,log status=failed。 """ conn = get_connection() try: with conn.cursor() as cur: # 获取 upload_log 记录 cur.execute( "SELECT site_id, upload_type, status, error_detail FROM biz.excel_upload_log WHERE id = %s", (body.upload_id,), ) log_row = cur.fetchone() if log_row is None: raise HTTPException(status_code=404, detail="上传记录不存在") site_id, upload_type, log_status, cached_data = log_row if log_status != "pending": raise HTTPException(status_code=409, detail="该上传批次已被处理") verify_site_access(site_id, admin=admin) # 从缓存中读取数据 if not cached_data: raise HTTPException(status_code=400, detail="上传数据已过期,请重新上传") if isinstance(cached_data, str): cached_data = json.loads(cached_data) new_rows = cached_data.get("new_rows", []) conflict_rows = cached_data.get("conflict_rows", []) # 构建 resolution 映射 resolution_map: dict[int, str] = {} for r in body.resolutions: resolution_map[r.row_index] = r.action table = TARGET_TABLES[upload_type] write_fields = TABLE_WRITE_FIELDS[upload_type] inserted_count = 0 updated_count = 0 resolved_count = 0 # 写入新增行 for row in new_rows: _insert_row(cur, table, write_fields, row, upload_type, site_id, body.upload_id, admin.admin_id) inserted_count += 1 # 处理冲突行 for row in conflict_rows: row_idx = row.get("row_index", 0) action = resolution_map.get(row_idx, "keep") existing_id = row.get("_existing_id") if action == "replace" and existing_id: _update_row(cur, table, write_fields, row, upload_type, existing_id, site_id, body.upload_id, admin.admin_id) updated_count += 1 resolved_count += 1 elif action == "keep": resolved_count += 1 else: # 无 existing_id 的冲突行按新增处理 _insert_row(cur, table, write_fields, row, upload_type, site_id, body.upload_id, admin.admin_id) inserted_count += 1 # 更新 upload_log cur.execute( """ UPDATE biz.excel_upload_log SET status = 'confirmed', row_count = %s, resolved_count = %s, confirmed_at = NOW(), error_detail = NULL WHERE id = %s """, (inserted_count + updated_count, resolved_count, body.upload_id), ) conn.commit() except HTTPException: conn.rollback() raise except Exception as e: conn.rollback() # 记录失败状态 _mark_upload_failed(body.upload_id, str(e)) logger.error("写入失败(upload_id=%s)", body.upload_id, exc_info=True) raise HTTPException(status_code=500, detail="数据写入失败,已回滚整批") finally: conn.close() return { "message": "写入成功", "inserted": inserted_count, "updated": updated_count, "resolved": resolved_count, } def _insert_row(cur, table: str, fields: list[str], row: dict, upload_type: str, site_id: int, upload_id: int, admin_id: int): """插入一行数据到目标表。""" values = _build_row_values(fields, row, upload_type, site_id, upload_id, admin_id) placeholders = ", ".join(["%s"] * len(fields)) cols = ", ".join(fields) cur.execute(f"INSERT INTO {table} ({cols}) VALUES ({placeholders})", tuple(values)) def _update_row(cur, table: str, fields: list[str], row: dict, upload_type: str, existing_id: int, site_id: int, upload_id: int, admin_id: int): """更新已有行。""" values = _build_row_values(fields, row, upload_type, site_id, upload_id, admin_id) set_parts = [f"{f} = %s" for f in fields] cur.execute( f"UPDATE {table} SET {', '.join(set_parts)} WHERE id = %s", (*values, existing_id), ) def _build_row_values(fields: list[str], row: dict, upload_type: str, site_id: int, upload_id: int, admin_id: int) -> list: """根据字段列表构建值列表。""" values = [] for f in fields: if f == "site_id": values.append(site_id) elif f == "upload_batch_id": values.append(upload_id) elif f == "created_at": values.append(datetime.now(timezone.utc)) elif f == "created_by": values.append(admin_id) elif f == "adjustment_type": # 中文 → 英文映射 raw = str(row.get(f, "")).strip() values.append(SALARY_ADJ_TYPES.get(raw, raw)) elif f in ("amount", "recharge_amount", "reward_amount"): try: values.append(float(row.get(f, 0))) except (ValueError, TypeError): values.append(0.0) elif f == "assistant_id": values.append(row.get("assistant_id")) else: values.append(str(row.get(f, "")).strip() if row.get(f) is not None else None) return values def _mark_upload_failed(upload_id: int, error_msg: str): """标记上传批次为失败状态。""" try: conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ UPDATE biz.excel_upload_log SET status = 'failed', error_detail = %s::jsonb WHERE id = %s """, (json.dumps({"error": error_msg}, ensure_ascii=False), upload_id), ) conn.commit() finally: conn.close() except Exception: logger.warning("标记上传失败状态失败(upload_id=%s)", upload_id, exc_info=True) # ── GET /api/tenant/excel/logs ──────────────────────────── @router.get("/logs") async def list_upload_logs( page: int = Query(1, ge=1, description="页码"), page_size: int = Query(20, ge=1, le=100, description="每页条数"), admin: CurrentTenantAdmin = Depends(require_tenant_admin), ): """上传记录列表,分页,附加 site_id IN 条件。""" site_sql, site_params = site_filter_clause(admin=admin) offset = (page - 1) * page_size conn = get_connection() try: with conn.cursor() as cur: cur.execute( f"SELECT COUNT(*) FROM biz.excel_upload_log WHERE {site_sql}", site_params, ) total = cur.fetchone()[0] cur.execute( f""" SELECT id, site_id, upload_type, file_name, uploaded_by, row_count, conflict_count, resolved_count, status, created_at::text, confirmed_at::text FROM biz.excel_upload_log WHERE {site_sql} ORDER BY created_at DESC LIMIT %s OFFSET %s """, (*site_params, page_size, offset), ) rows = cur.fetchall() finally: conn.close() items = [ UploadLogItem( id=r[0], site_id=r[1], upload_type=r[2], file_name=r[3], uploaded_by=r[4], row_count=r[5], conflict_count=r[6], resolved_count=r[7], status=r[8], created_at=r[9], confirmed_at=r[10], ).model_dump(by_alias=True) for r in rows ] return {"items": items, "total": total, "page": page, "pageSize": page_size} # ── GET /api/tenant/excel/template/{type} ───────────────── @router.get("/template/{template_type}") async def download_template(template_type: str): """返回空白 Excel 模板文件(含表头和格式说明)。""" if template_type not in VALID_UPLOAD_TYPES: raise HTTPException(status_code=400, detail=f"无效的模板类型,可选值:{', '.join(VALID_UPLOAD_TYPES)}") import openpyxl from openpyxl.styles import Font, PatternFill, Alignment wb = openpyxl.Workbook() ws = wb.active ws.title = "数据模板" columns = TEMPLATE_COLUMNS[template_type] header_font = Font(bold=True, color="FFFFFF") header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") # 写入表头 for col_idx, (header_name, _) in enumerate(columns, 1): cell = ws.cell(row=1, column=col_idx, value=header_name) cell.font = header_font cell.fill = header_fill cell.alignment = Alignment(horizontal="center") ws.column_dimensions[cell.column_letter].width = 18 # 写入格式说明行(第 2 行,灰色字体) hint_font = Font(color="808080", italic=True) hints = _get_template_hints(template_type) for col_idx, hint in enumerate(hints, 1): cell = ws.cell(row=2, column=col_idx, value=hint) cell.font = hint_font # 输出为字节流 output = io.BytesIO() wb.save(output) output.seek(0) from urllib.parse import quote as _url_quote filename_map = { "expense": "财务支出模板.xlsx", "platform_income": "团购收入模板.xlsx", "salary_adj": "助教奖罚模板.xlsx", "recharge_commission": "充值业绩归属模板.xlsx", } raw_name = filename_map.get(template_type, "template.xlsx") encoded_name = _url_quote(raw_name) return StreamingResponse( output, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={ "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_name}", }, ) def _get_template_hints(template_type: str) -> list[str]: """获取模板格式说明。""" if template_type == "expense": return [ "格式:YYYY-MM", f"可选值:{'、'.join(EXPENSE_CATEGORIES)}", "大于0,保留2位小数", "可选,最长500字符", ] elif template_type == "platform_income": return [ "格式:YYYY-MM", "必填", "大于0,保留2位小数", "可选,最长500字符", ] elif template_type == "salary_adj": return [ "格式:YYYY-MM", "必填", "必填", "可选值:扣款、奖金", "大于0,保留2位小数", "必填,最长200字符", ] elif template_type == "recharge_commission": return [ "格式:YYYY-MM-DD", "必填", "大于0,保留2位小数", "必填", "≥0,保留2位小数", ] return []