From 081dc405d9d0aa36161992626440bdf5044762e2 Mon Sep 17 00:00:00 2001 From: "rui.yang" Date: Sat, 21 Mar 2026 14:59:35 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96cli=20xlsx=20?= =?UTF-8?q?=E5=A4=84=E7=90=86=E6=8A=80=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- create_schedule.py | 265 ++++++ .../skills/document-skills/xlsx/SKILL.md | 520 +++++++---- .../document-skills/xlsx/xlsx_validator.py | 882 ++++++++++++++++++ "\346\216\222\347\217\255\350\241\250.xlsx" | Bin 0 -> 8261 bytes 4 files changed, 1505 insertions(+), 162 deletions(-) create mode 100644 create_schedule.py create mode 100755 mini_agent/skills/document-skills/xlsx/xlsx_validator.py create mode 100644 "\346\216\222\347\217\255\350\241\250.xlsx" diff --git a/create_schedule.py b/create_schedule.py new file mode 100644 index 0000000..8a79458 --- /dev/null +++ b/create_schedule.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +""" +排班表生成器 +- 每四天一个周期 +- 第1天:24小时值班(值班日) +- 第2天:正常上班 +- 第3天:正常上班,除非遇到周末(星期六、星期日)才休息 +- 第4天:正常休息 +""" + +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from datetime import datetime, timedelta +import calendar + +# 创建工作簿 +wb = Workbook() +ws = wb.active +ws.title = "排班表" + +# 隐藏网格线 +ws.sheet_view.showGridLines = False + +# 定义颜色 +header_fill = PatternFill(start_color="1F4E79", end_color="1F4E79", fill_type="solid") +header_font = Font(bold=True, color="FFFFFF", size=12) +title_font = Font(bold=True, size=16) +duty_fill = PatternFill(start_color="FF6B6B", end_color="FF6B6B", fill_type="solid") # 红色 - 值班日 +work_fill = PatternFill(start_color="4ECDC4", end_color="4ECDC4", fill_type="solid") # 青色 - 正常上班 +weekend_work_fill = PatternFill(start_color="95E1D3", end_color="95E1D3", fill_type="solid") # 浅绿 - 周末上班 +rest_fill = PatternFill(start_color="DDA0DD", end_color="DDA0DD", fill_type="solid") # 紫色 - 休息 +weekend_rest_fill = PatternFill(start_color="E6E6FA", end_color="E6E6FA", fill_type="solid") # 浅紫 - 周末休息 + +thin_border = Border( + left=Side(style='thin'), + right=Side(style='thin'), + top=Side(style='thin'), + bottom=Side(style='thin') +) + +# 起始日期 - 从2025年1月1日开始(可以根据需要修改) +start_date = datetime(2025, 1, 1) + +# 排班规则函数 +def get_schedule_status(day_in_cycle, weekday): + """ + day_in_cycle: 1-4 表示在4天周期中的第几天 + weekday: 0=周一, 1=周二, ..., 5=周六, 6=周日 + 返回: (状态, 状态描述) + """ + if day_in_cycle == 1: + return ("值班日", "24小时值班", duty_fill) + elif day_in_cycle == 2: + return ("上班", "正常上班", work_fill) + elif day_in_cycle == 3: + # 第三天:正常上班,除非遇到周末才休息 + if weekday >= 5: # 周六或周日 + return ("休息", "周末休息", weekend_rest_fill) + else: + return ("上班", "正常上班", weekend_work_fill) + elif day_in_cycle == 4: + return ("休息", "正常休息", rest_fill) + return ("", "", None) + +# 生成排班数据 +days_in_months = [ + (2025, 1, 31), + (2025, 2, 28), + (2025, 3, 31), + (2025, 4, 30), + (2025, 5, 31), + (2025, 6, 30), + (2025, 7, 31), + (2025, 8, 31), + (2025, 9, 30), + (2025, 10, 31), + (2025, 11, 30), + (2025, 12, 31), +] + +# 先计算全年的数据用于统计 +year_schedule = [] +for i in range(365): + current_date = start_date + timedelta(days=i) + day_in_cycle = (i % 4) + 1 + weekday = current_date.weekday() # 0=周一, 6=周日 + status, desc, fill = get_schedule_status(day_in_cycle, weekday) + year_schedule.append({ + 'date': current_date, + 'day': i + 1, + 'cycle': day_in_cycle, + 'weekday': weekday, + 'weekday_name': ['周一', '周二', '周三', '周四', '周五', '周六', '周日'][weekday], + 'status': status, + 'desc': desc, + 'fill': fill + }) + +# 统计全年休息天数 +total_rest_days = sum(1 for day in year_schedule if day['status'] == '休息') +total_duty_days = sum(1 for day in year_schedule if day['status'] == '值班日') +total_work_days = sum(1 for day in year_schedule if day['status'] == '上班') + +print(f"全年统计:") +print(f" 值班日: {total_duty_days} 天") +print(f" 正常上班: {total_work_days} 天") +print(f" 休息日: {total_rest_days} 天") +print(f" 合计: {total_duty_days + total_work_days + total_rest_days} 天") + +# 创建两个月的排班表(假设用户需要1月和2月) +# 可以根据需要修改为其他月份 +two_months = [ + (2025, 1, "2025年1月"), + (2025, 2, "2025年2月"), +] + +# 写入标题 +ws['B2'] = "排班表(2025年1月-2月)" +ws['B2'].font = title_font +ws.merge_cells('B2:H2') +ws['B2'].alignment = Alignment(horizontal='center', vertical='center') + +# 写入统计信息 +ws['B4'] = f"全年可休息天数: {total_rest_days} 天" +ws['B4'].font = Font(bold=True, size=11) +ws.merge_cells('B4:E4') + +ws['B5'] = f"全年值班天数: {total_duty_days} 天" +ws['B5'].font = Font(bold=True, size=11) +ws.merge_cells('B5:E5') + +ws['B6'] = f"全年上班天数: {total_work_days} 天" +ws['B6'].font = Font(bold=True, size=11) +ws.merge_cells('B6:E6') + +# 写入表头 +headers = ['日期', '星期', '第几天班', '状态', '说明'] +header_row = 8 + +for col, header in enumerate(headers, start=2): + cell = ws.cell(row=header_row, column=col, value=header) + cell.font = header_font + cell.fill = header_fill + cell.alignment = Alignment(horizontal='center', vertical='center') + cell.border = thin_border + +# 筛选出1月和2月的数据 +two_months_data = [day for day in year_schedule + if day['date'].month in [1, 2]] + +# 写入数据 +current_row = header_row + 1 +for day_data in two_months_data: + ws.cell(row=current_row, column=2, value=day_data['date'].strftime('%Y-%m-%d')) + ws.cell(row=current_row, column=3, value=day_data['weekday_name']) + ws.cell(row=current_row, column=4, value=day_data['cycle']) + ws.cell(row=current_row, column=5, value=day_data['status']) + ws.cell(row=current_row, column=6, value=day_data['desc']) + + # 设置状态单元格的填充颜色 + if day_data['fill']: + ws.cell(row=current_row, column=5).fill = day_data['fill'] + + # 设置边框和对齐 + for col in range(2, 7): + ws.cell(row=current_row, column=col).border = thin_border + ws.cell(row=current_row, column=col).alignment = Alignment(horizontal='center', vertical='center') + + current_row += 1 + +# 设置列宽 +ws.column_dimensions['B'].width = 15 +ws.column_dimensions['C'].width = 10 +ws.column_dimensions['D'].width = 12 +ws.column_dimensions['E'].width = 12 +ws.column_dimensions['F'].width = 15 + +# 添加图例 +legend_row = current_row + 2 +ws[f'B{legend_row}'] = "图例:" +ws[f'B{legend_row}'].font = Font(bold=True) + +legend_items = [ + (duty_fill, "值班日(24小时)"), + (work_fill, "正常上班"), + (weekend_work_fill, "周末上班"), + (rest_fill, "正常休息"), + (weekend_rest_fill, "周末休息"), +] + +for i, (fill, desc) in enumerate(legend_items): + row = legend_row + 1 + i + ws[f'B{row}'].fill = fill + ws[f'B{row}'].border = thin_border + ws[f'C{row}'] = desc + ws[f'C{row}'].alignment = Alignment(horizontal='left', vertical='center') + +# 创建全年统计表 +ws2 = wb.create_sheet("全年统计") +ws2.sheet_view.showGridLines = False + +ws2['B2'] = "2025年全年排班统计" +ws2['B2'].font = title_font +ws2.merge_cells('B2:F2') + +# 月度统计 +ws2['B4'] = "月度统计" +ws2['B4'].font = Font(bold=True, size=12) + +monthly_headers = ['月份', '值班日', '上班', '休息', '合计'] +for col, header in enumerate(monthly_headers, start=2): + cell = ws2.cell(row=5, column=col, value=header) + cell.font = header_font + cell.fill = header_fill + cell.alignment = Alignment(horizontal='center', vertical='center') + cell.border = thin_border + +month_stats = {} +for month in range(1, 13): + month_data = [day for day in year_schedule if day['date'].month == month] + duty = sum(1 for d in month_data if d['status'] == '值班日') + work = sum(1 for d in month_data if d['status'] == '上班') + rest = sum(1 for d in month_data if d['status'] == '休息') + month_stats[month] = {'duty': duty, 'work': work, 'rest': rest} + + row = 5 + month + month_name = f"{month}月" + ws2.cell(row=row, column=2, value=month_name) + ws2.cell(row=row, column=3, value=duty) + ws2.cell(row=row, column=4, value=work) + ws2.cell(row=row, column=5, value=rest) + ws2.cell(row=row, column=6, value=duty + work + rest) + + for col in range(2, 7): + ws2.cell(row=row, column=col).border = thin_border + ws2.cell(row=row, column=col).alignment = Alignment(horizontal='center', vertical='center') + +# 年度总计 +total_row = 5 + 13 +ws2[f'B{total_row}'] = "全年总计" +ws2[f'B{total_row}'].font = Font(bold=True) +ws2[f'C{total_row}'] = total_duty_days +ws2[f'C{total_row}'].font = Font(bold=True) +ws2[f'D{total_row}'] = total_work_days +ws2[f'D{total_row}'].font = Font(bold=True) +ws2[f'E{total_row}'] = total_rest_days +ws2[f'E{total_row}'].font = Font(bold=True) +ws2[f'F{total_row}'] = 365 +ws2[f'F{total_row}'].font = Font(bold=True) + +for col in range(2, 7): + ws2.cell(row=total_row, column=col).border = thin_border + ws2.cell(row=total_row, column=col).alignment = Alignment(horizontal='center', vertical='center') + +# 设置列宽 +ws2.column_dimensions['B'].width = 12 +ws2.column_dimensions['C'].width = 12 +ws2.column_dimensions['D'].width = 12 +ws2.column_dimensions['E'].width = 12 +ws2.column_dimensions['F'].width = 12 + +# 保存文件 +output_file = "排班表.xlsx" +wb.save(output_file) +print(f"\n排班表已保存到: {output_file}") diff --git a/mini_agent/skills/document-skills/xlsx/SKILL.md b/mini_agent/skills/document-skills/xlsx/SKILL.md index 22db189..39189f8 100644 --- a/mini_agent/skills/document-skills/xlsx/SKILL.md +++ b/mini_agent/skills/document-skills/xlsx/SKILL.md @@ -56,234 +56,430 @@ Unless otherwise stated by the user or existing template - Comment or in cells beside (if end of table). Format: "Source: [System/Document], [Date], [Specific Reference], [URL if applicable]" - Examples: - "Source: Company 10-K, FY2024, Page 45, Revenue Note, [SEC EDGAR URL]" - - "Source: Company 10-Q, Q2 2025, Exhibit 99.1, [SEC EDGAR URL]" - "Source: Bloomberg Terminal, 8/15/2025, AAPL US Equity" - - "Source: FactSet, 8/20/2025, Consensus Estimates Screen" -# XLSX creation, editing, and analysis +--- -## Overview +# Technology Stack -A user may ask you to create, edit, or analyze the contents of an .xlsx file. You have different tools and workflows available for different tasks. +**Runtime**: Python 3 +**Primary Library**: openpyxl (Excel creation, styling, formulas, charts) +**Data Processing**: pandas (data manipulation, then export via openpyxl) +**Formula Recalculation**: `recalc.py` (LibreOffice-based, same directory as this skill) +**Static Validation**: `xlsx_validator.py` (same directory as this skill) -## Important Requirements +--- -**LibreOffice Required for Formula Recalculation**: You can assume LibreOffice is installed for recalculating formula values using the `recalc.py` script. The script automatically configures LibreOffice on first run +# Validation Tools -## Reading and analyzing data +## 1. recalc.py — Formula Recalculation (LibreOffice) -### Data analysis with pandas -For data analysis, visualization, and basic operations, use **pandas** which provides powerful data manipulation capabilities: +Recalculates all formulas using LibreOffice and scans for Excel errors. **MANDATORY** after creating/modifying files with formulas. -```python -import pandas as pd +```bash +python recalc.py output.xlsx [timeout_seconds] +``` -# Read Excel -df = pd.read_excel('file.xlsx') # Default: first sheet -all_sheets = pd.read_excel('file.xlsx', sheet_name=None) # All sheets as dict +Returns JSON: +```json +{ + "status": "success", + "total_errors": 0, + "total_formulas": 42, + "error_summary": {} +} +``` -# Analyze -df.head() # Preview data -df.info() # Column info -df.describe() # Statistics +- If `status` is `errors_found`, check `error_summary` for types and locations +- Fix errors and recalculate again until `total_errors` = 0 -# Write Excel -df.to_excel('output.xlsx', index=False) -``` +## 2. xlsx_validator.py — Static Analysis (5 Commands) -## Excel File Workflows +Performs static analysis without LibreOffice. Use as a complement to recalc.py or as standalone when LibreOffice is unavailable. -## CRITICAL: Use Formulas, Not Hardcoded Values +| Command | Purpose | When to Run | +|---------|---------|-------------| +| `recheck ` | Detect formula errors, zero-values, forbidden functions, implicit array formulas | After creating each sheet | +| `refcheck ` | Detect reference anomalies (out-of-range, header inclusion, insufficient range, pattern inconsistencies) | After creating each sheet | +| `inspect --pretty` | Analyze file structure → JSON (sheets, headers, data ranges) | Before processing input files | +| `chart-verify ` | Verify all charts have actual data | After adding charts | +| `validate ` | Comprehensive pre-delivery validation | Before delivery | -**Always use Excel formulas instead of calculating values in Python and hardcoding them.** This ensures the spreadsheet remains dynamic and updateable. +```bash +python xlsx_validator.py recheck output.xlsx +python xlsx_validator.py refcheck output.xlsx +python xlsx_validator.py inspect input.xlsx --pretty +python xlsx_validator.py chart-verify output.xlsx +python xlsx_validator.py validate output.xlsx +``` -### ❌ WRONG - Hardcoding Calculated Values -```python -# Bad: Calculating in Python and hardcoding result -total = df['Sales'].sum() -sheet['B10'] = total # Hardcodes 5000 +--- -# Bad: Computing growth rate in Python -growth = (df.iloc[-1]['Revenue'] - df.iloc[0]['Revenue']) / df.iloc[0]['Revenue'] -sheet['C5'] = growth # Hardcodes 0.15 +# Excel Creation Workflow (MUST FOLLOW) -# Bad: Python calculation for average -avg = sum(values) / len(values) -sheet['D20'] = avg # Hardcodes 42.5 ``` +Phase 1: DESIGN + → Plan all sheets: structure, formulas, cross-references BEFORE coding + +Phase 2: CREATE & VALIDATE (Per-Sheet Loop) + For each sheet: + 1. Create sheet (data, formulas, styling, charts if needed) + 2. Save workbook + 3. Run: recalc.py output.xlsx (if LibreOffice available) + 4. Run: xlsx_validator.py recheck output.xlsx + 5. Run: xlsx_validator.py refcheck output.xlsx + 6. Run: xlsx_validator.py chart-verify output.xlsx (if charts present) + 7. If errors found → Fix and repeat + 8. Only proceed to next sheet when current sheet has 0 errors + +Phase 3: FINAL VALIDATION + → Run: xlsx_validator.py validate output.xlsx + → Exit code 0: Safe to deliver + → Exit code non-zero: Fix and regenerate + +Phase 4: DELIVER + → Only deliver files that passed ALL validations +``` + +**FORBIDDEN Patterns:** +- Creating all sheets first, then running validation once at the end +- Ignoring errors and proceeding to next sheet +- Delivering files that failed validation + +--- + +# CRITICAL: Use Formulas, Not Hardcoded Values + +**Always use Excel formulas instead of calculating values in Python and hardcoding them.** The spreadsheet must remain dynamic and updateable. -### ✅ CORRECT - Using Excel Formulas ```python -# Good: Let Excel calculate the sum +# CORRECT - Use Excel formulas sheet['B10'] = '=SUM(B2:B9)' - -# Good: Growth rate as Excel formula sheet['C5'] = '=(C4-C2)/C2' - -# Good: Average using Excel function sheet['D20'] = '=AVERAGE(D2:D19)' ``` -This applies to ALL calculations - totals, percentages, ratios, differences, etc. The spreadsheet should be able to recalculate when source data changes. - -## Common Workflow -1. **Choose tool**: pandas for data, openpyxl for formulas/formatting -2. **Create/Load**: Create new workbook or load existing file -3. **Modify**: Add/edit data, formulas, and formatting -4. **Save**: Write to file -5. **Recalculate formulas (MANDATORY IF USING FORMULAS)**: Use the recalc.py script - ```bash - python recalc.py output.xlsx - ``` -6. **Verify and fix any errors**: - - The script returns JSON with error details - - If `status` is `errors_found`, check `error_summary` for specific error types and locations - - Fix the identified errors and recalculate again - - Common errors to fix: - - `#REF!`: Invalid cell references - - `#DIV/0!`: Division by zero - - `#VALUE!`: Wrong data type in formula - - `#NAME?`: Unrecognized formula name - -### Creating new Excel files +```python +# FORBIDDEN - Pre-calculate in Python and paste static values +total = df['Sales'].sum() +sheet['B10'] = total # BAD: Static value, not a formula +``` + +**Only use static values when:** +- Data is fetched from external sources (web search, API) +- Values are constants that never change +- Formula would create circular reference + +--- + +# Forbidden Functions (Incompatible with older Excel) + +| Forbidden Function | Alternative | +|-------------------|-------------| +| `FILTER()` | AutoFilter, SUMIF/COUNTIF/INDEX-MATCH | +| `UNIQUE()` | Remove Duplicates, helper column with COUNTIF | +| `SORT()`, `SORTBY()` | Excel's Sort feature | +| `XLOOKUP()` | `INDEX()` + `MATCH()` | +| `XMATCH()` | `MATCH()` | +| `SEQUENCE()` | ROW() or manual fill | +| `LET()` | Helper cells for intermediate calculations | +| `LAMBDA()` | Named ranges or VBA | +| `RANDARRAY()` | `RAND()` with fill-down | + +**Implicit Array Formula Detection:** +- Patterns like `MATCH(TRUE(), A1:A10>0, 0)` show #N/A in MS Excel +- Rewrite as: `=SUMPRODUCT((A1:A10>0)*ROW(A1:A10))-ROW(A1)+1` +- The `xlsx_validator.py recheck` command detects these automatically + +--- + +# VLOOKUP Usage Rules + +**When to Use**: Lookup/match/search tasks; multiple tables sharing keys; master-detail relationships; cross-file data with common keys + +**Syntax**: `=VLOOKUP(lookup_value, table_array, col_index_num, FALSE)` +- Lookup column MUST be leftmost in table_array +- Use FALSE for exact match +- Lock range with `$A$2:$D$100` +- Wrap with `IFERROR(...,"N/A")` +- Cross-sheet: `Sheet2!$A$2:$C$100` +- **Alt**: INDEX/MATCH when lookup column is not leftmost ```python -# Using openpyxl for formulas and formatting -from openpyxl import Workbook -from openpyxl.styles import Font, PatternFill, Alignment +ws['D2'] = '=IFERROR(VLOOKUP(A2,$G$2:$I$50,3,FALSE),"N/A")' +``` -wb = Workbook() -sheet = wb.active +**FORBIDDEN**: Using Python merge() instead of VLOOKUP formulas for cross-table matching. -# Add data -sheet['A1'] = 'Hello' -sheet['B1'] = 'World' -sheet.append(['Row', 'of', 'data']) +--- -# Add formula -sheet['B2'] = '=SUM(A1:A10)' +# External Data in Excel -# Formatting -sheet['A1'].font = Font(bold=True, color='FF0000') -sheet['A1'].fill = PatternFill('solid', start_color='FFFF00') -sheet['A1'].alignment = Alignment(horizontal='center') +When creating Excel files with externally fetched data: -# Column width -sheet.column_dimensions['A'].width = 20 +- ALL external data MUST have source citations in the final Excel +- Use **two separate columns**: `Source Name` | `Source URL` +- Do NOT use HYPERLINK function (use plain text to avoid formula errors) +- If citation per-row is impractical, create a dedicated "Sources" sheet -wb.save('output.xlsx') +--- + +# Style Rules + +## Overall Visual Design Principles +- **MANDATORY: Hide Gridlines** on ALL sheets: `ws.sheet_view.showGridLines = False` +- Start data at B2 (top-left padding), not A1 +- Title Row Height: `ws.row_dimensions[2].height = 30` +- Professional business-style color schemes, avoid over-decoration +- Consistency: uniform formatting, fonts, and color schemes +- Appropriate cell width/height — no imbalanced display scale + +## Style Selection + +### Minimalist Monochrome Style — DEFAULT for non-financial tasks + +```python +# Base Colors (Black/White/Grey ONLY) +bg_white = "FFFFFF" +bg_light_grey = "F5F5F5" +bg_row_alt = "F9F9F9" +header_dark_grey = "333333" +text_dark = "000000" +border_grey = "D0D0D0" + +# Blue Accent (ONLY color for differentiation) +blue_primary = "0066CC" +blue_secondary = "4A90D9" +blue_light = "E6F0FA" ``` +- STRICTLY FORBIDDEN in monochrome style: Green, Red, Orange, Purple, Yellow, Pink, rainbow schemes -### Editing existing Excel files +### Professional Finance Style — For financial/fiscal tasks ```python -# Using openpyxl to preserve formulas and formatting -from openpyxl import load_workbook +bg_light = "ECF0F1" +text_dark = "000000" +accent_warm = "FFF3E0" +header_dark_blue = "1F4E79" +negative_red = "FF0000" +``` -# Load existing file -wb = load_workbook('existing.xlsx') -sheet = wb.active # or wb['SheetName'] for specific sheet +**Regional Financial Color Convention:** +| Region | Price Up | Price Down | +|--------|----------|------------| +| China (Mainland) | Red | Green | +| Outside China | Green | Red | -# Working with multiple sheets -for sheet_name in wb.sheetnames: - sheet = wb[sheet_name] - print(f"Sheet: {sheet_name}") +## Border Styles +- In general, do NOT add borders — keeps content focused +- Use borders only when needed to reflect calculation structure -# Modify cells -sheet['A1'] = 'New Value' -sheet.insert_rows(2) # Insert row at position 2 -sheet.delete_cols(3) # Delete column 3 +## Merged Cells +```python +ws.merge_cells('B2:F2') +ws['B2'] = "Report Title" +ws['B2'].font = Font(size=18, bold=True) +ws['B2'].alignment = Alignment(horizontal='center', vertical='center') +``` +- Use for: titles, section headers, category labels spanning columns +- Avoid in: data areas, formula ranges -# Add new sheet -new_sheet = wb.create_sheet('NewSheet') -new_sheet['A1'] = 'Data' +## Conditional Formatting (Proactive Use Required) -wb.save('modified.xlsx') +```python +from openpyxl.formatting.rule import DataBarRule, ColorScaleRule, IconSetRule + +# Data Bars on numeric columns +ws.conditional_formatting.add('C2:C100', + DataBarRule(start_type='min', end_type='max', color='4A90D9', showValue=True)) + +# Color Scale for distribution +ws.conditional_formatting.add('D2:D100', + ColorScaleRule(start_type='min', start_color='FFFFFF', end_type='max', end_color='4A90D9')) + +# Icon Sets for KPIs +ws.conditional_formatting.add('E2:E100', + IconSetRule(icon_style='3TrafficLights1', type='percent', values=[0, 33, 67], showValue=True)) ``` -## Recalculating formulas +--- -Excel files created or modified by openpyxl contain formulas as strings but not calculated values. Use the provided `recalc.py` script to recalculate formulas: +# Cover Page Design -```bash -python recalc.py [timeout_seconds] +**Every Excel deliverable MUST include a Cover Page as the FIRST sheet.** + +| Row | Content | Style | +|-----|---------|-------| +| 2-3 | Report Title | 18-20pt, Bold, Centered | +| 5 | Subtitle/Description | 12pt, Gray | +| 7-15 | Key Metrics Summary | Table with highlights | +| 17-20 | Sheet Index | All sheets with descriptions | +| 22+ | Notes & Instructions | Small font, Gray | + +Required Elements: +1. **Report Title** — clear, descriptive +2. **Key Metrics Summary** — 3-6 most important numbers/findings +3. **Sheet Index** — navigation guide with sheet names and descriptions +4. **Cover styling**: clean white/light gray background, no gridlines, merged title area + +--- + +# Visual Charts + +## You MUST Create REAL Excel Charts + +**Trigger Keywords**: "visual", "chart", "graph", "visualization", "diagram" + +When a workbook has multiple datasets, ensure **each dataset has at least one chart** unless user says otherwise. + +**FORBIDDEN:** +- Creating a "CHARTS DATA" sheet with instructions to insert charts manually +- Telling the user to create charts themselves + +**REQUIRED:** +- Create embedded Excel charts using openpyxl.chart module +- Run `xlsx_validator.py chart-verify` after creating charts + +```python +from openpyxl.chart import BarChart, LineChart, PieChart, Reference +from openpyxl.chart.label import DataLabelList + +chart = BarChart() +chart.type = "col" +chart.style = 10 +chart.title = "Sales by Category" +chart.y_axis.title = 'Value' + +data_ref = Reference(ws, min_col=2, min_row=1, max_row=4) +cats_ref = Reference(ws, min_col=1, min_row=2, max_row=4) +chart.add_data(data_ref, titles_from_data=True) +chart.set_categories(cats_ref) +ws.add_chart(chart, "E2") ``` -Example: -```bash -python recalc.py output.xlsx 30 +**Chart Type Selection:** +| Data Type | Chart | Use Case | +|-----------|-------|----------| +| Trend | Line | Time series | +| Compare | Column/Bar | Category comparison | +| Composition | Pie/Doughnut | Percentages (6 items max) | +| Distribution | Histogram | Data spread | +| Correlation | Scatter | Relationships | + +--- + +# Reading and Analyzing Data + +## Data analysis with pandas +```python +import pandas as pd + +df = pd.read_excel('file.xlsx') # First sheet +all_sheets = pd.read_excel('file.xlsx', sheet_name=None) # All sheets as dict + +df.head() # Preview +df.info() # Column info +df.describe() # Statistics + +df.to_excel('output.xlsx', index=False) ``` -The script: -- Automatically sets up LibreOffice macro on first run -- Recalculates all formulas in all sheets -- Scans ALL cells for Excel errors (#REF!, #DIV/0!, etc.) -- Returns JSON with detailed error locations and counts -- Works on both Linux and macOS +## Creating new Excel files +```python +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment -## Formula Verification Checklist +wb = Workbook() +ws = wb.active +ws.sheet_view.showGridLines = False -Quick checks to ensure formulas work correctly: +ws['A1'] = 'Header' +ws['A1'].font = Font(bold=True, color='FFFFFF') +ws['A1'].fill = PatternFill('solid', start_color='333333') -### Essential Verification -- [ ] **Test 2-3 sample references**: Verify they pull correct values before building full model -- [ ] **Column mapping**: Confirm Excel columns match (e.g., column 64 = BL, not BK) -- [ ] **Row offset**: Remember Excel rows are 1-indexed (DataFrame row 5 = Excel row 6) +ws['B2'] = '=SUM(A1:A10)' +ws.column_dimensions['A'].width = 20 +wb.save('output.xlsx') +``` -### Common Pitfalls -- [ ] **NaN handling**: Check for null values with `pd.notna()` -- [ ] **Far-right columns**: FY data often in columns 50+ -- [ ] **Multiple matches**: Search all occurrences, not just first -- [ ] **Division by zero**: Check denominators before using `/` in formulas (#DIV/0!) -- [ ] **Wrong references**: Verify all cell references point to intended cells (#REF!) -- [ ] **Cross-sheet references**: Use correct format (Sheet1!A1) for linking sheets - -### Formula Testing Strategy -- [ ] **Start small**: Test formulas on 2-3 cells before applying broadly -- [ ] **Verify dependencies**: Check all cells referenced in formulas exist -- [ ] **Test edge cases**: Include zero, negative, and very large values - -### Interpreting recalc.py Output -The script returns JSON with error details: -```json -{ - "status": "success", // or "errors_found" - "total_errors": 0, // Total error count - "total_formulas": 42, // Number of formulas in file - "error_summary": { // Only present if errors found - "#REF!": { - "count": 2, - "locations": ["Sheet1!B5", "Sheet1!C10"] - } - } -} +## Editing existing Excel files +```python +from openpyxl import load_workbook + +wb = load_workbook('existing.xlsx') +ws = wb.active # or wb['SheetName'] + +ws['A1'] = 'New Value' +ws.insert_rows(2) +new_sheet = wb.create_sheet('NewSheet') +wb.save('modified.xlsx') ``` -## Best Practices +--- + +# Best Practices -### Library Selection +## Library Selection - **pandas**: Best for data analysis, bulk operations, and simple data export - **openpyxl**: Best for complex formatting, formulas, and Excel-specific features -### Working with openpyxl -- Cell indices are 1-based (row=1, column=1 refers to cell A1) +## Working with openpyxl +- Cell indices are 1-based (row=1, column=1 = cell A1) - Use `data_only=True` to read calculated values: `load_workbook('file.xlsx', data_only=True)` -- **Warning**: If opened with `data_only=True` and saved, formulas are replaced with values and permanently lost -- For large files: Use `read_only=True` for reading or `write_only=True` for writing -- Formulas are preserved but not evaluated - use recalc.py to update values +- **Warning**: If opened with `data_only=True` and saved, formulas are permanently replaced with values +- Formulas are preserved but not evaluated — use recalc.py to update values -### Working with pandas +## Working with pandas - Specify data types to avoid inference issues: `pd.read_excel('file.xlsx', dtype={'id': str})` - For large files, read specific columns: `pd.read_excel('file.xlsx', usecols=['A', 'C', 'E'])` - Handle dates properly: `pd.read_excel('file.xlsx', parse_dates=['date_column'])` -## Code Style Guidelines -**IMPORTANT**: When generating Python code for Excel operations: -- Write minimal, concise Python code without unnecessary comments +## Formula Verification Checklist + +### Essential +- Test 2-3 sample references before building full model +- Column mapping: confirm Excel columns match (column 64 = BL, not BK) +- Row offset: Excel rows are 1-indexed (DataFrame row 5 = Excel row 6) + +### Common Pitfalls +- NaN handling: check for null values with `pd.notna()` +- Division by zero: check denominators before using `/` in formulas +- Cross-sheet references: use correct format (`Sheet1!A1`) +- Off-by-one: verify formula ranges don't include headers or extend beyond data + +## Code Style +- Write minimal, concise Python code - Avoid verbose variable names and redundant operations -- Avoid unnecessary print statements +- For Excel files: add comments to cells with complex formulas, document data sources for hardcoded values + +--- + +# Baseline Error Prevention + +**Forbidden in deliverables:** +1. Formula errors: #VALUE!, #DIV/0!, #REF!, #NAME?, #NULL!, #NUM!, #N/A +2. Off-by-one references (wrong cell/row/column) +3. Text starting with `=` interpreted as formula +4. Static values instead of formulas for calculations +5. Placeholder text: "TBD", "Pending", "Manual calculation required" +6. Missing units in headers; inconsistent units +7. Currency without format symbols +8. Result of 0 must be verified — often indicates reference error + +**Financial Values**: Store in smallest unit (15000000 not 1.5M). Use Excel format for display: `"$#,##0"` or `"¥#,##0"`. + +--- -**For Excel files themselves**: -- Add comments to cells with complex formulas or important assumptions -- Document data sources for hardcoded values -- Include notes for key calculations and model sections \ No newline at end of file +# Final Checklist Before Delivery + +1. Every sheet has content (not just headers) +2. All formula cells produce valid values (no errors, verify zeros) +3. Gridlines hidden on ALL sheets +4. Cover page present as first sheet +5. Charts verified with `chart-verify` (if applicable) +6. External data has source citations +7. Currency formatted with symbols for financial data +8. Cell dimensions are reasonable +9. Style matches task type (monochrome vs finance) +10. `recalc.py` returns `"status": "success"` (if LibreOffice available) +11. `xlsx_validator.py validate` returns exit code 0 diff --git a/mini_agent/skills/document-skills/xlsx/xlsx_validator.py b/mini_agent/skills/document-skills/xlsx/xlsx_validator.py new file mode 100755 index 0000000..189497e --- /dev/null +++ b/mini_agent/skills/document-skills/xlsx/xlsx_validator.py @@ -0,0 +1,882 @@ +#!/usr/bin/env python3 +""" +xlsx_validator.py - Excel file validation and inspection tool + +Replaces KimiXlsx CLI for local use with Claude Code. + +Commands: + recheck Detect formula errors, zero-value cells, implicit array formulas + refcheck Detect reference anomalies (out-of-range, header inclusion, etc.) + inspect Analyze Excel file structure → JSON + chart-verify Verify charts have actual data + validate Comprehensive pre-delivery validation + +Usage: + python3 xlsx_validator.py recheck output.xlsx + python3 xlsx_validator.py refcheck output.xlsx + python3 xlsx_validator.py inspect output.xlsx [--pretty] + python3 xlsx_validator.py chart-verify output.xlsx + python3 xlsx_validator.py validate output.xlsx +""" + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from collections import Counter, defaultdict +from pathlib import Path + +try: + import openpyxl + from openpyxl.utils import get_column_letter, column_index_from_string +except ImportError: + print("ERROR: openpyxl is required. Install with: pip install openpyxl") + sys.exit(1) + +# ───────────────────────────────────────────── +# Constants +# ───────────────────────────────────────────── + +FORMULA_ERROR_VALUES = { + "#VALUE!", "#DIV/0!", "#REF!", "#NAME?", "#NULL!", "#NUM!", "#N/A", + "#GETTING_DATA", "#SPILL!", "#CALC!", "#BLOCKED!", "#UNKNOWN!", +} + +FORBIDDEN_FUNCTIONS = { + "FILTER", "UNIQUE", "SORT", "SORTBY", "XLOOKUP", "XMATCH", + "SEQUENCE", "LET", "LAMBDA", "RANDARRAY", "ARRAYFORMULA", + "QUERY", "IMPORTRANGE", +} + +AGGREGATE_FUNCTIONS = {"SUM", "AVERAGE", "AVG", "COUNT", "COUNTA", "COUNTIF", + "SUMIF", "MIN", "MAX", "STDEV", "VAR", "MEDIAN"} + +# Regex patterns +RE_CELL_REF = re.compile( + r"(?:(?:'[^']+?'|[A-Za-z0-9_]+)!)?" # optional sheet reference + r"\$?([A-Z]{1,3})\$?(\d+)" # column + row +) +RE_RANGE_REF = re.compile( + r"(?:(?:'[^']+?'|[A-Za-z0-9_]+)!)?" + r"\$?([A-Z]{1,3})\$?(\d+)" + r":" + r"\$?([A-Z]{1,3})\$?(\d+)" +) +# Version that also captures the sheet name for cross-sheet resolution +RE_RANGE_REF_WITH_SHEET = re.compile( + r"(?:(?:'([^']+?)'|([A-Za-z0-9_]+))!)?" # group 1=quoted sheet, group 2=unquoted sheet + r"\$?([A-Z]{1,3})\$?(\d+)" # group 3=col1, group 4=row1 + r":" + r"\$?([A-Z]{1,3})\$?(\d+)" # group 5=col2, group 6=row2 +) +RE_FUNCTION_CALL = re.compile(r"([A-Z][A-Z0-9_.]+)\s*\(") +RE_IMPLICIT_ARRAY = re.compile( + r"MATCH\s*\(\s*TRUE\s*\(\s*\)\s*,", re.IGNORECASE +) + + +# ───────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────── + +def col_to_idx(col_str): + """Convert column letter(s) to 1-based index.""" + return column_index_from_string(col_str.upper()) + + +def get_sheet_data_bounds(ws): + """Return (min_row, max_row, min_col, max_col) of actual data in a sheet.""" + if ws.max_row is None or ws.max_column is None: + return (1, 1, 1, 1) + # Scan for actual data extent + max_r, max_c = 0, 0 + min_r, min_c = ws.max_row + 1, ws.max_column + 1 + for row in ws.iter_rows(min_row=1, max_row=ws.max_row, + min_col=1, max_col=ws.max_column): + for cell in row: + if cell.value is not None: + r, c = cell.row, cell.column + min_r = min(min_r, r) + min_c = min(min_c, c) + max_r = max(max_r, r) + max_c = max(max_c, c) + if max_r == 0: + return (1, 1, 1, 1) + return (min_r, max_r, min_c, max_c) + + +def extract_functions(formula): + """Extract all function names from a formula string.""" + if not formula or not isinstance(formula, str) or not formula.startswith("="): + return [] + return RE_FUNCTION_CALL.findall(formula) + + +def extract_ranges(formula): + """Extract all range references (A1:B10) from a formula.""" + if not formula or not isinstance(formula, str): + return [] + return RE_RANGE_REF.findall(formula) + + +def extract_ranges_with_sheet(formula): + """Extract range references with their target sheet name. + + Returns list of (sheet_name_or_None, col1, row1, col2, row2). + """ + if not formula or not isinstance(formula, str): + return [] + results = [] + for m in RE_RANGE_REF_WITH_SHEET.finditer(formula): + quoted_sheet, unquoted_sheet, c1, r1, c2, r2 = m.groups() + sheet = quoted_sheet or unquoted_sheet # None if same-sheet ref + results.append((sheet, c1, r1, c2, r2)) + return results + + +def extract_cell_refs(formula): + """Extract all individual cell references from a formula.""" + if not formula or not isinstance(formula, str): + return [] + return RE_CELL_REF.findall(formula) + + +def is_formula(value): + """Check if a cell value is a formula.""" + return isinstance(value, str) and value.startswith("=") + + +def print_header(title): + print(f"\n{'='*60}") + print(f" {title}") + print(f"{'='*60}") + + +def print_section(title): + print(f"\n--- {title} ---") + + +def load_workbook_safe(filepath, data_only=False): + """Load workbook with error handling.""" + try: + return openpyxl.load_workbook(filepath, data_only=data_only) + except Exception as e: + print(f"ERROR: Cannot open '{filepath}': {e}") + sys.exit(1) + + +# ───────────────────────────────────────────── +# Command: recheck +# ───────────────────────────────────────────── + +def cmd_recheck(filepath): + """Detect formula errors, zero-value cells, forbidden functions, and implicit array formulas.""" + print_header(f"RECHECK: {os.path.basename(filepath)}") + + wb_formula = load_workbook_safe(filepath, data_only=False) + wb_data = load_workbook_safe(filepath, data_only=True) + + error_count = 0 + zero_count = 0 + forbidden_count = 0 + implicit_array_count = 0 + issues = [] + + for sheet_name in wb_formula.sheetnames: + ws_f = wb_formula[sheet_name] + ws_d = wb_data[sheet_name] + + for row in ws_f.iter_rows(min_row=1, max_row=ws_f.max_row or 1, + min_col=1, max_col=ws_f.max_column or 1): + for cell in row: + if not is_formula(cell.value): + continue + + formula = cell.value + coord = f"'{sheet_name}'!{cell.coordinate}" + + # 1) Check cached value for errors + data_cell = ws_d[cell.coordinate] + if data_cell.value in FORMULA_ERROR_VALUES: + error_count += 1 + issues.append({ + "type": "formula_error", + "cell": coord, + "formula": formula, + "error": str(data_cell.value), + }) + elif isinstance(data_cell.value, str) and data_cell.value in FORMULA_ERROR_VALUES: + error_count += 1 + issues.append({ + "type": "formula_error", + "cell": coord, + "formula": formula, + "error": data_cell.value, + }) + + # 2) Check for zero values in formula cells + if data_cell.value == 0 or data_cell.value == 0.0: + zero_count += 1 + issues.append({ + "type": "zero_value", + "cell": coord, + "formula": formula, + "note": "Formula result is 0 - verify if this is expected", + }) + + # 3) Check forbidden functions + funcs = extract_functions(formula) + for fn in funcs: + if fn.upper() in FORBIDDEN_FUNCTIONS: + forbidden_count += 1 + issues.append({ + "type": "forbidden_function", + "cell": coord, + "formula": formula, + "function": fn, + }) + + # 4) Check implicit array formulas + if RE_IMPLICIT_ARRAY.search(formula): + implicit_array_count += 1 + issues.append({ + "type": "implicit_array", + "cell": coord, + "formula": formula, + "note": "MATCH(TRUE(),...) pattern - may show #N/A in MS Excel. " + "Use SUMPRODUCT or helper column instead.", + }) + + wb_formula.close() + wb_data.close() + + # Also try LibreOffice recalculation if available + lo_errors = _try_libreoffice_recheck(filepath) + if lo_errors: + for err in lo_errors: + if not any(i["cell"] == err["cell"] and i["type"] == "formula_error" for i in issues): + error_count += 1 + issues.append(err) + + # Report + print_section("Summary") + print(f" formula_error_count : {error_count}") + print(f" zero_value_count : {zero_count}") + print(f" forbidden_func_count: {forbidden_count}") + print(f" implicit_array_count: {implicit_array_count}") + total = error_count + forbidden_count + implicit_array_count + print(f" total_errors : {total}") + + if issues: + print_section("Details") + for i, issue in enumerate(issues, 1): + tp = issue["type"].upper() + cell = issue["cell"] + formula = issue.get("formula", "") + extra = issue.get("error") or issue.get("function") or issue.get("note", "") + print(f" [{i}] {tp} at {cell}") + print(f" Formula: {formula}") + if extra: + print(f" Detail : {extra}") + + if total == 0 and zero_count == 0: + print("\n ✅ PASS - No errors detected") + elif total == 0: + print(f"\n ⚠️ WARN - {zero_count} zero-value cells to verify (no hard errors)") + else: + print(f"\n ❌ FAIL - {total} errors MUST be fixed before delivery") + + return total + + +def _try_libreoffice_recheck(filepath): + """Attempt to recalculate with LibreOffice and check for errors.""" + lo_path = shutil.which("libreoffice") or shutil.which("soffice") + if not lo_path: + return [] + + errors = [] + try: + with tempfile.TemporaryDirectory() as tmpdir: + # Copy file to temp + tmp_file = os.path.join(tmpdir, os.path.basename(filepath)) + shutil.copy2(filepath, tmp_file) + + # Recalculate with LibreOffice + subprocess.run( + [lo_path, "--headless", "--calc", "--convert-to", "xlsx", + "--outdir", tmpdir, tmp_file], + capture_output=True, timeout=60 + ) + + # Re-read with data_only + recalc_file = tmp_file # LO overwrites in place with --convert-to same format + if os.path.exists(recalc_file): + wb = openpyxl.load_workbook(recalc_file, data_only=True) + wb_f = openpyxl.load_workbook(recalc_file, data_only=False) + for sn in wb.sheetnames: + ws_d = wb[sn] + ws_f = wb_f[sn] + for row_d, row_f in zip( + ws_d.iter_rows(min_row=1, max_row=ws_d.max_row or 1, + min_col=1, max_col=ws_d.max_column or 1), + ws_f.iter_rows(min_row=1, max_row=ws_f.max_row or 1, + min_col=1, max_col=ws_f.max_column or 1)): + for cd, cf in zip(row_d, row_f): + if is_formula(cf.value) and isinstance(cd.value, str) and cd.value in FORMULA_ERROR_VALUES: + errors.append({ + "type": "formula_error", + "cell": f"'{sn}'!{cd.coordinate}", + "formula": cf.value, + "error": f"{cd.value} (LibreOffice recalc)", + }) + wb.close() + wb_f.close() + except Exception: + pass # LibreOffice check is best-effort + + return errors + + +# ───────────────────────────────────────────── +# Command: refcheck (reference-check) +# ───────────────────────────────────────────── + +def cmd_refcheck(filepath): + """Detect reference anomalies in formulas.""" + print_header(f"REFERENCE CHECK: {os.path.basename(filepath)}") + + wb = load_workbook_safe(filepath, data_only=False) + issues = [] + + # Pre-compute data bounds for ALL sheets so cross-sheet refs can be resolved + all_sheet_bounds = {} + for sn in wb.sheetnames: + all_sheet_bounds[sn] = get_sheet_data_bounds(wb[sn]) + + def resolve_bounds(target_sheet, current_sheet_name): + """Get data bounds for the target sheet, falling back to current sheet.""" + if target_sheet and target_sheet in all_sheet_bounds: + return all_sheet_bounds[target_sheet] + return all_sheet_bounds.get(current_sheet_name, (1, 1, 1, 1)) + + def resolve_ws(target_sheet, current_ws): + """Get the worksheet object for the target sheet.""" + if target_sheet and target_sheet in wb.sheetnames: + return wb[target_sheet] + return current_ws + + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + if ws.max_row is None or ws.max_row <= 1: + continue + + local_bounds = all_sheet_bounds[sheet_name] + data_min_row, data_max_row, data_min_col, data_max_col = local_bounds + + # Collect formulas by column for pattern analysis + col_formulas = defaultdict(list) + + for row in ws.iter_rows(min_row=1, max_row=ws.max_row, + min_col=1, max_col=ws.max_column or 1): + for cell in row: + if not is_formula(cell.value): + continue + + formula = cell.value + coord = f"'{sheet_name}'!{cell.coordinate}" + col_formulas[cell.column].append((cell.row, cell.coordinate, formula)) + + # Use sheet-aware range extraction + ranges_with_sheet = extract_ranges_with_sheet(formula) + + # 1) Out-of-range references + for (target_sheet, c1, r1, c2, r2) in ranges_with_sheet: + try: + end_row = int(r2) + tb = resolve_bounds(target_sheet, sheet_name) + target_max_row = tb[1] + # Flag if range extends more than 5x beyond target sheet's data + if end_row > target_max_row * 5 and end_row > target_max_row + 100: + target_label = f"'{target_sheet}'" if target_sheet else "current sheet" + issues.append({ + "type": "out_of_range", + "cell": coord, + "formula": formula, + "detail": f"Range ends at row {end_row}, but {target_label} data ends at row {target_max_row}", + }) + except ValueError: + pass + + # 2) Header row inclusion in aggregate functions + funcs = extract_functions(formula) + agg_funcs = [f for f in funcs if f.upper() in AGGREGATE_FUNCTIONS] + if agg_funcs: + for (target_sheet, c1, r1, c2, r2) in ranges_with_sheet: + try: + start_row = int(r1) + if start_row == 1: + target_ws = resolve_ws(target_sheet, ws) + tb = resolve_bounds(target_sheet, sheet_name) + if tb[0] == 1: # data starts at row 1 + col_idx = col_to_idx(c1) + header_cell = target_ws.cell(row=1, column=col_idx) + if isinstance(header_cell.value, str) and not header_cell.value.startswith("="): + target_label = f"'{target_sheet}'!" if target_sheet else "" + issues.append({ + "type": "header_inclusion", + "cell": coord, + "formula": formula, + "detail": f"Aggregate function includes {target_label}row 1 (header: '{header_cell.value}')", + }) + except (ValueError, Exception): + pass + + # 3) Insufficient aggregate range (SUM/AVERAGE over ≤2 cells) + if agg_funcs: + for (target_sheet, c1, r1, c2, r2) in ranges_with_sheet: + try: + row_span = abs(int(r2) - int(r1)) + 1 + col_span = abs(col_to_idx(c2) - col_to_idx(c1)) + 1 + total_cells = row_span * col_span + if total_cells <= 2: + issues.append({ + "type": "insufficient_range", + "cell": coord, + "formula": formula, + "detail": f"Aggregate function covers only {total_cells} cell(s) " + f"({c1}{r1}:{c2}{r2})", + }) + except (ValueError, Exception): + pass + + # 4) Inconsistent formula patterns within a column + # Skip the last 1-2 rows of data — they are likely total/summary rows + for col_idx, formulas_list in col_formulas.items(): + if len(formulas_list) < 3: + continue + + # Identify the boundary: last row with a formula in this column + max_formula_row = max(r for r, _, _ in formulas_list) + + # Normalize formulas: replace row numbers with placeholder + def normalize_formula(f): + return re.sub(r'(\$?)(\d+)', r'\1{R}', f) + + patterns = Counter() + formula_map = {} + for (r, coord_str, f) in formulas_list: + norm = normalize_formula(f) + patterns[norm] += 1 + if norm not in formula_map: + formula_map[norm] = [] + formula_map[norm].append((r, coord_str, f)) + + if len(patterns) > 1: + dominant_pattern = patterns.most_common(1)[0] + dominant_norm, dominant_count = dominant_pattern + for norm, entries in formula_map.items(): + if norm != dominant_norm and len(entries) <= 2: + for (r, coord_str, f) in entries: + # Skip if this is a summary/total row (last 2 rows) + if r >= max_formula_row - 1: + continue + issues.append({ + "type": "inconsistent_pattern", + "cell": f"'{sheet_name}'!{coord_str}", + "formula": f, + "detail": f"Deviates from dominant pattern in column " + f"({dominant_count}/{len(formulas_list)} cells use a different pattern)", + }) + + wb.close() + + # Report + type_counts = Counter(i["type"] for i in issues) + print_section("Summary") + print(f" out_of_range : {type_counts.get('out_of_range', 0)}") + print(f" header_inclusion : {type_counts.get('header_inclusion', 0)}") + print(f" insufficient_range : {type_counts.get('insufficient_range', 0)}") + print(f" inconsistent_pattern: {type_counts.get('inconsistent_pattern', 0)}") + total = len(issues) + print(f" total_issues : {total}") + + if issues: + print_section("Details") + for i, issue in enumerate(issues, 1): + tp = issue["type"].upper() + cell = issue["cell"] + formula = issue.get("formula", "") + detail = issue.get("detail", "") + print(f" [{i}] {tp} at {cell}") + print(f" Formula: {formula}") + if detail: + print(f" Detail : {detail}") + + if total == 0: + print("\n ✅ PASS - No reference anomalies detected") + else: + print(f"\n ⚠️ {total} potential reference issues found - please review") + + return total + + +# ───────────────────────────────────────────── +# Command: inspect +# ───────────────────────────────────────────── + +def cmd_inspect(filepath, pretty=False): + """Analyze Excel file structure and output JSON.""" + wb = load_workbook_safe(filepath, data_only=False) + + result = { + "file": os.path.basename(filepath), + "sheets": [], + } + + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + bounds = get_sheet_data_bounds(ws) + min_r, max_r, min_c, max_c = bounds + + # Extract headers (first row of data) + headers = [] + if ws.max_row and ws.max_row >= min_r: + for col in range(min_c, max_c + 1): + val = ws.cell(row=min_r, column=col).value + headers.append(str(val) if val is not None else "") + + # Count formulas + formula_count = 0 + for row in ws.iter_rows(min_row=1, max_row=ws.max_row or 1, + min_col=1, max_col=ws.max_column or 1): + for cell in row: + if is_formula(cell.value): + formula_count += 1 + + # Charts + chart_count = len(ws._charts) if hasattr(ws, '_charts') else 0 + + # Merged cells + merged = [str(m) for m in ws.merged_cells.ranges] + + sheet_info = { + "name": sheet_name, + "dimensions": ws.dimensions, + "data_range": { + "start": f"{get_column_letter(min_c)}{min_r}", + "end": f"{get_column_letter(max_c)}{max_r}", + }, + "rows": max_r - min_r + 1 if max_r >= min_r else 0, + "columns": max_c - min_c + 1 if max_c >= min_c else 0, + "headers": headers, + "formula_count": formula_count, + "chart_count": chart_count, + "merged_cells": merged if merged else [], + "gridlines_hidden": not ws.sheet_view.showGridLines if ws.sheet_view else False, + } + result["sheets"].append(sheet_info) + + wb.close() + + if pretty: + output = json.dumps(result, indent=2, ensure_ascii=False) + else: + output = json.dumps(result, ensure_ascii=False) + + print(output) + return result + + +# ───────────────────────────────────────────── +# Command: chart-verify +# ───────────────────────────────────────────── + +def cmd_chart_verify(filepath): + """Verify that all charts have actual data content.""" + print_header(f"CHART VERIFY: {os.path.basename(filepath)}") + + wb = load_workbook_safe(filepath, data_only=False) + + total_charts = 0 + empty_charts = 0 + chart_details = [] + + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + charts = ws._charts if hasattr(ws, '_charts') else [] + for idx, chart in enumerate(charts): + total_charts += 1 + raw_title = chart.title + if raw_title is None: + title = f"(untitled chart #{idx+1})" + elif isinstance(raw_title, str): + title = raw_title + else: + # openpyxl Title object - extract text + try: + title = raw_title.text if hasattr(raw_title, 'text') else str(raw_title) + # Try to get from rich text + if not isinstance(title, str) or 'object' in title: + for p in getattr(getattr(raw_title, 'tx', None), 'rich', None).p: + for run in p.r: + title = run.t + break + break + except Exception: + title = f"Chart #{idx+1}" + + # Check if chart has data series + has_data = False + series_count = 0 + if hasattr(chart, 'series') and chart.series: + series_count = len(chart.series) + for s in chart.series: + if hasattr(s, 'val') and s.val is not None: + has_data = True + break + if hasattr(s, 'numRef') and s.numRef is not None: + has_data = True + break + + status = "OK" if has_data else "EMPTY" + if not has_data: + empty_charts += 1 + + chart_details.append({ + "sheet": sheet_name, + "title": title, + "series_count": series_count, + "status": status, + }) + + wb.close() + + # Report + print_section("Summary") + print(f" total_charts : {total_charts}") + print(f" charts_ok : {total_charts - empty_charts}") + print(f" charts_empty : {empty_charts}") + + if chart_details: + print_section("Details") + for cd in chart_details: + status_icon = "✅" if cd["status"] == "OK" else "❌" + print(f" {status_icon} [{cd['sheet']}] {cd['title']} " + f"- {cd['series_count']} series - {cd['status']}") + + if total_charts == 0: + print("\n ⚠️ No charts found in workbook") + return 1 + elif empty_charts > 0: + print(f"\n ❌ FAIL - {empty_charts} empty chart(s) detected. MUST FIX before delivery.") + return 1 + else: + print(f"\n ✅ PASS - All {total_charts} chart(s) have data") + return 0 + + +# ───────────────────────────────────────────── +# Command: validate +# ───────────────────────────────────────────── + +def cmd_validate(filepath): + """Comprehensive pre-delivery validation.""" + print_header(f"VALIDATE: {os.path.basename(filepath)}") + + wb_f = load_workbook_safe(filepath, data_only=False) + wb_d = load_workbook_safe(filepath, data_only=True) + + errors = [] + warnings = [] + + for sheet_name in wb_f.sheetnames: + ws_f = wb_f[sheet_name] + ws_d = wb_d[sheet_name] + + has_any_data = False + header_only = True + + for row in ws_f.iter_rows(min_row=1, max_row=ws_f.max_row or 1, + min_col=1, max_col=ws_f.max_column or 1): + for cell in row: + if cell.value is not None: + has_any_data = True + if cell.row > 1: + header_only = False + + if not is_formula(cell.value): + continue + + formula = cell.value + coord = f"'{sheet_name}'!{cell.coordinate}" + + # Check forbidden functions + funcs = extract_functions(formula) + for fn in funcs: + if fn.upper() in FORBIDDEN_FUNCTIONS: + errors.append(f"Forbidden function {fn}() at {coord}") + + # Check cached errors + data_cell = ws_d[cell.coordinate] + if data_cell.value in FORMULA_ERROR_VALUES: + errors.append(f"Formula error {data_cell.value} at {coord}") + + # Check implicit array formulas + if RE_IMPLICIT_ARRAY.search(formula): + errors.append( + f"Implicit array formula at {coord} - will show #N/A in MS Excel" + ) + + # Check for placeholder text + if isinstance(cell.value, str): + lower = cell.value.lower() + for placeholder in ["tbd", "pending", "manual calculation required", + "to be determined", "placeholder"]: + if placeholder in lower and not cell.value.startswith("="): + warnings.append(f"Placeholder text at {coord}: '{cell.value}'") + + # Check for empty sheets (has header but no data) + if has_any_data and header_only and (ws_f.max_row or 0) <= 1: + warnings.append(f"Sheet '{sheet_name}' appears to have only headers, no data rows") + + wb_f.close() + wb_d.close() + + # Try to check .rels for absolute paths (zip inspection) + try: + import zipfile + with zipfile.ZipFile(filepath, 'r') as zf: + for name in zf.namelist(): + if name.endswith('.rels'): + content = zf.read(name).decode('utf-8', errors='ignore') + # Check for Windows absolute paths (C:\...) - these crash Excel + if re.search(r'Target\s*=\s*"[A-Za-z]:\\', content): + errors.append(f"Windows absolute path found in {name} - will crash Excel") + # Check for filesystem absolute paths (NOT internal /xl/... refs) + # Internal refs like /xl/worksheets/sheet1.xml are normal in OOXML + abs_matches = re.findall(r'Target\s*=\s*"(/[^"]+)"', content) + for m in abs_matches: + # Internal OOXML refs starting with /xl/, /docProps/, /_rels/ are normal + if not re.match(r'^/(xl|docProps|_rels|customXml)/', m): + errors.append(f"Absolute filesystem path in {name}: {m}") + except Exception: + warnings.append("Could not inspect ZIP structure for .rels validation") + + # Report + print_section("Errors (MUST FIX)") + if errors: + for i, e in enumerate(errors, 1): + print(f" [{i}] ❌ {e}") + else: + print(" None") + + print_section("Warnings (Review)") + if warnings: + for i, w in enumerate(warnings, 1): + print(f" [{i}] ⚠️ {w}") + else: + print(" None") + + print_section("Result") + if errors: + print(f" ❌ VALIDATION FAILED - {len(errors)} error(s)") + print(" DO NOT deliver this file. Regenerate with fixes.") + return 1 + elif warnings: + print(f" ⚠️ PASSED with {len(warnings)} warning(s) - review before delivery") + return 0 + else: + print(" ✅ VALIDATION PASSED - Safe to deliver") + return 0 + + +# ───────────────────────────────────────────── +# Main CLI +# ───────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Excel file validation and inspection tool", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Commands: + recheck Detect formula errors, zero-values, forbidden functions + refcheck Detect reference anomalies (out-of-range, header inclusion, etc.) + inspect Analyze file structure → JSON output + chart-verify Verify all charts have data + validate Comprehensive pre-delivery validation (run before delivery) + +Examples: + %(prog)s recheck output.xlsx + %(prog)s refcheck output.xlsx + %(prog)s inspect output.xlsx --pretty + %(prog)s chart-verify output.xlsx + %(prog)s validate output.xlsx + """, + ) + + subparsers = parser.add_subparsers(dest="command", help="Command to run") + + # recheck + p_recheck = subparsers.add_parser("recheck", help="Detect formula errors and zero-value cells") + p_recheck.add_argument("file", help="Path to .xlsx file") + + # refcheck / reference-check + p_refcheck = subparsers.add_parser("refcheck", aliases=["reference-check"], + help="Detect reference anomalies") + p_refcheck.add_argument("file", help="Path to .xlsx file") + + # inspect + p_inspect = subparsers.add_parser("inspect", help="Analyze file structure → JSON") + p_inspect.add_argument("file", help="Path to .xlsx file") + p_inspect.add_argument("--pretty", action="store_true", help="Pretty-print JSON") + + # chart-verify + p_chart = subparsers.add_parser("chart-verify", help="Verify charts have data") + p_chart.add_argument("file", help="Path to .xlsx file") + + # validate + p_validate = subparsers.add_parser("validate", help="Comprehensive pre-delivery validation") + p_validate.add_argument("file", help="Path to .xlsx file") + + args = parser.parse_args() + + if not args.command: + parser.print_help() + sys.exit(1) + + command = args.command + if command in ("refcheck", "reference-check"): + command = "refcheck" + + filepath = args.file + if not os.path.isfile(filepath): + print(f"ERROR: File not found: {filepath}") + sys.exit(1) + + # Dispatch + if command == "recheck": + exit_code = cmd_recheck(filepath) + elif command == "refcheck": + exit_code = cmd_refcheck(filepath) + elif command == "inspect": + cmd_inspect(filepath, pretty=getattr(args, "pretty", False)) + exit_code = 0 + elif command == "chart-verify": + exit_code = cmd_chart_verify(filepath) + elif command == "validate": + exit_code = cmd_validate(filepath) + else: + parser.print_help() + exit_code = 1 + + sys.exit(min(exit_code, 1)) + + +if __name__ == "__main__": + main() diff --git "a/\346\216\222\347\217\255\350\241\250.xlsx" "b/\346\216\222\347\217\255\350\241\250.xlsx" new file mode 100644 index 0000000000000000000000000000000000000000..79b3b5f6d2310ca0b6f826f59d05a59b9e370e2e GIT binary patch literal 8261 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 literal 0 HcmV?d00001 From 48eea3dca49c9badd1e7b96696eb60a6175a911b Mon Sep 17 00:00:00 2001 From: "rui.yang" Date: Sat, 21 Mar 2026 15:56:42 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20python=20?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E5=BC=95=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mini_agent/skills/document-skills/xlsx/SKILL.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mini_agent/skills/document-skills/xlsx/SKILL.md b/mini_agent/skills/document-skills/xlsx/SKILL.md index 39189f8..c5fc656 100644 --- a/mini_agent/skills/document-skills/xlsx/SKILL.md +++ b/mini_agent/skills/document-skills/xlsx/SKILL.md @@ -446,6 +446,9 @@ wb.save('modified.xlsx') - Division by zero: check denominators before using `/` in formulas - Cross-sheet references: use correct format (`Sheet1!A1`) - Off-by-one: verify formula ranges don't include headers or extend beyond data +- **Chinese vs English quotation marks**: Excel formulas ONLY accept English double quotes (`"`, Unicode U+0022). Chinese quotation marks (`"` left, `"` right, Unicode U+201C/U+201D) will cause #NAME? errors. Always verify quotes in IF statements and text formulas. + - ❌ Wrong: `=IF(A1>30,"超过","正常")` (Chinese quotes) + - ✅ Correct: `=IF(A1>30,"超过","正常")` (English quotes) ## Code Style - Write minimal, concise Python code From 79a0c20f2db66037211f9ee353c40cc0829ebdd1 Mon Sep 17 00:00:00 2001 From: "rui.yang" Date: Sat, 21 Mar 2026 16:03:31 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E5=88=A0=E9=99=A4=E5=A4=9A=E4=BD=99?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- create_schedule.py | 265 -------------------- "\346\216\222\347\217\255\350\241\250.xlsx" | Bin 8261 -> 0 bytes 2 files changed, 265 deletions(-) delete mode 100644 create_schedule.py delete mode 100644 "\346\216\222\347\217\255\350\241\250.xlsx" diff --git a/create_schedule.py b/create_schedule.py deleted file mode 100644 index 8a79458..0000000 --- a/create_schedule.py +++ /dev/null @@ -1,265 +0,0 @@ -#!/usr/bin/env python3 -""" -排班表生成器 -- 每四天一个周期 -- 第1天:24小时值班(值班日) -- 第2天:正常上班 -- 第3天:正常上班,除非遇到周末(星期六、星期日)才休息 -- 第4天:正常休息 -""" - -from openpyxl import Workbook -from openpyxl.styles import Font, PatternFill, Alignment, Border, Side -from datetime import datetime, timedelta -import calendar - -# 创建工作簿 -wb = Workbook() -ws = wb.active -ws.title = "排班表" - -# 隐藏网格线 -ws.sheet_view.showGridLines = False - -# 定义颜色 -header_fill = PatternFill(start_color="1F4E79", end_color="1F4E79", fill_type="solid") -header_font = Font(bold=True, color="FFFFFF", size=12) -title_font = Font(bold=True, size=16) -duty_fill = PatternFill(start_color="FF6B6B", end_color="FF6B6B", fill_type="solid") # 红色 - 值班日 -work_fill = PatternFill(start_color="4ECDC4", end_color="4ECDC4", fill_type="solid") # 青色 - 正常上班 -weekend_work_fill = PatternFill(start_color="95E1D3", end_color="95E1D3", fill_type="solid") # 浅绿 - 周末上班 -rest_fill = PatternFill(start_color="DDA0DD", end_color="DDA0DD", fill_type="solid") # 紫色 - 休息 -weekend_rest_fill = PatternFill(start_color="E6E6FA", end_color="E6E6FA", fill_type="solid") # 浅紫 - 周末休息 - -thin_border = Border( - left=Side(style='thin'), - right=Side(style='thin'), - top=Side(style='thin'), - bottom=Side(style='thin') -) - -# 起始日期 - 从2025年1月1日开始(可以根据需要修改) -start_date = datetime(2025, 1, 1) - -# 排班规则函数 -def get_schedule_status(day_in_cycle, weekday): - """ - day_in_cycle: 1-4 表示在4天周期中的第几天 - weekday: 0=周一, 1=周二, ..., 5=周六, 6=周日 - 返回: (状态, 状态描述) - """ - if day_in_cycle == 1: - return ("值班日", "24小时值班", duty_fill) - elif day_in_cycle == 2: - return ("上班", "正常上班", work_fill) - elif day_in_cycle == 3: - # 第三天:正常上班,除非遇到周末才休息 - if weekday >= 5: # 周六或周日 - return ("休息", "周末休息", weekend_rest_fill) - else: - return ("上班", "正常上班", weekend_work_fill) - elif day_in_cycle == 4: - return ("休息", "正常休息", rest_fill) - return ("", "", None) - -# 生成排班数据 -days_in_months = [ - (2025, 1, 31), - (2025, 2, 28), - (2025, 3, 31), - (2025, 4, 30), - (2025, 5, 31), - (2025, 6, 30), - (2025, 7, 31), - (2025, 8, 31), - (2025, 9, 30), - (2025, 10, 31), - (2025, 11, 30), - (2025, 12, 31), -] - -# 先计算全年的数据用于统计 -year_schedule = [] -for i in range(365): - current_date = start_date + timedelta(days=i) - day_in_cycle = (i % 4) + 1 - weekday = current_date.weekday() # 0=周一, 6=周日 - status, desc, fill = get_schedule_status(day_in_cycle, weekday) - year_schedule.append({ - 'date': current_date, - 'day': i + 1, - 'cycle': day_in_cycle, - 'weekday': weekday, - 'weekday_name': ['周一', '周二', '周三', '周四', '周五', '周六', '周日'][weekday], - 'status': status, - 'desc': desc, - 'fill': fill - }) - -# 统计全年休息天数 -total_rest_days = sum(1 for day in year_schedule if day['status'] == '休息') -total_duty_days = sum(1 for day in year_schedule if day['status'] == '值班日') -total_work_days = sum(1 for day in year_schedule if day['status'] == '上班') - -print(f"全年统计:") -print(f" 值班日: {total_duty_days} 天") -print(f" 正常上班: {total_work_days} 天") -print(f" 休息日: {total_rest_days} 天") -print(f" 合计: {total_duty_days + total_work_days + total_rest_days} 天") - -# 创建两个月的排班表(假设用户需要1月和2月) -# 可以根据需要修改为其他月份 -two_months = [ - (2025, 1, "2025年1月"), - (2025, 2, "2025年2月"), -] - -# 写入标题 -ws['B2'] = "排班表(2025年1月-2月)" -ws['B2'].font = title_font -ws.merge_cells('B2:H2') -ws['B2'].alignment = Alignment(horizontal='center', vertical='center') - -# 写入统计信息 -ws['B4'] = f"全年可休息天数: {total_rest_days} 天" -ws['B4'].font = Font(bold=True, size=11) -ws.merge_cells('B4:E4') - -ws['B5'] = f"全年值班天数: {total_duty_days} 天" -ws['B5'].font = Font(bold=True, size=11) -ws.merge_cells('B5:E5') - -ws['B6'] = f"全年上班天数: {total_work_days} 天" -ws['B6'].font = Font(bold=True, size=11) -ws.merge_cells('B6:E6') - -# 写入表头 -headers = ['日期', '星期', '第几天班', '状态', '说明'] -header_row = 8 - -for col, header in enumerate(headers, start=2): - cell = ws.cell(row=header_row, column=col, value=header) - cell.font = header_font - cell.fill = header_fill - cell.alignment = Alignment(horizontal='center', vertical='center') - cell.border = thin_border - -# 筛选出1月和2月的数据 -two_months_data = [day for day in year_schedule - if day['date'].month in [1, 2]] - -# 写入数据 -current_row = header_row + 1 -for day_data in two_months_data: - ws.cell(row=current_row, column=2, value=day_data['date'].strftime('%Y-%m-%d')) - ws.cell(row=current_row, column=3, value=day_data['weekday_name']) - ws.cell(row=current_row, column=4, value=day_data['cycle']) - ws.cell(row=current_row, column=5, value=day_data['status']) - ws.cell(row=current_row, column=6, value=day_data['desc']) - - # 设置状态单元格的填充颜色 - if day_data['fill']: - ws.cell(row=current_row, column=5).fill = day_data['fill'] - - # 设置边框和对齐 - for col in range(2, 7): - ws.cell(row=current_row, column=col).border = thin_border - ws.cell(row=current_row, column=col).alignment = Alignment(horizontal='center', vertical='center') - - current_row += 1 - -# 设置列宽 -ws.column_dimensions['B'].width = 15 -ws.column_dimensions['C'].width = 10 -ws.column_dimensions['D'].width = 12 -ws.column_dimensions['E'].width = 12 -ws.column_dimensions['F'].width = 15 - -# 添加图例 -legend_row = current_row + 2 -ws[f'B{legend_row}'] = "图例:" -ws[f'B{legend_row}'].font = Font(bold=True) - -legend_items = [ - (duty_fill, "值班日(24小时)"), - (work_fill, "正常上班"), - (weekend_work_fill, "周末上班"), - (rest_fill, "正常休息"), - (weekend_rest_fill, "周末休息"), -] - -for i, (fill, desc) in enumerate(legend_items): - row = legend_row + 1 + i - ws[f'B{row}'].fill = fill - ws[f'B{row}'].border = thin_border - ws[f'C{row}'] = desc - ws[f'C{row}'].alignment = Alignment(horizontal='left', vertical='center') - -# 创建全年统计表 -ws2 = wb.create_sheet("全年统计") -ws2.sheet_view.showGridLines = False - -ws2['B2'] = "2025年全年排班统计" -ws2['B2'].font = title_font -ws2.merge_cells('B2:F2') - -# 月度统计 -ws2['B4'] = "月度统计" -ws2['B4'].font = Font(bold=True, size=12) - -monthly_headers = ['月份', '值班日', '上班', '休息', '合计'] -for col, header in enumerate(monthly_headers, start=2): - cell = ws2.cell(row=5, column=col, value=header) - cell.font = header_font - cell.fill = header_fill - cell.alignment = Alignment(horizontal='center', vertical='center') - cell.border = thin_border - -month_stats = {} -for month in range(1, 13): - month_data = [day for day in year_schedule if day['date'].month == month] - duty = sum(1 for d in month_data if d['status'] == '值班日') - work = sum(1 for d in month_data if d['status'] == '上班') - rest = sum(1 for d in month_data if d['status'] == '休息') - month_stats[month] = {'duty': duty, 'work': work, 'rest': rest} - - row = 5 + month - month_name = f"{month}月" - ws2.cell(row=row, column=2, value=month_name) - ws2.cell(row=row, column=3, value=duty) - ws2.cell(row=row, column=4, value=work) - ws2.cell(row=row, column=5, value=rest) - ws2.cell(row=row, column=6, value=duty + work + rest) - - for col in range(2, 7): - ws2.cell(row=row, column=col).border = thin_border - ws2.cell(row=row, column=col).alignment = Alignment(horizontal='center', vertical='center') - -# 年度总计 -total_row = 5 + 13 -ws2[f'B{total_row}'] = "全年总计" -ws2[f'B{total_row}'].font = Font(bold=True) -ws2[f'C{total_row}'] = total_duty_days -ws2[f'C{total_row}'].font = Font(bold=True) -ws2[f'D{total_row}'] = total_work_days -ws2[f'D{total_row}'].font = Font(bold=True) -ws2[f'E{total_row}'] = total_rest_days -ws2[f'E{total_row}'].font = Font(bold=True) -ws2[f'F{total_row}'] = 365 -ws2[f'F{total_row}'].font = Font(bold=True) - -for col in range(2, 7): - ws2.cell(row=total_row, column=col).border = thin_border - ws2.cell(row=total_row, column=col).alignment = Alignment(horizontal='center', vertical='center') - -# 设置列宽 -ws2.column_dimensions['B'].width = 12 -ws2.column_dimensions['C'].width = 12 -ws2.column_dimensions['D'].width = 12 -ws2.column_dimensions['E'].width = 12 -ws2.column_dimensions['F'].width = 12 - -# 保存文件 -output_file = "排班表.xlsx" -wb.save(output_file) -print(f"\n排班表已保存到: {output_file}") diff --git "a/\346\216\222\347\217\255\350\241\250.xlsx" "b/\346\216\222\347\217\255\350\241\250.xlsx" deleted file mode 100644 index 79b3b5f6d2310ca0b6f826f59d05a59b9e370e2e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8261 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