""" 项目与操作日志数据层(依据 schema.sql、API 文档第 8 节) API 使用 camelCase,数据库使用 snake_case,在此做映射。 """ import re from datetime import datetime from typing import Any, Optional from .database import get_connection, new_id DATE_STR_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$") # API 字段名(camelCase) -> DB 列名(snake_case),按 5 个分类 CONTRACT_API_TO_DB = { "serialNo": "serial_no", "contractCode": "contract_code", "powerBureauContractCode": "power_bureau_contract_code", "projectName": "project_name", "subItemCount": "sub_item_count", "subItemCode": "sub_item_code", "totalInvestment": "total_investment", "bidContractAmount": "bid_contract_amount", "warrantyRatio": "warranty_ratio", "settlementAmount": "settlement_amount", "totalCostEstimate": "total_cost_estimate", "voltageLevel": "voltage_level", "projectCategory": "project_category", "ownerUnit": "owner_unit", "ownerContact": "owner_contact", "bidType": "bid_type", "signDate": "sign_date", "startDate": "start_date", "plannedCompletionDate": "planned_completion_date", "actualCompletionDate": "actual_completion_date", "warrantyAmount": "warranty_amount", "warrantyEndDate": "warranty_end_date", "actualWarrantyRefundDate": "actual_warranty_refund_date", "projectDepartment": "project_department", "projectLeaderContact": "project_leader_contact", "paymentMethod": "payment_method", } COST_CONTROL_API_TO_DB = { "totalCost": "total_cost", "isAdjusted": "is_adjusted", "migrantWorkerPlan": "migrant_worker_plan", "migrantWorkerActual": "migrant_worker_actual", "selfSupplyMaterialControl": "self_supply_material_control", "materialPayableByRatio": "material_payable_by_ratio", "materialActualOccurred": "material_actual_occurred", "materialActualPaid": "material_actual_paid", "otherCostControl": "other_cost_control", "otherPayable": "other_payable", "otherActual": "other_actual", "tax": "tax", "profit": "profit", "actualProfit": "actual_profit", "costSettlementAmount": "cost_settlement_amount", } RECEIVABLE_API_TO_DB = { "receivableProgress": "receivable_progress", "invoiceAmount": "invoice_amount", "actualReceiptAmount": "actual_receipt_amount", "actualReceiptRate": "actual_receipt_rate", } PAYABLE_API_TO_DB = { "payableAmount": "payable_amount", "actualPaymentAmount": "actual_payment_amount", "unreceivedAmount": "unreceived_amount", "actualPaymentRate": "actual_payment_rate", "migrantWorkerArrears": "migrant_worker_arrears", } OTHER_API_TO_DB = { "settlementCostEstimate": "settlement_cost_estimate", "settlementLaborCost": "settlement_labor_cost", "settlementMaterialCost": "settlement_material_cost", "settlementOtherCost": "settlement_other_cost", "dueSettlementCount": "due_settlement_count", "overdueSettlementCount": "overdue_settlement_count", "existingProblems": "existing_problems", "suggestions": "suggestions", "cumulativeProgress": "cumulative_progress", "remark": "remark", } # 四个日期字段:API 返回时若 _str 为「日期正常」则返回日期,否则返回 _str DATE_FIELDS_WITH_STR = ("sign_date", "start_date", "planned_completion_date", "actual_completion_date") DATE_NORMAL = "日期正常" def _db_to_camel(d: dict[str, Any], api_to_db: dict[str, str]) -> dict[str, Any]: """DB 列名 -> API 字段名(反向映射)""" db_to_api = {v: k for k, v in api_to_db.items()} return {db_to_api.get(k, k): v for k, v in d.items() if k in db_to_api} def _api_to_db(d: Optional[dict], api_to_db: dict[str, str]) -> dict[str, Any]: """API 请求体(camelCase)-> DB 列(snake_case),仅包含有值的键""" if not d: return {} return {api_to_db[k]: v for k, v in d.items() if k in api_to_db and v is not None} def _normalize_contract_date_str_fields(c: dict[str, Any]) -> None: """对合同中的四个日期字段做规范化:日期则写 DATE + _str=日期正常,否则只写 _str。原地修改 c。""" for col in DATE_FIELDS_WITH_STR: if col not in c: continue val = c[col] s = (val if isinstance(val, str) else str(val)).strip() if val else "" if s and DATE_STR_PATTERN.match(s): c[col] = s c[col + "_str"] = DATE_NORMAL else: c[col + "_str"] = s or "" c[col] = None def _row_to_api_project(row: dict[str, Any]) -> dict[str, Any]: """数据库一行 -> API 项目详情(含 contract/costControl/receivable/payable/other)""" contract_cols = set(CONTRACT_API_TO_DB.values()) cost_cols = set(COST_CONTROL_API_TO_DB.values()) recv_cols = set(RECEIVABLE_API_TO_DB.values()) pay_cols = set(PAYABLE_API_TO_DB.values()) other_cols = set(OTHER_API_TO_DB.values()) contract = {k: v for k, v in row.items() if k in contract_cols} for col in DATE_FIELDS_WITH_STR: if row.get(col + "_str") == DATE_NORMAL and row.get(col): contract[col] = row[col] else: contract[col] = row.get(col + "_str") or "" cost_control = {k: v for k, v in row.items() if k in cost_cols} receivable = {k: v for k, v in row.items() if k in recv_cols} payable = {k: v for k, v in row.items() if k in pay_cols} other = {k: v for k, v in row.items() if k in other_cols} # 日期等转为 ISO 字符串 def _serialize(v: Any) -> Any: if hasattr(v, "isoformat"): return v.isoformat() return v return { "id": row.get("id"), "contract": _db_to_camel({k: _serialize(v) for k, v in contract.items()}, CONTRACT_API_TO_DB), "costControl": _db_to_camel({k: _serialize(v) for k, v in cost_control.items()}, COST_CONTROL_API_TO_DB), "receivable": _db_to_camel({k: _serialize(v) for k, v in receivable.items()}, RECEIVABLE_API_TO_DB), "payable": _db_to_camel({k: _serialize(v) for k, v in payable.items()}, PAYABLE_API_TO_DB), "other": _db_to_camel({k: _serialize(v) for k, v in other.items()}, OTHER_API_TO_DB), "createdAt": _serialize(row.get("created_at")), "updatedAt": _serialize(row.get("updated_at")), } # 时间筛选项 API -> DB 列名(仅对「日期正常」的项目按 DATE 列筛选) DATE_FILTER_COLUMNS = { "signDate": "sign_date", "startDate": "start_date", "plannedCompletionDate": "planned_completion_date", "actualCompletionDate": "actual_completion_date", } def list_projects( page: int = 1, page_size: int = 50, search_type: Optional[str] = None, keyword: Optional[str] = None, progress: Optional[str] = None, cost: Optional[str] = None, date_filter_type: Optional[str] = None, date_from: Optional[str] = None, date_to: Optional[str] = None, date_abnormal: Optional[bool] = None, ) -> tuple[list[dict], int]: """分页列表,支持按项目名称/合同编号搜索、按进度/费用筛选、时间筛选、日期异常筛选。返回 (list, total)。""" with get_connection() as conn: cur = conn.cursor(dictionary=True) where_parts = [] params: list[Any] = [] if keyword and keyword.strip(): if search_type == "name": where_parts.append("project_name LIKE %s") params.append(f"%{keyword.strip()}%") elif search_type == "code": where_parts.append("contract_code LIKE %s") params.append(f"%{keyword.strip()}%") else: where_parts.append("(project_name LIKE %s OR contract_code LIKE %s)") params.extend([f"%{keyword.strip()}%", f"%{keyword.strip()}%"]) if progress and progress.strip(): where_parts.append("progress = %s") params.append(progress.strip()) if cost and cost.strip(): where_parts.append("cost = %s") params.append(cost.strip()) if date_abnormal: where_parts.append( "((sign_date_str IS NULL OR sign_date_str != %s) OR (start_date_str IS NULL OR start_date_str != %s) " "OR (planned_completion_date_str IS NULL OR planned_completion_date_str != %s) " "OR (actual_completion_date_str IS NULL OR actual_completion_date_str != %s))" ) params.extend([DATE_NORMAL, DATE_NORMAL, DATE_NORMAL, DATE_NORMAL]) date_col = DATE_FILTER_COLUMNS.get(date_filter_type) if date_filter_type else None if date_col and (date_from or date_to) and not date_abnormal: where_parts.append(f"({date_col}_str = %s)") params.append(DATE_NORMAL) if date_from and date_from.strip(): where_parts.append(f"{date_col} >= %s") params.append(date_from.strip()) if date_to and date_to.strip(): where_parts.append(f"{date_col} <= %s") params.append(date_to.strip()) where_sql = " AND ".join(where_parts) if where_parts else "1=1" cur.execute( f"SELECT COUNT(*) AS cnt FROM projects WHERE {where_sql}", tuple(params), ) total = cur.fetchone()["cnt"] offset = (page - 1) * page_size cur.execute( f"SELECT id, project_name, contract_code, progress, cost, updated_at FROM projects WHERE {where_sql} ORDER BY updated_at DESC LIMIT %s OFFSET %s", tuple(params) + (page_size, offset), ) rows = cur.fetchall() cur.close() list_ = [ { "id": r["id"], "projectName": r["project_name"] or "", "contractCode": r["contract_code"] or "", "progress": r["progress"] or "", "cost": r["cost"] or "", "updatedAt": r["updated_at"].isoformat() if r.get("updated_at") else "", } for r in rows ] return list_, total def get_project(project_id: str) -> Optional[dict[str, Any]]: """按 id 查项目,返回 API 形态详情,不存在返回 None。""" with get_connection() as conn: cur = conn.cursor(dictionary=True) cur.execute("SELECT * FROM projects WHERE id = %s", (project_id,)) row = cur.fetchone() cur.close() if not row: return None return _row_to_api_project(row) def create_project( contract: dict, cost_control: Optional[dict] = None, receivable: Optional[dict] = None, payable: Optional[dict] = None, other: Optional[dict] = None, ) -> str: """新建项目,合同信息必含 contract_code、project_name。返回新项目 id。""" pid = new_id() now = datetime.utcnow() c = _api_to_db(contract, CONTRACT_API_TO_DB) _normalize_contract_date_str_fields(c) cc = _api_to_db(cost_control, COST_CONTROL_API_TO_DB) recv = _api_to_db(receivable, RECEIVABLE_API_TO_DB) pay = _api_to_db(payable, PAYABLE_API_TO_DB) oth = _api_to_db(other, OTHER_API_TO_DB) row: dict[str, Any] = {**c, **cc, **recv, **pay, **oth} row["id"] = pid row["progress"] = (row.get("cumulative_progress") or row.get("project_name") or "")[:32] if row.get("cumulative_progress") or row.get("project_name") else "" row["cost"] = "" row["created_at"] = now row["updated_at"] = now cols = [k for k in row.keys()] vals = [row[k] for k in cols] placeholders = ", ".join(["%s"] * len(cols)) with get_connection() as conn: cur = conn.cursor() cur.execute( f"INSERT INTO projects ({', '.join(cols)}) VALUES ({placeholders})", vals, ) cur.close() return pid def update_project( project_id: str, contract: Optional[dict] = None, cost_control: Optional[dict] = None, receivable: Optional[dict] = None, payable: Optional[dict] = None, other: Optional[dict] = None, ) -> bool: """更新项目(合并传入的字段),不存在返回 False。""" c = _api_to_db(contract, CONTRACT_API_TO_DB) _normalize_contract_date_str_fields(c) cc = _api_to_db(cost_control, COST_CONTROL_API_TO_DB) recv = _api_to_db(receivable, RECEIVABLE_API_TO_DB) pay = _api_to_db(payable, PAYABLE_API_TO_DB) oth = _api_to_db(other, OTHER_API_TO_DB) merged = {**c, **cc, **recv, **pay, **oth} if not merged: return project_exists(project_id) merged["updated_at"] = datetime.utcnow() updates = [f"`{k}` = %s" for k in merged.keys()] params: list[Any] = list(merged.values()) + [project_id] with get_connection() as conn: cur = conn.cursor() cur.execute( "UPDATE projects SET " + ", ".join(updates) + " WHERE id = %s", params, ) cur.close() return True def project_exists(project_id: str) -> bool: """项目是否存在""" with get_connection() as conn: cur = conn.cursor() cur.execute("SELECT 1 FROM projects WHERE id = %s", (project_id,)) ok = cur.fetchone() is not None cur.close() return ok def insert_operation_log( project_id: str, operator_id: str, summary: str = "", detail: Optional[str] = None, ) -> None: """写入一条操作日志。""" log_id = new_id() with get_connection() as conn: cur = conn.cursor() cur.execute( "INSERT INTO operation_logs (id, project_id, operator_id, summary, detail) VALUES (%s, %s, %s, %s, %s)", (log_id, project_id, operator_id, summary or "项目信息", detail), ) cur.close() def list_operation_logs( project_id: str, page: int = 1, page_size: int = 20, ) -> tuple[list[dict], int]: """分页查询某项目的操作日志,返回 (list, total)。含 operator_name(联表 users)。""" with get_connection() as conn: cur = conn.cursor(dictionary=True) cur.execute( "SELECT COUNT(*) AS cnt FROM operation_logs WHERE project_id = %s", (project_id,), ) total = cur.fetchone()["cnt"] offset = (page - 1) * page_size cur.execute( """ SELECT l.id, l.operator_id, l.operated_at, l.summary, l.detail, u.display_name AS operator_name FROM operation_logs l LEFT JOIN users u ON l.operator_id = u.id WHERE l.project_id = %s ORDER BY l.operated_at DESC LIMIT %s OFFSET %s """, (project_id, page_size, offset), ) rows = cur.fetchall() cur.close() list_ = [ { "id": r["id"], "operatorId": r["operator_id"], "operatorName": r["operator_name"] or r["operator_id"], "operatedAt": r["operated_at"].isoformat() if r.get("operated_at") else "", "summary": r["summary"] or "", "detail": r.get("detail"), } for r in rows ] return list_, total