@huicai/cli 2026.804.0 → 2026.1019.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +391 -32
- package/README.md +176 -10
- package/bin/huicai.js +166 -73
- package/package.json +27 -8
- package/skills/ab-testing/trigger-cases.json +30 -0
- package/skills/academic-writing/trigger-cases.json +30 -0
- package/skills/accounting-ops/trigger-cases.json +30 -0
- package/skills/ai-agent/trigger-cases.json +30 -0
- package/skills/ai-aiops/trigger-cases.json +30 -0
- package/skills/ai-coding/trigger-cases.json +30 -0
- package/skills/ai-compliance/trigger-cases.json +30 -0
- package/skills/ai-eval/trigger-cases.json +30 -0
- package/skills/ai-llm-app/trigger-cases.json +30 -0
- package/skills/ai-mlops/trigger-cases.json +30 -0
- package/skills/ai-multimodal/trigger-cases.json +30 -0
- package/skills/ai-prompt/trigger-cases.json +30 -0
- package/skills/ai-rag/trigger-cases.json +30 -0
- package/skills/anomaly-detection/trigger-cases.json +30 -0
- package/skills/api-design/trigger-cases.json +31 -0
- package/skills/api-integration/trigger-cases.json +31 -0
- package/skills/arch-design/trigger-cases.json +12 -0
- package/skills/arch-diagram/SKILL.md +118 -0
- package/skills/arch-diagram/trigger-cases.json +30 -0
- package/skills/arch-distributed/trigger-cases.json +30 -0
- package/skills/arch-multitenant/trigger-cases.json +30 -0
- package/skills/audio-brief/SKILL.md +65 -0
- package/skills/audio-brief/trigger-cases.json +30 -0
- package/skills/audit-assist/trigger-cases.json +30 -0
- package/skills/audit-chain/trigger-cases.json +31 -0
- package/skills/banking-credit/trigger-cases.json +30 -0
- package/skills/brand-design/trigger-cases.json +30 -0
- package/skills/business-plan/trigger-cases.json +30 -0
- package/skills/capital-ops/trigger-cases.json +30 -0
- package/skills/cicd-pipeline/trigger-cases.json +31 -0
- package/skills/code-archaeology/SKILL.md +55 -0
- package/skills/code-archaeology/trigger-cases.json +9 -0
- package/skills/code-quality/SKILL.md +138 -0
- package/skills/code-quality/references/languages.md +107 -0
- package/skills/code-quality/scripts/__pycache__/code_battery.cpython-312.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_check.cpython-312.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_check.cpython-313.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_csharp.cpython-312.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_csharp.cpython-313.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_fix.cpython-312.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_fix.cpython-313.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_java.cpython-312.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_java.cpython-313.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_jsts.cpython-312.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_jsts.cpython-313.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_lens.cpython-312.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_lens.cpython-313.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_python.cpython-312.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_python.cpython-313.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_testplan.cpython-312.pyc +0 -0
- package/skills/code-quality/scripts/code_battery.py +1072 -0
- package/skills/code-quality/scripts/code_check.py +1269 -0
- package/skills/code-quality/scripts/code_csharp.py +371 -0
- package/skills/code-quality/scripts/code_fix.py +201 -0
- package/skills/code-quality/scripts/code_java.py +695 -0
- package/skills/code-quality/scripts/code_jsts.py +313 -0
- package/skills/code-quality/scripts/code_lens.py +1079 -0
- package/skills/code-quality/scripts/code_python.py +536 -0
- package/skills/code-quality/scripts/code_testplan.py +239 -0
- package/skills/code-quality/trigger-cases.json +45 -0
- package/skills/code-review/SKILL.md +24 -0
- package/skills/code-review/trigger-cases.json +35 -0
- package/skills/commit-and-pr/trigger-cases.json +31 -0
- package/skills/content-creation/trigger-cases.json +30 -0
- package/skills/corporate-governance/trigger-cases.json +30 -0
- package/skills/crawl-chain/trigger-cases.json +31 -0
- package/skills/customer-service/trigger-cases.json +30 -0
- package/skills/data-analysis/trigger-cases.json +30 -0
- package/skills/data-chain/SKILL.md +1 -1
- package/skills/data-chain/trigger-cases.json +31 -0
- package/skills/data-governance/trigger-cases.json +30 -0
- package/skills/data-report-pro/trigger-cases.json +30 -0
- package/skills/data-streaming/trigger-cases.json +30 -0
- package/skills/data-wrangling/SKILL.md +1 -1
- package/skills/data-wrangling/trigger-cases.json +30 -0
- package/skills/db-es/trigger-cases.json +30 -0
- package/skills/db-migration/trigger-cases.json +30 -0
- package/skills/db-mysql/trigger-cases.json +30 -0
- package/skills/db-postgres/trigger-cases.json +30 -0
- package/skills/db-redis/trigger-cases.json +30 -0
- package/skills/debug-root-cause/trigger-cases.json +30 -0
- package/skills/dependency-audit/SKILL.md +56 -0
- package/skills/dependency-audit/trigger-cases.json +7 -0
- package/skills/dependency-upgrade/trigger-cases.json +31 -0
- package/skills/dev-cpp/trigger-cases.json +30 -0
- package/skills/dev-cross-platform/trigger-cases.json +30 -0
- package/skills/dev-csharp/SKILL.md +23 -0
- package/skills/dev-csharp/trigger-cases.json +30 -0
- package/skills/dev-frontend/SKILL.md +19 -0
- package/skills/dev-frontend/trigger-cases.json +30 -0
- package/skills/dev-go/trigger-cases.json +30 -0
- package/skills/dev-harmony/trigger-cases.json +30 -0
- package/skills/dev-java/SKILL.md +20 -0
- package/skills/dev-java/trigger-cases.json +30 -0
- package/skills/dev-mobile/trigger-cases.json +30 -0
- package/skills/dev-node/trigger-cases.json +30 -0
- package/skills/dev-php/trigger-cases.json +30 -0
- package/skills/dev-python/SKILL.md +21 -0
- package/skills/dev-python/trigger-cases.json +30 -0
- package/skills/dev-rust/trigger-cases.json +30 -0
- package/skills/dev-toolchain/trigger-cases.json +31 -0
- package/skills/doc-chain/trigger-cases.json +31 -0
- package/skills/doc-compare/trigger-cases.json +30 -0
- package/skills/docx-author/SKILL.md +127 -25
- package/skills/docx-author/scripts/__pycache__/docx_battery.cpython-312.pyc +0 -0
- package/skills/docx-author/scripts/__pycache__/docx_build.cpython-312.pyc +0 -0
- package/skills/docx-author/scripts/__pycache__/docx_check.cpython-312.pyc +0 -0
- package/skills/docx-author/scripts/__pycache__/docx_layout.cpython-312.pyc +0 -0
- package/skills/docx-author/scripts/__pycache__/zh_prose.cpython-312.pyc +0 -0
- package/skills/docx-author/scripts/docx_battery.py +586 -0
- package/skills/docx-author/scripts/docx_build.py +995 -0
- package/skills/docx-author/scripts/docx_check.py +1306 -0
- package/skills/docx-author/scripts/docx_doc.py +14 -154
- package/skills/docx-author/scripts/docx_layout.py +490 -0
- package/skills/docx-author/scripts/zh_prose.py +639 -0
- package/skills/docx-author/trigger-cases.json +58 -0
- package/skills/echo-brief/SKILL.md +84 -0
- package/skills/echo-brief/references/echo-format.md +68 -0
- package/skills/echo-brief/trigger-cases.json +40 -0
- package/skills/ecommerce-ops/trigger-cases.json +30 -0
- package/skills/education-training/trigger-cases.json +30 -0
- package/skills/ehs-safety/trigger-cases.json +30 -0
- package/skills/email-writing/trigger-cases.json +30 -0
- package/skills/eng-ai/trigger-cases.json +30 -0
- package/skills/eng-architecture/trigger-cases.json +31 -0
- package/skills/eng-automation/trigger-cases.json +31 -0
- package/skills/eng-collaboration/trigger-cases.json +30 -0
- package/skills/eng-complex-env/trigger-cases.json +30 -0
- package/skills/eng-data-platform/trigger-cases.json +31 -0
- package/skills/eng-database/trigger-cases.json +31 -0
- package/skills/eng-develop/trigger-cases.json +30 -0
- package/skills/eng-docs/SKILL.md +24 -0
- package/skills/eng-docs/trigger-cases.json +30 -0
- package/skills/eng-efficiency/trigger-cases.json +30 -0
- package/skills/eng-infra/trigger-cases.json +30 -0
- package/skills/eng-integration/trigger-cases.json +31 -0
- package/skills/eng-legacy/trigger-cases.json +31 -0
- package/skills/eng-observability/trigger-cases.json +31 -0
- package/skills/eng-ops/trigger-cases.json +31 -0
- package/skills/eng-performance/trigger-cases.json +31 -0
- package/skills/eng-planning/trigger-cases.json +31 -0
- package/skills/eng-quality/SKILL.md +8 -0
- package/skills/eng-quality/trigger-cases.json +31 -0
- package/skills/eng-release/trigger-cases.json +31 -0
- package/skills/eng-reliability/trigger-cases.json +31 -0
- package/skills/eng-requirements/trigger-cases.json +12 -0
- package/skills/eng-research/trigger-cases.json +30 -0
- package/skills/eng-security/trigger-cases.json +31 -0
- package/skills/eng-ux/trigger-cases.json +30 -0
- package/skills/engineering-chain/trigger-cases.json +30 -0
- package/skills/env-offline/trigger-cases.json +31 -0
- package/skills/env-private-deploy/trigger-cases.json +31 -0
- package/skills/env-xinchuang/trigger-cases.json +31 -0
- package/skills/equipment-tpm/trigger-cases.json +30 -0
- package/skills/esg-report/trigger-cases.json +30 -0
- package/skills/event-management/trigger-cases.json +30 -0
- package/skills/excel-analysis-chain/SKILL.md +17 -4
- package/skills/excel-analysis-chain/trigger-cases.json +31 -0
- package/skills/explain-codebase/trigger-cases.json +11 -0
- package/skills/file-ingest/trigger-cases.json +30 -0
- package/skills/finance-analysis/trigger-cases.json +31 -0
- package/skills/finance-chain/trigger-cases.json +31 -0
- package/skills/financial-modeling/trigger-cases.json +31 -0
- package/skills/find-simplifications/SKILL.md +43 -0
- package/skills/find-simplifications/trigger-cases.json +23 -0
- package/skills/foreign-trade/trigger-cases.json +30 -0
- package/skills/funnel-cohort/trigger-cases.json +30 -0
- package/skills/gov-document/trigger-cases.json +30 -0
- package/skills/gui-change-evidence/SKILL.md +47 -0
- package/skills/gui-change-evidence/trigger-cases.json +23 -0
- package/skills/hr-chain/trigger-cases.json +31 -0
- package/skills/hr-operations/trigger-cases.json +30 -0
- package/skills/info-disclosure/trigger-cases.json +31 -0
- package/skills/infra-gateway/trigger-cases.json +31 -0
- package/skills/infra-k8s/trigger-cases.json +31 -0
- package/skills/infra-mq/trigger-cases.json +30 -0
- package/skills/insurance-advisory/trigger-cases.json +30 -0
- package/skills/internal-audit/trigger-cases.json +30 -0
- package/skills/ip-management/trigger-cases.json +31 -0
- package/skills/ipo-listing/trigger-cases.json +31 -0
- package/skills/it-audit/trigger-cases.json +31 -0
- package/skills/itsm-ops/trigger-cases.json +31 -0
- package/skills/knowledge-sop/trigger-cases.json +31 -0
- package/skills/legal-chain/trigger-cases.json +31 -0
- package/skills/legal-contract/trigger-cases.json +30 -0
- package/skills/litigation-support/trigger-cases.json +31 -0
- package/skills/logistics-warehouse/trigger-cases.json +30 -0
- package/skills/long-doc-analysis/trigger-cases.json +31 -0
- package/skills/management-accounting/trigger-cases.json +30 -0
- package/skills/manufacturing-chain/trigger-cases.json +31 -0
- package/skills/manufacturing-quality/trigger-cases.json +30 -0
- package/skills/marketing-analytics/trigger-cases.json +31 -0
- package/skills/marketing-chain/trigger-cases.json +31 -0
- package/skills/meeting-minutes/trigger-cases.json +30 -0
- package/skills/metrics-system/trigger-cases.json +31 -0
- package/skills/negotiation-prep/trigger-cases.json +30 -0
- package/skills/obs-prometheus/trigger-cases.json +31 -0
- package/skills/office-automation/trigger-cases.json +31 -0
- package/skills/okr-review/trigger-cases.json +30 -0
- package/skills/ops-fleet/trigger-cases.json +31 -0
- package/skills/ops-linux/trigger-cases.json +31 -0
- package/skills/optimization-solver/trigger-cases.json +30 -0
- package/skills/patent-disclosure/SKILL.md +105 -0
- package/skills/patent-disclosure/references/figures.md +110 -0
- package/skills/patent-disclosure/references/outline.md +162 -0
- package/skills/patent-disclosure/scripts/patent_docx.py +230 -0
- package/skills/patent-disclosure/trigger-cases.json +31 -0
- package/skills/patent-drafting/SKILL.md +108 -0
- package/skills/patent-drafting/trigger-cases.json +31 -0
- package/skills/patent-portfolio/SKILL.md +81 -0
- package/skills/patent-portfolio/trigger-cases.json +31 -0
- package/skills/pdf-toolkit/trigger-cases.json +31 -0
- package/skills/perf-optimize/trigger-cases.json +31 -0
- package/skills/pivot-crosstab/trigger-cases.json +30 -0
- package/skills/policy-analysis/trigger-cases.json +31 -0
- package/skills/portfolio-analysis/trigger-cases.json +31 -0
- package/skills/ppt-chain/SKILL.md +14 -6
- package/skills/ppt-chain/trigger-cases.json +31 -0
- package/skills/pptx-author/SKILL.md +86 -33
- package/skills/pptx-author/scripts/__pycache__/pptx_check.cpython-312.pyc +0 -0
- package/skills/pptx-author/scripts/__pycache__/pptx_deck.cpython-312.pyc +0 -0
- package/skills/pptx-author/scripts/__pycache__/pptx_layout.cpython-312.pyc +0 -0
- package/skills/pptx-author/scripts/pptx_battery.py +178 -0
- package/skills/pptx-author/scripts/pptx_build.py +119 -0
- package/skills/pptx-author/scripts/pptx_check.py +1182 -0
- package/skills/pptx-author/scripts/pptx_deck.py +736 -145
- package/skills/pptx-author/scripts/pptx_layout.py +384 -0
- package/skills/pptx-author/trigger-cases.json +15 -0
- package/skills/pr-comms/trigger-cases.json +30 -0
- package/skills/procurement-supply/trigger-cases.json +31 -0
- package/skills/product-design/trigger-cases.json +31 -0
- package/skills/production-planning/trigger-cases.json +31 -0
- package/skills/project-management/trigger-cases.json +31 -0
- package/skills/project-proposal/trigger-cases.json +31 -0
- package/skills/prose-standard/SKILL.md +53 -0
- package/skills/prose-standard/trigger-cases.json +23 -0
- package/skills/qa-api-automation/trigger-cases.json +31 -0
- package/skills/qa-mobile-automation/trigger-cases.json +31 -0
- package/skills/qa-ui-automation/trigger-cases.json +31 -0
- package/skills/quant-analysis/trigger-cases.json +31 -0
- package/skills/rag-triage/SKILL.md +64 -0
- package/skills/rag-triage/evals.json +57 -0
- package/skills/rag-triage/references/triage-playbook.md +75 -0
- package/skills/rag-triage/trigger-cases.json +31 -0
- package/skills/real-estate-property/trigger-cases.json +30 -0
- package/skills/refactor-safely/SKILL.md +1 -1
- package/skills/refactor-safely/trigger-cases.json +30 -0
- package/skills/regression-forecast/trigger-cases.json +31 -0
- package/skills/release-checklist/trigger-cases.json +31 -0
- package/skills/report-craft/SKILL.md +76 -0
- package/skills/report-craft/references/feasibility.md +118 -0
- package/skills/report-craft/references/review-checklist.md +51 -0
- package/skills/report-craft/references/visuals.md +114 -0
- package/skills/report-craft/references/writing.md +80 -0
- package/skills/report-craft/scripts/__pycache__/chart_render.cpython-312.pyc +0 -0
- package/skills/report-craft/scripts/__pycache__/fin_model.cpython-312.pyc +0 -0
- package/skills/report-craft/scripts/__pycache__/project_facts.cpython-312.pyc +0 -0
- package/skills/report-craft/scripts/chart_render.py +1222 -0
- package/skills/report-craft/scripts/fin_model.py +602 -0
- package/skills/report-craft/scripts/project_facts.py +580 -0
- package/skills/report-craft/scripts/report_battery.py +349 -0
- package/skills/report-craft/trigger-cases.json +15 -0
- package/skills/retail-store-ops/trigger-cases.json +30 -0
- package/skills/risk-compliance/trigger-cases.json +31 -0
- package/skills/scope-creep/SKILL.md +45 -0
- package/skills/scope-creep/trigger-cases.json +7 -0
- package/skills/sec-auth/trigger-cases.json +31 -0
- package/skills/sec-compliance/trigger-cases.json +31 -0
- package/skills/securities-chain/trigger-cases.json +31 -0
- package/skills/securities-research/trigger-cases.json +31 -0
- package/skills/security-audit/trigger-cases.json +31 -0
- package/skills/slides-html/SKILL.md +25 -10
- package/skills/slides-html/trigger-cases.json +11 -0
- package/skills/sql-analysis/SKILL.md +110 -0
- package/skills/sql-analysis/trigger-cases.json +34 -0
- package/skills/sql-tuning/trigger-cases.json +31 -0
- package/skills/statistical-testing/trigger-cases.json +31 -0
- package/skills/strategy-planning/trigger-cases.json +30 -0
- package/skills/supply-chain/trigger-cases.json +31 -0
- package/skills/tax-compliance/trigger-cases.json +30 -0
- package/skills/tech-doc/SKILL.md +262 -0
- package/skills/tech-doc/scripts/__pycache__/archive_battery.cpython-312.pyc +0 -0
- package/skills/tech-doc/scripts/__pycache__/archive_build.cpython-312.pyc +0 -0
- package/skills/tech-doc/scripts/__pycache__/archive_check.cpython-312.pyc +0 -0
- package/skills/tech-doc/scripts/__pycache__/archive_spec.cpython-312.pyc +0 -0
- package/skills/tech-doc/scripts/__pycache__/doc_templates.cpython-312.pyc +0 -0
- package/skills/tech-doc/scripts/archive_battery.py +860 -0
- package/skills/tech-doc/scripts/archive_build.py +599 -0
- package/skills/tech-doc/scripts/archive_check.py +1307 -0
- package/skills/tech-doc/scripts/archive_spec.py +499 -0
- package/skills/tech-doc/scripts/doc_templates.py +624 -0
- package/skills/tech-doc/trigger-cases.json +57 -0
- package/skills/tech-research/trigger-cases.json +31 -0
- package/skills/tender-bidding/trigger-cases.json +31 -0
- package/skills/translation-pro/trigger-cases.json +30 -0
- package/skills/treasury-mgmt/trigger-cases.json +31 -0
- package/skills/trim-cot-leakage/SKILL.md +58 -0
- package/skills/trim-cot-leakage/trigger-cases.json +23 -0
- package/skills/web-craft/SKILL.md +103 -0
- package/skills/web-craft/references/craft-checklist.md +75 -0
- package/skills/web-craft/references/design-direction.md +122 -0
- package/skills/web-craft/references/h5-mobile.md +100 -0
- package/skills/web-craft/references/motion.md +136 -0
- package/skills/web-craft/references/recipes.md +74 -0
- package/skills/web-craft/templates/wedding-invitation.html +1180 -0
- package/skills/web-craft/trigger-cases.json +14 -0
- package/skills/web-scraping/trigger-cases.json +34 -0
- package/skills/web-search/SKILL.md +11 -10
- package/skills/web-search/trigger-cases.json +31 -0
- package/skills/weekly-report/SKILL.md +134 -0
- package/skills/weekly-report/trigger-cases.json +11 -0
- package/skills/write-tests/trigger-cases.json +31 -0
- package/skills/xlsx-analysis/SKILL.md +143 -77
- package/skills/xlsx-analysis/scripts/__pycache__/data_profile.cpython-312.pyc +0 -0
- package/skills/xlsx-analysis/scripts/__pycache__/xlsx_build.cpython-312.pyc +0 -0
- package/skills/xlsx-analysis/scripts/__pycache__/xlsx_check.cpython-312.pyc +0 -0
- package/skills/xlsx-analysis/scripts/__pycache__/xlsx_layout.cpython-312.pyc +0 -0
- package/skills/xlsx-analysis/scripts/__pycache__/xlsx_toolkit.cpython-312.pyc +0 -0
- package/skills/xlsx-analysis/scripts/data_profile.py +391 -0
- package/skills/xlsx-analysis/scripts/xlsx_battery.py +389 -0
- package/skills/xlsx-analysis/scripts/xlsx_build.py +522 -0
- package/skills/xlsx-analysis/scripts/xlsx_check.py +924 -0
- package/skills/xlsx-analysis/scripts/xlsx_layout.py +235 -0
- package/skills/xlsx-analysis/scripts/xlsx_style.py +28 -66
- package/skills/xlsx-analysis/scripts/xlsx_toolkit.py +61 -5
- package/skills/xlsx-analysis/trigger-cases.json +59 -0
- package/squads/hc-defense.md +47 -0
- package/squads/hc-rdarchive.md +110 -0
- package/squads/hc-redteam.md +50 -0
- package/squads/hc-weekly.md +125 -0
- package/src/account.js +1 -0
- package/src/acp.js +1 -0
- package/src/advisor.js +1 -1
- package/src/agent.js +1 -1
- package/src/api.js +1 -1
- package/src/archaeology.js +1 -0
- package/src/ask-ui.js +1 -0
- package/src/assets-import.js +1 -1
- package/src/autofix.js +1 -1
- package/src/automation-tools.js +1 -0
- package/src/automation.js +1 -1
- package/src/autorun.js +1 -1
- package/src/banner.js +1 -1
- package/src/capability-probe.js +1 -0
- package/src/checkpoints.js +1 -1
- package/src/code-tools.js +1 -0
- package/src/codeindex.js +1 -1
- package/src/codemode.js +1 -0
- package/src/command-bridge.js +1 -0
- package/src/command-ui.js +1 -0
- package/src/commands.js +1 -1
- package/src/compaction.js +1 -1
- package/src/computer.js +1 -0
- package/src/config.js +1 -1
- package/src/consult.js +1 -1
- package/src/context-report.js +1 -1
- package/src/context-window.js +1 -0
- package/src/context.js +1 -1
- package/src/crew.js +1 -0
- package/src/crush.js +1 -0
- package/src/daemon.js +1 -1
- package/src/decisions.js +1 -1
- package/src/depaudit.js +1 -0
- package/src/device.js +1 -1
- package/src/diagram-draw.js +1 -0
- package/src/diagram-layouts.js +1 -0
- package/src/diagram-raster.js +1 -0
- package/src/diagram.js +1 -0
- package/src/dictation.js +1 -0
- package/src/diffview.js +1 -1
- package/src/doctor.js +1 -1
- package/src/docx-tools.js +1 -0
- package/src/errkind.js +1 -0
- package/src/evidence.js +1 -0
- package/src/executors.js +1 -1
- package/src/expert-catalog.js +1 -1
- package/src/experts-store.js +1 -1
- package/src/experts.js +1 -1
- package/src/external-agents.js +1 -0
- package/src/forks.js +1 -1
- package/src/fs-observe.js +1 -0
- package/src/gitflow.js +1 -1
- package/src/goalround.js +1 -0
- package/src/hooks.js +1 -1
- package/src/input.js +1 -1
- package/src/insights.js +1 -1
- package/src/jobs.js +1 -0
- package/src/kbsync.js +1 -1
- package/src/lsp.js +1 -0
- package/src/mcp-apps.js +1 -0
- package/src/mcp.js +1 -1
- package/src/memory.js +1 -1
- package/src/notify.js +1 -0
- package/src/permissions.js +1 -1
- package/src/plan-limits.js +1 -0
- package/src/policy.js +1 -0
- package/src/pptx-tools.js +1 -0
- package/src/presence.js +1 -0
- package/src/privatedir.js +1 -0
- package/src/pyruntime.js +1 -1
- package/src/radar.js +1 -0
- package/src/render.js +1 -1
- package/src/repl.js +1 -1
- package/src/report-tools.js +1 -0
- package/src/routing.js +1 -0
- package/src/sandbox-probe.js +1 -0
- package/src/sandbox-win-launcher.mjs +156 -0
- package/src/sandbox.js +1 -0
- package/src/scan.js +1 -1
- package/src/schedule.js +1 -1
- package/src/scopecreep.js +1 -0
- package/src/sdk.js +1 -0
- package/src/selection.js +1 -0
- package/src/session-events.js +1 -0
- package/src/session.js +1 -1
- package/src/sessionindex.js +1 -0
- package/src/sessionlog.js +1 -0
- package/src/skill-catalog.js +1 -0
- package/src/skill-lint.js +1 -0
- package/src/skill-optimize.js +1 -0
- package/src/skill-scan.js +1 -0
- package/src/skills.js +1 -1
- package/src/spill.js +1 -0
- package/src/squad.js +1 -1
- package/src/subagent.js +1 -1
- package/src/surface-state.js +1 -0
- package/src/tapd.js +1 -0
- package/src/terminal-select.js +1 -0
- package/src/terminal.js +1 -0
- package/src/test-runner.js +1 -0
- package/src/todo.js +1 -1
- package/src/toolpipe.js +1 -0
- package/src/tools.js +1 -1
- package/src/toon.js +1 -0
- package/src/transcript.js +1 -1
- package/src/tui/app.js +1 -1
- package/src/tui/command-menu.js +1 -0
- package/src/tui/progress.js +1 -0
- package/src/tui/scroll.js +1 -0
- package/src/tui/welcome.js +1 -0
- package/src/tui/workbench.js +1 -0
- package/src/ui-spec.js +1 -0
- package/src/uitree.js +1 -0
- package/src/uninstall.js +1 -1
- package/src/vision.js +1 -0
- package/src/visual.js +1 -0
- package/src/voice-form.js +1 -0
- package/src/voice.js +1 -0
- package/src/watch.js +1 -1
- package/src/web-audit.js +1 -0
- package/src/web-cdp.js +1 -0
- package/src/web-inspect.js +1 -0
- package/src/websearch.js +1 -0
- package/src/weekly.js +1 -0
- package/src/workflow.js +1 -0
- package/src/worklog.js +1 -0
- package/src/xlsx-tools.js +1 -0
- package/ui/command-picker.js +79 -0
- package/skills/ab-testing/scripts/__pycache__/ab_test.cpython-312.pyc +0 -0
- package/skills/accounting-ops/scripts/__pycache__/acct_tools.cpython-312.pyc +0 -0
- package/skills/anomaly-detection/scripts/__pycache__/anomaly.cpython-312.pyc +0 -0
- package/skills/audit-assist/scripts/__pycache__/audit_sampling.cpython-312.pyc +0 -0
- package/skills/banking-credit/scripts/__pycache__/credit_metrics.cpython-312.pyc +0 -0
- package/skills/data-report-pro/scripts/__pycache__/bigdata.cpython-312.pyc +0 -0
- package/skills/data-wrangling/scripts/__pycache__/join_tools.cpython-312.pyc +0 -0
- package/skills/ecommerce-ops/scripts/__pycache__/gmv_decompose.cpython-312.pyc +0 -0
- package/skills/funnel-cohort/scripts/__pycache__/funnel.cpython-312.pyc +0 -0
- package/skills/management-accounting/scripts/__pycache__/costing.cpython-312.pyc +0 -0
- package/skills/manufacturing-quality/scripts/__pycache__/spc.cpython-312.pyc +0 -0
- package/skills/marketing-analytics/scripts/__pycache__/rfm.cpython-312.pyc +0 -0
- package/skills/optimization-solver/scripts/__pycache__/solver.cpython-312.pyc +0 -0
- package/skills/pivot-crosstab/scripts/__pycache__/pivot_tools.cpython-312.pyc +0 -0
- package/skills/portfolio-analysis/scripts/__pycache__/portfolio.cpython-312.pyc +0 -0
- package/skills/procurement-supply/scripts/__pycache__/supply.cpython-312.pyc +0 -0
- package/skills/production-planning/scripts/__pycache__/mrp_capacity.cpython-312.pyc +0 -0
- package/skills/project-management/scripts/__pycache__/cpm.cpython-312.pyc +0 -0
- package/skills/quant-analysis/scripts/__pycache__/quant.cpython-312.pyc +0 -0
- package/skills/real-estate-property/scripts/__pycache__/property_calc.cpython-312.pyc +0 -0
- package/skills/regression-forecast/scripts/__pycache__/regress.cpython-312.pyc +0 -0
- package/skills/securities-research/scripts/__pycache__/valuation.cpython-312.pyc +0 -0
- package/skills/statistical-testing/scripts/__pycache__/stat_tests.cpython-312.pyc +0 -0
- package/skills/treasury-mgmt/scripts/__pycache__/cashflow_forecast.cpython-312.pyc +0 -0
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
# Excel 工作簿体检与自动校正。
|
|
2
|
+
#
|
|
3
|
+
# 定位:Excel 的事故与 Word/PPT 都不同——它**不改变外观,只改变数字**。
|
|
4
|
+
# 金额存成文本,SUM 得 0;合计行是上周算好的常量,改了明细它纹丝不动;
|
|
5
|
+
# SUM(B2:B100) 但数据长到了 B180,少算 80 行;合并单元格一排序整张表错位;
|
|
6
|
+
# 明细里混着「合计」行,一汇总就翻倍。这些全都不报错、不变色、不出框——
|
|
7
|
+
# 它们只是安静地给出一个错的数,而错的数和对的数长得一模一样。
|
|
8
|
+
#
|
|
9
|
+
# 所以本模块查六类:数据 / 结构 / 公式 / 勾稽 / 呈现 / 元信息。
|
|
10
|
+
# 其中「公式」与「勾稽」两类是 Excel 独有的、也是最值钱的——别的工具基本不查。
|
|
11
|
+
#
|
|
12
|
+
# fix=true 只修**可确定性修**的那几类,且一律另存 `-已校正.xlsx` 不覆盖原件。
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
from collections import Counter, defaultdict
|
|
19
|
+
|
|
20
|
+
from openpyxl import load_workbook
|
|
21
|
+
from openpyxl.styles import Alignment, Font
|
|
22
|
+
from openpyxl.utils import get_column_letter
|
|
23
|
+
|
|
24
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
25
|
+
import xlsx_layout as L # noqa: E402
|
|
26
|
+
|
|
27
|
+
SUBTOTAL_MARKS = ('合计', '小计', '总计', '合 计', '小 计', '总 计', '本页合计', '累计',
|
|
28
|
+
'total', 'subtotal', '汇总')
|
|
29
|
+
INVISIBLE = '\xa0 \t\r\n'
|
|
30
|
+
NUM_TEXT_RE = re.compile(r'^\s*[((]?\s*[-+]?[¥¥$]?\s*\d[\d,,]*(\.\d+)?\s*[%元]?\s*[))]?\s*$')
|
|
31
|
+
DATE_TEXT_RE = re.compile(r'^\s*\d{4}[-/.年]\d{1,2}([-/.月]\d{1,2}日?)?\s*$')
|
|
32
|
+
ID_HINTS = ('编码', '编号', '代码', '卡号', '工号', '身份证', '邮编', '单号', 'id', 'code', 'no')
|
|
33
|
+
SUM_RE = re.compile(r'\b(SUM|SUBTOTAL)\s*\(\s*(?:9\s*,\s*|109\s*,\s*)?\$?([A-Z]{1,3})\$?(\d+)\s*:\s*\$?([A-Z]{1,3})\$?(\d+)\s*\)', re.I)
|
|
34
|
+
SHEET_REF_RE = re.compile(r"(?:'([^']+)'|([A-Za-z0-9_一-鿿]+))!\$?[A-Z]{1,3}\$?\d+")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# 工作表「类型」识别:体检必须先分清自己在看什么,否则会对**完全合规的报表**喊狼来了——
|
|
38
|
+
# 说明页当然没有筛选、标题横幅当然在表头上面、分组小计当然是公式而明细是常量。
|
|
39
|
+
# 与 docx_check 的段落角色识别是同一件事:一个天天误报的体检器,用两次就没人看了。
|
|
40
|
+
def _sheet_kind(s):
|
|
41
|
+
"""table 数据表 / form 说明页与表单(键值两列)/ blank 空表 / other 说不清就少判。
|
|
42
|
+
|
|
43
|
+
**不能只按列数判**:「省份 + 销售额」两列二十行是标准的数据表,不是说明页。
|
|
44
|
+
真正的分界是第二列有没有度量——说明页那一列是「数据一部」「含税」这类文字,
|
|
45
|
+
数据表那一列是数字(哪怕被存成了文本,也仍然是度量)。
|
|
46
|
+
早先只数列数,结果两列的数据表被整组跳过检查,坏表反而查不出问题。"""
|
|
47
|
+
if not s['max_row'] or not s['max_col']:
|
|
48
|
+
return 'blank'
|
|
49
|
+
# 表单是**键→值两列**。单列表是清单,不是表单——早先把 max_col<=2 一并算作表单,
|
|
50
|
+
# 结果任何单列工作表都被整组跳过检查,等于有一整类表从来没被查过。
|
|
51
|
+
if s['max_col'] == 2 and s['max_row'] <= 40 and not _has_measure(s):
|
|
52
|
+
return 'form'
|
|
53
|
+
if s['header_row'] and s['max_row'] - s['first_data'] + 1 >= 2:
|
|
54
|
+
return 'table'
|
|
55
|
+
return 'other'
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _has_measure(s):
|
|
59
|
+
"""最后一列是不是度量列(数字,或看起来是数字的文本)。"""
|
|
60
|
+
ws, j = s['ws'], s['max_col']
|
|
61
|
+
vals = [ws.cell(r, j).value for r in range(max(s['first_data'], 2), s['max_row'] + 1)]
|
|
62
|
+
vals = [v for v in vals if v is not None and str(v).strip() != '']
|
|
63
|
+
if len(vals) < 3:
|
|
64
|
+
return False
|
|
65
|
+
hit = sum(1 for v in vals
|
|
66
|
+
if (isinstance(v, (int, float)) and not isinstance(v, bool))
|
|
67
|
+
or (isinstance(v, str) and NUM_TEXT_RE.match(v)))
|
|
68
|
+
return hit >= len(vals) * 0.6
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_title_banner(s):
|
|
72
|
+
"""表头之上那几行是不是「刻意的标题横幅」。是 → 表头不在首行属正常排版,不该报。
|
|
73
|
+
|
|
74
|
+
判据:行数不多(≤3),且每一行要么整行为空,要么只有第一格有内容/跨列合并——
|
|
75
|
+
这正是「标题 + 口径说明 + 空行」的样子。反过来,上面塞着半张残表就不是横幅。"""
|
|
76
|
+
h = s['header_row']
|
|
77
|
+
if not h or h > 4:
|
|
78
|
+
return False
|
|
79
|
+
ws = s['ws']
|
|
80
|
+
for r in range(1, h):
|
|
81
|
+
vals = [ws.cell(r, j).value for j in range(1, s['max_col'] + 1)]
|
|
82
|
+
nonnull = [j for j, v in enumerate(vals, 1) if v is not None and str(v).strip() != '']
|
|
83
|
+
if not nonnull:
|
|
84
|
+
continue
|
|
85
|
+
if nonnull != [1]:
|
|
86
|
+
merged_full = any(re.match(rf'A{r}:[A-Z]+{r}$', m) for m in s['merges'])
|
|
87
|
+
if not merged_full:
|
|
88
|
+
return False
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _subtotal_cells_are_formulas(s, rows):
|
|
93
|
+
"""分组小计行里的数值格是不是都用了 SUBTOTAL——是的话这是**正确构造**,不是隐患。"""
|
|
94
|
+
ws, seen = s['ws'], False
|
|
95
|
+
for r in rows:
|
|
96
|
+
for j in range(1, s['max_col'] + 1):
|
|
97
|
+
v = ws.cell(r, j).value
|
|
98
|
+
if _num(v) is not None and not _is_formula(v):
|
|
99
|
+
return False
|
|
100
|
+
if _is_formula(v):
|
|
101
|
+
seen = True
|
|
102
|
+
if 'SUBTOTAL' not in str(v).upper():
|
|
103
|
+
return False
|
|
104
|
+
return seen
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _issue(level, group, kind, where, msg, detail='', fixable=False):
|
|
108
|
+
return {'level': level, 'group': group, 'kind': kind, 'where': where,
|
|
109
|
+
'msg': msg, 'detail': detail, 'fixable': fixable}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _is_formula(v):
|
|
113
|
+
return isinstance(v, str) and v.startswith('=')
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _is_err(v):
|
|
117
|
+
return isinstance(v, str) and v.strip().upper() in L.ERR_VALUES
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _num(v):
|
|
121
|
+
if isinstance(v, bool):
|
|
122
|
+
return None
|
|
123
|
+
if isinstance(v, (int, float)):
|
|
124
|
+
return float(v)
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _num_loose(v):
|
|
129
|
+
"""数字,或「看着像数字的文本」(1,200.00 / (1234) / ¥500)。勾稽判定用它。"""
|
|
130
|
+
n = _num(v)
|
|
131
|
+
if n is not None:
|
|
132
|
+
return n
|
|
133
|
+
if isinstance(v, str) and NUM_TEXT_RE.match(v):
|
|
134
|
+
s = v.strip()
|
|
135
|
+
neg = s.startswith(('(', '(')) and s.endswith((')', ')'))
|
|
136
|
+
t = re.sub(r'[(()),,¥¥$元\s%]', '', s)
|
|
137
|
+
try:
|
|
138
|
+
x = float(t)
|
|
139
|
+
except ValueError:
|
|
140
|
+
return None
|
|
141
|
+
return -abs(x) if neg else x
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _from_legacy(path):
|
|
146
|
+
"""把旧版 .xls 的**值**搬进一个内存 openpyxl 工作簿,只为数据层检查服务。
|
|
147
|
+
搬不动的(公式、列宽、合并、冻结、打印)在调用处被明确停掉,不做猜测。"""
|
|
148
|
+
import pandas as pd
|
|
149
|
+
from openpyxl import Workbook
|
|
150
|
+
sheets = pd.read_excel(path, sheet_name=None, header=None, dtype=object)
|
|
151
|
+
wb = Workbook()
|
|
152
|
+
wb.remove(wb.active)
|
|
153
|
+
for name, df in sheets.items():
|
|
154
|
+
ws = wb.create_sheet(str(name)[:31] or 'Sheet1')
|
|
155
|
+
for row in df.itertuples(index=False, name=None):
|
|
156
|
+
ws.append([None if pd.isna(v) else v for v in row])
|
|
157
|
+
if not wb.sheetnames:
|
|
158
|
+
wb.create_sheet('Sheet1')
|
|
159
|
+
return wb
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Book:
|
|
163
|
+
"""解析后的工作簿模型:每张表的数据区、表头行、列画像、公式画像。"""
|
|
164
|
+
|
|
165
|
+
def __init__(self, path, profile_name='auto'):
|
|
166
|
+
self.path = path
|
|
167
|
+
self.legacy = False
|
|
168
|
+
try:
|
|
169
|
+
self.wb = load_workbook(path, data_only=False)
|
|
170
|
+
except Exception:
|
|
171
|
+
# 旧版 .xls:openpyxl 读不了。真实归档里这类占一半(评审报告、预算表全是 .xls),
|
|
172
|
+
# 直接抛异常等于半数交付件根本没法体检。用 pandas/xlrd 把**值**搬进内存工作簿,
|
|
173
|
+
# 数据层照查;公式、列宽、合并、打印设置在这一步全丢了,
|
|
174
|
+
# 所以呈现层与公式层必须停掉——照查会凭空报出「合计是写死的常量」「缺冻结窗格」,
|
|
175
|
+
# 那是编造出来的结论,比读不了更糟。
|
|
176
|
+
self.wb = _from_legacy(path)
|
|
177
|
+
self.legacy = True
|
|
178
|
+
self.wbv = self.wb if self.legacy else None
|
|
179
|
+
if not self.legacy:
|
|
180
|
+
try:
|
|
181
|
+
self.wbv = load_workbook(path, data_only=True)
|
|
182
|
+
except Exception:
|
|
183
|
+
self.wbv = None
|
|
184
|
+
self.sheets = [self._sheet(ws) for ws in self.wb.worksheets]
|
|
185
|
+
self.profile = L.profile(self._detect(profile_name))
|
|
186
|
+
|
|
187
|
+
def _detect(self, name):
|
|
188
|
+
if name and name != 'auto':
|
|
189
|
+
return name
|
|
190
|
+
for s in self.sheets:
|
|
191
|
+
if any('(' in str(c.get('format') or '') for c in s['cols']):
|
|
192
|
+
return 'finance'
|
|
193
|
+
return 'report'
|
|
194
|
+
|
|
195
|
+
def _sheet(self, ws):
|
|
196
|
+
wsv = self.wbv[ws.title] if self.wbv is not None and ws.title in self.wbv.sheetnames else None
|
|
197
|
+
max_r, max_c = self._extent(ws)
|
|
198
|
+
head = self._header_row(ws, max_r, max_c)
|
|
199
|
+
s = {'name': ws.title, 'ws': ws, 'wsv': wsv, 'max_row': max_r, 'max_col': max_c,
|
|
200
|
+
'header_row': head, 'first_data': head + 1 if head else 1,
|
|
201
|
+
'hidden': ws.sheet_state != 'visible',
|
|
202
|
+
'freeze': ws.freeze_panes, 'autofilter': ws.auto_filter.ref,
|
|
203
|
+
'print_titles': ws.print_title_rows,
|
|
204
|
+
'fit_width': bool(getattr(ws.page_setup, 'fitToWidth', None)),
|
|
205
|
+
'landscape': str(getattr(ws.page_setup, 'orientation', '')) == 'landscape',
|
|
206
|
+
'merges': [str(m) for m in ws.merged_cells.ranges],
|
|
207
|
+
'charts': len(getattr(ws, '_charts', []) or []),
|
|
208
|
+
'cols': [], 'formulas': 0, 'errors': [], 'subtotal_rows': [], 'total_rows': []}
|
|
209
|
+
if not max_r or not max_c:
|
|
210
|
+
return s
|
|
211
|
+
s['cols'] = [self._col(ws, wsv, j, s) for j in range(1, max_c + 1)]
|
|
212
|
+
s['formulas'] = sum(c['n_formula'] for c in s['cols'])
|
|
213
|
+
for c in s['cols']:
|
|
214
|
+
s['errors'].extend(c['errors'])
|
|
215
|
+
s['subtotal_rows'] = self._subtotal_rows(ws, s)
|
|
216
|
+
s['hidden_rows'] = [r for r in range(1, max_r + 1)
|
|
217
|
+
if ws.row_dimensions[r].hidden]
|
|
218
|
+
s['hidden_cols'] = [get_column_letter(j) for j in range(1, max_c + 1)
|
|
219
|
+
if ws.column_dimensions[get_column_letter(j)].hidden]
|
|
220
|
+
s['kind'] = _sheet_kind(s)
|
|
221
|
+
s['banner'] = _is_title_banner(s)
|
|
222
|
+
return s
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def _extent(ws):
|
|
226
|
+
"""真实数据范围:openpyxl 的 max_row/max_column 会被「只设过格式的空单元格」撑虚。"""
|
|
227
|
+
max_r = max_c = 0
|
|
228
|
+
for row in ws.iter_rows():
|
|
229
|
+
for cell in row:
|
|
230
|
+
if cell.value is not None and str(cell.value).strip() != '':
|
|
231
|
+
if cell.row > max_r:
|
|
232
|
+
max_r = cell.row
|
|
233
|
+
if cell.column > max_c:
|
|
234
|
+
max_c = cell.column
|
|
235
|
+
return max_r, max_c
|
|
236
|
+
|
|
237
|
+
@staticmethod
|
|
238
|
+
def _header_row(ws, max_r, max_c, scan=12):
|
|
239
|
+
"""找表头行:文字格最密、且下一行开始出现数值的那一行。找不到返回 0(无表头结构)。"""
|
|
240
|
+
best, best_score = 0, 0.0
|
|
241
|
+
for r in range(1, min(scan, max_r) + 1):
|
|
242
|
+
vals = [ws.cell(r, j).value for j in range(1, max_c + 1)]
|
|
243
|
+
nonnull = [v for v in vals if v is not None and str(v).strip() != '']
|
|
244
|
+
if not nonnull:
|
|
245
|
+
continue
|
|
246
|
+
strs = [v for v in nonnull if isinstance(v, str) and not NUM_TEXT_RE.match(v)]
|
|
247
|
+
cover = len(nonnull) / max(1, max_c)
|
|
248
|
+
txt = len(strs) / max(1, len(nonnull))
|
|
249
|
+
nxt = [ws.cell(r + 1, j).value for j in range(1, max_c + 1)] if r < max_r else []
|
|
250
|
+
has_num = any(isinstance(v, (int, float)) and not isinstance(v, bool) for v in nxt)
|
|
251
|
+
score = cover * 1.0 + txt * 1.2 + (0.8 if has_num else 0)
|
|
252
|
+
if score > best_score and cover >= 0.5 and txt >= 0.6:
|
|
253
|
+
best, best_score = r, score
|
|
254
|
+
return best
|
|
255
|
+
|
|
256
|
+
def _col(self, ws, wsv, j, s):
|
|
257
|
+
letter = get_column_letter(j)
|
|
258
|
+
header = ws.cell(s['header_row'], j).value if s['header_row'] else None
|
|
259
|
+
vals, kinds, errors = [], Counter(), []
|
|
260
|
+
n_formula = 0
|
|
261
|
+
formulas = []
|
|
262
|
+
for r in range(s['first_data'], s['max_row'] + 1):
|
|
263
|
+
cell = ws.cell(r, j)
|
|
264
|
+
v = cell.value
|
|
265
|
+
if _is_formula(v):
|
|
266
|
+
n_formula += 1
|
|
267
|
+
formulas.append((r, v))
|
|
268
|
+
vv = wsv.cell(r, j).value if wsv is not None else None
|
|
269
|
+
if _is_err(vv):
|
|
270
|
+
errors.append((r, str(vv)))
|
|
271
|
+
kinds['error'] += 1
|
|
272
|
+
elif vv is not None:
|
|
273
|
+
vals.append(vv)
|
|
274
|
+
kinds['number' if _num(vv) is not None else 'text'] += 1
|
|
275
|
+
continue
|
|
276
|
+
if v is None or (isinstance(v, str) and v.strip() == ''):
|
|
277
|
+
kinds['blank'] += 1
|
|
278
|
+
continue
|
|
279
|
+
if _is_err(v):
|
|
280
|
+
errors.append((r, str(v)))
|
|
281
|
+
kinds['error'] += 1
|
|
282
|
+
continue
|
|
283
|
+
vals.append(v)
|
|
284
|
+
if _num(v) is not None:
|
|
285
|
+
kinds['number'] += 1
|
|
286
|
+
elif isinstance(v, str):
|
|
287
|
+
kinds['num-text' if NUM_TEXT_RE.match(v) else
|
|
288
|
+
('date-text' if DATE_TEXT_RE.match(v) else 'text')] += 1
|
|
289
|
+
else:
|
|
290
|
+
kinds['date' if hasattr(v, 'year') else 'other'] += 1
|
|
291
|
+
fmts = Counter(ws.cell(r, j).number_format
|
|
292
|
+
for r in range(s['first_data'], min(s['max_row'], s['first_data'] + 400) + 1)
|
|
293
|
+
if ws.cell(r, j).value is not None)
|
|
294
|
+
width = ws.column_dimensions[letter].width
|
|
295
|
+
return {'j': j, 'letter': letter, 'header': header, 'vals': vals[:2000], 'kinds': kinds,
|
|
296
|
+
'n_formula': n_formula, 'formulas': formulas, 'errors': errors,
|
|
297
|
+
'formats': fmts, 'format': fmts.most_common(1)[0][0] if fmts else 'General',
|
|
298
|
+
'width': width, 'width_set': width is not None}
|
|
299
|
+
|
|
300
|
+
@staticmethod
|
|
301
|
+
def _subtotal_rows(ws, s):
|
|
302
|
+
out = []
|
|
303
|
+
for r in range(s['first_data'], s['max_row'] + 1):
|
|
304
|
+
for j in range(1, min(s['max_col'], 4) + 1):
|
|
305
|
+
v = ws.cell(r, j).value
|
|
306
|
+
if isinstance(v, str):
|
|
307
|
+
t = v.strip().lower()
|
|
308
|
+
if t and len(t) <= 12 and any(k in t for k in SUBTOTAL_MARKS):
|
|
309
|
+
out.append(r)
|
|
310
|
+
break
|
|
311
|
+
return out
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ── 检查族 ─────────────────────────────────────────────────────────────────
|
|
315
|
+
def check_data(B):
|
|
316
|
+
out = []
|
|
317
|
+
for s in B.sheets:
|
|
318
|
+
w = s['name']
|
|
319
|
+
if not s['max_row']:
|
|
320
|
+
out.append(_issue('warn', 'data', 'empty-sheet', w, '空工作表', '', fixable=False))
|
|
321
|
+
continue
|
|
322
|
+
for c in s['cols']:
|
|
323
|
+
k, head = c['kinds'], str(c['header'] or c['letter'])
|
|
324
|
+
n = sum(k.values()) - k['blank']
|
|
325
|
+
if n <= 0:
|
|
326
|
+
continue
|
|
327
|
+
# ① 数字存成文本:SUM 会得 0,是「对账对不平」的头号原因
|
|
328
|
+
if k['num-text'] >= max(3, n * 0.6) and not any(h in head.lower() for h in ID_HINTS):
|
|
329
|
+
out.append(_issue('error', 'data', 'num-as-text', f'{w}!{c["letter"]}',
|
|
330
|
+
f'「{head}」列的数值被存成文本({k["num-text"]}/{n} 格)',
|
|
331
|
+
'Excel 里 SUM 会跳过文本,结果得 0 或偏小', fixable=True))
|
|
332
|
+
# ② 一列里数字与文本混:排序、筛选、比较全都会出鬼
|
|
333
|
+
elif k['number'] and k['num-text'] and 0.1 < k['num-text'] / n < 0.9:
|
|
334
|
+
out.append(_issue('warn', 'data', 'mixed-type', f'{w}!{c["letter"]}',
|
|
335
|
+
f'「{head}」列数字与文本混杂({k["number"]} 数字 / {k["num-text"]} 文本)',
|
|
336
|
+
'排序会把文本排到数字后面,比较大小也不对', fixable=True))
|
|
337
|
+
if k['date-text'] >= max(3, n * 0.6):
|
|
338
|
+
out.append(_issue('warn', 'data', 'date-as-text', f'{w}!{c["letter"]}',
|
|
339
|
+
f'「{head}」列的日期被存成文本({k["date-text"]}/{n} 格)',
|
|
340
|
+
'不能参与日期计算,按月分组要先转换'))
|
|
341
|
+
# ③ 编号列被存成数字:前导零没了、18 位变科学计数法
|
|
342
|
+
if any(h in head.lower() for h in ID_HINTS) and k['number'] >= max(3, n * 0.8):
|
|
343
|
+
mx = max((abs(float(v)) for v in c['vals'] if _num(v) is not None), default=0)
|
|
344
|
+
out.append(_issue('error' if mx >= 1e15 else 'warn', 'data', 'id-as-number',
|
|
345
|
+
f'{w}!{c["letter"]}', f'「{head}」是编号列却存成了数字',
|
|
346
|
+
('已超 15 位,末位精度已丢失——只能让对方按文本重新导出,'
|
|
347
|
+
'这一步任何工具都救不回来'
|
|
348
|
+
if mx >= 1e15 else '前导零会消失,长号会显示成科学计数法'),
|
|
349
|
+
fixable=mx < 1e15))
|
|
350
|
+
# ④ 首尾空白与不可见字符:分组会分成两组
|
|
351
|
+
dirty = sum(1 for v in c['vals'] if isinstance(v, str)
|
|
352
|
+
and (v != v.strip() or any(ch in v for ch in INVISIBLE)))
|
|
353
|
+
if dirty:
|
|
354
|
+
out.append(_issue('warn', 'data', 'whitespace', f'{w}!{c["letter"]}',
|
|
355
|
+
f'「{head}」列有 {dirty} 格带首尾空白或不可见字符',
|
|
356
|
+
'「北京 」与「北京」会被当成两个值', fixable=True))
|
|
357
|
+
# ⑤ 超长单元格(放在数据类而不是呈现类:这是内容问题,
|
|
358
|
+
# 而呈现类只对被判为「数据表」的 sheet 跑,单列清单那种会被整组跳过)
|
|
359
|
+
longest, where = 0, ''
|
|
360
|
+
for c in s['cols']:
|
|
361
|
+
for v in c['vals']:
|
|
362
|
+
if isinstance(v, str) and len(v) > longest:
|
|
363
|
+
longest, where = len(v), c['letter']
|
|
364
|
+
if longest > 200:
|
|
365
|
+
out.append(_issue('info', 'data', 'long-cell', f'{w}!{where}',
|
|
366
|
+
f'最长单元格 {longest} 字',
|
|
367
|
+
'一格几千字会把整行撑得极高;长文本应放正文或另附说明'))
|
|
368
|
+
# ⑥ 完全重复行
|
|
369
|
+
if s['header_row'] and s['max_row'] - s['first_data'] >= 2:
|
|
370
|
+
seen, dup = set(), 0
|
|
371
|
+
for r in range(s['first_data'], s['max_row'] + 1):
|
|
372
|
+
key = tuple(s['ws'].cell(r, j).value for j in range(1, s['max_col'] + 1))
|
|
373
|
+
if all(v is None for v in key):
|
|
374
|
+
continue
|
|
375
|
+
if key in seen:
|
|
376
|
+
dup += 1
|
|
377
|
+
seen.add(key)
|
|
378
|
+
if dup:
|
|
379
|
+
out.append(_issue('warn', 'data', 'dup-row', w,
|
|
380
|
+
f'{dup} 行完全重复', '确认是真实业务重复还是导出重复;汇总前要不要去重'))
|
|
381
|
+
return out
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def check_structure(B):
|
|
385
|
+
out = []
|
|
386
|
+
for s in B.sheets:
|
|
387
|
+
w = s['name']
|
|
388
|
+
if not s['max_row'] or s['kind'] in ('blank', 'form'):
|
|
389
|
+
continue # 说明页/表单没有「数据区」这回事,那套判据不适用
|
|
390
|
+
# 合并单元格:只有落在**数据区**里的才是问题;表格上方的标题横幅是正常排版
|
|
391
|
+
bad_merges = []
|
|
392
|
+
for m in s['merges']:
|
|
393
|
+
mm = re.match(r'([A-Z]+)(\d+):([A-Z]+)(\d+)', m)
|
|
394
|
+
if not mm:
|
|
395
|
+
continue
|
|
396
|
+
r1, r2 = int(mm.group(2)), int(mm.group(4))
|
|
397
|
+
if r2 >= s['first_data'] and s['header_row']:
|
|
398
|
+
bad_merges.append(m)
|
|
399
|
+
if bad_merges:
|
|
400
|
+
out.append(_issue('error', 'structure', 'merged-cells', w,
|
|
401
|
+
f'数据区内有 {len(bad_merges)} 处合并单元格:{bad_merges[:5]}',
|
|
402
|
+
'排序会报错、筛选会漏行、透视表读不到——合并只该出现在表格上方的标题',
|
|
403
|
+
fixable=True))
|
|
404
|
+
if s['header_row'] > 1 and not s['banner']:
|
|
405
|
+
out.append(_issue('info', 'structure', 'header-not-first', w,
|
|
406
|
+
f'表头在第 {s["header_row"]} 行而不是第 1 行',
|
|
407
|
+
'别人用 pandas 读会拿到一排 Unnamed 列——除非上方是刻意的标题行'))
|
|
408
|
+
if not s['header_row']:
|
|
409
|
+
out.append(_issue('warn', 'structure', 'no-header', w, '识别不出表头行',
|
|
410
|
+
'整表可能没有列名,或首行就是数据'))
|
|
411
|
+
if s.get('hidden_rows'):
|
|
412
|
+
out.append(_issue('warn', 'structure', 'hidden-row', w,
|
|
413
|
+
f'{len(s["hidden_rows"])} 行被隐藏(行号 {s["hidden_rows"][:6]})',
|
|
414
|
+
'隐藏行仍会被 SUM 计入、却不会被人看见,是对不上账时最难找的原因'))
|
|
415
|
+
if s.get('hidden_cols'):
|
|
416
|
+
out.append(_issue('warn', 'structure', 'hidden-col', w,
|
|
417
|
+
f'列 {s["hidden_cols"][:6]} 被隐藏', ''))
|
|
418
|
+
# 合计行混在明细中:一汇总就双计
|
|
419
|
+
mid = [r for r in s['subtotal_rows'] if r < s['max_row']]
|
|
420
|
+
if mid:
|
|
421
|
+
tail_only = all(r >= s['max_row'] - 1 for r in s['subtotal_rows'])
|
|
422
|
+
# 小计行全部用 SUBTOTAL(9,…) 写成公式 = 规范的分组报表构造:
|
|
423
|
+
# SUBTOTAL 自己会忽略嵌套的 SUBTOTAL,总计不会把小计再加一遍。这种不该报。
|
|
424
|
+
if not tail_only and not _subtotal_cells_are_formulas(s, mid):
|
|
425
|
+
out.append(_issue('warn', 'structure', 'subtotal-inline', w,
|
|
426
|
+
f'{len(mid)} 行「合计/小计」混在明细中(行 {mid[:6]})',
|
|
427
|
+
'别人拿去做透视或求和会翻倍;分组小计是可以的,但要让下游知道'))
|
|
428
|
+
# 列名问题
|
|
429
|
+
if s['header_row']:
|
|
430
|
+
heads = [str(c['header']) if c['header'] is not None else '' for c in s['cols']]
|
|
431
|
+
blank = [c['letter'] for c, h in zip(s['cols'], heads) if not h.strip()]
|
|
432
|
+
if blank:
|
|
433
|
+
out.append(_issue('warn', 'structure', 'blank-header', w,
|
|
434
|
+
f'{len(blank)} 列没有列名:{blank[:6]}', ''))
|
|
435
|
+
dup = [h for h, n in Counter(h for h in heads if h.strip()).items() if n > 1]
|
|
436
|
+
if dup:
|
|
437
|
+
out.append(_issue('error', 'structure', 'dup-header', w,
|
|
438
|
+
f'重名列:{dup[:5]}', 'pandas 读取时取列会拿到 DataFrame 而不是 Series'))
|
|
439
|
+
names = [s['name'] for s in B.sheets]
|
|
440
|
+
default = [n for n in names if re.fullmatch(r'(Sheet|sheet|工作表)\d*', n)]
|
|
441
|
+
if default:
|
|
442
|
+
out.append(_issue('info', 'meta', 'default-name', '工作簿',
|
|
443
|
+
f'{len(default)} 张表还是默认名:{default[:4]}',
|
|
444
|
+
'交付件里的 Sheet1 很扎眼;但改名会断掉其它公式的跨表引用,改前先查引用'))
|
|
445
|
+
hid = [s['name'] for s in B.sheets if s['hidden']]
|
|
446
|
+
if hid:
|
|
447
|
+
out.append(_issue('warn', 'meta', 'hidden-sheet', '工作簿',
|
|
448
|
+
f'隐藏工作表:{hid}', '收件人可能根本不知道这些表存在'))
|
|
449
|
+
return out
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def check_formula(B):
|
|
453
|
+
"""Excel 独有、也最值钱的一类:公式看着对,算出来的范围却不对。"""
|
|
454
|
+
out = []
|
|
455
|
+
for s in B.sheets:
|
|
456
|
+
w = s['name']
|
|
457
|
+
for c in s['cols']:
|
|
458
|
+
if c['errors']:
|
|
459
|
+
kinds = Counter(e[1] for e in c['errors'])
|
|
460
|
+
out.append(_issue('error', 'formula', 'error-value', f'{w}!{c["letter"]}',
|
|
461
|
+
f'{len(c["errors"])} 个错误值:{dict(kinds)}',
|
|
462
|
+
f'首个在第 {c["errors"][0][0]} 行——交付前必须清零'))
|
|
463
|
+
# 列内公式不一致:一列里有的是公式有的是手打的数,最容易被改坏。
|
|
464
|
+
# **小计/合计行必须排除**——「明细是常量、小计是公式」正是正确构造,
|
|
465
|
+
# 不排除就会对每一张分组报表都报一次,那是纯噪声。
|
|
466
|
+
body_f = sum(1 for r, _f in c['formulas'] if r not in s['subtotal_rows'])
|
|
467
|
+
n_val = c['kinds']['number'] + c['kinds']['num-text']
|
|
468
|
+
if body_f and n_val and 0.1 < body_f / (body_f + n_val) < 0.9:
|
|
469
|
+
out.append(_issue('warn', 'formula', 'mixed-formula', f'{w}!{c["letter"]}',
|
|
470
|
+
f'「{c["header"] or c["letter"]}」列 {body_f} 格是公式、'
|
|
471
|
+
f'{n_val} 格是手打的常量',
|
|
472
|
+
'手打的那几格不会随源数据更新,是最隐蔽的过期数据'))
|
|
473
|
+
# SUM 范围没覆盖到数据末行:少算了后面新增的行
|
|
474
|
+
for r, f in c['formulas'][:200]:
|
|
475
|
+
for m in SUM_RE.finditer(f):
|
|
476
|
+
c1, r1, c2, r2 = m.group(2), int(m.group(3)), m.group(4), int(m.group(5))
|
|
477
|
+
if c1 != c2:
|
|
478
|
+
continue
|
|
479
|
+
jj = L.col_index(c1)
|
|
480
|
+
if not (1 <= jj <= s['max_col']):
|
|
481
|
+
continue
|
|
482
|
+
last = _last_value_row(s, jj, r1, r)
|
|
483
|
+
if last and r2 < last:
|
|
484
|
+
out.append(_issue('error', 'formula', 'sum-short', f'{w}!{c["letter"]}{r}',
|
|
485
|
+
f'求和范围只到第 {r2} 行,但该列数据到第 {last} 行',
|
|
486
|
+
f'{f[:48]} —— 少算了 {last - r2} 行', fixable=True))
|
|
487
|
+
for r, f in c['formulas'][:200]:
|
|
488
|
+
if '[' in f and ']' in f:
|
|
489
|
+
out.append(_issue('warn', 'formula', 'external-link', f'{w}!{c["letter"]}{r}',
|
|
490
|
+
'公式引用了外部工作簿', f[:60] + '——对方打开时会提示更新链接、或直接断链'))
|
|
491
|
+
break
|
|
492
|
+
vol = [v for v in L.VOLATILE if any(v in f for _r, f in c['formulas'][:200])]
|
|
493
|
+
if vol and c['n_formula'] > 20:
|
|
494
|
+
out.append(_issue('info', 'formula', 'volatile', f'{w}!{c["letter"]}',
|
|
495
|
+
f'大量使用易失函数 {vol}', '每次改任何单元格都会全表重算,大表会卡'))
|
|
496
|
+
# 跨表引用指向不存在的工作表
|
|
497
|
+
names = {x['name'] for x in B.sheets}
|
|
498
|
+
for c in s['cols']:
|
|
499
|
+
for r, f in c['formulas'][:200]:
|
|
500
|
+
for m in SHEET_REF_RE.finditer(f):
|
|
501
|
+
ref = m.group(1) or m.group(2)
|
|
502
|
+
if ref and ref not in names and not ref.upper() in ('IF', 'SUM'):
|
|
503
|
+
out.append(_issue('error', 'formula', 'bad-sheet-ref', f'{w}!{c["letter"]}{r}',
|
|
504
|
+
f'公式引用了不存在的工作表「{ref}」', f[:60]))
|
|
505
|
+
break
|
|
506
|
+
return out
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _last_value_row(s, j, from_row, before_row):
|
|
510
|
+
last = 0
|
|
511
|
+
for r in range(max(from_row, 1), min(before_row, s['max_row'] + 1)):
|
|
512
|
+
v = s['ws'].cell(r, j).value
|
|
513
|
+
if v is not None and str(v).strip() != '' and not (
|
|
514
|
+
isinstance(v, str) and any(k in v.lower() for k in SUBTOTAL_MARKS)):
|
|
515
|
+
last = r
|
|
516
|
+
return last
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def check_reconcile(B):
|
|
520
|
+
"""勾稽:合计对不对得上明细。**这是 Excel 交付件最值钱的一条检查**——
|
|
521
|
+
硬编码的合计不会报错,只会安静地过期。"""
|
|
522
|
+
out = []
|
|
523
|
+
for s in B.sheets:
|
|
524
|
+
if not s['header_row'] or not s['subtotal_rows']:
|
|
525
|
+
continue
|
|
526
|
+
w = s['name']
|
|
527
|
+
tail = [r for r in s['subtotal_rows'] if r >= s['max_row'] - 1]
|
|
528
|
+
for tr in (tail or s['subtotal_rows'][-1:]):
|
|
529
|
+
for c in s['cols']:
|
|
530
|
+
cell = s['ws'].cell(tr, c['j'])
|
|
531
|
+
v = cell.value
|
|
532
|
+
if v is None:
|
|
533
|
+
continue
|
|
534
|
+
# 明细可能是「看着像数字的文本」——那恰恰是最该报的场景:
|
|
535
|
+
# 金额存成文本 + 合计写死,两个毛病叠在一起,SUM 得 0 而合计看着还挺像。
|
|
536
|
+
detail = [_num_loose(s['ws'].cell(r, c['j']).value)
|
|
537
|
+
for r in range(s['first_data'], tr)
|
|
538
|
+
if r not in s['subtotal_rows']]
|
|
539
|
+
detail = [x for x in detail if x is not None]
|
|
540
|
+
if len(detail) < 2:
|
|
541
|
+
continue
|
|
542
|
+
if _is_formula(v):
|
|
543
|
+
continue # 公式合计天然跟着明细走,不必对
|
|
544
|
+
got = _num_loose(v)
|
|
545
|
+
if got is None:
|
|
546
|
+
continue
|
|
547
|
+
# 旧版 .xls 走的是「值搬运」路径,公式在读入那一刻就没了——
|
|
548
|
+
# 此时每个合计都会长得像常量。照报等于编造:原件很可能本来就是 SUM。
|
|
549
|
+
# 「合计对不上明细」不受影响(按值算),继续报。
|
|
550
|
+
if not B.legacy:
|
|
551
|
+
out.append(_issue('error', 'reconcile', 'hardcoded-total',
|
|
552
|
+
f'{w}!{c["letter"]}{tr}',
|
|
553
|
+
f'「{c["header"] or c["letter"]}」的合计是写死的常量,不是公式',
|
|
554
|
+
f'当前值 {got:,.2f};改任何一行明细它都不会变', fixable=True))
|
|
555
|
+
want = sum(detail)
|
|
556
|
+
if abs(got - want) > max(0.01, abs(want) * 1e-6):
|
|
557
|
+
out.append(_issue('error', 'reconcile', 'total-mismatch',
|
|
558
|
+
f'{w}!{c["letter"]}{tr}',
|
|
559
|
+
f'合计对不上明细:合计 {got:,.2f},明细之和 {want:,.2f}'
|
|
560
|
+
f'(差 {got - want:,.2f})',
|
|
561
|
+
'多半是明细改过而合计没跟着改', fixable=True))
|
|
562
|
+
pass
|
|
563
|
+
for s in B.sheets:
|
|
564
|
+
if not s['header_row'] or s['kind'] != 'table':
|
|
565
|
+
continue
|
|
566
|
+
w = s['name']
|
|
567
|
+
# 百分比列合计 ≈ 100%。**不依赖有没有小计行**——占比列自己就该加得起来,
|
|
568
|
+
# 早先把这条塞进小计循环里,没有合计行的表就一次也查不到。
|
|
569
|
+
for c in s['cols']:
|
|
570
|
+
if '%' not in str(c['format']):
|
|
571
|
+
continue
|
|
572
|
+
vals = [_num(x) for x in c['vals']]
|
|
573
|
+
vals = [x for x in vals if x is not None]
|
|
574
|
+
if len(vals) >= 3 and 0.9 < sum(vals) < 1.1 and abs(sum(vals) - 1.0) > 0.005:
|
|
575
|
+
out.append(_issue('warn', 'reconcile', 'pct-sum', f'{w}!{c["letter"]}',
|
|
576
|
+
f'占比列合计 {sum(vals)*100:.2f}%,不等于 100%',
|
|
577
|
+
'四舍五入误差要在末位调平,或注明「因四舍五入合计可能不为 100%」'))
|
|
578
|
+
return out
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def check_present(B):
|
|
582
|
+
out = []
|
|
583
|
+
prof = B.profile
|
|
584
|
+
for s in B.sheets:
|
|
585
|
+
if not s['max_row'] or not s['header_row'] or s['kind'] != 'table':
|
|
586
|
+
continue # 说明页不需要冻结/筛选/打印一页宽
|
|
587
|
+
w = s['name']
|
|
588
|
+
widths = []
|
|
589
|
+
for c in s['cols']:
|
|
590
|
+
width = c['width'] or 8.43
|
|
591
|
+
widths.append(width)
|
|
592
|
+
nums = [_num_loose(v) for v in c['vals']]
|
|
593
|
+
nums = [v for v in nums if v is not None]
|
|
594
|
+
if not nums:
|
|
595
|
+
continue
|
|
596
|
+
need = L.width_needed(nums, c['header'], c['format'])
|
|
597
|
+
if need > width:
|
|
598
|
+
out.append(_issue('error', 'present', 'too-narrow', f'{w}!{c["letter"]}',
|
|
599
|
+
f'「{c["header"] or c["letter"]}」列宽 {width:.1f} 不够显示(需要 {need:.0f})',
|
|
600
|
+
'数字列宽度不足会显示成 ####,打印出来一片井号', fixable=True))
|
|
601
|
+
fmts = {f for f in c['formats'] if f not in (None, 'General')}
|
|
602
|
+
if len(fmts) > 1:
|
|
603
|
+
out.append(_issue('warn', 'present', 'format-mix', f'{w}!{c["letter"]}',
|
|
604
|
+
f'「{c["header"] or c["letter"]}」列有 {len(fmts)} 种数字格式:{list(fmts)[:3]}',
|
|
605
|
+
'同一列有的两位小数有的整数,是报表最扎眼的不专业', fixable=True))
|
|
606
|
+
if (any(h in str(c['header'] or '') for h in L.AMOUNT_HINTS)
|
|
607
|
+
and ',' not in str(c['format']) and c['format'] in ('General', None)):
|
|
608
|
+
out.append(_issue('warn', 'present', 'no-thousands', f'{w}!{c["letter"]}',
|
|
609
|
+
f'「{c["header"]}」是金额列但没有千分位格式',
|
|
610
|
+
'七位数不加千分位没人读得出量级', fixable=True))
|
|
611
|
+
n_rows = s['max_row'] - s['first_data'] + 1
|
|
612
|
+
if n_rows > 20 and not s['freeze']:
|
|
613
|
+
out.append(_issue('warn', 'present', 'no-freeze', w,
|
|
614
|
+
f'{n_rows} 行数据但没有冻结窗格',
|
|
615
|
+
'翻到第 30 行就不知道每列是什么了', fixable=True))
|
|
616
|
+
if n_rows > 20 and not s['autofilter']:
|
|
617
|
+
out.append(_issue('info', 'present', 'no-filter', w, '数据表没有开启筛选',
|
|
618
|
+
'', fixable=True))
|
|
619
|
+
if widths and not s['fit_width']:
|
|
620
|
+
pages, total, usable = L.print_pages_wide(widths, landscape=s['landscape'])
|
|
621
|
+
if pages > 1:
|
|
622
|
+
out.append(_issue('warn', 'present', 'print-wide', w,
|
|
623
|
+
f'打印会横跨 {pages} 页(表宽 {total}cm,可用 {usable}cm)',
|
|
624
|
+
'设置「缩放到一页宽」或改横向;跨页的报表没人看得下去', fixable=True))
|
|
625
|
+
if n_rows > 40 and not s['print_titles']:
|
|
626
|
+
out.append(_issue('info', 'present', 'no-print-title', w,
|
|
627
|
+
'长表没有设置打印时重复标题行', '', fixable=True))
|
|
628
|
+
return out
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
# ── 自动校正 ───────────────────────────────────────────────────────────────
|
|
632
|
+
def fix(path, out_path=None, profile_name='auto', inplace=False):
|
|
633
|
+
B = Book(path, profile_name)
|
|
634
|
+
if B.legacy:
|
|
635
|
+
# 旧版 .xls 是「值搬运」读进来的:另存会把原件的公式、格式、合并、打印设置
|
|
636
|
+
# 一次性丢光,而输出文件看起来完全正常。这种「修完更糟且看不出来」的事不能做。
|
|
637
|
+
raise ValueError('旧版 .xls 不做自动校正:读入时只保留了值,'
|
|
638
|
+
'另存会丢掉原件的公式与格式。请先用 Excel 另存为 .xlsx 再跑 fix')
|
|
639
|
+
prof = B.profile
|
|
640
|
+
done = Counter()
|
|
641
|
+
for s in B.sheets:
|
|
642
|
+
ws = s['ws']
|
|
643
|
+
if not s['max_row'] or not s['header_row']:
|
|
644
|
+
continue
|
|
645
|
+
# ① 拆掉数据区里的合并单元格并把值填满(排序/筛选/透视的前提)
|
|
646
|
+
for m in list(s['merges']):
|
|
647
|
+
mm = re.match(r'([A-Z]+)(\d+):([A-Z]+)(\d+)', m)
|
|
648
|
+
if not mm:
|
|
649
|
+
continue
|
|
650
|
+
r1, r2 = int(mm.group(2)), int(mm.group(4))
|
|
651
|
+
if r2 < s['first_data']:
|
|
652
|
+
continue # 表格上方的标题横幅,保留
|
|
653
|
+
c1, c2 = L.col_index(mm.group(1)), L.col_index(mm.group(3))
|
|
654
|
+
v = ws.cell(r1, c1).value
|
|
655
|
+
try:
|
|
656
|
+
ws.unmerge_cells(m)
|
|
657
|
+
except Exception:
|
|
658
|
+
continue
|
|
659
|
+
for r in range(r1, r2 + 1):
|
|
660
|
+
for j in range(c1, c2 + 1):
|
|
661
|
+
if ws.cell(r, j).value is None:
|
|
662
|
+
ws.cell(r, j).value = v
|
|
663
|
+
done['拆合并'] += 1
|
|
664
|
+
for c in s['cols']:
|
|
665
|
+
head = str(c['header'] or '')
|
|
666
|
+
k = c['kinds']
|
|
667
|
+
n = sum(k.values()) - k['blank']
|
|
668
|
+
is_id = any(h in head.lower() for h in ID_HINTS)
|
|
669
|
+
# ② 文本数字 → 真数字(SUM 才算得出来);编号列反过来锁成文本
|
|
670
|
+
if n > 0 and k['num-text'] >= max(3, n * 0.6) and not is_id:
|
|
671
|
+
fmt = L.guess_format(head, [])
|
|
672
|
+
for r in range(s['first_data'], s['max_row'] + 1):
|
|
673
|
+
cell = ws.cell(r, c['j'])
|
|
674
|
+
if isinstance(cell.value, str) and NUM_TEXT_RE.match(cell.value):
|
|
675
|
+
cell.value = _parse_num(cell.value)
|
|
676
|
+
cell.number_format = fmt if fmt != 'General' else L.FORMATS['amount']
|
|
677
|
+
cell.alignment = Alignment(horizontal='right', vertical='center')
|
|
678
|
+
done['文本转数字'] += 1
|
|
679
|
+
elif is_id and k['number'] >= max(3, n * 0.8):
|
|
680
|
+
mx = max((abs(float(v)) for v in c['vals'] if _num(v) is not None), default=0)
|
|
681
|
+
if mx >= 1e15:
|
|
682
|
+
continue # 精度已经丢了,把错值定型成文本只会把错误固化
|
|
683
|
+
for r in range(s['first_data'], s['max_row'] + 1):
|
|
684
|
+
cell = ws.cell(r, c['j'])
|
|
685
|
+
v = cell.value
|
|
686
|
+
if _num(v) is not None:
|
|
687
|
+
# 必须连**值**一起转成文本:只改 number_format,值还是数字,
|
|
688
|
+
# 下次导出/读取照样按数字走,等于没修
|
|
689
|
+
cell.value = ('%d' % v) if float(v).is_integer() else str(v)
|
|
690
|
+
cell.number_format = L.FORMATS['text']
|
|
691
|
+
cell.alignment = Alignment(horizontal='left', vertical='center')
|
|
692
|
+
done['编号列转文本'] += 1
|
|
693
|
+
# ③ 去首尾空白与不可见字符
|
|
694
|
+
dirty = 0
|
|
695
|
+
for r in range(s['first_data'], s['max_row'] + 1):
|
|
696
|
+
cell = ws.cell(r, c['j'])
|
|
697
|
+
if isinstance(cell.value, str):
|
|
698
|
+
t = cell.value
|
|
699
|
+
for ch in INVISIBLE:
|
|
700
|
+
t = t.replace(ch, ' ' if ch in '\t\r\n' else '')
|
|
701
|
+
t = t.strip()
|
|
702
|
+
if t != cell.value:
|
|
703
|
+
cell.value = t
|
|
704
|
+
dirty += 1
|
|
705
|
+
if dirty:
|
|
706
|
+
done['清空白'] += 1
|
|
707
|
+
# ④ 同列数字格式归一
|
|
708
|
+
fmts = {f for f in c['formats'] if f not in (None, 'General')}
|
|
709
|
+
if len(fmts) > 1:
|
|
710
|
+
main = c['formats'].most_common(1)[0][0]
|
|
711
|
+
if main in (None, 'General'):
|
|
712
|
+
main = sorted(fmts)[0]
|
|
713
|
+
for r in range(s['first_data'], s['max_row'] + 1):
|
|
714
|
+
if ws.cell(r, c['j']).value is not None:
|
|
715
|
+
ws.cell(r, c['j']).number_format = main
|
|
716
|
+
done['格式归一'] += 1
|
|
717
|
+
# ⑤ 金额列补千分位
|
|
718
|
+
if (any(h in head for h in L.AMOUNT_HINTS) and c['format'] in ('General', None)
|
|
719
|
+
and k['number'] >= max(2, n * 0.5)):
|
|
720
|
+
for r in range(s['first_data'], s['max_row'] + 1):
|
|
721
|
+
if _num(ws.cell(r, c['j']).value) is not None:
|
|
722
|
+
ws.cell(r, c['j']).number_format = L.FORMATS['amount']
|
|
723
|
+
done['补千分位'] += 1
|
|
724
|
+
# ⑥ 硬编码合计 → SUM 公式;SUM 范围补到数据末行
|
|
725
|
+
done += _fix_totals(s)
|
|
726
|
+
done += _fix_sum_range(s)
|
|
727
|
+
# ⑦ 列宽自适应(放在最后,前面改过值与格式)
|
|
728
|
+
for c in s['cols']:
|
|
729
|
+
vals = [ws.cell(r, c['j']).value for r in range(s['first_data'], s['max_row'] + 1)]
|
|
730
|
+
vals = [v for v in vals if v is not None and not _is_formula(v)]
|
|
731
|
+
fmt = ws.cell(s['first_data'], c['j']).number_format
|
|
732
|
+
w = L.col_width(vals, c['header'], fmt)
|
|
733
|
+
if (c['width'] or 0) < w:
|
|
734
|
+
ws.column_dimensions[c['letter']].width = w
|
|
735
|
+
done['列宽'] += 1
|
|
736
|
+
# ⑧ 冻结 / 筛选 / 打印
|
|
737
|
+
n_rows = s['max_row'] - s['first_data'] + 1
|
|
738
|
+
if n_rows > 20 and not s['freeze']:
|
|
739
|
+
ws.freeze_panes = f'A{s["header_row"] + 1}'
|
|
740
|
+
done['冻结'] += 1
|
|
741
|
+
if n_rows > 20 and not s['autofilter']:
|
|
742
|
+
ws.auto_filter.ref = f'A{s["header_row"]}:{get_column_letter(s["max_col"])}{s["max_row"]}'
|
|
743
|
+
done['筛选'] += 1
|
|
744
|
+
if not s['fit_width']:
|
|
745
|
+
from openpyxl.worksheet.properties import PageSetupProperties
|
|
746
|
+
ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=True)
|
|
747
|
+
ws.page_setup.fitToWidth = 1
|
|
748
|
+
ws.page_setup.fitToHeight = 0
|
|
749
|
+
done['打印一页宽'] += 1
|
|
750
|
+
if n_rows > 40 and not s['print_titles']:
|
|
751
|
+
ws.print_title_rows = f'{s["header_row"]}:{s["header_row"]}'
|
|
752
|
+
done['重复标题行'] += 1
|
|
753
|
+
|
|
754
|
+
if not out_path:
|
|
755
|
+
base, ext = os.path.splitext(path)
|
|
756
|
+
out_path = path if inplace else base + '-已校正' + ext
|
|
757
|
+
B.wb.save(out_path)
|
|
758
|
+
return out_path, done
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _parse_num(t):
|
|
762
|
+
s = str(t).strip()
|
|
763
|
+
neg = s.startswith(('(', '(')) and s.endswith((')', ')'))
|
|
764
|
+
s = re.sub(r'[(()),,¥¥$元\s%]', '', s)
|
|
765
|
+
try:
|
|
766
|
+
v = float(s)
|
|
767
|
+
except ValueError:
|
|
768
|
+
return t
|
|
769
|
+
if '%' in str(t):
|
|
770
|
+
v /= 100.0
|
|
771
|
+
return -abs(v) if neg else v
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _fix_totals(s):
|
|
775
|
+
"""把写死的合计换成 SUBTOTAL 公式——**这一条是本模块最值钱的修复**:
|
|
776
|
+
常量合计不会报错,只会在下次改明细后安静地给出过期的数。"""
|
|
777
|
+
done = Counter()
|
|
778
|
+
ws = s['ws']
|
|
779
|
+
tail = [r for r in s['subtotal_rows'] if r >= s['max_row'] - 1] or s['subtotal_rows'][-1:]
|
|
780
|
+
for tr in tail:
|
|
781
|
+
for c in s['cols']:
|
|
782
|
+
cell = ws.cell(tr, c['j'])
|
|
783
|
+
if _num(cell.value) is None or _is_formula(cell.value):
|
|
784
|
+
continue
|
|
785
|
+
body = [r for r in range(s['first_data'], tr) if r not in s['subtotal_rows']]
|
|
786
|
+
if len(body) < 2:
|
|
787
|
+
continue
|
|
788
|
+
nums = sum(1 for r in body if _num(ws.cell(r, c['j']).value) is not None)
|
|
789
|
+
if nums < 2:
|
|
790
|
+
continue
|
|
791
|
+
cell.value = f'=SUBTOTAL(9,{c["letter"]}{s["first_data"]}:{c["letter"]}{tr - 1})'
|
|
792
|
+
cell.font = Font(name=cell.font.name, size=cell.font.size, bold=True)
|
|
793
|
+
done['合计改公式'] += 1
|
|
794
|
+
return done
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _fix_sum_range(s):
|
|
798
|
+
done = Counter()
|
|
799
|
+
ws = s['ws']
|
|
800
|
+
for c in s['cols']:
|
|
801
|
+
for r, f in c['formulas']:
|
|
802
|
+
new = f
|
|
803
|
+
for m in SUM_RE.finditer(f):
|
|
804
|
+
c1, r1, c2, r2 = m.group(2), int(m.group(3)), m.group(4), int(m.group(5))
|
|
805
|
+
if c1 != c2:
|
|
806
|
+
continue
|
|
807
|
+
jj = L.col_index(c1)
|
|
808
|
+
if not (1 <= jj <= s['max_col']):
|
|
809
|
+
continue
|
|
810
|
+
last = _last_value_row(s, jj, r1, r)
|
|
811
|
+
if last and r2 < last:
|
|
812
|
+
new = new.replace(f'{c2}{r2}', f'{c2}{last}', 1)
|
|
813
|
+
if new != f:
|
|
814
|
+
ws.cell(r, c['j']).value = new
|
|
815
|
+
done['补齐求和范围'] += 1
|
|
816
|
+
return done
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
# ── 汇总与报告 ─────────────────────────────────────────────────────────────
|
|
820
|
+
LEVEL_LABEL = {'error': '必修', 'warn': '建议', 'info': '提示'}
|
|
821
|
+
GROUP_LABEL = {'data': '数据', 'structure': '结构', 'formula': '公式',
|
|
822
|
+
'reconcile': '勾稽', 'present': '呈现', 'meta': '元信息'}
|
|
823
|
+
ORDER = {'error': 0, 'warn': 1, 'info': 2}
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def inspect(path, profile_name='auto', strict=False):
|
|
827
|
+
B = Book(path, profile_name)
|
|
828
|
+
issues = []
|
|
829
|
+
issues += check_data(B)
|
|
830
|
+
issues += check_structure(B)
|
|
831
|
+
if B.legacy:
|
|
832
|
+
# 说清楚哪几层没查——不说,用户会以为「必修 0」等于这份表没问题
|
|
833
|
+
issues.append({'level': 'info', 'group': 'present', 'kind': 'legacy-xls',
|
|
834
|
+
'where': os.path.basename(path),
|
|
835
|
+
'msg': '这是旧版 .xls,只做了数据层体检',
|
|
836
|
+
'detail': '公式、列宽、合并单元格、冻结窗格、打印设置这几类在 .xls 里读不到,'
|
|
837
|
+
'本次未做判断——不是「没问题」,是「没查」',
|
|
838
|
+
'suggest': '用 Excel 另存为 .xlsx 后重跑,可拿到完整的六类体检'})
|
|
839
|
+
else:
|
|
840
|
+
issues += check_formula(B)
|
|
841
|
+
issues += check_reconcile(B)
|
|
842
|
+
if not B.legacy:
|
|
843
|
+
issues += check_present(B)
|
|
844
|
+
counts = Counter(i['level'] for i in issues)
|
|
845
|
+
return {
|
|
846
|
+
'path': path, 'profile': B.profile['name'], 'profile_label': B.profile['label'],
|
|
847
|
+
'stats': {'legacy': B.legacy, 'sheets': len(B.sheets),
|
|
848
|
+
'rows': sum(s['max_row'] for s in B.sheets),
|
|
849
|
+
'cols': max((s['max_col'] for s in B.sheets), default=0),
|
|
850
|
+
'formulas': sum(s['formulas'] for s in B.sheets),
|
|
851
|
+
'merged': sum(len(s['merges']) for s in B.sheets),
|
|
852
|
+
'charts': sum(s['charts'] for s in B.sheets),
|
|
853
|
+
'sheet_names': [s['name'] for s in B.sheets]},
|
|
854
|
+
'counts': {'error': counts.get('error', 0), 'warn': counts.get('warn', 0),
|
|
855
|
+
'info': counts.get('info', 0)},
|
|
856
|
+
'issues': issues,
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def report(res, limit=5, show_info=True):
|
|
861
|
+
s, c = res['stats'], res['counts']
|
|
862
|
+
lines = [f"【表格体检】{os.path.basename(res['path'])} · {res['profile_label']}",
|
|
863
|
+
(f" 规模:{s['sheets']} 表 / 共 {s['rows']} 行 / 最宽 {s['cols']} 列"
|
|
864
|
+
f"(旧版 .xls:公式/合并/图表读不到,**不是没有**)"
|
|
865
|
+
if s.get('legacy') else
|
|
866
|
+
f" 规模:{s['sheets']} 表 / 共 {s['rows']} 行 / 最宽 {s['cols']} 列 / "
|
|
867
|
+
f"{s['formulas']} 个公式 / {s['merged']} 处合并 / {s['charts']} 张图"),
|
|
868
|
+
f" 结论:必修 {c['error']} · 建议 {c['warn']} · 提示 {c['info']}"]
|
|
869
|
+
grouped = defaultdict(list)
|
|
870
|
+
for it in res['issues']:
|
|
871
|
+
grouped[(it['level'], it['group'], it['kind'])].append(it)
|
|
872
|
+
for key in sorted(grouped, key=lambda k: (ORDER[k[0]], k[1])):
|
|
873
|
+
lvl, grp, _ = key
|
|
874
|
+
if lvl == 'info' and not show_info:
|
|
875
|
+
continue
|
|
876
|
+
items = grouped[key]
|
|
877
|
+
head = f" [{LEVEL_LABEL[lvl]}·{GROUP_LABEL.get(grp, grp)}] {items[0]['msg']}"
|
|
878
|
+
if len(items) > 1:
|
|
879
|
+
head += f'({len(items)} 处)'
|
|
880
|
+
if items[0].get('fixable'):
|
|
881
|
+
head += ' ⟨可自动修⟩'
|
|
882
|
+
lines.append(head)
|
|
883
|
+
for it in items[:limit]:
|
|
884
|
+
d = f" — {it['detail']}" if it.get('detail') else ''
|
|
885
|
+
lines.append(f" · {it['where']}{d}")
|
|
886
|
+
if len(items) > limit:
|
|
887
|
+
lines.append(f' · …另有 {len(items)-limit} 处')
|
|
888
|
+
if not res['issues']:
|
|
889
|
+
lines.append(' 未发现问题。')
|
|
890
|
+
return '\n'.join(lines)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def main(argv):
|
|
894
|
+
args = [a for a in argv if not a.startswith('--')]
|
|
895
|
+
if not args:
|
|
896
|
+
print('用法:python xlsx_check.py 表.xlsx [--fix] [--inplace] [--strict] '
|
|
897
|
+
'[--profile=report|plain|finance] [--json]')
|
|
898
|
+
return 2
|
|
899
|
+
path = args[0]
|
|
900
|
+
prof = 'auto'
|
|
901
|
+
for a in argv:
|
|
902
|
+
if a.startswith('--profile='):
|
|
903
|
+
prof = a.split('=', 1)[1]
|
|
904
|
+
if not os.path.exists(path):
|
|
905
|
+
print(f'文件不存在:{path}')
|
|
906
|
+
return 2
|
|
907
|
+
if os.path.isdir(path):
|
|
908
|
+
print('这是目录不是工作簿:请指定具体的 .xlsx 文件')
|
|
909
|
+
return 2
|
|
910
|
+
res = inspect(path, prof, '--strict' in argv)
|
|
911
|
+
if '--json' in argv:
|
|
912
|
+
print(json.dumps(res, ensure_ascii=False, default=str))
|
|
913
|
+
return 0
|
|
914
|
+
print(report(res))
|
|
915
|
+
if '--fix' in argv:
|
|
916
|
+
out, done = fix(path, None, prof, inplace='--inplace' in argv)
|
|
917
|
+
print('\n【自动校正】' + ('、'.join(f'{k} {v} 处' for k, v in done.items()) if done else '无可自动修复项'))
|
|
918
|
+
print(f' 已另存:{out}' if '--inplace' not in argv else f' 已就地修改:{out}')
|
|
919
|
+
print('\n' + report(inspect(out, prof)))
|
|
920
|
+
return 0
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
if __name__ == '__main__':
|
|
924
|
+
sys.exit(main(sys.argv[1:]))
|