diff --git a/.agents/skills/ui-ux-pro-max/SKILL.md b/.agents/skills/ui-ux-pro-max/SKILL.md new file mode 100644 index 00000000..e58d6187 --- /dev/null +++ b/.agents/skills/ui-ux-pro-max/SKILL.md @@ -0,0 +1,386 @@ +--- +name: ui-ux-pro-max +description: "UI/UX design intelligence. 50 styles, 21 palettes, 50 font pairings, 20 charts, 9 stacks (React, Next.js, Vue, Svelte, SwiftUI, React Native, Flutter, Tailwind, shadcn/ui). Actions: plan, build, create, design, implement, review, fix, improve, optimize, enhance, refactor, check UI/UX code. Projects: website, landing page, dashboard, admin panel, e-commerce, SaaS, portfolio, blog, mobile app, .html, .tsx, .vue, .svelte. Elements: button, modal, navbar, sidebar, card, table, form, chart. Styles: glassmorphism, claymorphism, minimalism, brutalism, neumorphism, bento grid, dark mode, responsive, skeuomorphism, flat design. Topics: color palette, accessibility, animation, layout, typography, font pairing, spacing, hover, shadow, gradient. Integrations: shadcn/ui MCP for component search and examples." +--- + +# UI/UX Pro Max - Design Intelligence + +Comprehensive design guide for web and mobile applications. Contains 50+ styles, 97 color palettes, 57 font pairings, 99 UX guidelines, and 25 chart types across 9 technology stacks. Searchable database with priority-based recommendations. + +## When to Apply + +Reference these guidelines when: +- Designing new UI components or pages +- Choosing color palettes and typography +- Reviewing code for UX issues +- Building landing pages or dashboards +- Implementing accessibility requirements + +## Rule Categories by Priority + +| Priority | Category | Impact | Domain | +|----------|----------|--------|--------| +| 1 | Accessibility | CRITICAL | `ux` | +| 2 | Touch & Interaction | CRITICAL | `ux` | +| 3 | Performance | HIGH | `ux` | +| 4 | Layout & Responsive | HIGH | `ux` | +| 5 | Typography & Color | MEDIUM | `typography`, `color` | +| 6 | Animation | MEDIUM | `ux` | +| 7 | Style Selection | MEDIUM | `style`, `product` | +| 8 | Charts & Data | LOW | `chart` | + +## Quick Reference + +### 1. Accessibility (CRITICAL) + +- `color-contrast` - Minimum 4.5:1 ratio for normal text +- `focus-states` - Visible focus rings on interactive elements +- `alt-text` - Descriptive alt text for meaningful images +- `aria-labels` - aria-label for icon-only buttons +- `keyboard-nav` - Tab order matches visual order +- `form-labels` - Use label with for attribute + +### 2. Touch & Interaction (CRITICAL) + +- `touch-target-size` - Minimum 44x44px touch targets +- `hover-vs-tap` - Use click/tap for primary interactions +- `loading-buttons` - Disable button during async operations +- `error-feedback` - Clear error messages near problem +- `cursor-pointer` - Add cursor-pointer to clickable elements + +### 3. Performance (HIGH) + +- `image-optimization` - Use WebP, srcset, lazy loading +- `reduced-motion` - Check prefers-reduced-motion +- `content-jumping` - Reserve space for async content + +### 4. Layout & Responsive (HIGH) + +- `viewport-meta` - width=device-width initial-scale=1 +- `readable-font-size` - Minimum 16px body text on mobile +- `horizontal-scroll` - Ensure content fits viewport width +- `z-index-management` - Define z-index scale (10, 20, 30, 50) + +### 5. Typography & Color (MEDIUM) + +- `line-height` - Use 1.5-1.75 for body text +- `line-length` - Limit to 65-75 characters per line +- `font-pairing` - Match heading/body font personalities + +### 6. Animation (MEDIUM) + +- `duration-timing` - Use 150-300ms for micro-interactions +- `transform-performance` - Use transform/opacity, not width/height +- `loading-states` - Skeleton screens or spinners + +### 7. Style Selection (MEDIUM) + +- `style-match` - Match style to product type +- `consistency` - Use same style across all pages +- `no-emoji-icons` - Use SVG icons, not emojis + +### 8. Charts & Data (LOW) + +- `chart-type` - Match chart type to data type +- `color-guidance` - Use accessible color palettes +- `data-table` - Provide table alternative for accessibility + +## How to Use + +Search specific domains using the CLI tool below. + +--- + +## Prerequisites + +Check if Python is installed: + +```bash +python3 --version || python --version +``` + +If Python is not installed, install it based on user's OS: + +**macOS:** +```bash +brew install python3 +``` + +**Ubuntu/Debian:** +```bash +sudo apt update && sudo apt install python3 +``` + +**Windows:** +```powershell +winget install Python.Python.3.12 +``` + +--- + +## How to Use This Skill + +When user requests UI/UX work (design, build, create, implement, review, fix, improve), follow this workflow: + +### Step 1: Analyze User Requirements + +Extract key information from user request: +- **Product type**: SaaS, e-commerce, portfolio, dashboard, landing page, etc. +- **Style keywords**: minimal, playful, professional, elegant, dark mode, etc. +- **Industry**: healthcare, fintech, gaming, education, etc. +- **Stack**: React, Vue, Next.js, or default to `html-tailwind` + +### Step 2: Generate Design System (REQUIRED) + +**Always start with `--design-system`** to get comprehensive recommendations with reasoning: + +```bash +python3 skills/ui-ux-pro-max/scripts/search.py " " --design-system [-p "Project Name"] +``` + +This command: +1. Searches 5 domains in parallel (product, style, color, landing, typography) +2. Applies reasoning rules from `ui-reasoning.csv` to select best matches +3. Returns complete design system: pattern, style, colors, typography, effects +4. Includes anti-patterns to avoid + +**Example:** +```bash +python3 skills/ui-ux-pro-max/scripts/search.py "beauty spa wellness service" --design-system -p "Serenity Spa" +``` + +### Step 2b: Persist Design System (Master + Overrides Pattern) + +To save the design system for **hierarchical retrieval across sessions**, add `--persist`: + +```bash +python3 skills/ui-ux-pro-max/scripts/search.py "" --design-system --persist -p "Project Name" +``` + +This creates: +- `design-system/MASTER.md` — Global Source of Truth with all design rules +- `design-system/pages/` — Folder for page-specific overrides + +**With page-specific override:** +```bash +python3 skills/ui-ux-pro-max/scripts/search.py "" --design-system --persist -p "Project Name" --page "dashboard" +``` + +This also creates: +- `design-system/pages/dashboard.md` — Page-specific deviations from Master + +**How hierarchical retrieval works:** +1. When building a specific page (e.g., "Checkout"), first check `design-system/pages/checkout.md` +2. If the page file exists, its rules **override** the Master file +3. If not, use `design-system/MASTER.md` exclusively + +**Context-aware retrieval prompt:** +``` +I am building the [Page Name] page. Please read design-system/MASTER.md. +Also check if design-system/pages/[page-name].md exists. +If the page file exists, prioritize its rules. +If not, use the Master rules exclusively. +Now, generate the code... +``` + +### Step 3: Supplement with Detailed Searches (as needed) + +After getting the design system, use domain searches to get additional details: + +```bash +python3 skills/ui-ux-pro-max/scripts/search.py "" --domain [-n ] +``` + +**When to use detailed searches:** + +| Need | Domain | Example | +|------|--------|---------| +| More style options | `style` | `--domain style "glassmorphism dark"` | +| Chart recommendations | `chart` | `--domain chart "real-time dashboard"` | +| UX best practices | `ux` | `--domain ux "animation accessibility"` | +| Alternative fonts | `typography` | `--domain typography "elegant luxury"` | +| Landing structure | `landing` | `--domain landing "hero social-proof"` | + +### Step 4: Stack Guidelines (Default: html-tailwind) + +Get implementation-specific best practices. If user doesn't specify a stack, **default to `html-tailwind`**. + +```bash +python3 skills/ui-ux-pro-max/scripts/search.py "" --stack html-tailwind +``` + +Available stacks: `html-tailwind`, `react`, `nextjs`, `vue`, `svelte`, `swiftui`, `react-native`, `flutter`, `shadcn`, `jetpack-compose` + +--- + +## Search Reference + +### Available Domains + +| Domain | Use For | Example Keywords | +|--------|---------|------------------| +| `product` | Product type recommendations | SaaS, e-commerce, portfolio, healthcare, beauty, service | +| `style` | UI styles, colors, effects | glassmorphism, minimalism, dark mode, brutalism | +| `typography` | Font pairings, Google Fonts | elegant, playful, professional, modern | +| `color` | Color palettes by product type | saas, ecommerce, healthcare, beauty, fintech, service | +| `landing` | Page structure, CTA strategies | hero, hero-centric, testimonial, pricing, social-proof | +| `chart` | Chart types, library recommendations | trend, comparison, timeline, funnel, pie | +| `ux` | Best practices, anti-patterns | animation, accessibility, z-index, loading | +| `react` | React/Next.js performance | waterfall, bundle, suspense, memo, rerender, cache | +| `web` | Web interface guidelines | aria, focus, keyboard, semantic, virtualize | +| `prompt` | AI prompts, CSS keywords | (style name) | + +### Available Stacks + +| Stack | Focus | +|-------|-------| +| `html-tailwind` | Tailwind utilities, responsive, a11y (DEFAULT) | +| `react` | State, hooks, performance, patterns | +| `nextjs` | SSR, routing, images, API routes | +| `vue` | Composition API, Pinia, Vue Router | +| `svelte` | Runes, stores, SvelteKit | +| `swiftui` | Views, State, Navigation, Animation | +| `react-native` | Components, Navigation, Lists | +| `flutter` | Widgets, State, Layout, Theming | +| `shadcn` | shadcn/ui components, theming, forms, patterns | +| `jetpack-compose` | Composables, Modifiers, State Hoisting, Recomposition | + +--- + +## Example Workflow + +**User request:** "Làm landing page cho dịch vụ chăm sóc da chuyên nghiệp" + +### Step 1: Analyze Requirements +- Product type: Beauty/Spa service +- Style keywords: elegant, professional, soft +- Industry: Beauty/Wellness +- Stack: html-tailwind (default) + +### Step 2: Generate Design System (REQUIRED) + +```bash +python3 skills/ui-ux-pro-max/scripts/search.py "beauty spa wellness service elegant" --design-system -p "Serenity Spa" +``` + +**Output:** Complete design system with pattern, style, colors, typography, effects, and anti-patterns. + +### Step 3: Supplement with Detailed Searches (as needed) + +```bash +# Get UX guidelines for animation and accessibility +python3 skills/ui-ux-pro-max/scripts/search.py "animation accessibility" --domain ux + +# Get alternative typography options if needed +python3 skills/ui-ux-pro-max/scripts/search.py "elegant luxury serif" --domain typography +``` + +### Step 4: Stack Guidelines + +```bash +python3 skills/ui-ux-pro-max/scripts/search.py "layout responsive form" --stack html-tailwind +``` + +**Then:** Synthesize design system + detailed searches and implement the design. + +--- + +## Output Formats + +The `--design-system` flag supports two output formats: + +```bash +# ASCII box (default) - best for terminal display +python3 skills/ui-ux-pro-max/scripts/search.py "fintech crypto" --design-system + +# Markdown - best for documentation +python3 skills/ui-ux-pro-max/scripts/search.py "fintech crypto" --design-system -f markdown +``` + +--- + +## Tips for Better Results + +1. **Be specific with keywords** - "healthcare SaaS dashboard" > "app" +2. **Search multiple times** - Different keywords reveal different insights +3. **Combine domains** - Style + Typography + Color = Complete design system +4. **Always check UX** - Search "animation", "z-index", "accessibility" for common issues +5. **Use stack flag** - Get implementation-specific best practices +6. **Iterate** - If first search doesn't match, try different keywords + +--- + +## Common Rules for Professional UI + +These are frequently overlooked issues that make UI look unprofessional: + +### Icons & Visual Elements + +| Rule | Do | Don't | +|------|----|----- | +| **No emoji icons** | Use SVG icons (Heroicons, Lucide, Simple Icons) | Use emojis like 🎨 🚀 ⚙️ as UI icons | +| **Stable hover states** | Use color/opacity transitions on hover | Use scale transforms that shift layout | +| **Correct brand logos** | Research official SVG from Simple Icons | Guess or use incorrect logo paths | +| **Consistent icon sizing** | Use fixed viewBox (24x24) with w-6 h-6 | Mix different icon sizes randomly | + +### Interaction & Cursor + +| Rule | Do | Don't | +|------|----|----- | +| **Cursor pointer** | Add `cursor-pointer` to all clickable/hoverable cards | Leave default cursor on interactive elements | +| **Hover feedback** | Provide visual feedback (color, shadow, border) | No indication element is interactive | +| **Smooth transitions** | Use `transition-colors duration-200` | Instant state changes or too slow (>500ms) | + +### Light/Dark Mode Contrast + +| Rule | Do | Don't | +|------|----|----- | +| **Glass card light mode** | Use `bg-white/80` or higher opacity | Use `bg-white/10` (too transparent) | +| **Text contrast light** | Use `#0F172A` (slate-900) for text | Use `#94A3B8` (slate-400) for body text | +| **Muted text light** | Use `#475569` (slate-600) minimum | Use gray-400 or lighter | +| **Border visibility** | Use `border-gray-200` in light mode | Use `border-white/10` (invisible) | + +### Layout & Spacing + +| Rule | Do | Don't | +|------|----|----- | +| **Floating navbar** | Add `top-4 left-4 right-4` spacing | Stick navbar to `top-0 left-0 right-0` | +| **Content padding** | Account for fixed navbar height | Let content hide behind fixed elements | +| **Consistent max-width** | Use same `max-w-6xl` or `max-w-7xl` | Mix different container widths | + +--- + +## Pre-Delivery Checklist + +Before delivering UI code, verify these items: + +### Visual Quality +- [ ] No emojis used as icons (use SVG instead) +- [ ] All icons from consistent icon set (Heroicons/Lucide) +- [ ] Brand logos are correct (verified from Simple Icons) +- [ ] Hover states don't cause layout shift +- [ ] Use theme colors directly (bg-primary) not var() wrapper + +### Interaction +- [ ] All clickable elements have `cursor-pointer` +- [ ] Hover states provide clear visual feedback +- [ ] Transitions are smooth (150-300ms) +- [ ] Focus states visible for keyboard navigation + +### Light/Dark Mode +- [ ] Light mode text has sufficient contrast (4.5:1 minimum) +- [ ] Glass/transparent elements visible in light mode +- [ ] Borders visible in both modes +- [ ] Test both modes before delivery + +### Layout +- [ ] Floating elements have proper spacing from edges +- [ ] No content hidden behind fixed navbars +- [ ] Responsive at 375px, 768px, 1024px, 1440px +- [ ] No horizontal scroll on mobile + +### Accessibility +- [ ] All images have alt text +- [ ] Form inputs have labels +- [ ] Color is not the only indicator +- [ ] `prefers-reduced-motion` respected diff --git a/.agents/skills/ui-ux-pro-max/data b/.agents/skills/ui-ux-pro-max/data new file mode 100644 index 00000000..e5b94699 --- /dev/null +++ b/.agents/skills/ui-ux-pro-max/data @@ -0,0 +1 @@ +../../../src/ui-ux-pro-max/data \ No newline at end of file diff --git a/.agents/skills/ui-ux-pro-max/scripts b/.agents/skills/ui-ux-pro-max/scripts new file mode 100644 index 00000000..ccb93f77 --- /dev/null +++ b/.agents/skills/ui-ux-pro-max/scripts @@ -0,0 +1 @@ +../../../src/ui-ux-pro-max/scripts \ No newline at end of file diff --git a/.agents/skills/web-design-guidelines/SKILL.md b/.agents/skills/web-design-guidelines/SKILL.md new file mode 100644 index 00000000..ceae92ab --- /dev/null +++ b/.agents/skills/web-design-guidelines/SKILL.md @@ -0,0 +1,39 @@ +--- +name: web-design-guidelines +description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices". +metadata: + author: vercel + version: "1.0.0" + argument-hint: +--- + +# Web Interface Guidelines + +Review files for compliance with Web Interface Guidelines. + +## How It Works + +1. Fetch the latest guidelines from the source URL below +2. Read the specified files (or prompt user for files/pattern) +3. Check against all rules in the fetched guidelines +4. Output findings in the terse `file:line` format + +## Guidelines Source + +Fetch fresh guidelines before each review: + +``` +https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md +``` + +Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions. + +## Usage + +When a user provides a file or pattern argument: +1. Fetch guidelines from the source URL above +2. Read the specified files +3. Apply all rules from the fetched guidelines +4. Output findings using the format specified in the guidelines + +If no files specified, ask the user which files to review. diff --git a/.agents/skills/xlsx/LICENSE.txt b/.agents/skills/xlsx/LICENSE.txt new file mode 100644 index 00000000..c55ab422 --- /dev/null +++ b/.agents/skills/xlsx/LICENSE.txt @@ -0,0 +1,30 @@ +© 2025 Anthropic, PBC. All rights reserved. + +LICENSE: Use of these materials (including all code, prompts, assets, files, +and other components of this Skill) is governed by your agreement with +Anthropic regarding use of Anthropic's services. If no separate agreement +exists, use is governed by Anthropic's Consumer Terms of Service or +Commercial Terms of Service, as applicable: +https://www.anthropic.com/legal/consumer-terms +https://www.anthropic.com/legal/commercial-terms +Your applicable agreement is referred to as the "Agreement." "Services" are +as defined in the Agreement. + +ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the +contrary, users may not: + +- Extract these materials from the Services or retain copies of these + materials outside the Services +- Reproduce or copy these materials, except for temporary copies created + automatically during authorized use of the Services +- Create derivative works based on these materials +- Distribute, sublicense, or transfer these materials to any third party +- Make, offer to sell, sell, or import any inventions embodied in these + materials +- Reverse engineer, decompile, or disassemble these materials + +The receipt, viewing, or possession of these materials does not convey or +imply any license or right beyond those expressly granted above. + +Anthropic retains all right, title, and interest in these materials, +including all copyrights, patents, and other intellectual property rights. diff --git a/.agents/skills/xlsx/SKILL.md b/.agents/skills/xlsx/SKILL.md new file mode 100644 index 00000000..c5c881be --- /dev/null +++ b/.agents/skills/xlsx/SKILL.md @@ -0,0 +1,292 @@ +--- +name: xlsx +description: "Use this skill any time a spreadsheet file is the primary input or output. This means any task where the user wants to: open, read, edit, or fix an existing .xlsx, .xlsm, .csv, or .tsv file (e.g., adding columns, computing formulas, formatting, charting, cleaning messy data); create a new spreadsheet from scratch or from other data sources; or convert between tabular file formats. Trigger especially when the user references a spreadsheet file by name or path — even casually (like \"the xlsx in my downloads\") — and wants something done to it or produced from it. Also trigger for cleaning or restructuring messy tabular data files (malformed rows, misplaced headers, junk data) into proper spreadsheets. The deliverable must be a spreadsheet file. Do NOT trigger when the primary deliverable is a Word document, HTML report, standalone Python script, database pipeline, or Google Sheets API integration, even if tabular data is involved." +license: Proprietary. LICENSE.txt has complete terms +--- + +# Requirements for Outputs + +## All Excel files + +### Professional Font +- Use a consistent, professional font (e.g., Arial, Times New Roman) for all deliverables unless otherwise instructed by the user + +### Zero Formula Errors +- Every Excel model MUST be delivered with ZERO formula errors (#REF!, #DIV/0!, #VALUE!, #N/A, #NAME?) + +### Preserve Existing Templates (when updating templates) +- Study and EXACTLY match existing format, style, and conventions when modifying files +- Never impose standardized formatting on files with established patterns +- Existing template conventions ALWAYS override these guidelines + +## Financial models + +### Color Coding Standards +Unless otherwise stated by the user or existing template + +#### Industry-Standard Color Conventions +- **Blue text (RGB: 0,0,255)**: Hardcoded inputs, and numbers users will change for scenarios +- **Black text (RGB: 0,0,0)**: ALL formulas and calculations +- **Green text (RGB: 0,128,0)**: Links pulling from other worksheets within same workbook +- **Red text (RGB: 255,0,0)**: External links to other files +- **Yellow background (RGB: 255,255,0)**: Key assumptions needing attention or cells that need to be updated + +### Number Formatting Standards + +#### Required Format Rules +- **Years**: Format as text strings (e.g., "2024" not "2,024") +- **Currency**: Use $#,##0 format; ALWAYS specify units in headers ("Revenue ($mm)") +- **Zeros**: Use number formatting to make all zeros "-", including percentages (e.g., "$#,##0;($#,##0);-") +- **Percentages**: Default to 0.0% format (one decimal) +- **Multiples**: Format as 0.0x for valuation multiples (EV/EBITDA, P/E) +- **Negative numbers**: Use parentheses (123) not minus -123 + +### Formula Construction Rules + +#### Assumptions Placement +- Place ALL assumptions (growth rates, margins, multiples, etc.) in separate assumption cells +- Use cell references instead of hardcoded values in formulas +- Example: Use =B5*(1+$B$6) instead of =B5*1.05 + +#### Formula Error Prevention +- Verify all cell references are correct +- Check for off-by-one errors in ranges +- Ensure consistent formulas across all projection periods +- Test with edge cases (zero values, negative numbers) +- Verify no unintended circular references + +#### Documentation Requirements for Hardcodes +- 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 + +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. + +## Important Requirements + +**LibreOffice Required for Formula Recalculation**: You can assume LibreOffice is installed for recalculating formula values using the `scripts/recalc.py` script. The script automatically configures LibreOffice on first run, including in sandboxed environments where Unix sockets are restricted (handled by `scripts/office/soffice.py`) + +## Reading and analyzing data + +### Data analysis with pandas +For data analysis, visualization, and basic operations, use **pandas** which provides powerful data manipulation capabilities: + +```python +import pandas as pd + +# 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 + +# Analyze +df.head() # Preview data +df.info() # Column info +df.describe() # Statistics + +# Write Excel +df.to_excel('output.xlsx', index=False) +``` + +## Excel File Workflows + +## CRITICAL: Use Formulas, Not Hardcoded Values + +**Always use Excel formulas instead of calculating values in Python and hardcoding them.** This ensures the spreadsheet remains dynamic and updateable. + +### ❌ 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 + +# Bad: Python calculation for average +avg = sum(values) / len(values) +sheet['D20'] = avg # Hardcodes 42.5 +``` + +### ✅ CORRECT - Using Excel Formulas +```python +# Good: Let Excel calculate the sum +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 scripts/recalc.py script + ```bash + python scripts/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 +# Using openpyxl for formulas and formatting +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment + +wb = Workbook() +sheet = wb.active + +# Add data +sheet['A1'] = 'Hello' +sheet['B1'] = 'World' +sheet.append(['Row', 'of', 'data']) + +# Add formula +sheet['B2'] = '=SUM(A1:A10)' + +# Formatting +sheet['A1'].font = Font(bold=True, color='FF0000') +sheet['A1'].fill = PatternFill('solid', start_color='FFFF00') +sheet['A1'].alignment = Alignment(horizontal='center') + +# Column width +sheet.column_dimensions['A'].width = 20 + +wb.save('output.xlsx') +``` + +### Editing existing Excel files + +```python +# Using openpyxl to preserve formulas and formatting +from openpyxl import load_workbook + +# Load existing file +wb = load_workbook('existing.xlsx') +sheet = wb.active # or wb['SheetName'] for specific sheet + +# Working with multiple sheets +for sheet_name in wb.sheetnames: + sheet = wb[sheet_name] + print(f"Sheet: {sheet_name}") + +# Modify cells +sheet['A1'] = 'New Value' +sheet.insert_rows(2) # Insert row at position 2 +sheet.delete_cols(3) # Delete column 3 + +# Add new sheet +new_sheet = wb.create_sheet('NewSheet') +new_sheet['A1'] = 'Data' + +wb.save('modified.xlsx') +``` + +## Recalculating formulas + +Excel files created or modified by openpyxl contain formulas as strings but not calculated values. Use the provided `scripts/recalc.py` script to recalculate formulas: + +```bash +python scripts/recalc.py [timeout_seconds] +``` + +Example: +```bash +python scripts/recalc.py output.xlsx 30 +``` + +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 + +## Formula Verification Checklist + +Quick checks to ensure formulas work correctly: + +### 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) + +### 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 scripts/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"] + } + } +} +``` + +## Best Practices + +### 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) +- 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 scripts/recalc.py to update values + +### 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 +- Avoid verbose variable names and redundant operations +- Avoid unnecessary print statements + +**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 diff --git a/.agents/skills/xlsx/scripts/office/helpers/merge_runs.py b/.agents/skills/xlsx/scripts/office/helpers/merge_runs.py new file mode 100644 index 00000000..ad7c25ee --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/helpers/merge_runs.py @@ -0,0 +1,199 @@ +"""Merge adjacent runs with identical formatting in DOCX. + +Merges adjacent elements that have identical properties. +Works on runs in paragraphs and inside tracked changes (, ). + +Also: +- Removes rsid attributes from runs (revision metadata that doesn't affect rendering) +- Removes proofErr elements (spell/grammar markers that block merging) +""" + +from pathlib import Path + +import defusedxml.minidom + + +def merge_runs(input_dir: str) -> tuple[int, str]: + doc_xml = Path(input_dir) / "word" / "document.xml" + + if not doc_xml.exists(): + return 0, f"Error: {doc_xml} not found" + + try: + dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8")) + root = dom.documentElement + + _remove_elements(root, "proofErr") + _strip_run_rsid_attrs(root) + + containers = {run.parentNode for run in _find_elements(root, "r")} + + merge_count = 0 + for container in containers: + merge_count += _merge_runs_in(container) + + doc_xml.write_bytes(dom.toxml(encoding="UTF-8")) + return merge_count, f"Merged {merge_count} runs" + + except Exception as e: + return 0, f"Error: {e}" + + + + +def _find_elements(root, tag: str) -> list: + results = [] + + def traverse(node): + if node.nodeType == node.ELEMENT_NODE: + name = node.localName or node.tagName + if name == tag or name.endswith(f":{tag}"): + results.append(node) + for child in node.childNodes: + traverse(child) + + traverse(root) + return results + + +def _get_child(parent, tag: str): + for child in parent.childNodes: + if child.nodeType == child.ELEMENT_NODE: + name = child.localName or child.tagName + if name == tag or name.endswith(f":{tag}"): + return child + return None + + +def _get_children(parent, tag: str) -> list: + results = [] + for child in parent.childNodes: + if child.nodeType == child.ELEMENT_NODE: + name = child.localName or child.tagName + if name == tag or name.endswith(f":{tag}"): + results.append(child) + return results + + +def _is_adjacent(elem1, elem2) -> bool: + node = elem1.nextSibling + while node: + if node == elem2: + return True + if node.nodeType == node.ELEMENT_NODE: + return False + if node.nodeType == node.TEXT_NODE and node.data.strip(): + return False + node = node.nextSibling + return False + + + + +def _remove_elements(root, tag: str): + for elem in _find_elements(root, tag): + if elem.parentNode: + elem.parentNode.removeChild(elem) + + +def _strip_run_rsid_attrs(root): + for run in _find_elements(root, "r"): + for attr in list(run.attributes.values()): + if "rsid" in attr.name.lower(): + run.removeAttribute(attr.name) + + + + +def _merge_runs_in(container) -> int: + merge_count = 0 + run = _first_child_run(container) + + while run: + while True: + next_elem = _next_element_sibling(run) + if next_elem and _is_run(next_elem) and _can_merge(run, next_elem): + _merge_run_content(run, next_elem) + container.removeChild(next_elem) + merge_count += 1 + else: + break + + _consolidate_text(run) + run = _next_sibling_run(run) + + return merge_count + + +def _first_child_run(container): + for child in container.childNodes: + if child.nodeType == child.ELEMENT_NODE and _is_run(child): + return child + return None + + +def _next_element_sibling(node): + sibling = node.nextSibling + while sibling: + if sibling.nodeType == sibling.ELEMENT_NODE: + return sibling + sibling = sibling.nextSibling + return None + + +def _next_sibling_run(node): + sibling = node.nextSibling + while sibling: + if sibling.nodeType == sibling.ELEMENT_NODE: + if _is_run(sibling): + return sibling + sibling = sibling.nextSibling + return None + + +def _is_run(node) -> bool: + name = node.localName or node.tagName + return name == "r" or name.endswith(":r") + + +def _can_merge(run1, run2) -> bool: + rpr1 = _get_child(run1, "rPr") + rpr2 = _get_child(run2, "rPr") + + if (rpr1 is None) != (rpr2 is None): + return False + if rpr1 is None: + return True + return rpr1.toxml() == rpr2.toxml() + + +def _merge_run_content(target, source): + for child in list(source.childNodes): + if child.nodeType == child.ELEMENT_NODE: + name = child.localName or child.tagName + if name != "rPr" and not name.endswith(":rPr"): + target.appendChild(child) + + +def _consolidate_text(run): + t_elements = _get_children(run, "t") + + for i in range(len(t_elements) - 1, 0, -1): + curr, prev = t_elements[i], t_elements[i - 1] + + if _is_adjacent(prev, curr): + prev_text = prev.firstChild.data if prev.firstChild else "" + curr_text = curr.firstChild.data if curr.firstChild else "" + merged = prev_text + curr_text + + if prev.firstChild: + prev.firstChild.data = merged + else: + prev.appendChild(run.ownerDocument.createTextNode(merged)) + + if merged.startswith(" ") or merged.endswith(" "): + prev.setAttribute("xml:space", "preserve") + elif prev.hasAttribute("xml:space"): + prev.removeAttribute("xml:space") + + run.removeChild(curr) diff --git a/.agents/skills/xlsx/scripts/office/helpers/simplify_redlines.py b/.agents/skills/xlsx/scripts/office/helpers/simplify_redlines.py new file mode 100644 index 00000000..db963bb9 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/helpers/simplify_redlines.py @@ -0,0 +1,197 @@ +"""Simplify tracked changes by merging adjacent w:ins or w:del elements. + +Merges adjacent elements from the same author into a single element. +Same for elements. This makes heavily-redlined documents easier to +work with by reducing the number of tracked change wrappers. + +Rules: +- Only merges w:ins with w:ins, w:del with w:del (same element type) +- Only merges if same author (ignores timestamp differences) +- Only merges if truly adjacent (only whitespace between them) +""" + +import xml.etree.ElementTree as ET +import zipfile +from pathlib import Path + +import defusedxml.minidom + +WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + + +def simplify_redlines(input_dir: str) -> tuple[int, str]: + doc_xml = Path(input_dir) / "word" / "document.xml" + + if not doc_xml.exists(): + return 0, f"Error: {doc_xml} not found" + + try: + dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8")) + root = dom.documentElement + + merge_count = 0 + + containers = _find_elements(root, "p") + _find_elements(root, "tc") + + for container in containers: + merge_count += _merge_tracked_changes_in(container, "ins") + merge_count += _merge_tracked_changes_in(container, "del") + + doc_xml.write_bytes(dom.toxml(encoding="UTF-8")) + return merge_count, f"Simplified {merge_count} tracked changes" + + except Exception as e: + return 0, f"Error: {e}" + + +def _merge_tracked_changes_in(container, tag: str) -> int: + merge_count = 0 + + tracked = [ + child + for child in container.childNodes + if child.nodeType == child.ELEMENT_NODE and _is_element(child, tag) + ] + + if len(tracked) < 2: + return 0 + + i = 0 + while i < len(tracked) - 1: + curr = tracked[i] + next_elem = tracked[i + 1] + + if _can_merge_tracked(curr, next_elem): + _merge_tracked_content(curr, next_elem) + container.removeChild(next_elem) + tracked.pop(i + 1) + merge_count += 1 + else: + i += 1 + + return merge_count + + +def _is_element(node, tag: str) -> bool: + name = node.localName or node.tagName + return name == tag or name.endswith(f":{tag}") + + +def _get_author(elem) -> str: + author = elem.getAttribute("w:author") + if not author: + for attr in elem.attributes.values(): + if attr.localName == "author" or attr.name.endswith(":author"): + return attr.value + return author + + +def _can_merge_tracked(elem1, elem2) -> bool: + if _get_author(elem1) != _get_author(elem2): + return False + + node = elem1.nextSibling + while node and node != elem2: + if node.nodeType == node.ELEMENT_NODE: + return False + if node.nodeType == node.TEXT_NODE and node.data.strip(): + return False + node = node.nextSibling + + return True + + +def _merge_tracked_content(target, source): + while source.firstChild: + child = source.firstChild + source.removeChild(child) + target.appendChild(child) + + +def _find_elements(root, tag: str) -> list: + results = [] + + def traverse(node): + if node.nodeType == node.ELEMENT_NODE: + name = node.localName or node.tagName + if name == tag or name.endswith(f":{tag}"): + results.append(node) + for child in node.childNodes: + traverse(child) + + traverse(root) + return results + + +def get_tracked_change_authors(doc_xml_path: Path) -> dict[str, int]: + if not doc_xml_path.exists(): + return {} + + try: + tree = ET.parse(doc_xml_path) + root = tree.getroot() + except ET.ParseError: + return {} + + namespaces = {"w": WORD_NS} + author_attr = f"{{{WORD_NS}}}author" + + authors: dict[str, int] = {} + for tag in ["ins", "del"]: + for elem in root.findall(f".//w:{tag}", namespaces): + author = elem.get(author_attr) + if author: + authors[author] = authors.get(author, 0) + 1 + + return authors + + +def _get_authors_from_docx(docx_path: Path) -> dict[str, int]: + try: + with zipfile.ZipFile(docx_path, "r") as zf: + if "word/document.xml" not in zf.namelist(): + return {} + with zf.open("word/document.xml") as f: + tree = ET.parse(f) + root = tree.getroot() + + namespaces = {"w": WORD_NS} + author_attr = f"{{{WORD_NS}}}author" + + authors: dict[str, int] = {} + for tag in ["ins", "del"]: + for elem in root.findall(f".//w:{tag}", namespaces): + author = elem.get(author_attr) + if author: + authors[author] = authors.get(author, 0) + 1 + return authors + except (zipfile.BadZipFile, ET.ParseError): + return {} + + +def infer_author(modified_dir: Path, original_docx: Path, default: str = "Claude") -> str: + modified_xml = modified_dir / "word" / "document.xml" + modified_authors = get_tracked_change_authors(modified_xml) + + if not modified_authors: + return default + + original_authors = _get_authors_from_docx(original_docx) + + new_changes: dict[str, int] = {} + for author, count in modified_authors.items(): + original_count = original_authors.get(author, 0) + diff = count - original_count + if diff > 0: + new_changes[author] = diff + + if not new_changes: + return default + + if len(new_changes) == 1: + return next(iter(new_changes)) + + raise ValueError( + f"Multiple authors added new changes: {new_changes}. " + "Cannot infer which author to validate." + ) diff --git a/.agents/skills/xlsx/scripts/office/pack.py b/.agents/skills/xlsx/scripts/office/pack.py new file mode 100644 index 00000000..db29ed8b --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/pack.py @@ -0,0 +1,159 @@ +"""Pack a directory into a DOCX, PPTX, or XLSX file. + +Validates with auto-repair, condenses XML formatting, and creates the Office file. + +Usage: + python pack.py [--original ] [--validate true|false] + +Examples: + python pack.py unpacked/ output.docx --original input.docx + python pack.py unpacked/ output.pptx --validate false +""" + +import argparse +import sys +import shutil +import tempfile +import zipfile +from pathlib import Path + +import defusedxml.minidom + +from validators import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator + +def pack( + input_directory: str, + output_file: str, + original_file: str | None = None, + validate: bool = True, + infer_author_func=None, +) -> tuple[None, str]: + input_dir = Path(input_directory) + output_path = Path(output_file) + suffix = output_path.suffix.lower() + + if not input_dir.is_dir(): + return None, f"Error: {input_dir} is not a directory" + + if suffix not in {".docx", ".pptx", ".xlsx"}: + return None, f"Error: {output_file} must be a .docx, .pptx, or .xlsx file" + + if validate and original_file: + original_path = Path(original_file) + if original_path.exists(): + success, output = _run_validation( + input_dir, original_path, suffix, infer_author_func + ) + if output: + print(output) + if not success: + return None, f"Error: Validation failed for {input_dir}" + + with tempfile.TemporaryDirectory() as temp_dir: + temp_content_dir = Path(temp_dir) / "content" + shutil.copytree(input_dir, temp_content_dir) + + for pattern in ["*.xml", "*.rels"]: + for xml_file in temp_content_dir.rglob(pattern): + _condense_xml(xml_file) + + output_path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: + for f in temp_content_dir.rglob("*"): + if f.is_file(): + zf.write(f, f.relative_to(temp_content_dir)) + + return None, f"Successfully packed {input_dir} to {output_file}" + + +def _run_validation( + unpacked_dir: Path, + original_file: Path, + suffix: str, + infer_author_func=None, +) -> tuple[bool, str | None]: + output_lines = [] + validators = [] + + if suffix == ".docx": + author = "Claude" + if infer_author_func: + try: + author = infer_author_func(unpacked_dir, original_file) + except ValueError as e: + print(f"Warning: {e} Using default author 'Claude'.", file=sys.stderr) + + validators = [ + DOCXSchemaValidator(unpacked_dir, original_file), + RedliningValidator(unpacked_dir, original_file, author=author), + ] + elif suffix == ".pptx": + validators = [PPTXSchemaValidator(unpacked_dir, original_file)] + + if not validators: + return True, None + + total_repairs = sum(v.repair() for v in validators) + if total_repairs: + output_lines.append(f"Auto-repaired {total_repairs} issue(s)") + + success = all(v.validate() for v in validators) + + if success: + output_lines.append("All validations PASSED!") + + return success, "\n".join(output_lines) if output_lines else None + + +def _condense_xml(xml_file: Path) -> None: + try: + with open(xml_file, encoding="utf-8") as f: + dom = defusedxml.minidom.parse(f) + + for element in dom.getElementsByTagName("*"): + if element.tagName.endswith(":t"): + continue + + for child in list(element.childNodes): + if ( + child.nodeType == child.TEXT_NODE + and child.nodeValue + and child.nodeValue.strip() == "" + ) or child.nodeType == child.COMMENT_NODE: + element.removeChild(child) + + xml_file.write_bytes(dom.toxml(encoding="UTF-8")) + except Exception as e: + print(f"ERROR: Failed to parse {xml_file.name}: {e}", file=sys.stderr) + raise + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Pack a directory into a DOCX, PPTX, or XLSX file" + ) + parser.add_argument("input_directory", help="Unpacked Office document directory") + parser.add_argument("output_file", help="Output Office file (.docx/.pptx/.xlsx)") + parser.add_argument( + "--original", + help="Original file for validation comparison", + ) + parser.add_argument( + "--validate", + type=lambda x: x.lower() == "true", + default=True, + metavar="true|false", + help="Run validation with auto-repair (default: true)", + ) + args = parser.parse_args() + + _, message = pack( + args.input_directory, + args.output_file, + original_file=args.original, + validate=args.validate, + ) + print(message) + + if "Error" in message: + sys.exit(1) diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd new file mode 100644 index 00000000..6454ef9a --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd @@ -0,0 +1,1499 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd new file mode 100644 index 00000000..afa4f463 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd new file mode 100644 index 00000000..64e66b8a --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd @@ -0,0 +1,1085 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd new file mode 100644 index 00000000..687eea82 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd @@ -0,0 +1,11 @@ + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd new file mode 100644 index 00000000..6ac81b06 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd @@ -0,0 +1,3081 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd new file mode 100644 index 00000000..1dbf0514 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd new file mode 100644 index 00000000..f1af17db --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd new file mode 100644 index 00000000..0a185ab6 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd new file mode 100644 index 00000000..14ef4888 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd @@ -0,0 +1,1676 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd new file mode 100644 index 00000000..c20f3bf1 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd new file mode 100644 index 00000000..ac602522 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd new file mode 100644 index 00000000..424b8ba8 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd new file mode 100644 index 00000000..2bddce29 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd new file mode 100644 index 00000000..8a8c18ba --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd new file mode 100644 index 00000000..5c42706a --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd new file mode 100644 index 00000000..853c341c --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd new file mode 100644 index 00000000..da835ee8 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd new file mode 100644 index 00000000..87ad2658 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd @@ -0,0 +1,582 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd new file mode 100644 index 00000000..9e86f1b2 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd new file mode 100644 index 00000000..d0be42e7 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd @@ -0,0 +1,4439 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd new file mode 100644 index 00000000..8821dd18 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd @@ -0,0 +1,570 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd new file mode 100644 index 00000000..ca2575c7 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd @@ -0,0 +1,509 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd new file mode 100644 index 00000000..dd079e60 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd new file mode 100644 index 00000000..3dd6cf62 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd new file mode 100644 index 00000000..f1041e34 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd new file mode 100644 index 00000000..9c5b7a63 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd @@ -0,0 +1,3646 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd new file mode 100644 index 00000000..0f13678d --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd @@ -0,0 +1,116 @@ + + + + + + See http://www.w3.org/XML/1998/namespace.html and + http://www.w3.org/TR/REC-xml for information about this namespace. + + This schema document describes the XML namespace, in a form + suitable for import by other schema documents. + + Note that local names in this namespace are intended to be defined + only by the World Wide Web Consortium or its subgroups. The + following names are currently defined in this namespace and should + not be used with conflicting semantics by any Working Group, + specification, or document instance: + + base (as an attribute name): denotes an attribute whose value + provides a URI to be used as the base for interpreting any + relative URIs in the scope of the element on which it + appears; its value is inherited. This name is reserved + by virtue of its definition in the XML Base specification. + + lang (as an attribute name): denotes an attribute whose value + is a language code for the natural language of the content of + any element; its value is inherited. This name is reserved + by virtue of its definition in the XML specification. + + space (as an attribute name): denotes an attribute whose + value is a keyword indicating what whitespace processing + discipline is intended for the content of the element; its + value is inherited. This name is reserved by virtue of its + definition in the XML specification. + + Father (in any context at all): denotes Jon Bosak, the chair of + the original XML Working Group. This name is reserved by + the following decision of the W3C XML Plenary and + XML Coordination groups: + + In appreciation for his vision, leadership and dedication + the W3C XML Plenary on this 10th day of February, 2000 + reserves for Jon Bosak in perpetuity the XML name + xml:Father + + + + + This schema defines attributes and an attribute group + suitable for use by + schemas wishing to allow xml:base, xml:lang or xml:space attributes + on elements they define. + + To enable this, such a schema must import this schema + for the XML namespace, e.g. as follows: + <schema . . .> + . . . + <import namespace="http://www.w3.org/XML/1998/namespace" + schemaLocation="http://www.w3.org/2001/03/xml.xsd"/> + + Subsequently, qualified reference to any of the attributes + or the group defined below will have the desired effect, e.g. + + <type . . .> + . . . + <attributeGroup ref="xml:specialAttrs"/> + + will define a type which will schema-validate an instance + element with any of those attributes + + + + In keeping with the XML Schema WG's standard versioning + policy, this schema document will persist at + http://www.w3.org/2001/03/xml.xsd. + At the date of issue it can also be found at + http://www.w3.org/2001/xml.xsd. + The schema document at that URI may however change in the future, + in order to remain compatible with the latest version of XML Schema + itself. In other words, if the XML Schema namespace changes, the version + of this document at + http://www.w3.org/2001/xml.xsd will change + accordingly; the version at + http://www.w3.org/2001/03/xml.xsd will not change. + + + + + + In due course, we should install the relevant ISO 2- and 3-letter + codes as the enumerated possible values . . . + + + + + + + + + + + + + + + See http://www.w3.org/TR/xmlbase/ for + information about this attribute. + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd b/.agents/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd new file mode 100644 index 00000000..a6de9d27 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd b/.agents/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd new file mode 100644 index 00000000..10e978b6 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd b/.agents/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd new file mode 100644 index 00000000..4248bf7a --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd b/.agents/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd new file mode 100644 index 00000000..56497467 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/mce/mc.xsd b/.agents/skills/xlsx/scripts/office/schemas/mce/mc.xsd new file mode 100644 index 00000000..ef725457 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/mce/mc.xsd @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd b/.agents/skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd new file mode 100644 index 00000000..f65f7777 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd @@ -0,0 +1,560 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd b/.agents/skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd new file mode 100644 index 00000000..6b00755a --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd b/.agents/skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd new file mode 100644 index 00000000..f321d333 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd b/.agents/skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd new file mode 100644 index 00000000..364c6a9b --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd b/.agents/skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd new file mode 100644 index 00000000..fed9d15b --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd b/.agents/skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd new file mode 100644 index 00000000..680cf154 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd @@ -0,0 +1,4 @@ + + + + diff --git a/.agents/skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd b/.agents/skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd new file mode 100644 index 00000000..89ada908 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/.agents/skills/xlsx/scripts/office/soffice.py b/.agents/skills/xlsx/scripts/office/soffice.py new file mode 100644 index 00000000..c7f7e328 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/soffice.py @@ -0,0 +1,183 @@ +""" +Helper for running LibreOffice (soffice) in environments where AF_UNIX +sockets may be blocked (e.g., sandboxed VMs). Detects the restriction +at runtime and applies an LD_PRELOAD shim if needed. + +Usage: + from office.soffice import run_soffice, get_soffice_env + + # Option 1 – run soffice directly + result = run_soffice(["--headless", "--convert-to", "pdf", "input.docx"]) + + # Option 2 – get env dict for your own subprocess calls + env = get_soffice_env() + subprocess.run(["soffice", ...], env=env) +""" + +import os +import socket +import subprocess +import tempfile +from pathlib import Path + + +def get_soffice_env() -> dict: + env = os.environ.copy() + env["SAL_USE_VCLPLUGIN"] = "svp" + + if _needs_shim(): + shim = _ensure_shim() + env["LD_PRELOAD"] = str(shim) + + return env + + +def run_soffice(args: list[str], **kwargs) -> subprocess.CompletedProcess: + env = get_soffice_env() + return subprocess.run(["soffice"] + args, env=env, **kwargs) + + + +_SHIM_SO = Path(tempfile.gettempdir()) / "lo_socket_shim.so" + + +def _needs_shim() -> bool: + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.close() + return False + except OSError: + return True + + +def _ensure_shim() -> Path: + if _SHIM_SO.exists(): + return _SHIM_SO + + src = Path(tempfile.gettempdir()) / "lo_socket_shim.c" + src.write_text(_SHIM_SOURCE) + subprocess.run( + ["gcc", "-shared", "-fPIC", "-o", str(_SHIM_SO), str(src), "-ldl"], + check=True, + capture_output=True, + ) + src.unlink() + return _SHIM_SO + + + +_SHIM_SOURCE = r""" +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include + +static int (*real_socket)(int, int, int); +static int (*real_socketpair)(int, int, int, int[2]); +static int (*real_listen)(int, int); +static int (*real_accept)(int, struct sockaddr *, socklen_t *); +static int (*real_close)(int); +static int (*real_read)(int, void *, size_t); + +/* Per-FD bookkeeping (FDs >= 1024 are passed through unshimmed). */ +static int is_shimmed[1024]; +static int peer_of[1024]; +static int wake_r[1024]; /* accept() blocks reading this */ +static int wake_w[1024]; /* close() writes to this */ +static int listener_fd = -1; /* FD that received listen() */ + +__attribute__((constructor)) +static void init(void) { + real_socket = dlsym(RTLD_NEXT, "socket"); + real_socketpair = dlsym(RTLD_NEXT, "socketpair"); + real_listen = dlsym(RTLD_NEXT, "listen"); + real_accept = dlsym(RTLD_NEXT, "accept"); + real_close = dlsym(RTLD_NEXT, "close"); + real_read = dlsym(RTLD_NEXT, "read"); + for (int i = 0; i < 1024; i++) { + peer_of[i] = -1; + wake_r[i] = -1; + wake_w[i] = -1; + } +} + +/* ---- socket ---------------------------------------------------------- */ +int socket(int domain, int type, int protocol) { + if (domain == AF_UNIX) { + int fd = real_socket(domain, type, protocol); + if (fd >= 0) return fd; + /* socket(AF_UNIX) blocked – fall back to socketpair(). */ + int sv[2]; + if (real_socketpair(domain, type, protocol, sv) == 0) { + if (sv[0] >= 0 && sv[0] < 1024) { + is_shimmed[sv[0]] = 1; + peer_of[sv[0]] = sv[1]; + int wp[2]; + if (pipe(wp) == 0) { + wake_r[sv[0]] = wp[0]; + wake_w[sv[0]] = wp[1]; + } + } + return sv[0]; + } + errno = EPERM; + return -1; + } + return real_socket(domain, type, protocol); +} + +/* ---- listen ---------------------------------------------------------- */ +int listen(int sockfd, int backlog) { + if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) { + listener_fd = sockfd; + return 0; + } + return real_listen(sockfd, backlog); +} + +/* ---- accept ---------------------------------------------------------- */ +int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen) { + if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) { + /* Block until close() writes to the wake pipe. */ + if (wake_r[sockfd] >= 0) { + char buf; + real_read(wake_r[sockfd], &buf, 1); + } + errno = ECONNABORTED; + return -1; + } + return real_accept(sockfd, addr, addrlen); +} + +/* ---- close ----------------------------------------------------------- */ +int close(int fd) { + if (fd >= 0 && fd < 1024 && is_shimmed[fd]) { + int was_listener = (fd == listener_fd); + is_shimmed[fd] = 0; + + if (wake_w[fd] >= 0) { /* unblock accept() */ + char c = 0; + write(wake_w[fd], &c, 1); + real_close(wake_w[fd]); + wake_w[fd] = -1; + } + if (wake_r[fd] >= 0) { real_close(wake_r[fd]); wake_r[fd] = -1; } + if (peer_of[fd] >= 0) { real_close(peer_of[fd]); peer_of[fd] = -1; } + + if (was_listener) + _exit(0); /* conversion done – exit */ + } + return real_close(fd); +} +""" + + + +if __name__ == "__main__": + import sys + result = run_soffice(sys.argv[1:]) + sys.exit(result.returncode) diff --git a/.agents/skills/xlsx/scripts/office/unpack.py b/.agents/skills/xlsx/scripts/office/unpack.py new file mode 100644 index 00000000..00152533 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/unpack.py @@ -0,0 +1,132 @@ +"""Unpack Office files (DOCX, PPTX, XLSX) for editing. + +Extracts the ZIP archive, pretty-prints XML files, and optionally: +- Merges adjacent runs with identical formatting (DOCX only) +- Simplifies adjacent tracked changes from same author (DOCX only) + +Usage: + python unpack.py [options] + +Examples: + python unpack.py document.docx unpacked/ + python unpack.py presentation.pptx unpacked/ + python unpack.py document.docx unpacked/ --merge-runs false +""" + +import argparse +import sys +import zipfile +from pathlib import Path + +import defusedxml.minidom + +from helpers.merge_runs import merge_runs as do_merge_runs +from helpers.simplify_redlines import simplify_redlines as do_simplify_redlines + +SMART_QUOTE_REPLACEMENTS = { + "\u201c": "“", + "\u201d": "”", + "\u2018": "‘", + "\u2019": "’", +} + + +def unpack( + input_file: str, + output_directory: str, + merge_runs: bool = True, + simplify_redlines: bool = True, +) -> tuple[None, str]: + input_path = Path(input_file) + output_path = Path(output_directory) + suffix = input_path.suffix.lower() + + if not input_path.exists(): + return None, f"Error: {input_file} does not exist" + + if suffix not in {".docx", ".pptx", ".xlsx"}: + return None, f"Error: {input_file} must be a .docx, .pptx, or .xlsx file" + + try: + output_path.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile(input_path, "r") as zf: + zf.extractall(output_path) + + xml_files = list(output_path.rglob("*.xml")) + list(output_path.rglob("*.rels")) + for xml_file in xml_files: + _pretty_print_xml(xml_file) + + message = f"Unpacked {input_file} ({len(xml_files)} XML files)" + + if suffix == ".docx": + if simplify_redlines: + simplify_count, _ = do_simplify_redlines(str(output_path)) + message += f", simplified {simplify_count} tracked changes" + + if merge_runs: + merge_count, _ = do_merge_runs(str(output_path)) + message += f", merged {merge_count} runs" + + for xml_file in xml_files: + _escape_smart_quotes(xml_file) + + return None, message + + except zipfile.BadZipFile: + return None, f"Error: {input_file} is not a valid Office file" + except Exception as e: + return None, f"Error unpacking: {e}" + + +def _pretty_print_xml(xml_file: Path) -> None: + try: + content = xml_file.read_text(encoding="utf-8") + dom = defusedxml.minidom.parseString(content) + xml_file.write_bytes(dom.toprettyxml(indent=" ", encoding="utf-8")) + except Exception: + pass + + +def _escape_smart_quotes(xml_file: Path) -> None: + try: + content = xml_file.read_text(encoding="utf-8") + for char, entity in SMART_QUOTE_REPLACEMENTS.items(): + content = content.replace(char, entity) + xml_file.write_text(content, encoding="utf-8") + except Exception: + pass + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Unpack an Office file (DOCX, PPTX, XLSX) for editing" + ) + parser.add_argument("input_file", help="Office file to unpack") + parser.add_argument("output_directory", help="Output directory") + parser.add_argument( + "--merge-runs", + type=lambda x: x.lower() == "true", + default=True, + metavar="true|false", + help="Merge adjacent runs with identical formatting (DOCX only, default: true)", + ) + parser.add_argument( + "--simplify-redlines", + type=lambda x: x.lower() == "true", + default=True, + metavar="true|false", + help="Merge adjacent tracked changes from same author (DOCX only, default: true)", + ) + args = parser.parse_args() + + _, message = unpack( + args.input_file, + args.output_directory, + merge_runs=args.merge_runs, + simplify_redlines=args.simplify_redlines, + ) + print(message) + + if "Error" in message: + sys.exit(1) diff --git a/.agents/skills/xlsx/scripts/office/validate.py b/.agents/skills/xlsx/scripts/office/validate.py new file mode 100644 index 00000000..03b01f6e --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/validate.py @@ -0,0 +1,111 @@ +""" +Command line tool to validate Office document XML files against XSD schemas and tracked changes. + +Usage: + python validate.py [--original ] [--auto-repair] [--author NAME] + +The first argument can be either: +- An unpacked directory containing the Office document XML files +- A packed Office file (.docx/.pptx/.xlsx) which will be unpacked to a temp directory + +Auto-repair fixes: +- paraId/durableId values that exceed OOXML limits +- Missing xml:space="preserve" on w:t elements with whitespace +""" + +import argparse +import sys +import tempfile +import zipfile +from pathlib import Path + +from validators import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator + + +def main(): + parser = argparse.ArgumentParser(description="Validate Office document XML files") + parser.add_argument( + "path", + help="Path to unpacked directory or packed Office file (.docx/.pptx/.xlsx)", + ) + parser.add_argument( + "--original", + required=False, + default=None, + help="Path to original file (.docx/.pptx/.xlsx). If omitted, all XSD errors are reported and redlining validation is skipped.", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose output", + ) + parser.add_argument( + "--auto-repair", + action="store_true", + help="Automatically repair common issues (hex IDs, whitespace preservation)", + ) + parser.add_argument( + "--author", + default="Claude", + help="Author name for redlining validation (default: Claude)", + ) + args = parser.parse_args() + + path = Path(args.path) + assert path.exists(), f"Error: {path} does not exist" + + original_file = None + if args.original: + original_file = Path(args.original) + assert original_file.is_file(), f"Error: {original_file} is not a file" + assert original_file.suffix.lower() in [".docx", ".pptx", ".xlsx"], ( + f"Error: {original_file} must be a .docx, .pptx, or .xlsx file" + ) + + file_extension = (original_file or path).suffix.lower() + assert file_extension in [".docx", ".pptx", ".xlsx"], ( + f"Error: Cannot determine file type from {path}. Use --original or provide a .docx/.pptx/.xlsx file." + ) + + if path.is_file() and path.suffix.lower() in [".docx", ".pptx", ".xlsx"]: + temp_dir = tempfile.mkdtemp() + with zipfile.ZipFile(path, "r") as zf: + zf.extractall(temp_dir) + unpacked_dir = Path(temp_dir) + else: + assert path.is_dir(), f"Error: {path} is not a directory or Office file" + unpacked_dir = path + + match file_extension: + case ".docx": + validators = [ + DOCXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), + ] + if original_file: + validators.append( + RedliningValidator(unpacked_dir, original_file, verbose=args.verbose, author=args.author) + ) + case ".pptx": + validators = [ + PPTXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), + ] + case _: + print(f"Error: Validation not supported for file type {file_extension}") + sys.exit(1) + + if args.auto_repair: + total_repairs = sum(v.repair() for v in validators) + if total_repairs: + print(f"Auto-repaired {total_repairs} issue(s)") + + success = all(v.validate() for v in validators) + + if success: + print("All validations PASSED!") + + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/xlsx/scripts/office/validators/base.py b/.agents/skills/xlsx/scripts/office/validators/base.py new file mode 100644 index 00000000..db4a06a2 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/validators/base.py @@ -0,0 +1,847 @@ +""" +Base validator with common validation logic for document files. +""" + +import re +from pathlib import Path + +import defusedxml.minidom +import lxml.etree + + +class BaseSchemaValidator: + + IGNORED_VALIDATION_ERRORS = [ + "hyphenationZone", + "purl.org/dc/terms", + ] + + UNIQUE_ID_REQUIREMENTS = { + "comment": ("id", "file"), + "commentrangestart": ("id", "file"), + "commentrangeend": ("id", "file"), + "bookmarkstart": ("id", "file"), + "bookmarkend": ("id", "file"), + "sldid": ("id", "file"), + "sldmasterid": ("id", "global"), + "sldlayoutid": ("id", "global"), + "cm": ("authorid", "file"), + "sheet": ("sheetid", "file"), + "definedname": ("id", "file"), + "cxnsp": ("id", "file"), + "sp": ("id", "file"), + "pic": ("id", "file"), + "grpsp": ("id", "file"), + } + + EXCLUDED_ID_CONTAINERS = { + "sectionlst", + } + + ELEMENT_RELATIONSHIP_TYPES = {} + + SCHEMA_MAPPINGS = { + "word": "ISO-IEC29500-4_2016/wml.xsd", + "ppt": "ISO-IEC29500-4_2016/pml.xsd", + "xl": "ISO-IEC29500-4_2016/sml.xsd", + "[Content_Types].xml": "ecma/fouth-edition/opc-contentTypes.xsd", + "app.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd", + "core.xml": "ecma/fouth-edition/opc-coreProperties.xsd", + "custom.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd", + ".rels": "ecma/fouth-edition/opc-relationships.xsd", + "people.xml": "microsoft/wml-2012.xsd", + "commentsIds.xml": "microsoft/wml-cid-2016.xsd", + "commentsExtensible.xml": "microsoft/wml-cex-2018.xsd", + "commentsExtended.xml": "microsoft/wml-2012.xsd", + "chart": "ISO-IEC29500-4_2016/dml-chart.xsd", + "theme": "ISO-IEC29500-4_2016/dml-main.xsd", + "drawing": "ISO-IEC29500-4_2016/dml-main.xsd", + } + + MC_NAMESPACE = "http://schemas.openxmlformats.org/markup-compatibility/2006" + XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace" + + PACKAGE_RELATIONSHIPS_NAMESPACE = ( + "http://schemas.openxmlformats.org/package/2006/relationships" + ) + OFFICE_RELATIONSHIPS_NAMESPACE = ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + ) + CONTENT_TYPES_NAMESPACE = ( + "http://schemas.openxmlformats.org/package/2006/content-types" + ) + + MAIN_CONTENT_FOLDERS = {"word", "ppt", "xl"} + + OOXML_NAMESPACES = { + "http://schemas.openxmlformats.org/officeDocument/2006/math", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "http://schemas.openxmlformats.org/schemaLibrary/2006/main", + "http://schemas.openxmlformats.org/drawingml/2006/main", + "http://schemas.openxmlformats.org/drawingml/2006/chart", + "http://schemas.openxmlformats.org/drawingml/2006/chartDrawing", + "http://schemas.openxmlformats.org/drawingml/2006/diagram", + "http://schemas.openxmlformats.org/drawingml/2006/picture", + "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing", + "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", + "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + "http://schemas.openxmlformats.org/presentationml/2006/main", + "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + "http://schemas.openxmlformats.org/officeDocument/2006/sharedTypes", + "http://www.w3.org/XML/1998/namespace", + } + + def __init__(self, unpacked_dir, original_file=None, verbose=False): + self.unpacked_dir = Path(unpacked_dir).resolve() + self.original_file = Path(original_file) if original_file else None + self.verbose = verbose + + self.schemas_dir = Path(__file__).parent.parent / "schemas" + + patterns = ["*.xml", "*.rels"] + self.xml_files = [ + f for pattern in patterns for f in self.unpacked_dir.rglob(pattern) + ] + + if not self.xml_files: + print(f"Warning: No XML files found in {self.unpacked_dir}") + + def validate(self): + raise NotImplementedError("Subclasses must implement the validate method") + + def repair(self) -> int: + return self.repair_whitespace_preservation() + + def repair_whitespace_preservation(self) -> int: + repairs = 0 + + for xml_file in self.xml_files: + try: + content = xml_file.read_text(encoding="utf-8") + dom = defusedxml.minidom.parseString(content) + modified = False + + for elem in dom.getElementsByTagName("*"): + if elem.tagName.endswith(":t") and elem.firstChild: + text = elem.firstChild.nodeValue + if text and (text.startswith((' ', '\t')) or text.endswith((' ', '\t'))): + if elem.getAttribute("xml:space") != "preserve": + elem.setAttribute("xml:space", "preserve") + text_preview = repr(text[:30]) + "..." if len(text) > 30 else repr(text) + print(f" Repaired: {xml_file.name}: Added xml:space='preserve' to {elem.tagName}: {text_preview}") + repairs += 1 + modified = True + + if modified: + xml_file.write_bytes(dom.toxml(encoding="UTF-8")) + + except Exception: + pass + + return repairs + + def validate_xml(self): + errors = [] + + for xml_file in self.xml_files: + try: + lxml.etree.parse(str(xml_file)) + except lxml.etree.XMLSyntaxError as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {e.lineno}: {e.msg}" + ) + except Exception as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Unexpected error: {str(e)}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} XML violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All XML files are well-formed") + return True + + def validate_namespaces(self): + errors = [] + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + declared = set(root.nsmap.keys()) - {None} + + for attr_val in [ + v for k, v in root.attrib.items() if k.endswith("Ignorable") + ]: + undeclared = set(attr_val.split()) - declared + errors.extend( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Namespace '{ns}' in Ignorable but not declared" + for ns in undeclared + ) + except lxml.etree.XMLSyntaxError: + continue + + if errors: + print(f"FAILED - {len(errors)} namespace issues:") + for error in errors: + print(error) + return False + if self.verbose: + print("PASSED - All namespace prefixes properly declared") + return True + + def validate_unique_ids(self): + errors = [] + global_ids = {} + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + file_ids = {} + + mc_elements = root.xpath( + ".//mc:AlternateContent", namespaces={"mc": self.MC_NAMESPACE} + ) + for elem in mc_elements: + elem.getparent().remove(elem) + + for elem in root.iter(): + tag = ( + elem.tag.split("}")[-1].lower() + if "}" in elem.tag + else elem.tag.lower() + ) + + if tag in self.UNIQUE_ID_REQUIREMENTS: + in_excluded_container = any( + ancestor.tag.split("}")[-1].lower() in self.EXCLUDED_ID_CONTAINERS + for ancestor in elem.iterancestors() + ) + if in_excluded_container: + continue + + attr_name, scope = self.UNIQUE_ID_REQUIREMENTS[tag] + + id_value = None + for attr, value in elem.attrib.items(): + attr_local = ( + attr.split("}")[-1].lower() + if "}" in attr + else attr.lower() + ) + if attr_local == attr_name: + id_value = value + break + + if id_value is not None: + if scope == "global": + if id_value in global_ids: + prev_file, prev_line, prev_tag = global_ids[ + id_value + ] + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: Global ID '{id_value}' in <{tag}> " + f"already used in {prev_file} at line {prev_line} in <{prev_tag}>" + ) + else: + global_ids[id_value] = ( + xml_file.relative_to(self.unpacked_dir), + elem.sourceline, + tag, + ) + elif scope == "file": + key = (tag, attr_name) + if key not in file_ids: + file_ids[key] = {} + + if id_value in file_ids[key]: + prev_line = file_ids[key][id_value] + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: Duplicate {attr_name}='{id_value}' in <{tag}> " + f"(first occurrence at line {prev_line})" + ) + else: + file_ids[key][id_value] = elem.sourceline + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} ID uniqueness violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All required IDs are unique") + return True + + def validate_file_references(self): + errors = [] + + rels_files = list(self.unpacked_dir.rglob("*.rels")) + + if not rels_files: + if self.verbose: + print("PASSED - No .rels files found") + return True + + all_files = [] + for file_path in self.unpacked_dir.rglob("*"): + if ( + file_path.is_file() + and file_path.name != "[Content_Types].xml" + and not file_path.name.endswith(".rels") + ): + all_files.append(file_path.resolve()) + + all_referenced_files = set() + + if self.verbose: + print( + f"Found {len(rels_files)} .rels files and {len(all_files)} target files" + ) + + for rels_file in rels_files: + try: + rels_root = lxml.etree.parse(str(rels_file)).getroot() + + rels_dir = rels_file.parent + + referenced_files = set() + broken_refs = [] + + for rel in rels_root.findall( + ".//ns:Relationship", + namespaces={"ns": self.PACKAGE_RELATIONSHIPS_NAMESPACE}, + ): + target = rel.get("Target") + if target and not target.startswith( + ("http", "mailto:") + ): + if target.startswith("/"): + target_path = self.unpacked_dir / target.lstrip("/") + elif rels_file.name == ".rels": + target_path = self.unpacked_dir / target + else: + base_dir = rels_dir.parent + target_path = base_dir / target + + try: + target_path = target_path.resolve() + if target_path.exists() and target_path.is_file(): + referenced_files.add(target_path) + all_referenced_files.add(target_path) + else: + broken_refs.append((target, rel.sourceline)) + except (OSError, ValueError): + broken_refs.append((target, rel.sourceline)) + + if broken_refs: + rel_path = rels_file.relative_to(self.unpacked_dir) + for broken_ref, line_num in broken_refs: + errors.append( + f" {rel_path}: Line {line_num}: Broken reference to {broken_ref}" + ) + + except Exception as e: + rel_path = rels_file.relative_to(self.unpacked_dir) + errors.append(f" Error parsing {rel_path}: {e}") + + unreferenced_files = set(all_files) - all_referenced_files + + if unreferenced_files: + for unref_file in sorted(unreferenced_files): + unref_rel_path = unref_file.relative_to(self.unpacked_dir) + errors.append(f" Unreferenced file: {unref_rel_path}") + + if errors: + print(f"FAILED - Found {len(errors)} relationship validation errors:") + for error in errors: + print(error) + print( + "CRITICAL: These errors will cause the document to appear corrupt. " + + "Broken references MUST be fixed, " + + "and unreferenced files MUST be referenced or removed." + ) + return False + else: + if self.verbose: + print( + "PASSED - All references are valid and all files are properly referenced" + ) + return True + + def validate_all_relationship_ids(self): + import lxml.etree + + errors = [] + + for xml_file in self.xml_files: + if xml_file.suffix == ".rels": + continue + + rels_dir = xml_file.parent / "_rels" + rels_file = rels_dir / f"{xml_file.name}.rels" + + if not rels_file.exists(): + continue + + try: + rels_root = lxml.etree.parse(str(rels_file)).getroot() + rid_to_type = {} + + for rel in rels_root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rid = rel.get("Id") + rel_type = rel.get("Type", "") + if rid: + if rid in rid_to_type: + rels_rel_path = rels_file.relative_to(self.unpacked_dir) + errors.append( + f" {rels_rel_path}: Line {rel.sourceline}: " + f"Duplicate relationship ID '{rid}' (IDs must be unique)" + ) + type_name = ( + rel_type.split("/")[-1] if "/" in rel_type else rel_type + ) + rid_to_type[rid] = type_name + + xml_root = lxml.etree.parse(str(xml_file)).getroot() + + r_ns = self.OFFICE_RELATIONSHIPS_NAMESPACE + rid_attrs_to_check = ["id", "embed", "link"] + for elem in xml_root.iter(): + for attr_name in rid_attrs_to_check: + rid_attr = elem.get(f"{{{r_ns}}}{attr_name}") + if not rid_attr: + continue + xml_rel_path = xml_file.relative_to(self.unpacked_dir) + elem_name = ( + elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag + ) + + if rid_attr not in rid_to_type: + errors.append( + f" {xml_rel_path}: Line {elem.sourceline}: " + f"<{elem_name}> r:{attr_name} references non-existent relationship '{rid_attr}' " + f"(valid IDs: {', '.join(sorted(rid_to_type.keys())[:5])}{'...' if len(rid_to_type) > 5 else ''})" + ) + elif attr_name == "id" and self.ELEMENT_RELATIONSHIP_TYPES: + expected_type = self._get_expected_relationship_type( + elem_name + ) + if expected_type: + actual_type = rid_to_type[rid_attr] + if expected_type not in actual_type.lower(): + errors.append( + f" {xml_rel_path}: Line {elem.sourceline}: " + f"<{elem_name}> references '{rid_attr}' which points to '{actual_type}' " + f"but should point to a '{expected_type}' relationship" + ) + + except Exception as e: + xml_rel_path = xml_file.relative_to(self.unpacked_dir) + errors.append(f" Error processing {xml_rel_path}: {e}") + + if errors: + print(f"FAILED - Found {len(errors)} relationship ID reference errors:") + for error in errors: + print(error) + print("\nThese ID mismatches will cause the document to appear corrupt!") + return False + else: + if self.verbose: + print("PASSED - All relationship ID references are valid") + return True + + def _get_expected_relationship_type(self, element_name): + elem_lower = element_name.lower() + + if elem_lower in self.ELEMENT_RELATIONSHIP_TYPES: + return self.ELEMENT_RELATIONSHIP_TYPES[elem_lower] + + if elem_lower.endswith("id") and len(elem_lower) > 2: + prefix = elem_lower[:-2] + if prefix.endswith("master"): + return prefix.lower() + elif prefix.endswith("layout"): + return prefix.lower() + else: + if prefix == "sld": + return "slide" + return prefix.lower() + + if elem_lower.endswith("reference") and len(elem_lower) > 9: + prefix = elem_lower[:-9] + return prefix.lower() + + return None + + def validate_content_types(self): + errors = [] + + content_types_file = self.unpacked_dir / "[Content_Types].xml" + if not content_types_file.exists(): + print("FAILED - [Content_Types].xml file not found") + return False + + try: + root = lxml.etree.parse(str(content_types_file)).getroot() + declared_parts = set() + declared_extensions = set() + + for override in root.findall( + f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Override" + ): + part_name = override.get("PartName") + if part_name is not None: + declared_parts.add(part_name.lstrip("/")) + + for default in root.findall( + f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Default" + ): + extension = default.get("Extension") + if extension is not None: + declared_extensions.add(extension.lower()) + + declarable_roots = { + "sld", + "sldLayout", + "sldMaster", + "presentation", + "document", + "workbook", + "worksheet", + "theme", + } + + media_extensions = { + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "gif": "image/gif", + "bmp": "image/bmp", + "tiff": "image/tiff", + "wmf": "image/x-wmf", + "emf": "image/x-emf", + } + + all_files = list(self.unpacked_dir.rglob("*")) + all_files = [f for f in all_files if f.is_file()] + + for xml_file in self.xml_files: + path_str = str(xml_file.relative_to(self.unpacked_dir)).replace( + "\\", "/" + ) + + if any( + skip in path_str + for skip in [".rels", "[Content_Types]", "docProps/", "_rels/"] + ): + continue + + try: + root_tag = lxml.etree.parse(str(xml_file)).getroot().tag + root_name = root_tag.split("}")[-1] if "}" in root_tag else root_tag + + if root_name in declarable_roots and path_str not in declared_parts: + errors.append( + f" {path_str}: File with <{root_name}> root not declared in [Content_Types].xml" + ) + + except Exception: + continue + + for file_path in all_files: + if file_path.suffix.lower() in {".xml", ".rels"}: + continue + if file_path.name == "[Content_Types].xml": + continue + if "_rels" in file_path.parts or "docProps" in file_path.parts: + continue + + extension = file_path.suffix.lstrip(".").lower() + if extension and extension not in declared_extensions: + if extension in media_extensions: + relative_path = file_path.relative_to(self.unpacked_dir) + errors.append( + f' {relative_path}: File with extension \'{extension}\' not declared in [Content_Types].xml - should add: ' + ) + + except Exception as e: + errors.append(f" Error parsing [Content_Types].xml: {e}") + + if errors: + print(f"FAILED - Found {len(errors)} content type declaration errors:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print( + "PASSED - All content files are properly declared in [Content_Types].xml" + ) + return True + + def validate_file_against_xsd(self, xml_file, verbose=False): + xml_file = Path(xml_file).resolve() + unpacked_dir = self.unpacked_dir.resolve() + + is_valid, current_errors = self._validate_single_file_xsd( + xml_file, unpacked_dir + ) + + if is_valid is None: + return None, set() + elif is_valid: + return True, set() + + original_errors = self._get_original_file_errors(xml_file) + + assert current_errors is not None + new_errors = current_errors - original_errors + + new_errors = { + e for e in new_errors + if not any(pattern in e for pattern in self.IGNORED_VALIDATION_ERRORS) + } + + if new_errors: + if verbose: + relative_path = xml_file.relative_to(unpacked_dir) + print(f"FAILED - {relative_path}: {len(new_errors)} new error(s)") + for error in list(new_errors)[:3]: + truncated = error[:250] + "..." if len(error) > 250 else error + print(f" - {truncated}") + return False, new_errors + else: + if verbose: + print( + f"PASSED - No new errors (original had {len(current_errors)} errors)" + ) + return True, set() + + def validate_against_xsd(self): + new_errors = [] + original_error_count = 0 + valid_count = 0 + skipped_count = 0 + + for xml_file in self.xml_files: + relative_path = str(xml_file.relative_to(self.unpacked_dir)) + is_valid, new_file_errors = self.validate_file_against_xsd( + xml_file, verbose=False + ) + + if is_valid is None: + skipped_count += 1 + continue + elif is_valid and not new_file_errors: + valid_count += 1 + continue + elif is_valid: + original_error_count += 1 + valid_count += 1 + continue + + new_errors.append(f" {relative_path}: {len(new_file_errors)} new error(s)") + for error in list(new_file_errors)[:3]: + new_errors.append( + f" - {error[:250]}..." if len(error) > 250 else f" - {error}" + ) + + if self.verbose: + print(f"Validated {len(self.xml_files)} files:") + print(f" - Valid: {valid_count}") + print(f" - Skipped (no schema): {skipped_count}") + if original_error_count: + print(f" - With original errors (ignored): {original_error_count}") + print( + f" - With NEW errors: {len(new_errors) > 0 and len([e for e in new_errors if not e.startswith(' ')]) or 0}" + ) + + if new_errors: + print("\nFAILED - Found NEW validation errors:") + for error in new_errors: + print(error) + return False + else: + if self.verbose: + print("\nPASSED - No new XSD validation errors introduced") + return True + + def _get_schema_path(self, xml_file): + if xml_file.name in self.SCHEMA_MAPPINGS: + return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.name] + + if xml_file.suffix == ".rels": + return self.schemas_dir / self.SCHEMA_MAPPINGS[".rels"] + + if "charts/" in str(xml_file) and xml_file.name.startswith("chart"): + return self.schemas_dir / self.SCHEMA_MAPPINGS["chart"] + + if "theme/" in str(xml_file) and xml_file.name.startswith("theme"): + return self.schemas_dir / self.SCHEMA_MAPPINGS["theme"] + + if xml_file.parent.name in self.MAIN_CONTENT_FOLDERS: + return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.parent.name] + + return None + + def _clean_ignorable_namespaces(self, xml_doc): + xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") + xml_copy = lxml.etree.fromstring(xml_string) + + for elem in xml_copy.iter(): + attrs_to_remove = [] + + for attr in elem.attrib: + if "{" in attr: + ns = attr.split("}")[0][1:] + if ns not in self.OOXML_NAMESPACES: + attrs_to_remove.append(attr) + + for attr in attrs_to_remove: + del elem.attrib[attr] + + self._remove_ignorable_elements(xml_copy) + + return lxml.etree.ElementTree(xml_copy) + + def _remove_ignorable_elements(self, root): + elements_to_remove = [] + + for elem in list(root): + if not hasattr(elem, "tag") or callable(elem.tag): + continue + + tag_str = str(elem.tag) + if tag_str.startswith("{"): + ns = tag_str.split("}")[0][1:] + if ns not in self.OOXML_NAMESPACES: + elements_to_remove.append(elem) + continue + + self._remove_ignorable_elements(elem) + + for elem in elements_to_remove: + root.remove(elem) + + def _preprocess_for_mc_ignorable(self, xml_doc): + root = xml_doc.getroot() + + if f"{{{self.MC_NAMESPACE}}}Ignorable" in root.attrib: + del root.attrib[f"{{{self.MC_NAMESPACE}}}Ignorable"] + + return xml_doc + + def _validate_single_file_xsd(self, xml_file, base_path): + schema_path = self._get_schema_path(xml_file) + if not schema_path: + return None, None + + try: + with open(schema_path, "rb") as xsd_file: + parser = lxml.etree.XMLParser() + xsd_doc = lxml.etree.parse( + xsd_file, parser=parser, base_url=str(schema_path) + ) + schema = lxml.etree.XMLSchema(xsd_doc) + + with open(xml_file, "r") as f: + xml_doc = lxml.etree.parse(f) + + xml_doc, _ = self._remove_template_tags_from_text_nodes(xml_doc) + xml_doc = self._preprocess_for_mc_ignorable(xml_doc) + + relative_path = xml_file.relative_to(base_path) + if ( + relative_path.parts + and relative_path.parts[0] in self.MAIN_CONTENT_FOLDERS + ): + xml_doc = self._clean_ignorable_namespaces(xml_doc) + + if schema.validate(xml_doc): + return True, set() + else: + errors = set() + for error in schema.error_log: + errors.add(error.message) + return False, errors + + except Exception as e: + return False, {str(e)} + + def _get_original_file_errors(self, xml_file): + if self.original_file is None: + return set() + + import tempfile + import zipfile + + xml_file = Path(xml_file).resolve() + unpacked_dir = self.unpacked_dir.resolve() + relative_path = xml_file.relative_to(unpacked_dir) + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + with zipfile.ZipFile(self.original_file, "r") as zip_ref: + zip_ref.extractall(temp_path) + + original_xml_file = temp_path / relative_path + + if not original_xml_file.exists(): + return set() + + is_valid, errors = self._validate_single_file_xsd( + original_xml_file, temp_path + ) + return errors if errors else set() + + def _remove_template_tags_from_text_nodes(self, xml_doc): + warnings = [] + template_pattern = re.compile(r"\{\{[^}]*\}\}") + + xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") + xml_copy = lxml.etree.fromstring(xml_string) + + def process_text_content(text, content_type): + if not text: + return text + matches = list(template_pattern.finditer(text)) + if matches: + for match in matches: + warnings.append( + f"Found template tag in {content_type}: {match.group()}" + ) + return template_pattern.sub("", text) + return text + + for elem in xml_copy.iter(): + if not hasattr(elem, "tag") or callable(elem.tag): + continue + tag_str = str(elem.tag) + if tag_str.endswith("}t") or tag_str == "t": + continue + + elem.text = process_text_content(elem.text, "text content") + elem.tail = process_text_content(elem.tail, "tail content") + + return lxml.etree.ElementTree(xml_copy), warnings + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/.agents/skills/xlsx/scripts/office/validators/docx.py b/.agents/skills/xlsx/scripts/office/validators/docx.py new file mode 100644 index 00000000..fec405e6 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/validators/docx.py @@ -0,0 +1,446 @@ +""" +Validator for Word document XML files against XSD schemas. +""" + +import random +import re +import tempfile +import zipfile + +import defusedxml.minidom +import lxml.etree + +from .base import BaseSchemaValidator + + +class DOCXSchemaValidator(BaseSchemaValidator): + + WORD_2006_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + W14_NAMESPACE = "http://schemas.microsoft.com/office/word/2010/wordml" + W16CID_NAMESPACE = "http://schemas.microsoft.com/office/word/2016/wordml/cid" + + ELEMENT_RELATIONSHIP_TYPES = {} + + def validate(self): + if not self.validate_xml(): + return False + + all_valid = True + if not self.validate_namespaces(): + all_valid = False + + if not self.validate_unique_ids(): + all_valid = False + + if not self.validate_file_references(): + all_valid = False + + if not self.validate_content_types(): + all_valid = False + + if not self.validate_against_xsd(): + all_valid = False + + if not self.validate_whitespace_preservation(): + all_valid = False + + if not self.validate_deletions(): + all_valid = False + + if not self.validate_insertions(): + all_valid = False + + if not self.validate_all_relationship_ids(): + all_valid = False + + if not self.validate_id_constraints(): + all_valid = False + + if not self.validate_comment_markers(): + all_valid = False + + self.compare_paragraph_counts() + + return all_valid + + def validate_whitespace_preservation(self): + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + + for elem in root.iter(f"{{{self.WORD_2006_NAMESPACE}}}t"): + if elem.text: + text = elem.text + if re.search(r"^[ \t\n\r]", text) or re.search( + r"[ \t\n\r]$", text + ): + xml_space_attr = f"{{{self.XML_NAMESPACE}}}space" + if ( + xml_space_attr not in elem.attrib + or elem.attrib[xml_space_attr] != "preserve" + ): + text_preview = ( + repr(text)[:50] + "..." + if len(repr(text)) > 50 + else repr(text) + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: w:t element with whitespace missing xml:space='preserve': {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} whitespace preservation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All whitespace is properly preserved") + return True + + def validate_deletions(self): + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + for t_elem in root.xpath(".//w:del//w:t", namespaces=namespaces): + if t_elem.text: + text_preview = ( + repr(t_elem.text)[:50] + "..." + if len(repr(t_elem.text)) > 50 + else repr(t_elem.text) + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {t_elem.sourceline}: found within : {text_preview}" + ) + + for instr_elem in root.xpath( + ".//w:del//w:instrText", namespaces=namespaces + ): + text_preview = ( + repr(instr_elem.text or "")[:50] + "..." + if len(repr(instr_elem.text or "")) > 50 + else repr(instr_elem.text or "") + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {instr_elem.sourceline}: found within (use ): {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} deletion validation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - No w:t elements found within w:del elements") + return True + + def count_paragraphs_in_unpacked(self): + count = 0 + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") + count = len(paragraphs) + except Exception as e: + print(f"Error counting paragraphs in unpacked document: {e}") + + return count + + def count_paragraphs_in_original(self): + original = self.original_file + if original is None: + return 0 + + count = 0 + + try: + with tempfile.TemporaryDirectory() as temp_dir: + with zipfile.ZipFile(original, "r") as zip_ref: + zip_ref.extractall(temp_dir) + + doc_xml_path = temp_dir + "/word/document.xml" + root = lxml.etree.parse(doc_xml_path).getroot() + + paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") + count = len(paragraphs) + + except Exception as e: + print(f"Error counting paragraphs in original document: {e}") + + return count + + def validate_insertions(self): + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + invalid_elements = root.xpath( + ".//w:ins//w:delText[not(ancestor::w:del)]", namespaces=namespaces + ) + + for elem in invalid_elements: + text_preview = ( + repr(elem.text or "")[:50] + "..." + if len(repr(elem.text or "")) > 50 + else repr(elem.text or "") + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: within : {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} insertion validation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - No w:delText elements within w:ins elements") + return True + + def compare_paragraph_counts(self): + original_count = self.count_paragraphs_in_original() + new_count = self.count_paragraphs_in_unpacked() + + diff = new_count - original_count + diff_str = f"+{diff}" if diff > 0 else str(diff) + print(f"\nParagraphs: {original_count} → {new_count} ({diff_str})") + + def _parse_id_value(self, val: str, base: int = 16) -> int: + return int(val, base) + + def validate_id_constraints(self): + errors = [] + para_id_attr = f"{{{self.W14_NAMESPACE}}}paraId" + durable_id_attr = f"{{{self.W16CID_NAMESPACE}}}durableId" + + for xml_file in self.xml_files: + try: + for elem in lxml.etree.parse(str(xml_file)).iter(): + if val := elem.get(para_id_attr): + if self._parse_id_value(val, base=16) >= 0x80000000: + errors.append( + f" {xml_file.name}:{elem.sourceline}: paraId={val} >= 0x80000000" + ) + + if val := elem.get(durable_id_attr): + if xml_file.name == "numbering.xml": + try: + if self._parse_id_value(val, base=10) >= 0x7FFFFFFF: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} >= 0x7FFFFFFF" + ) + except ValueError: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} must be decimal in numbering.xml" + ) + else: + if self._parse_id_value(val, base=16) >= 0x7FFFFFFF: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} >= 0x7FFFFFFF" + ) + except Exception: + pass + + if errors: + print(f"FAILED - {len(errors)} ID constraint violations:") + for e in errors: + print(e) + elif self.verbose: + print("PASSED - All paraId/durableId values within constraints") + return not errors + + def validate_comment_markers(self): + errors = [] + + document_xml = None + comments_xml = None + for xml_file in self.xml_files: + if xml_file.name == "document.xml" and "word" in str(xml_file): + document_xml = xml_file + elif xml_file.name == "comments.xml": + comments_xml = xml_file + + if not document_xml: + if self.verbose: + print("PASSED - No document.xml found (skipping comment validation)") + return True + + try: + doc_root = lxml.etree.parse(str(document_xml)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + range_starts = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in doc_root.xpath( + ".//w:commentRangeStart", namespaces=namespaces + ) + } + range_ends = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in doc_root.xpath( + ".//w:commentRangeEnd", namespaces=namespaces + ) + } + references = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in doc_root.xpath( + ".//w:commentReference", namespaces=namespaces + ) + } + + orphaned_ends = range_ends - range_starts + for comment_id in sorted( + orphaned_ends, key=lambda x: int(x) if x and x.isdigit() else 0 + ): + errors.append( + f' document.xml: commentRangeEnd id="{comment_id}" has no matching commentRangeStart' + ) + + orphaned_starts = range_starts - range_ends + for comment_id in sorted( + orphaned_starts, key=lambda x: int(x) if x and x.isdigit() else 0 + ): + errors.append( + f' document.xml: commentRangeStart id="{comment_id}" has no matching commentRangeEnd' + ) + + comment_ids = set() + if comments_xml and comments_xml.exists(): + comments_root = lxml.etree.parse(str(comments_xml)).getroot() + comment_ids = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in comments_root.xpath( + ".//w:comment", namespaces=namespaces + ) + } + + marker_ids = range_starts | range_ends | references + invalid_refs = marker_ids - comment_ids + for comment_id in sorted( + invalid_refs, key=lambda x: int(x) if x and x.isdigit() else 0 + ): + if comment_id: + errors.append( + f' document.xml: marker id="{comment_id}" references non-existent comment' + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append(f" Error parsing XML: {e}") + + if errors: + print(f"FAILED - {len(errors)} comment marker violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All comment markers properly paired") + return True + + def repair(self) -> int: + repairs = super().repair() + repairs += self.repair_durableId() + return repairs + + def repair_durableId(self) -> int: + repairs = 0 + + for xml_file in self.xml_files: + try: + content = xml_file.read_text(encoding="utf-8") + dom = defusedxml.minidom.parseString(content) + modified = False + + for elem in dom.getElementsByTagName("*"): + if not elem.hasAttribute("w16cid:durableId"): + continue + + durable_id = elem.getAttribute("w16cid:durableId") + needs_repair = False + + if xml_file.name == "numbering.xml": + try: + needs_repair = ( + self._parse_id_value(durable_id, base=10) >= 0x7FFFFFFF + ) + except ValueError: + needs_repair = True + else: + try: + needs_repair = ( + self._parse_id_value(durable_id, base=16) >= 0x7FFFFFFF + ) + except ValueError: + needs_repair = True + + if needs_repair: + value = random.randint(1, 0x7FFFFFFE) + if xml_file.name == "numbering.xml": + new_id = str(value) + else: + new_id = f"{value:08X}" + + elem.setAttribute("w16cid:durableId", new_id) + print( + f" Repaired: {xml_file.name}: durableId {durable_id} → {new_id}" + ) + repairs += 1 + modified = True + + if modified: + xml_file.write_bytes(dom.toxml(encoding="UTF-8")) + + except Exception: + pass + + return repairs + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/.agents/skills/xlsx/scripts/office/validators/pptx.py b/.agents/skills/xlsx/scripts/office/validators/pptx.py new file mode 100644 index 00000000..09842aa9 --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/validators/pptx.py @@ -0,0 +1,275 @@ +""" +Validator for PowerPoint presentation XML files against XSD schemas. +""" + +import re + +from .base import BaseSchemaValidator + + +class PPTXSchemaValidator(BaseSchemaValidator): + + PRESENTATIONML_NAMESPACE = ( + "http://schemas.openxmlformats.org/presentationml/2006/main" + ) + + ELEMENT_RELATIONSHIP_TYPES = { + "sldid": "slide", + "sldmasterid": "slidemaster", + "notesmasterid": "notesmaster", + "sldlayoutid": "slidelayout", + "themeid": "theme", + "tablestyleid": "tablestyles", + } + + def validate(self): + if not self.validate_xml(): + return False + + all_valid = True + if not self.validate_namespaces(): + all_valid = False + + if not self.validate_unique_ids(): + all_valid = False + + if not self.validate_uuid_ids(): + all_valid = False + + if not self.validate_file_references(): + all_valid = False + + if not self.validate_slide_layout_ids(): + all_valid = False + + if not self.validate_content_types(): + all_valid = False + + if not self.validate_against_xsd(): + all_valid = False + + if not self.validate_notes_slide_references(): + all_valid = False + + if not self.validate_all_relationship_ids(): + all_valid = False + + if not self.validate_no_duplicate_slide_layouts(): + all_valid = False + + return all_valid + + def validate_uuid_ids(self): + import lxml.etree + + errors = [] + uuid_pattern = re.compile( + r"^[\{\(]?[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{12}[\}\)]?$" + ) + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + + for elem in root.iter(): + for attr, value in elem.attrib.items(): + attr_name = attr.split("}")[-1].lower() + if attr_name == "id" or attr_name.endswith("id"): + if self._looks_like_uuid(value): + if not uuid_pattern.match(value): + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: ID '{value}' appears to be a UUID but contains invalid hex characters" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} UUID ID validation errors:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All UUID-like IDs contain valid hex values") + return True + + def _looks_like_uuid(self, value): + clean_value = value.strip("{}()").replace("-", "") + return len(clean_value) == 32 and all(c.isalnum() for c in clean_value) + + def validate_slide_layout_ids(self): + import lxml.etree + + errors = [] + + slide_masters = list(self.unpacked_dir.glob("ppt/slideMasters/*.xml")) + + if not slide_masters: + if self.verbose: + print("PASSED - No slide masters found") + return True + + for slide_master in slide_masters: + try: + root = lxml.etree.parse(str(slide_master)).getroot() + + rels_file = slide_master.parent / "_rels" / f"{slide_master.name}.rels" + + if not rels_file.exists(): + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: " + f"Missing relationships file: {rels_file.relative_to(self.unpacked_dir)}" + ) + continue + + rels_root = lxml.etree.parse(str(rels_file)).getroot() + + valid_layout_rids = set() + for rel in rels_root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rel_type = rel.get("Type", "") + if "slideLayout" in rel_type: + valid_layout_rids.add(rel.get("Id")) + + for sld_layout_id in root.findall( + f".//{{{self.PRESENTATIONML_NAMESPACE}}}sldLayoutId" + ): + r_id = sld_layout_id.get( + f"{{{self.OFFICE_RELATIONSHIPS_NAMESPACE}}}id" + ) + layout_id = sld_layout_id.get("id") + + if r_id and r_id not in valid_layout_rids: + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: " + f"Line {sld_layout_id.sourceline}: sldLayoutId with id='{layout_id}' " + f"references r:id='{r_id}' which is not found in slide layout relationships" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} slide layout ID validation errors:") + for error in errors: + print(error) + print( + "Remove invalid references or add missing slide layouts to the relationships file." + ) + return False + else: + if self.verbose: + print("PASSED - All slide layout IDs reference valid slide layouts") + return True + + def validate_no_duplicate_slide_layouts(self): + import lxml.etree + + errors = [] + slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) + + for rels_file in slide_rels_files: + try: + root = lxml.etree.parse(str(rels_file)).getroot() + + layout_rels = [ + rel + for rel in root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ) + if "slideLayout" in rel.get("Type", "") + ] + + if len(layout_rels) > 1: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: has {len(layout_rels)} slideLayout references" + ) + + except Exception as e: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print("FAILED - Found slides with duplicate slideLayout references:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All slides have exactly one slideLayout reference") + return True + + def validate_notes_slide_references(self): + import lxml.etree + + errors = [] + notes_slide_references = {} + + slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) + + if not slide_rels_files: + if self.verbose: + print("PASSED - No slide relationship files found") + return True + + for rels_file in slide_rels_files: + try: + root = lxml.etree.parse(str(rels_file)).getroot() + + for rel in root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rel_type = rel.get("Type", "") + if "notesSlide" in rel_type: + target = rel.get("Target", "") + if target: + normalized_target = target.replace("../", "") + + slide_name = rels_file.stem.replace( + ".xml", "" + ) + + if normalized_target not in notes_slide_references: + notes_slide_references[normalized_target] = [] + notes_slide_references[normalized_target].append( + (slide_name, rels_file) + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + for target, references in notes_slide_references.items(): + if len(references) > 1: + slide_names = [ref[0] for ref in references] + errors.append( + f" Notes slide '{target}' is referenced by multiple slides: {', '.join(slide_names)}" + ) + for slide_name, rels_file in references: + errors.append(f" - {rels_file.relative_to(self.unpacked_dir)}") + + if errors: + print( + f"FAILED - Found {len([e for e in errors if not e.startswith(' ')])} notes slide reference validation errors:" + ) + for error in errors: + print(error) + print("Each slide may optionally have its own slide file.") + return False + else: + if self.verbose: + print("PASSED - All notes slide references are unique") + return True + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/.agents/skills/xlsx/scripts/office/validators/redlining.py b/.agents/skills/xlsx/scripts/office/validators/redlining.py new file mode 100644 index 00000000..71c81b6b --- /dev/null +++ b/.agents/skills/xlsx/scripts/office/validators/redlining.py @@ -0,0 +1,247 @@ +""" +Validator for tracked changes in Word documents. +""" + +import subprocess +import tempfile +import zipfile +from pathlib import Path + + +class RedliningValidator: + + def __init__(self, unpacked_dir, original_docx, verbose=False, author="Claude"): + self.unpacked_dir = Path(unpacked_dir) + self.original_docx = Path(original_docx) + self.verbose = verbose + self.author = author + self.namespaces = { + "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + } + + def repair(self) -> int: + return 0 + + def validate(self): + modified_file = self.unpacked_dir / "word" / "document.xml" + if not modified_file.exists(): + print(f"FAILED - Modified document.xml not found at {modified_file}") + return False + + try: + import xml.etree.ElementTree as ET + + tree = ET.parse(modified_file) + root = tree.getroot() + + del_elements = root.findall(".//w:del", self.namespaces) + ins_elements = root.findall(".//w:ins", self.namespaces) + + author_del_elements = [ + elem + for elem in del_elements + if elem.get(f"{{{self.namespaces['w']}}}author") == self.author + ] + author_ins_elements = [ + elem + for elem in ins_elements + if elem.get(f"{{{self.namespaces['w']}}}author") == self.author + ] + + if not author_del_elements and not author_ins_elements: + if self.verbose: + print(f"PASSED - No tracked changes by {self.author} found.") + return True + + except Exception: + pass + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + try: + with zipfile.ZipFile(self.original_docx, "r") as zip_ref: + zip_ref.extractall(temp_path) + except Exception as e: + print(f"FAILED - Error unpacking original docx: {e}") + return False + + original_file = temp_path / "word" / "document.xml" + if not original_file.exists(): + print( + f"FAILED - Original document.xml not found in {self.original_docx}" + ) + return False + + try: + import xml.etree.ElementTree as ET + + modified_tree = ET.parse(modified_file) + modified_root = modified_tree.getroot() + original_tree = ET.parse(original_file) + original_root = original_tree.getroot() + except ET.ParseError as e: + print(f"FAILED - Error parsing XML files: {e}") + return False + + self._remove_author_tracked_changes(original_root) + self._remove_author_tracked_changes(modified_root) + + modified_text = self._extract_text_content(modified_root) + original_text = self._extract_text_content(original_root) + + if modified_text != original_text: + error_message = self._generate_detailed_diff( + original_text, modified_text + ) + print(error_message) + return False + + if self.verbose: + print(f"PASSED - All changes by {self.author} are properly tracked") + return True + + def _generate_detailed_diff(self, original_text, modified_text): + error_parts = [ + f"FAILED - Document text doesn't match after removing {self.author}'s tracked changes", + "", + "Likely causes:", + " 1. Modified text inside another author's or tags", + " 2. Made edits without proper tracked changes", + " 3. Didn't nest inside when deleting another's insertion", + "", + "For pre-redlined documents, use correct patterns:", + " - To reject another's INSERTION: Nest inside their ", + " - To restore another's DELETION: Add new AFTER their ", + "", + ] + + git_diff = self._get_git_word_diff(original_text, modified_text) + if git_diff: + error_parts.extend(["Differences:", "============", git_diff]) + else: + error_parts.append("Unable to generate word diff (git not available)") + + return "\n".join(error_parts) + + def _get_git_word_diff(self, original_text, modified_text): + try: + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + original_file = temp_path / "original.txt" + modified_file = temp_path / "modified.txt" + + original_file.write_text(original_text, encoding="utf-8") + modified_file.write_text(modified_text, encoding="utf-8") + + result = subprocess.run( + [ + "git", + "diff", + "--word-diff=plain", + "--word-diff-regex=.", + "-U0", + "--no-index", + str(original_file), + str(modified_file), + ], + capture_output=True, + text=True, + ) + + if result.stdout.strip(): + lines = result.stdout.split("\n") + content_lines = [] + in_content = False + for line in lines: + if line.startswith("@@"): + in_content = True + continue + if in_content and line.strip(): + content_lines.append(line) + + if content_lines: + return "\n".join(content_lines) + + result = subprocess.run( + [ + "git", + "diff", + "--word-diff=plain", + "-U0", + "--no-index", + str(original_file), + str(modified_file), + ], + capture_output=True, + text=True, + ) + + if result.stdout.strip(): + lines = result.stdout.split("\n") + content_lines = [] + in_content = False + for line in lines: + if line.startswith("@@"): + in_content = True + continue + if in_content and line.strip(): + content_lines.append(line) + return "\n".join(content_lines) + + except (subprocess.CalledProcessError, FileNotFoundError, Exception): + pass + + return None + + def _remove_author_tracked_changes(self, root): + ins_tag = f"{{{self.namespaces['w']}}}ins" + del_tag = f"{{{self.namespaces['w']}}}del" + author_attr = f"{{{self.namespaces['w']}}}author" + + for parent in root.iter(): + to_remove = [] + for child in parent: + if child.tag == ins_tag and child.get(author_attr) == self.author: + to_remove.append(child) + for elem in to_remove: + parent.remove(elem) + + deltext_tag = f"{{{self.namespaces['w']}}}delText" + t_tag = f"{{{self.namespaces['w']}}}t" + + for parent in root.iter(): + to_process = [] + for child in parent: + if child.tag == del_tag and child.get(author_attr) == self.author: + to_process.append((child, list(parent).index(child))) + + for del_elem, del_index in reversed(to_process): + for elem in del_elem.iter(): + if elem.tag == deltext_tag: + elem.tag = t_tag + + for child in reversed(list(del_elem)): + parent.insert(del_index, child) + parent.remove(del_elem) + + def _extract_text_content(self, root): + p_tag = f"{{{self.namespaces['w']}}}p" + t_tag = f"{{{self.namespaces['w']}}}t" + + paragraphs = [] + for p_elem in root.findall(f".//{p_tag}"): + text_parts = [] + for t_elem in p_elem.findall(f".//{t_tag}"): + if t_elem.text: + text_parts.append(t_elem.text) + paragraph_text = "".join(text_parts) + if paragraph_text: + paragraphs.append(paragraph_text) + + return "\n".join(paragraphs) + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/.agents/skills/xlsx/scripts/recalc.py b/.agents/skills/xlsx/scripts/recalc.py new file mode 100644 index 00000000..f472e9a5 --- /dev/null +++ b/.agents/skills/xlsx/scripts/recalc.py @@ -0,0 +1,184 @@ +""" +Excel Formula Recalculation Script +Recalculates all formulas in an Excel file using LibreOffice +""" + +import json +import os +import platform +import subprocess +import sys +from pathlib import Path + +from office.soffice import get_soffice_env + +from openpyxl import load_workbook + +MACRO_DIR_MACOS = "~/Library/Application Support/LibreOffice/4/user/basic/Standard" +MACRO_DIR_LINUX = "~/.config/libreoffice/4/user/basic/Standard" +MACRO_FILENAME = "Module1.xba" + +RECALCULATE_MACRO = """ + + + Sub RecalculateAndSave() + ThisComponent.calculateAll() + ThisComponent.store() + ThisComponent.close(True) + End Sub +""" + + +def has_gtimeout(): + try: + subprocess.run( + ["gtimeout", "--version"], capture_output=True, timeout=1, check=False + ) + return True + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def setup_libreoffice_macro(): + macro_dir = os.path.expanduser( + MACRO_DIR_MACOS if platform.system() == "Darwin" else MACRO_DIR_LINUX + ) + macro_file = os.path.join(macro_dir, MACRO_FILENAME) + + if ( + os.path.exists(macro_file) + and "RecalculateAndSave" in Path(macro_file).read_text() + ): + return True + + if not os.path.exists(macro_dir): + subprocess.run( + ["soffice", "--headless", "--terminate_after_init"], + capture_output=True, + timeout=10, + env=get_soffice_env(), + ) + os.makedirs(macro_dir, exist_ok=True) + + try: + Path(macro_file).write_text(RECALCULATE_MACRO) + return True + except Exception: + return False + + +def recalc(filename, timeout=30): + if not Path(filename).exists(): + return {"error": f"File {filename} does not exist"} + + abs_path = str(Path(filename).absolute()) + + if not setup_libreoffice_macro(): + return {"error": "Failed to setup LibreOffice macro"} + + cmd = [ + "soffice", + "--headless", + "--norestore", + "vnd.sun.star.script:Standard.Module1.RecalculateAndSave?language=Basic&location=application", + abs_path, + ] + + if platform.system() == "Linux": + cmd = ["timeout", str(timeout)] + cmd + elif platform.system() == "Darwin" and has_gtimeout(): + cmd = ["gtimeout", str(timeout)] + cmd + + result = subprocess.run(cmd, capture_output=True, text=True, env=get_soffice_env()) + + if result.returncode != 0 and result.returncode != 124: + error_msg = result.stderr or "Unknown error during recalculation" + if "Module1" in error_msg or "RecalculateAndSave" not in error_msg: + return {"error": "LibreOffice macro not configured properly"} + return {"error": error_msg} + + try: + wb = load_workbook(filename, data_only=True) + + excel_errors = [ + "#VALUE!", + "#DIV/0!", + "#REF!", + "#NAME?", + "#NULL!", + "#NUM!", + "#N/A", + ] + error_details = {err: [] for err in excel_errors} + total_errors = 0 + + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + for row in ws.iter_rows(): + for cell in row: + if cell.value is not None and isinstance(cell.value, str): + for err in excel_errors: + if err in cell.value: + location = f"{sheet_name}!{cell.coordinate}" + error_details[err].append(location) + total_errors += 1 + break + + wb.close() + + result = { + "status": "success" if total_errors == 0 else "errors_found", + "total_errors": total_errors, + "error_summary": {}, + } + + for err_type, locations in error_details.items(): + if locations: + result["error_summary"][err_type] = { + "count": len(locations), + "locations": locations[:20], + } + + wb_formulas = load_workbook(filename, data_only=False) + formula_count = 0 + for sheet_name in wb_formulas.sheetnames: + ws = wb_formulas[sheet_name] + for row in ws.iter_rows(): + for cell in row: + if ( + cell.value + and isinstance(cell.value, str) + and cell.value.startswith("=") + ): + formula_count += 1 + wb_formulas.close() + + result["total_formulas"] = formula_count + + return result + + except Exception as e: + return {"error": str(e)} + + +def main(): + if len(sys.argv) < 2: + print("Usage: python recalc.py [timeout_seconds]") + print("\nRecalculates all formulas in an Excel file using LibreOffice") + print("\nReturns JSON with error details:") + print(" - status: 'success' or 'errors_found'") + print(" - total_errors: Total number of Excel errors found") + print(" - total_formulas: Number of formulas in the file") + print(" - error_summary: Breakdown by error type with locations") + print(" - #VALUE!, #DIV/0!, #REF!, #NAME?, #NULL!, #NUM!, #N/A") + sys.exit(1) + + filename = sys.argv[1] + timeout = int(sys.argv[2]) if len(sys.argv) > 2 else 30 + + result = recalc(filename, timeout) + print(json.dumps(result, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..eda61f30 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,188 @@ +# OpenHIS - AI Agent Development Guide + +## 项目概览 +OpenHIS 是一个医院管理系统,采用 Java 17 + Spring Boot 后端和 Vue 3 + Vite 前端架构。 + +## 构建和运行命令 + +### 后端(Java/Spring Boot) +```bash +# 构建整个项目 +cd openhis-server-new +mvn clean package -DskipTests + +# 运行后端(开发模式) +cd openhis-server-new/openhis-application +mvn spring-boot:run + +# 运行特定模块 +cd openhis-server-new/[module-name] +mvn spring-boot:run +``` + +### 前端(Vue 3 + Vite) +```bash +# 安装依赖 +cd openhis-ui-vue3 +npm install + +# 开发服务器 +npm run dev + +# 生产构建 +npm run build:prod + +# 测试环境构建 +npm run build:test + +# 预览构建结果 +npm run preview +``` + +### 测试 +项目当前没有配置正式的测试框架。如需添加测试: +- 后端:考虑使用 JUnit 5 + Mockito +- 前端:考虑使用 Vitest + Vue Test Utils + +## 代码风格规范 + +### Java 后端规范 +- **Java 版本**: 17 +- **框架**: Spring Boot 2.5.15 +- **ORM**: MyBatis Plus 3.5.5 +- **数据库**: PostgreSQL +- **包结构**: + - `com.openhis` - 业务逻辑 + - `com.core` - 核心框架 +- **命名约定**: + - 类名:PascalCase(如 `UserController`) + - 方法名:camelCase(如 `getUserList`) + - 常量:SCREAMING_SNAKE_CASE + - 配置文件:kebab-case +- **注解使用**: + - 使用 `@Slf4j` 替代手动声明 logger + - 使用 `@Data` 在实体类中 + - 使用 `@Service/@Controller/@Repository` 等 Spring 注解 +- **异常处理**: + - 使用统一的异常处理机制 + - 自定义业务异常继承 `RuntimeException` + +### Vue 前端规范 +- **框架**: Vue 3 + Composition API +- **UI 库**: Element Plus +- **状态管理**: Pinia +- **路由**: Vue Router 4 +- **构建工具**: Vite 5 +- **组件命名**: PascalCase +- **文件命名**: kebab-case +- **变量命名**: camelCase +- **常量命名**: SCREAMING_SNAKE_CASE +- **函数命名**: + - 事件处理:`handle` 前缀 + - 数据获取:`get`/`load` 前缀 + - 提交操作:`submit` 前缀 + +### 导入顺序 +#### Java +1. `java.*` +2. `javax.*` +3. 第三方库 +4. `com.core.*` +5. `com.openhis.*` +6. `*.*`(其他包) + +#### JavaScript/Vue +1. `vue` 相关 +2. 第三方库 +3. `@/` 别名导入 +4. 相对路径导入 + +### 代码格式 +#### Java +- 缩进:4个空格 +- 行长度:120字符 +- 左大括号不换行 + +#### Vue/JavaScript +- 缩进:2个空格 +- 字符串:优先使用单引号 +- 行长度:100字符 + +## 关键配置文件 + +### 后端配置 +- 主配置:`openhis-server-new/openhis-application/src/main/resources/application.yml` +- 环境配置:`application-{profile}.yml` +- Maven 父 POM:`openhis-server-new/pom.xml` + +### 前端配置 +- Vite 配置:`openhis-ui-vue3/vite.config.js` +- 环境变量:`.env.*` 文件 +- 路由配置:`openhis-ui-vue3/src/router/index.js` + +## 开发约定 + +### API 设计 +- RESTful API 风格 +- 统一响应格式 +- 使用 Swagger 文档 +- 错误码统一管理 + +### 数据库 +- 表名:snake_case +- 字段名:snake_case +- 主键:使用 `id` +- 软删除:使用 `valid_flag` 字段 + +### 前端组件 +- 单一职责原则 +- Props 使用 camelCase +- Events 使用 kebab-case +- 使用 Composition API +- 组件文档使用 JSDoc + +### 状态管理 +- 模块化设计 +- 异步操作使用 actions +- 避免在组件中直接修改状态 + +## 环境变量 + +### 前端 +- `VITE_APP_BASE_API`: API 基础路径 +- `VITE_APP_ENV`: 环境标识 + +### 后端 +- `spring.profiles.active`: 激活的配置文件 +- `core.name`: 应用名称 +- `core.version`: 应用版本 + +## 安全规范 +- 所有 API 接口需要权限验证 +- 敏感信息使用环境变量 +- SQL 注入防护 +- XSS 攻击防护 + +## 性能优化 +- 后端使用连接池(Druid) +- 前端使用路由懒加载 +- 图片使用 WebP 格式 +- 大列表使用虚拟滚动 + +## 常用工具类 +- 后端:`com.core.common.utils.*` +- 前端:`@/utils/*` + +## 注意事项 +1. 修改数据库结构需要同步 SQL 脚本 +2. 新增功能需要添加权限配置 +3. 前端路由需要在权限系统中注册 +4. 接口变更需要更新 Swagger 文档 +5. 遵循现有代码风格,避免不必要的变化 + +## 故障排除 +- 后端端口:18080 +- 前端端口:81 +- API 前缀:`/openhis` +- Swagger UI:`/openhis/swagger-ui/index.html` +- Druid 监控:`/openhis/druid/login.html` \ No newline at end of file diff --git a/add_missing_fields_to_sys_user_config.sql b/add_missing_fields_to_sys_user_config.sql new file mode 100644 index 00000000..70fe1825 --- /dev/null +++ b/add_missing_fields_to_sys_user_config.sql @@ -0,0 +1,13 @@ +-- 添加缺失的字段到 sys_user_config 表 + +-- 添加 create_by 字段 +ALTER TABLE sys_user_config ADD COLUMN IF NOT EXISTS create_by VARCHAR(64) DEFAULT NULL COMMENT '创建者'; + +-- 添加 create_time 字段 +ALTER TABLE sys_user_config ADD COLUMN IF NOT EXISTS create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'; + +-- 添加 update_by 字段 +ALTER TABLE sys_user_config ADD COLUMN IF NOT EXISTS update_by VARCHAR(64) DEFAULT NULL COMMENT '更新者'; + +-- 添加 update_time 字段 +ALTER TABLE sys_user_config ADD COLUMN IF NOT EXISTS update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; \ No newline at end of file diff --git a/add_missing_fields_to_sys_user_config_postgresql.sql b/add_missing_fields_to_sys_user_config_postgresql.sql new file mode 100644 index 00000000..916b917c --- /dev/null +++ b/add_missing_fields_to_sys_user_config_postgresql.sql @@ -0,0 +1,39 @@ +-- 添加缺失的字段到 sys_user_config 表 (PostgreSQL 版本) + +-- 添加 create_by 字段 +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name='sys_user_config' AND column_name='create_by') THEN + ALTER TABLE sys_user_config ADD COLUMN create_by VARCHAR(64) DEFAULT NULL; + END IF; +END $$; + +-- 添加 create_time 字段 +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name='sys_user_config' AND column_name='create_time') THEN + ALTER TABLE sys_user_config ADD COLUMN create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP; + COMMENT ON COLUMN sys_user_config.create_time IS '创建时间'; + END IF; +END $$; + +-- 添加 update_by 字段 +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name='sys_user_config' AND column_name='update_by') THEN + ALTER TABLE sys_user_config ADD COLUMN update_by VARCHAR(64) DEFAULT NULL; + END IF; +END $$; + +-- 添加 update_time 字段 +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name='sys_user_config' AND column_name='update_time') THEN + ALTER TABLE sys_user_config ADD COLUMN update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP; + COMMENT ON COLUMN sys_user_config.update_time IS '更新时间'; + END IF; +END $$; \ No newline at end of file diff --git a/openhis-server-new/.agents/skills/java-spring-boot/SKILL.md b/openhis-server-new/.agents/skills/java-spring-boot/SKILL.md new file mode 100644 index 00000000..f3b1df54 --- /dev/null +++ b/openhis-server-new/.agents/skills/java-spring-boot/SKILL.md @@ -0,0 +1,208 @@ +--- +name: java-spring-boot +description: Build production Spring Boot applications - REST APIs, Security, Data, Actuator +sasmp_version: "1.3.0" +version: "3.0.0" +bonded_agent: 03-java-spring +bond_type: PRIMARY_BOND +allowed-tools: Read, Write, Bash, Glob, Grep + +# Parameter Validation +parameters: + spring_version: + type: string + default: "3.2" + description: Spring Boot version + module: + type: string + enum: [web, security, data, actuator, cloud] + description: Spring module focus +--- + +# Java Spring Boot Skill + +Build production-ready Spring Boot applications with modern best practices. + +## Overview + +This skill covers Spring Boot development including REST APIs, security configuration, data access, actuator monitoring, and cloud integration. Follows Spring Boot 3.x patterns with emphasis on production readiness. + +## When to Use This Skill + +Use when you need to: +- Create REST APIs with Spring MVC/WebFlux +- Configure Spring Security (OAuth2, JWT) +- Set up database access with Spring Data +- Enable monitoring with Actuator +- Integrate with Spring Cloud + +## Topics Covered + +### Spring Boot Core +- Auto-configuration and starters +- Application properties and profiles +- Bean lifecycle and configuration +- DevTools and hot reload + +### REST API Development +- @RestController and @RequestMapping +- Request/response handling +- Validation with Bean Validation +- Exception handling with @ControllerAdvice + +### Spring Security +- SecurityFilterChain configuration +- OAuth2 and JWT authentication +- Method security (@PreAuthorize) +- CORS and CSRF configuration + +### Spring Data JPA +- Repository pattern +- Query methods and @Query +- Pagination and sorting +- Auditing and transactions + +### Actuator & Monitoring +- Health checks and probes +- Metrics with Micrometer +- Custom endpoints +- Prometheus integration + +## Quick Reference + +```java +// REST Controller +@RestController +@RequestMapping("/api/users") +@Validated +public class UserController { + + @GetMapping("/{id}") + public ResponseEntity getUser(@PathVariable Long id) { + return userService.findById(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping + public ResponseEntity createUser(@Valid @RequestBody UserRequest request) { + User user = userService.create(request); + URI location = URI.create("/api/users/" + user.getId()); + return ResponseEntity.created(location).body(user); + } +} + +// Security Configuration +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .csrf(csrf -> csrf.disable()) + .sessionManagement(s -> s.sessionCreationPolicy(STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/actuator/health/**").permitAll() + .requestMatchers("/api/public/**").permitAll() + .anyRequest().authenticated()) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) + .build(); + } +} + +// Exception Handler +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(EntityNotFoundException.class) + public ProblemDetail handleNotFound(EntityNotFoundException ex) { + return ProblemDetail.forStatusAndDetail(NOT_FOUND, ex.getMessage()); + } +} +``` + +## Configuration Templates + +```yaml +# application.yml +spring: + application: + name: ${APP_NAME:my-service} + profiles: + active: ${SPRING_PROFILES_ACTIVE:local} + jpa: + open-in-view: false + properties: + hibernate: + jdbc.batch_size: 50 + +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + endpoint: + health: + probes: + enabled: true + +server: + error: + include-stacktrace: never +``` + +## Common Patterns + +### Layer Architecture +``` +Controller → Service → Repository → Database + ↓ ↓ ↓ + DTOs Entities Entities +``` + +### Validation Patterns +```java +public record CreateUserRequest( + @NotBlank @Size(max = 100) String name, + @Email @NotBlank String email, + @NotNull @Min(18) Integer age +) {} +``` + +## Troubleshooting + +### Common Issues + +| Problem | Cause | Solution | +|---------|-------|----------| +| Bean not found | Missing @Component | Add annotation or @Bean | +| Circular dependency | Constructor injection | Use @Lazy or refactor | +| 401 Unauthorized | Security config | Check permitAll paths | +| Slow startup | Heavy auto-config | Exclude unused starters | + +### Debug Properties +```properties +debug=true +logging.level.org.springframework.security=DEBUG +spring.jpa.show-sql=true +``` + +### Debug Checklist +``` +□ Check /actuator/conditions +□ Verify active profiles +□ Review security filter chain +□ Check bean definitions +□ Test health endpoints +``` + +## Usage + +``` +Skill("java-spring-boot") +``` + +## Related Skills +- `java-testing` - Spring test patterns +- `java-jpa-hibernate` - Data access diff --git a/openhis-server-new/.agents/skills/java-spring-boot/assets/config.yaml b/openhis-server-new/.agents/skills/java-spring-boot/assets/config.yaml new file mode 100644 index 00000000..2df75205 --- /dev/null +++ b/openhis-server-new/.agents/skills/java-spring-boot/assets/config.yaml @@ -0,0 +1,41 @@ +# java-spring-boot Configuration +# Category: general +# Generated: 2025-12-30 + +skill: + name: java-spring-boot + version: "1.0.0" + category: general + +settings: + # Default settings for java-spring-boot + enabled: true + log_level: info + + # Category-specific defaults + validation: + strict_mode: false + auto_fix: false + + output: + format: markdown + include_examples: true + +# Environment-specific overrides +environments: + development: + log_level: debug + validation: + strict_mode: false + + production: + log_level: warn + validation: + strict_mode: true + +# Integration settings +integrations: + # Enable/disable integrations + git: true + linter: true + formatter: true diff --git a/openhis-server-new/.agents/skills/java-spring-boot/assets/schema.json b/openhis-server-new/.agents/skills/java-spring-boot/assets/schema.json new file mode 100644 index 00000000..d709a4de --- /dev/null +++ b/openhis-server-new/.agents/skills/java-spring-boot/assets/schema.json @@ -0,0 +1,60 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "java-spring-boot Configuration Schema", + "type": "object", + "properties": { + "skill": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "category": { + "type": "string", + "enum": [ + "api", + "testing", + "devops", + "security", + "database", + "frontend", + "algorithms", + "machine-learning", + "cloud", + "containers", + "general" + ] + } + }, + "required": [ + "name", + "version" + ] + }, + "settings": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "log_level": { + "type": "string", + "enum": [ + "debug", + "info", + "warn", + "error" + ] + } + } + } + }, + "required": [ + "skill" + ] +} \ No newline at end of file diff --git a/openhis-server-new/.agents/skills/java-spring-boot/references/GUIDE.md b/openhis-server-new/.agents/skills/java-spring-boot/references/GUIDE.md new file mode 100644 index 00000000..590755cb --- /dev/null +++ b/openhis-server-new/.agents/skills/java-spring-boot/references/GUIDE.md @@ -0,0 +1,95 @@ +# Java Spring Boot Guide + +## Overview + +This guide provides comprehensive documentation for the **java-spring-boot** skill in the custom-plugin-java plugin. + +## Category: General + +## Quick Start + +### Prerequisites + +- Familiarity with general concepts +- Development environment set up +- Plugin installed and configured + +### Basic Usage + +```bash +# Invoke the skill +claude "java-spring-boot - [your task description]" + +# Example +claude "java-spring-boot - analyze the current implementation" +``` + +## Core Concepts + +### Key Principles + +1. **Consistency** - Follow established patterns +2. **Clarity** - Write readable, maintainable code +3. **Quality** - Validate before deployment + +### Best Practices + +- Always validate input data +- Handle edge cases explicitly +- Document your decisions +- Write tests for critical paths + +## Common Tasks + +### Task 1: Basic Implementation + +```python +# Example implementation pattern +def implement_java_spring_boot(input_data): + """ + Implement java-spring-boot functionality. + + Args: + input_data: Input to process + + Returns: + Processed result + """ + # Validate input + if not input_data: + raise ValueError("Input required") + + # Process + result = process(input_data) + + # Return + return result +``` + +### Task 2: Advanced Usage + +For advanced scenarios, consider: + +- Configuration customization via `assets/config.yaml` +- Validation using `scripts/validate.py` +- Integration with other skills + +## Troubleshooting + +### Common Issues + +| Issue | Cause | Solution | +|-------|-------|----------| +| Skill not found | Not installed | Run plugin sync | +| Validation fails | Invalid config | Check config.yaml | +| Unexpected output | Missing context | Provide more details | + +## Related Resources + +- SKILL.md - Skill specification +- config.yaml - Configuration options +- validate.py - Validation script + +--- + +*Last updated: 2025-12-30* diff --git a/openhis-server-new/.agents/skills/java-spring-boot/references/PATTERNS.md b/openhis-server-new/.agents/skills/java-spring-boot/references/PATTERNS.md new file mode 100644 index 00000000..431a1c14 --- /dev/null +++ b/openhis-server-new/.agents/skills/java-spring-boot/references/PATTERNS.md @@ -0,0 +1,87 @@ +# Java Spring Boot Patterns + +## Design Patterns + +### Pattern 1: Input Validation + +Always validate input before processing: + +```python +def validate_input(data): + if data is None: + raise ValueError("Data cannot be None") + if not isinstance(data, dict): + raise TypeError("Data must be a dictionary") + return True +``` + +### Pattern 2: Error Handling + +Use consistent error handling: + +```python +try: + result = risky_operation() +except SpecificError as e: + logger.error(f"Operation failed: {e}") + handle_error(e) +except Exception as e: + logger.exception("Unexpected error") + raise +``` + +### Pattern 3: Configuration Loading + +Load and validate configuration: + +```python +import yaml + +def load_config(config_path): + with open(config_path) as f: + config = yaml.safe_load(f) + validate_config(config) + return config +``` + +## Anti-Patterns to Avoid + +### ❌ Don't: Swallow Exceptions + +```python +# BAD +try: + do_something() +except: + pass +``` + +### ✅ Do: Handle Explicitly + +```python +# GOOD +try: + do_something() +except SpecificError as e: + logger.warning(f"Expected error: {e}") + return default_value +``` + +## Category-Specific Patterns: General + +### Recommended Approach + +1. Start with the simplest implementation +2. Add complexity only when needed +3. Test each addition +4. Document decisions + +### Common Integration Points + +- Configuration: `assets/config.yaml` +- Validation: `scripts/validate.py` +- Documentation: `references/GUIDE.md` + +--- + +*Pattern library for java-spring-boot skill* diff --git a/openhis-server-new/.agents/skills/java-spring-boot/scripts/validate.py b/openhis-server-new/.agents/skills/java-spring-boot/scripts/validate.py new file mode 100644 index 00000000..bf5f0659 --- /dev/null +++ b/openhis-server-new/.agents/skills/java-spring-boot/scripts/validate.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +""" +Validation script for java-spring-boot skill. +Category: general +""" + +import os +import sys +import yaml +import json +from pathlib import Path + + +def validate_config(config_path: str) -> dict: + """ + Validate skill configuration file. + + Args: + config_path: Path to config.yaml + + Returns: + dict: Validation result with 'valid' and 'errors' keys + """ + errors = [] + + if not os.path.exists(config_path): + return {"valid": False, "errors": ["Config file not found"]} + + try: + with open(config_path, 'r') as f: + config = yaml.safe_load(f) + except yaml.YAMLError as e: + return {"valid": False, "errors": [f"YAML parse error: {e}"]} + + # Validate required fields + if 'skill' not in config: + errors.append("Missing 'skill' section") + else: + if 'name' not in config['skill']: + errors.append("Missing skill.name") + if 'version' not in config['skill']: + errors.append("Missing skill.version") + + # Validate settings + if 'settings' in config: + settings = config['settings'] + if 'log_level' in settings: + valid_levels = ['debug', 'info', 'warn', 'error'] + if settings['log_level'] not in valid_levels: + errors.append(f"Invalid log_level: {settings['log_level']}") + + return { + "valid": len(errors) == 0, + "errors": errors, + "config": config if not errors else None + } + + +def validate_skill_structure(skill_path: str) -> dict: + """ + Validate skill directory structure. + + Args: + skill_path: Path to skill directory + + Returns: + dict: Structure validation result + """ + required_dirs = ['assets', 'scripts', 'references'] + required_files = ['SKILL.md'] + + errors = [] + + # Check required files + for file in required_files: + if not os.path.exists(os.path.join(skill_path, file)): + errors.append(f"Missing required file: {file}") + + # Check required directories + for dir in required_dirs: + dir_path = os.path.join(skill_path, dir) + if not os.path.isdir(dir_path): + errors.append(f"Missing required directory: {dir}/") + else: + # Check for real content (not just .gitkeep) + files = [f for f in os.listdir(dir_path) if f != '.gitkeep'] + if not files: + errors.append(f"Directory {dir}/ has no real content") + + return { + "valid": len(errors) == 0, + "errors": errors, + "skill_name": os.path.basename(skill_path) + } + + +def main(): + """Main validation entry point.""" + skill_path = Path(__file__).parent.parent + + print(f"Validating java-spring-boot skill...") + print(f"Path: {skill_path}") + + # Validate structure + structure_result = validate_skill_structure(str(skill_path)) + print(f"\nStructure validation: {'PASS' if structure_result['valid'] else 'FAIL'}") + if structure_result['errors']: + for error in structure_result['errors']: + print(f" - {error}") + + # Validate config + config_path = skill_path / 'assets' / 'config.yaml' + if config_path.exists(): + config_result = validate_config(str(config_path)) + print(f"\nConfig validation: {'PASS' if config_result['valid'] else 'FAIL'}") + if config_result['errors']: + for error in config_result['errors']: + print(f" - {error}") + else: + print("\nConfig validation: SKIPPED (no config.yaml)") + + # Summary + all_valid = structure_result['valid'] + print(f"\n==================================================") + print(f"Overall: {'VALID' if all_valid else 'INVALID'}") + + return 0 if all_valid else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/openhis-server-new/.agents/skills/spring-boot-engineer/SKILL.md b/openhis-server-new/.agents/skills/spring-boot-engineer/SKILL.md new file mode 100644 index 00000000..d2cf9125 --- /dev/null +++ b/openhis-server-new/.agents/skills/spring-boot-engineer/SKILL.md @@ -0,0 +1,94 @@ +--- +name: spring-boot-engineer +description: Use when building Spring Boot 3.x applications, microservices, or reactive Java applications. Invoke for Spring Data JPA, Spring Security 6, WebFlux, Spring Cloud integration. +license: MIT +metadata: + author: https://github.com/Jeffallan + version: "1.0.0" + domain: backend + triggers: Spring Boot, Spring Framework, Spring Cloud, Spring Security, Spring Data JPA, Spring WebFlux, Microservices Java, Java REST API, Reactive Java + role: specialist + scope: implementation + output-format: code + related-skills: java-architect, database-optimizer, microservices-architect, devops-engineer +--- + +# Spring Boot Engineer + +Senior Spring Boot engineer with expertise in Spring Boot 3+, cloud-native Java development, and enterprise microservices architecture. + +## Role Definition + +You are a senior Spring Boot engineer with 10+ years of enterprise Java experience. You specialize in Spring Boot 3.x with Java 17+, reactive programming, Spring Cloud ecosystem, and building production-grade microservices. You focus on creating scalable, secure, and maintainable applications with comprehensive testing and observability. + +## When to Use This Skill + +- Building REST APIs with Spring Boot +- Implementing reactive applications with WebFlux +- Setting up Spring Data JPA repositories +- Implementing Spring Security 6 authentication +- Creating microservices with Spring Cloud +- Optimizing Spring Boot performance +- Writing comprehensive tests with Spring Boot Test + +## Core Workflow + +1. **Analyze requirements** - Identify service boundaries, APIs, data models, security needs +2. **Design architecture** - Plan microservices, data access, cloud integration, security +3. **Implement** - Create services with proper dependency injection and layered architecture +4. **Secure** - Add Spring Security, OAuth2, method security, CORS configuration +5. **Test** - Write unit, integration, and slice tests with high coverage +6. **Deploy** - Configure for cloud deployment with health checks and observability + +## Reference Guide + +Load detailed guidance based on context: + +| Topic | Reference | Load When | +|-------|-----------|-----------| +| Web Layer | `references/web.md` | Controllers, REST APIs, validation, exception handling | +| Data Access | `references/data.md` | Spring Data JPA, repositories, transactions, projections | +| Security | `references/security.md` | Spring Security 6, OAuth2, JWT, method security | +| Cloud Native | `references/cloud.md` | Spring Cloud, Config, Discovery, Gateway, resilience | +| Testing | `references/testing.md` | @SpringBootTest, MockMvc, Testcontainers, test slices | + +## Constraints + +### MUST DO +- Use Spring Boot 3.x with Java 17+ features +- Apply dependency injection via constructor injection +- Use @RestController for REST APIs with proper HTTP methods +- Implement validation with @Valid and constraint annotations +- Use Spring Data repositories for data access +- Apply @Transactional appropriately for transaction management +- Write tests with @SpringBootTest and test slices +- Configure application.yml/properties properly +- Use @ConfigurationProperties for type-safe configuration +- Implement proper exception handling with @ControllerAdvice + +### MUST NOT DO +- Use field injection (@Autowired on fields) +- Skip input validation on API endpoints +- Expose internal exceptions to API clients +- Use @Component when @Service/@Repository/@Controller applies +- Mix blocking and reactive code improperly +- Store secrets in application.properties +- Skip transaction management for multi-step operations +- Use deprecated Spring Boot 2.x patterns +- Hardcode URLs, credentials, or configuration + +## Output Templates + +When implementing Spring Boot features, provide: +1. Entity/model classes with JPA annotations +2. Repository interfaces extending Spring Data +3. Service layer with business logic +4. Controller with REST endpoints +5. DTO classes for API requests/responses +6. Configuration classes if needed +7. Test classes with appropriate test slices +8. Brief explanation of architecture decisions + +## Knowledge Reference + +Spring Boot 3.x, Spring Framework 6, Spring Data JPA, Spring Security 6, Spring Cloud, Project Reactor (WebFlux), JPA/Hibernate, Bean Validation, RestTemplate/WebClient, Actuator, Micrometer, JUnit 5, Mockito, Testcontainers, Docker, Kubernetes diff --git a/openhis-server-new/.agents/skills/spring-boot-engineer/references/cloud.md b/openhis-server-new/.agents/skills/spring-boot-engineer/references/cloud.md new file mode 100644 index 00000000..7be0b169 --- /dev/null +++ b/openhis-server-new/.agents/skills/spring-boot-engineer/references/cloud.md @@ -0,0 +1,498 @@ +# Cloud Native - Spring Cloud + +## Spring Cloud Config Server + +```java +// Config Server +@SpringBootApplication +@EnableConfigServer +public class ConfigServerApplication { + public static void main(String[] args) { + SpringApplication.run(ConfigServerApplication.class, args); + } +} + +// application.yml +server: + port: 8888 + +spring: + cloud: + config: + server: + git: + uri: https://github.com/example/config-repo + default-label: main + search-paths: '{application}' + username: ${GIT_USERNAME} + password: ${GIT_PASSWORD} + native: + search-locations: classpath:/config + security: + user: + name: config-user + password: ${CONFIG_PASSWORD} + +// Config Client +@SpringBootApplication +public class ClientApplication { + public static void main(String[] args) { + SpringApplication.run(ClientApplication.class, args); + } +} + +// application.yml (Config Client) +spring: + application: + name: user-service + config: + import: "configserver:http://localhost:8888" + cloud: + config: + username: config-user + password: ${CONFIG_PASSWORD} + fail-fast: true + retry: + max-attempts: 6 + initial-interval: 1000 +``` + +## Dynamic Configuration Refresh + +```java +@RestController +@RefreshScope +public class ConfigController { + @Value("${app.feature.enabled:false}") + private boolean featureEnabled; + + @Value("${app.max-connections:100}") + private int maxConnections; + + @GetMapping("/config") + public Map getConfig() { + return Map.of( + "featureEnabled", featureEnabled, + "maxConnections", maxConnections + ); + } +} + +// Refresh configuration via Actuator endpoint: +// POST /actuator/refresh +``` + +## Service Discovery - Eureka + +```java +// Eureka Server +@SpringBootApplication +@EnableEurekaServer +public class EurekaServerApplication { + public static void main(String[] args) { + SpringApplication.run(EurekaServerApplication.class, args); + } +} + +// application.yml (Eureka Server) +server: + port: 8761 + +eureka: + instance: + hostname: localhost + client: + register-with-eureka: false + fetch-registry: false + service-url: + defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/ + +// Eureka Client +@SpringBootApplication +@EnableDiscoveryClient +public class UserServiceApplication { + public static void main(String[] args) { + SpringApplication.run(UserServiceApplication.class, args); + } +} + +// application.yml (Eureka Client) +spring: + application: + name: user-service + +eureka: + client: + service-url: + defaultZone: http://localhost:8761/eureka/ + registry-fetch-interval-seconds: 5 + instance: + prefer-ip-address: true + lease-renewal-interval-in-seconds: 10 + lease-expiration-duration-in-seconds: 30 +``` + +## Spring Cloud Gateway + +```java +@SpringBootApplication +public class GatewayApplication { + public static void main(String[] args) { + SpringApplication.run(GatewayApplication.class, args); + } + + @Bean + public RouteLocator customRouteLocator(RouteLocatorBuilder builder) { + return builder.routes() + .route("user-service", r -> r + .path("/api/users/**") + .filters(f -> f + .rewritePath("/api/users/(?.*)", "/users/${segment}") + .addRequestHeader("X-Gateway", "Spring-Cloud-Gateway") + .circuitBreaker(config -> config + .setName("userServiceCircuitBreaker") + .setFallbackUri("forward:/fallback/users") + ) + .retry(config -> config + .setRetries(3) + .setStatuses(HttpStatus.SERVICE_UNAVAILABLE) + ) + ) + .uri("lb://user-service") + ) + .route("order-service", r -> r + .path("/api/orders/**") + .filters(f -> f + .rewritePath("/api/orders/(?.*)", "/orders/${segment}") + .requestRateLimiter(config -> config + .setRateLimiter(redisRateLimiter()) + .setKeyResolver(userKeyResolver()) + ) + ) + .uri("lb://order-service") + ) + .build(); + } + + @Bean + public RedisRateLimiter redisRateLimiter() { + return new RedisRateLimiter(10, 20); // replenishRate, burstCapacity + } + + @Bean + public KeyResolver userKeyResolver() { + return exchange -> Mono.just( + exchange.getRequest().getHeaders().getFirst("X-User-Id") + ); + } +} + +// application.yml (Gateway) +spring: + cloud: + gateway: + discovery: + locator: + enabled: true + lower-case-service-id: true + default-filters: + - DedupeResponseHeader=Access-Control-Allow-Origin + globalcors: + cors-configurations: + '[/**]': + allowed-origins: "*" + allowed-methods: + - GET + - POST + - PUT + - DELETE + allowed-headers: "*" +``` + +## Circuit Breaker - Resilience4j + +```java +@Service +@RequiredArgsConstructor +public class ExternalApiService { + private final WebClient webClient; + + @CircuitBreaker(name = "externalApi", fallbackMethod = "getFallbackData") + @Retry(name = "externalApi") + @RateLimiter(name = "externalApi") + public Mono getData(String id) { + return webClient + .get() + .uri("/data/{id}", id) + .retrieve() + .bodyToMono(ExternalData.class) + .timeout(Duration.ofSeconds(3)); + } + + private Mono getFallbackData(String id, Exception e) { + log.warn("Fallback triggered for id: {}, error: {}", id, e.getMessage()); + return Mono.just(new ExternalData(id, "Fallback data", LocalDateTime.now())); + } +} + +// application.yml +resilience4j: + circuitbreaker: + instances: + externalApi: + register-health-indicator: true + sliding-window-size: 10 + minimum-number-of-calls: 5 + permitted-number-of-calls-in-half-open-state: 3 + automatic-transition-from-open-to-half-open-enabled: true + wait-duration-in-open-state: 5s + failure-rate-threshold: 50 + event-consumer-buffer-size: 10 + + retry: + instances: + externalApi: + max-attempts: 3 + wait-duration: 1s + enable-exponential-backoff: true + exponential-backoff-multiplier: 2 + + ratelimiter: + instances: + externalApi: + limit-for-period: 10 + limit-refresh-period: 1s + timeout-duration: 0s +``` + +## Distributed Tracing - Micrometer Tracing + +```java +// application.yml +management: + tracing: + sampling: + probability: 1.0 + zipkin: + tracing: + endpoint: http://localhost:9411/api/v2/spans + +logging: + pattern: + level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]" + +// Custom spans +@Service +@RequiredArgsConstructor +public class OrderService { + private final Tracer tracer; + private final OrderRepository orderRepository; + + public Order processOrder(OrderRequest request) { + Span span = tracer.nextSpan().name("processOrder").start(); + try (Tracer.SpanInScope ws = tracer.withSpan(span)) { + span.tag("order.type", request.type()); + span.tag("order.items", String.valueOf(request.items().size())); + + // Business logic + Order order = createOrder(request); + + span.event("order.created"); + return order; + } finally { + span.end(); + } + } +} +``` + +## Load Balancing with Spring Cloud LoadBalancer + +```java +@Configuration +@LoadBalancerClient(name = "user-service", configuration = UserServiceLoadBalancerConfig.class) +public class LoadBalancerConfiguration { +} + +@Configuration +public class UserServiceLoadBalancerConfig { + + @Bean + public ReactorLoadBalancer randomLoadBalancer( + LoadBalancerClientFactory clientFactory, + ObjectProvider properties) { + return new RandomLoadBalancer( + clientFactory.getLazyProvider("user-service", ServiceInstanceListSupplier.class), + "user-service" + ); + } +} + +@Service +@RequiredArgsConstructor +public class UserClientService { + private final WebClient.Builder webClientBuilder; + + public Mono getUser(Long id) { + return webClientBuilder + .baseUrl("http://user-service") + .build() + .get() + .uri("/users/{id}", id) + .retrieve() + .bodyToMono(User.class); + } +} +``` + +## Health Checks & Actuator + +```java +@Component +public class CustomHealthIndicator implements HealthIndicator { + + @Override + public Health health() { + boolean serviceUp = checkExternalService(); + + if (serviceUp) { + return Health.up() + .withDetail("externalService", "Available") + .withDetail("timestamp", LocalDateTime.now()) + .build(); + } else { + return Health.down() + .withDetail("externalService", "Unavailable") + .withDetail("error", "Connection timeout") + .build(); + } + } + + private boolean checkExternalService() { + // Check external dependency + return true; + } +} + +// application.yml +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + endpoint: + health: + show-details: always + probes: + enabled: true + health: + livenessState: + enabled: true + readinessState: + enabled: true + metrics: + export: + prometheus: + enabled: true + tags: + application: ${spring.application.name} +``` + +## Kubernetes Deployment + +```yaml +# deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: user-service +spec: + replicas: 3 + selector: + matchLabels: + app: user-service + template: + metadata: + labels: + app: user-service + spec: + containers: + - name: user-service + image: user-service:1.0.0 + ports: + - containerPort: 8080 + env: + - name: SPRING_PROFILES_ACTIVE + value: "kubernetes" + - name: JAVA_OPTS + value: "-Xmx512m -Xms256m" + livenessProbe: + httpGet: + path: /actuator/health/liveness + port: 8080 + initialDelaySeconds: 60 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /actuator/health/readiness + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 5 + resources: + requests: + memory: "512Mi" + cpu: "500m" + limits: + memory: "1Gi" + cpu: "1000m" +--- +apiVersion: v1 +kind: Service +metadata: + name: user-service +spec: + selector: + app: user-service + ports: + - port: 80 + targetPort: 8080 + type: ClusterIP +``` + +## Docker Configuration + +```dockerfile +# Dockerfile (Multi-stage) +FROM eclipse-temurin:17-jdk-alpine AS build +WORKDIR /workspace/app + +COPY mvnw . +COPY .mvn .mvn +COPY pom.xml . +COPY src src + +RUN ./mvnw install -DskipTests +RUN mkdir -p target/dependency && (cd target/dependency; jar -xf ../*.jar) + +FROM eclipse-temurin:17-jre-alpine +VOLUME /tmp +ARG DEPENDENCY=/workspace/app/target/dependency +COPY --from=build ${DEPENDENCY}/BOOT-INF/lib /app/lib +COPY --from=build ${DEPENDENCY}/META-INF /app/META-INF +COPY --from=build ${DEPENDENCY}/BOOT-INF/classes /app + +ENTRYPOINT ["java","-cp","app:app/lib/*","com.example.Application"] +``` + +## Quick Reference + +| Component | Purpose | +|-----------|---------| +| **Config Server** | Centralized configuration management | +| **Eureka** | Service discovery and registration | +| **Gateway** | API gateway with routing, filtering, load balancing | +| **Circuit Breaker** | Fault tolerance and fallback patterns | +| **Load Balancer** | Client-side load balancing | +| **Tracing** | Distributed tracing across services | +| **Actuator** | Production-ready monitoring and management | +| **Kubernetes** | Container orchestration and deployment | diff --git a/openhis-server-new/.agents/skills/spring-boot-engineer/references/data.md b/openhis-server-new/.agents/skills/spring-boot-engineer/references/data.md new file mode 100644 index 00000000..f7643b22 --- /dev/null +++ b/openhis-server-new/.agents/skills/spring-boot-engineer/references/data.md @@ -0,0 +1,381 @@ +# Data Access - Spring Data JPA + +## JPA Entity Pattern + +```java +@Entity +@Table(name = "users", indexes = { + @Index(name = "idx_email", columnList = "email", unique = true), + @Index(name = "idx_username", columnList = "username") +}) +@EntityListeners(AuditingEntityListener.class) +@Getter @Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 100) + private String email; + + @Column(nullable = false, length = 100) + private String password; + + @Column(nullable = false, unique = true, length = 50) + private String username; + + @Column(nullable = false) + @Builder.Default + private Boolean active = true; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List
addresses = new ArrayList<>(); + + @ManyToMany + @JoinTable( + name = "user_roles", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "role_id") + ) + @Builder.Default + private Set roles = new HashSet<>(); + + @CreatedDate + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; + + @Version + private Long version; + + // Helper methods for bidirectional relationships + public void addAddress(Address address) { + addresses.add(address); + address.setUser(this); + } + + public void removeAddress(Address address) { + addresses.remove(address); + address.setUser(null); + } +} +``` + +## Spring Data JPA Repository + +```java +@Repository +public interface UserRepository extends JpaRepository, + JpaSpecificationExecutor { + + Optional findByEmail(String email); + + Optional findByUsername(String username); + + boolean existsByEmail(String email); + + boolean existsByUsername(String username); + + @Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.email = :email") + Optional findByEmailWithRoles(@Param("email") String email); + + @Query("SELECT u FROM User u WHERE u.active = true AND u.createdAt >= :since") + List findActiveUsersSince(@Param("since") LocalDateTime since); + + @Modifying + @Query("UPDATE User u SET u.active = false WHERE u.lastLoginAt < :threshold") + int deactivateInactiveUsers(@Param("threshold") LocalDateTime threshold); + + // Projection for read-only DTOs + @Query("SELECT new com.example.dto.UserSummary(u.id, u.username, u.email) " + + "FROM User u WHERE u.active = true") + List findAllActiveSummaries(); +} +``` + +## Repository with Specifications + +```java +public class UserSpecifications { + + public static Specification hasEmail(String email) { + return (root, query, cb) -> + email == null ? null : cb.equal(root.get("email"), email); + } + + public static Specification isActive() { + return (root, query, cb) -> cb.isTrue(root.get("active")); + } + + public static Specification createdAfter(LocalDateTime date) { + return (root, query, cb) -> + date == null ? null : cb.greaterThanOrEqualTo(root.get("createdAt"), date); + } + + public static Specification hasRole(String roleName) { + return (root, query, cb) -> { + Join roles = root.join("roles", JoinType.INNER); + return cb.equal(roles.get("name"), roleName); + }; + } +} + +// Usage in service +@Service +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + + public Page searchUsers(UserSearchCriteria criteria, Pageable pageable) { + Specification spec = Specification + .where(UserSpecifications.hasEmail(criteria.email())) + .and(UserSpecifications.isActive()) + .and(UserSpecifications.createdAfter(criteria.createdAfter())); + + return userRepository.findAll(spec, pageable); + } +} +``` + +## Transaction Management + +```java +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class OrderService { + private final OrderRepository orderRepository; + private final PaymentService paymentService; + private final InventoryService inventoryService; + private final NotificationService notificationService; + + @Transactional + public Order createOrder(OrderCreateRequest request) { + // All operations in single transaction + Order order = Order.builder() + .customerId(request.customerId()) + .status(OrderStatus.PENDING) + .build(); + + request.items().forEach(item -> { + inventoryService.reserveStock(item.productId(), item.quantity()); + order.addItem(item); + }); + + order = orderRepository.save(order); + + try { + paymentService.processPayment(order); + order.setStatus(OrderStatus.PAID); + } catch (PaymentException e) { + order.setStatus(OrderStatus.PAYMENT_FAILED); + throw e; // Transaction will rollback + } + + return orderRepository.save(order); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void logOrderEvent(Long orderId, String event) { + // Separate transaction - will commit even if parent rolls back + OrderEvent orderEvent = new OrderEvent(orderId, event); + orderEventRepository.save(orderEvent); + } + + @Transactional(noRollbackFor = NotificationException.class) + public void completeOrder(Long orderId) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new ResourceNotFoundException("Order not found")); + + order.setStatus(OrderStatus.COMPLETED); + orderRepository.save(order); + + // Won't rollback transaction if notification fails + try { + notificationService.sendCompletionEmail(order); + } catch (NotificationException e) { + log.error("Failed to send notification for order {}", orderId, e); + } + } +} +``` + +## Auditing Configuration + +```java +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { + + @Bean + public AuditorAware auditorProvider() { + return () -> { + Authentication authentication = SecurityContextHolder + .getContext() + .getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + return Optional.of("system"); + } + + return Optional.of(authentication.getName()); + }; + } +} + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter @Setter +public abstract class AuditableEntity { + + @CreatedDate + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @CreatedBy + @Column(nullable = false, updatable = false, length = 100) + private String createdBy; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; + + @LastModifiedBy + @Column(nullable = false, length = 100) + private String updatedBy; +} +``` + +## Projections + +```java +// Interface-based projection +public interface UserSummary { + Long getId(); + String getUsername(); + String getEmail(); + + @Value("#{target.firstName + ' ' + target.lastName}") + String getFullName(); +} + +// Class-based projection (DTO) +public record UserSummaryDto( + Long id, + String username, + String email +) {} + +// Usage +public interface UserRepository extends JpaRepository { + List findAllBy(); + + List findAllBy(Class type); +} + +// Service usage +List summaries = userRepository.findAllBy(); +List dtos = userRepository.findAllBy(UserSummaryDto.class); +``` + +## Query Optimization + +```java +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserQueryService { + private final UserRepository userRepository; + private final EntityManager entityManager; + + // N+1 problem solved with JOIN FETCH + @Query("SELECT DISTINCT u FROM User u " + + "LEFT JOIN FETCH u.addresses " + + "LEFT JOIN FETCH u.roles " + + "WHERE u.active = true") + List findAllActiveWithAssociations(); + + // Batch fetching + @BatchSize(size = 25) + @OneToMany(mappedBy = "user") + private List orders; + + // EntityGraph for dynamic fetching + @EntityGraph(attributePaths = {"addresses", "roles"}) + List findAllByActiveTrue(); + + // Pagination to avoid loading all data + public Page findAllUsers(Pageable pageable) { + return userRepository.findAll(pageable); + } + + // Native query for complex queries + @Query(value = """ + SELECT u.* FROM users u + INNER JOIN orders o ON u.id = o.user_id + WHERE o.created_at >= :since + GROUP BY u.id + HAVING COUNT(o.id) >= :minOrders + """, nativeQuery = true) + List findFrequentBuyers(@Param("since") LocalDateTime since, + @Param("minOrders") int minOrders); +} +``` + +## Database Migrations (Flyway) + +```sql +-- V1__create_users_table.sql +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(100) NOT NULL UNIQUE, + password VARCHAR(100) NOT NULL, + username VARCHAR(50) NOT NULL UNIQUE, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0 +); + +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_username ON users(username); +CREATE INDEX idx_users_active ON users(active); + +-- V2__create_addresses_table.sql +CREATE TABLE addresses ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + street VARCHAR(200) NOT NULL, + city VARCHAR(100) NOT NULL, + country VARCHAR(2) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_addresses_user_id ON addresses(user_id); +``` + +## Quick Reference + +| Annotation | Purpose | +|------------|---------| +| `@Entity` | Marks class as JPA entity | +| `@Table` | Specifies table details and indexes | +| `@Id` | Marks primary key field | +| `@GeneratedValue` | Auto-generated primary key strategy | +| `@Column` | Column constraints and mapping | +| `@OneToMany/@ManyToOne` | One-to-many/many-to-one relationships | +| `@ManyToMany` | Many-to-many relationships | +| `@JoinColumn/@JoinTable` | Join column/table configuration | +| `@Transactional` | Declares transaction boundaries | +| `@Query` | Custom JPQL/native queries | +| `@Modifying` | Marks query as UPDATE/DELETE | +| `@EntityGraph` | Defines fetch graph for associations | +| `@Version` | Optimistic locking version field | diff --git a/openhis-server-new/.agents/skills/spring-boot-engineer/references/security.md b/openhis-server-new/.agents/skills/spring-boot-engineer/references/security.md new file mode 100644 index 00000000..9a4bfa32 --- /dev/null +++ b/openhis-server-new/.agents/skills/spring-boot-engineer/references/security.md @@ -0,0 +1,459 @@ +# Security - Spring Security 6 + +## Security Configuration + +```java +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf + .ignoringRequestMatchers("/api/auth/**") + .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + ) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/**", "/actuator/health").permitAll() + .requestMatchers("/api/admin/**").hasRole("ADMIN") + .requestMatchers("/api/users/**").hasAnyRole("USER", "ADMIN") + .anyRequest().authenticated() + ) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(authenticationEntryPoint()) + .accessDeniedHandler(accessDeniedHandler()) + ) + .addFilterBefore(jwtAuthenticationFilter(), + UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of("http://localhost:3000")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + public AuthenticationManager authenticationManager( + AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(12); + } +} +``` + +## JWT Authentication Filter + +```java +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtService jwtService; + private final UserDetailsService userDetailsService; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletRequest response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + + final String authHeader = request.getHeader("Authorization"); + final String jwt; + final String username; + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + jwt = authHeader.substring(7); + + try { + username = jwtService.extractUsername(jwt); + + if (username != null && SecurityContextHolder.getContext() + .getAuthentication() == null) { + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + if (jwtService.isTokenValid(jwt, userDetails)) { + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + + authToken.setDetails( + new WebAuthenticationDetailsSource().buildDetails(request) + ); + + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + } catch (JwtException e) { + log.error("JWT validation failed", e); + } + + filterChain.doFilter(request, response); + } +} +``` + +## JWT Service + +```java +@Service +public class JwtService { + @Value("${jwt.secret}") + private String secretKey; + + @Value("${jwt.expiration}") + private long jwtExpiration; + + @Value("${jwt.refresh-expiration}") + private long refreshExpiration; + + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + public String generateToken(UserDetails userDetails) { + Map extraClaims = new HashMap<>(); + extraClaims.put("roles", userDetails.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList())); + + return generateToken(extraClaims, userDetails); + } + + public String generateToken( + Map extraClaims, + UserDetails userDetails) { + return buildToken(extraClaims, userDetails, jwtExpiration); + } + + public String generateRefreshToken(UserDetails userDetails) { + return buildToken(new HashMap<>(), userDetails, refreshExpiration); + } + + private String buildToken( + Map extraClaims, + UserDetails userDetails, + long expiration) { + return Jwts + .builder() + .setClaims(extraClaims) + .setSubject(userDetails.getUsername()) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(getSignInKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public boolean isTokenValid(String token, UserDetails userDetails) { + final String username = extractUsername(token); + return username.equals(userDetails.getUsername()) && !isTokenExpired(token); + } + + private boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + private Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + private Claims extractAllClaims(String token) { + return Jwts + .parserBuilder() + .setSigningKey(getSignInKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + private Key getSignInKey() { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + return Keys.hmacShaKeyFor(keyBytes); + } +} +``` + +## UserDetailsService Implementation + +```java +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + private final UserRepository userRepository; + + @Override + @Transactional(readOnly = true) + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByEmailWithRoles(username) + .orElseThrow(() -> new UsernameNotFoundException( + "User not found with email: " + username)); + + return org.springframework.security.core.userdetails.User + .builder() + .username(user.getEmail()) + .password(user.getPassword()) + .authorities(user.getRoles().stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName())) + .collect(Collectors.toList())) + .accountExpired(false) + .accountLocked(!user.getActive()) + .credentialsExpired(false) + .disabled(!user.getActive()) + .build(); + } +} +``` + +## Authentication Controller + +```java +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthenticationController { + private final AuthenticationService authenticationService; + + @PostMapping("/register") + public ResponseEntity register( + @Valid @RequestBody RegisterRequest request) { + AuthenticationResponse response = authenticationService.register(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PostMapping("/login") + public ResponseEntity login( + @Valid @RequestBody LoginRequest request) { + AuthenticationResponse response = authenticationService.login(request); + return ResponseEntity.ok(response); + } + + @PostMapping("/refresh") + public ResponseEntity refreshToken( + @RequestBody RefreshTokenRequest request) { + AuthenticationResponse response = authenticationService.refreshToken(request); + return ResponseEntity.ok(response); + } + + @PostMapping("/logout") + @PreAuthorize("isAuthenticated()") + public ResponseEntity logout() { + SecurityContextHolder.clearContext(); + return ResponseEntity.noContent().build(); + } +} +``` + +## Authentication Service + +```java +@Service +@RequiredArgsConstructor +@Transactional +public class AuthenticationService { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + private final AuthenticationManager authenticationManager; + + public AuthenticationResponse register(RegisterRequest request) { + if (userRepository.existsByEmail(request.email())) { + throw new DuplicateResourceException("Email already registered"); + } + + User user = User.builder() + .email(request.email()) + .password(passwordEncoder.encode(request.password())) + .username(request.username()) + .active(true) + .roles(Set.of(Role.builder().name("USER").build())) + .build(); + + user = userRepository.save(user); + + String accessToken = jwtService.generateToken(convertToUserDetails(user)); + String refreshToken = jwtService.generateRefreshToken(convertToUserDetails(user)); + + return new AuthenticationResponse(accessToken, refreshToken); + } + + public AuthenticationResponse login(LoginRequest request) { + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken( + request.email(), + request.password() + ) + ); + + User user = userRepository.findByEmail(request.email()) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + String accessToken = jwtService.generateToken(convertToUserDetails(user)); + String refreshToken = jwtService.generateRefreshToken(convertToUserDetails(user)); + + return new AuthenticationResponse(accessToken, refreshToken); + } + + public AuthenticationResponse refreshToken(RefreshTokenRequest request) { + String username = jwtService.extractUsername(request.refreshToken()); + + User user = userRepository.findByEmail(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + UserDetails userDetails = convertToUserDetails(user); + + if (!jwtService.isTokenValid(request.refreshToken(), userDetails)) { + throw new InvalidTokenException("Invalid refresh token"); + } + + String accessToken = jwtService.generateToken(userDetails); + + return new AuthenticationResponse(accessToken, request.refreshToken()); + } + + private UserDetails convertToUserDetails(User user) { + return org.springframework.security.core.userdetails.User + .builder() + .username(user.getEmail()) + .password(user.getPassword()) + .authorities(user.getRoles().stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName())) + .collect(Collectors.toList())) + .build(); + } +} +``` + +## Method Security + +```java +@Service +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + + @PreAuthorize("hasRole('ADMIN')") + public List getAllUsers() { + return userRepository.findAll(); + } + + @PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id") + public User getUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new ResourceNotFoundException("User not found")); + } + + @PreAuthorize("isAuthenticated()") + @PostAuthorize("returnObject.email == authentication.principal.username") + public User updateProfile(Long userId, UserUpdateRequest request) { + User user = getUserById(userId); + // Update logic + return userRepository.save(user); + } + + @Secured({"ROLE_ADMIN", "ROLE_MANAGER"}) + public void deleteUser(Long userId) { + userRepository.deleteById(userId); + } +} +``` + +## OAuth2 Resource Server (JWT) + +```java +@Configuration +@EnableWebSecurity +public class OAuth2ResourceServerConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(auth -> auth + .requestMatchers("/public/**").permitAll() + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt + .jwtAuthenticationConverter(jwtAuthenticationConverter()) + ) + ); + + return http.build(); + } + + @Bean + public JwtDecoder jwtDecoder() { + return JwtDecoders.fromIssuerLocation("https://auth.example.com"); + } + + @Bean + public JwtAuthenticationConverter jwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = + new JwtGrantedAuthoritiesConverter(); + grantedAuthoritiesConverter.setAuthoritiesClaimName("roles"); + grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_"); + + JwtAuthenticationConverter jwtAuthenticationConverter = + new JwtAuthenticationConverter(); + jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter( + grantedAuthoritiesConverter); + + return jwtAuthenticationConverter; + } +} +``` + +## Quick Reference + +| Annotation | Purpose | +|------------|---------| +| `@EnableWebSecurity` | Enables Spring Security | +| `@EnableMethodSecurity` | Enables method-level security annotations | +| `@PreAuthorize` | Checks authorization before method execution | +| `@PostAuthorize` | Checks authorization after method execution | +| `@Secured` | Role-based method security | +| `@WithMockUser` | Mock authenticated user in tests | +| `@AuthenticationPrincipal` | Inject current user in controller | + +## Security Best Practices + +- Always use HTTPS in production +- Store JWT secret in environment variables +- Use strong password encoding (BCrypt with strength 12+) +- Implement token refresh mechanism +- Add rate limiting to authentication endpoints +- Validate all user inputs +- Log security events +- Keep dependencies updated +- Use CSRF protection for state-changing operations +- Implement proper session timeout diff --git a/openhis-server-new/.agents/skills/spring-boot-engineer/references/testing.md b/openhis-server-new/.agents/skills/spring-boot-engineer/references/testing.md new file mode 100644 index 00000000..9bfec1de --- /dev/null +++ b/openhis-server-new/.agents/skills/spring-boot-engineer/references/testing.md @@ -0,0 +1,545 @@ +# Testing - Spring Boot Test + +## Unit Testing with JUnit 5 + +```java +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private UserService userService; + + @Test + @DisplayName("Should create user successfully") + void shouldCreateUser() { + // Given + UserCreateRequest request = new UserCreateRequest( + "test@example.com", + "Password123", + "testuser", + 25 + ); + + User user = User.builder() + .id(1L) + .email(request.email()) + .username(request.username()) + .build(); + + when(userRepository.existsByEmail(request.email())).thenReturn(false); + when(passwordEncoder.encode(request.password())).thenReturn("encodedPassword"); + when(userRepository.save(any(User.class))).thenReturn(user); + + // When + UserResponse response = userService.create(request); + + // Then + assertThat(response).isNotNull(); + assertThat(response.email()).isEqualTo(request.email()); + + verify(userRepository).existsByEmail(request.email()); + verify(passwordEncoder).encode(request.password()); + verify(userRepository).save(any(User.class)); + } + + @Test + @DisplayName("Should throw exception when email already exists") + void shouldThrowExceptionWhenEmailExists() { + // Given + UserCreateRequest request = new UserCreateRequest( + "test@example.com", + "Password123", + "testuser", + 25 + ); + + when(userRepository.existsByEmail(request.email())).thenReturn(true); + + // When & Then + assertThatThrownBy(() -> userService.create(request)) + .isInstanceOf(DuplicateResourceException.class) + .hasMessageContaining("Email already registered"); + + verify(userRepository, never()).save(any(User.class)); + } +} +``` + +## Integration Testing with @SpringBootTest + +```java +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class UserIntegrationTest { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private UserRepository userRepository; + + @BeforeEach + void setUp() { + userRepository.deleteAll(); + } + + @Test + @Order(1) + @DisplayName("Should create user via API") + void shouldCreateUserViaApi() { + // Given + UserCreateRequest request = new UserCreateRequest( + "test@example.com", + "Password123", + "testuser", + 25 + ); + + // When + ResponseEntity response = restTemplate.postForEntity( + "/api/v1/users", + request, + UserResponse.class + ); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().email()).isEqualTo(request.email()); + assertThat(response.getHeaders().getLocation()).isNotNull(); + } + + @Test + @Order(2) + @DisplayName("Should return validation error for invalid request") + void shouldReturnValidationError() { + // Given + UserCreateRequest request = new UserCreateRequest( + "invalid-email", + "short", + "u", + 15 + ); + + // When + ResponseEntity response = restTemplate.postForEntity( + "/api/v1/users", + request, + ValidationErrorResponse.class + ); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().errors()).isNotEmpty(); + } +} +``` + +## Web Layer Testing with MockMvc + +```java +@WebMvcTest(UserController.class) +@Import(SecurityConfig.class) +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private UserService userService; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @WithMockUser(roles = "ADMIN") + @DisplayName("Should get all users") + void shouldGetAllUsers() throws Exception { + // Given + Page users = new PageImpl<>(List.of( + new UserResponse(1L, "user1@example.com", "user1", 25, true, null, null), + new UserResponse(2L, "user2@example.com", "user2", 30, true, null, null) + )); + + when(userService.findAll(any(Pageable.class))).thenReturn(users); + + // When & Then + mockMvc.perform(get("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(2)) + .andExpect(jsonPath("$.content[0].email").value("user1@example.com")) + .andDo(print()); + } + + @Test + @WithMockUser(roles = "ADMIN") + @DisplayName("Should create user") + void shouldCreateUser() throws Exception { + // Given + UserCreateRequest request = new UserCreateRequest( + "test@example.com", + "Password123", + "testuser", + 25 + ); + + UserResponse response = new UserResponse( + 1L, + request.email(), + request.username(), + request.age(), + true, + LocalDateTime.now(), + LocalDateTime.now() + ); + + when(userService.create(any(UserCreateRequest.class))).thenReturn(response); + + // When & Then + mockMvc.perform(post("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(header().exists("Location")) + .andExpect(jsonPath("$.email").value(request.email())) + .andExpect(jsonPath("$.username").value(request.username())) + .andDo(print()); + } + + @Test + @WithMockUser(roles = "USER") + @DisplayName("Should return 403 for non-admin user") + void shouldReturn403ForNonAdmin() throws Exception { + mockMvc.perform(get("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } +} +``` + +## Data JPA Testing + +```java +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +class UserRepositoryTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private TestEntityManager entityManager; + + @Test + @DisplayName("Should find user by email") + void shouldFindUserByEmail() { + // Given + User user = User.builder() + .email("test@example.com") + .password("password") + .username("testuser") + .active(true) + .build(); + + entityManager.persistAndFlush(user); + + // When + Optional found = userRepository.findByEmail("test@example.com"); + + // Then + assertThat(found).isPresent(); + assertThat(found.get().getEmail()).isEqualTo("test@example.com"); + } + + @Test + @DisplayName("Should check if email exists") + void shouldCheckIfEmailExists() { + // Given + User user = User.builder() + .email("test@example.com") + .password("password") + .username("testuser") + .active(true) + .build(); + + entityManager.persistAndFlush(user); + + // When + boolean exists = userRepository.existsByEmail("test@example.com"); + + // Then + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("Should fetch user with roles") + void shouldFetchUserWithRoles() { + // Given + Role adminRole = Role.builder().name("ADMIN").build(); + entityManager.persist(adminRole); + + User user = User.builder() + .email("admin@example.com") + .password("password") + .username("admin") + .active(true) + .roles(Set.of(adminRole)) + .build(); + + entityManager.persistAndFlush(user); + entityManager.clear(); + + // When + Optional found = userRepository.findByEmailWithRoles("admin@example.com"); + + // Then + assertThat(found).isPresent(); + assertThat(found.get().getRoles()).hasSize(1); + assertThat(found.get().getRoles()).extracting(Role::getName).contains("ADMIN"); + } +} +``` + +## Testcontainers for Database + +```java +@SpringBootTest +@Testcontainers +@ActiveProfiles("test") +class UserServiceIntegrationTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:15-alpine") + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + } + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @BeforeEach + void setUp() { + userRepository.deleteAll(); + } + + @Test + @DisplayName("Should create and find user in real database") + void shouldCreateAndFindUser() { + // Given + UserCreateRequest request = new UserCreateRequest( + "test@example.com", + "Password123", + "testuser", + 25 + ); + + // When + UserResponse created = userService.create(request); + UserResponse found = userService.findById(created.id()); + + // Then + assertThat(found).isNotNull(); + assertThat(found.email()).isEqualTo(request.email()); + } +} +``` + +## Testing Reactive Endpoints with WebTestClient + +```java +@WebFluxTest(UserReactiveController.class) +class UserReactiveControllerTest { + + @Autowired + private WebTestClient webTestClient; + + @MockBean + private UserReactiveService userService; + + @Test + @DisplayName("Should get user reactively") + void shouldGetUserReactively() { + // Given + UserResponse user = new UserResponse( + 1L, + "test@example.com", + "testuser", + 25, + true, + LocalDateTime.now(), + LocalDateTime.now() + ); + + when(userService.findById(1L)).thenReturn(Mono.just(user)); + + // When & Then + webTestClient.get() + .uri("/api/v1/users/{id}", 1L) + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBody(UserResponse.class) + .value(response -> { + assertThat(response.id()).isEqualTo(1L); + assertThat(response.email()).isEqualTo("test@example.com"); + }); + } + + @Test + @DisplayName("Should create user reactively") + void shouldCreateUserReactively() { + // Given + UserCreateRequest request = new UserCreateRequest( + "test@example.com", + "Password123", + "testuser", + 25 + ); + + UserResponse response = new UserResponse( + 1L, + request.email(), + request.username(), + request.age(), + true, + LocalDateTime.now(), + LocalDateTime.now() + ); + + when(userService.create(any(UserCreateRequest.class))).thenReturn(Mono.just(response)); + + // When & Then + webTestClient.post() + .uri("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .body(Mono.just(request), UserCreateRequest.class) + .exchange() + .expectStatus().isCreated() + .expectHeader().exists("Location") + .expectBody(UserResponse.class) + .value(user -> { + assertThat(user.email()).isEqualTo(request.email()); + }); + } +} +``` + +## Testing Configuration + +```java +// application-test.yml +spring: + datasource: + url: jdbc:h2:mem:testdb + driver-class-name: org.h2.Driver + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + security: + user: + name: test + password: test + +logging: + level: + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE + +// Test Configuration Class +@TestConfiguration +public class TestConfig { + + @Bean + @Primary + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(4); // Faster for tests + } + + @Bean + public Clock fixedClock() { + return Clock.fixed( + Instant.parse("2024-01-01T00:00:00Z"), + ZoneId.of("UTC") + ); + } +} +``` + +## Test Fixtures with @DataJpaTest + +```java +@Component +public class TestDataFactory { + + public static User createUser(String email, String username) { + return User.builder() + .email(email) + .password("encodedPassword") + .username(username) + .active(true) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + } + + public static UserCreateRequest createUserRequest() { + return new UserCreateRequest( + "test@example.com", + "Password123", + "testuser", + 25 + ); + } +} +``` + +## Quick Reference + +| Annotation | Purpose | +|------------|---------| +| `@SpringBootTest` | Full application context integration test | +| `@WebMvcTest` | Test MVC controllers with mocked services | +| `@WebFluxTest` | Test reactive controllers | +| `@DataJpaTest` | Test JPA repositories with in-memory database | +| `@MockBean` | Add mock bean to Spring context | +| `@WithMockUser` | Mock authenticated user for security tests | +| `@Testcontainers` | Enable Testcontainers support | +| `@ActiveProfiles` | Activate specific Spring profiles for test | + +## Testing Best Practices + +- Write tests following AAA pattern (Arrange, Act, Assert) +- Use descriptive test names with @DisplayName +- Mock external dependencies, use real DB with Testcontainers +- Achieve 85%+ code coverage +- Test happy path and edge cases +- Use @Transactional for test data cleanup +- Separate unit tests from integration tests +- Use parameterized tests for multiple scenarios +- Test security rules and validation +- Keep tests fast and independent diff --git a/openhis-server-new/.agents/skills/spring-boot-engineer/references/web.md b/openhis-server-new/.agents/skills/spring-boot-engineer/references/web.md new file mode 100644 index 00000000..2eabbbb1 --- /dev/null +++ b/openhis-server-new/.agents/skills/spring-boot-engineer/references/web.md @@ -0,0 +1,295 @@ +# Web Layer - Controllers & REST APIs + +## REST Controller Pattern + +```java +@RestController +@RequestMapping("/api/v1/users") +@Validated +@RequiredArgsConstructor +public class UserController { + private final UserService userService; + + @GetMapping + public ResponseEntity> getUsers( + @PageableDefault(size = 20, sort = "createdAt") Pageable pageable) { + Page users = userService.findAll(pageable); + return ResponseEntity.ok(users); + } + + @GetMapping("/{id}") + public ResponseEntity getUser(@PathVariable Long id) { + UserResponse user = userService.findById(id); + return ResponseEntity.ok(user); + } + + @PostMapping + public ResponseEntity createUser( + @Valid @RequestBody UserCreateRequest request) { + UserResponse user = userService.create(request); + URI location = ServletUriComponentsBuilder + .fromCurrentRequest() + .path("/{id}") + .buildAndExpand(user.id()) + .toUri(); + return ResponseEntity.created(location).body(user); + } + + @PutMapping("/{id}") + public ResponseEntity updateUser( + @PathVariable Long id, + @Valid @RequestBody UserUpdateRequest request) { + UserResponse user = userService.update(id, request); + return ResponseEntity.ok(user); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteUser(@PathVariable Long id) { + userService.delete(id); + } +} +``` + +## Request DTOs with Validation + +```java +public record UserCreateRequest( + @NotBlank(message = "Email is required") + @Email(message = "Email must be valid") + String email, + + @NotBlank(message = "Password is required") + @Size(min = 8, max = 100, message = "Password must be 8-100 characters") + @Pattern(regexp = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d).*$", + message = "Password must contain uppercase, lowercase, and digit") + String password, + + @NotBlank(message = "Username is required") + @Size(min = 3, max = 50) + @Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "Username must be alphanumeric") + String username, + + @Min(value = 18, message = "Must be at least 18") + @Max(value = 120, message = "Must be at most 120") + Integer age +) {} + +public record UserUpdateRequest( + @Email(message = "Email must be valid") + String email, + + @Size(min = 3, max = 50) + String username +) {} +``` + +## Response DTOs + +```java +public record UserResponse( + Long id, + String email, + String username, + Integer age, + Boolean active, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static UserResponse from(User user) { + return new UserResponse( + user.getId(), + user.getEmail(), + user.getUsername(), + user.getAge(), + user.getActive(), + user.getCreatedAt(), + user.getUpdatedAt() + ); + } +} +``` + +## Global Exception Handling + +```java +@RestControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity handleNotFound( + ResourceNotFoundException ex, WebRequest request) { + log.error("Resource not found: {}", ex.getMessage()); + ErrorResponse error = new ErrorResponse( + HttpStatus.NOT_FOUND.value(), + ex.getMessage(), + request.getDescription(false), + LocalDateTime.now() + ); + return new ResponseEntity<>(error, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation( + MethodArgumentNotValidException ex) { + Map errors = ex.getBindingResult() + .getFieldErrors() + .stream() + .collect(Collectors.toMap( + FieldError::getField, + error -> error.getDefaultMessage() != null + ? error.getDefaultMessage() + : "Invalid value" + )); + + ValidationErrorResponse response = new ValidationErrorResponse( + HttpStatus.BAD_REQUEST.value(), + "Validation failed", + errors, + LocalDateTime.now() + ); + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrity( + DataIntegrityViolationException ex, WebRequest request) { + log.error("Data integrity violation", ex); + ErrorResponse error = new ErrorResponse( + HttpStatus.CONFLICT.value(), + "Data integrity violation - resource may already exist", + request.getDescription(false), + LocalDateTime.now() + ); + return new ResponseEntity<>(error, HttpStatus.CONFLICT); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGlobalException( + Exception ex, WebRequest request) { + log.error("Unexpected error", ex); + ErrorResponse error = new ErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR.value(), + "An unexpected error occurred", + request.getDescription(false), + LocalDateTime.now() + ); + return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR); + } +} + +record ErrorResponse( + int status, + String message, + String path, + LocalDateTime timestamp +) {} + +record ValidationErrorResponse( + int status, + String message, + Map errors, + LocalDateTime timestamp +) {} +``` + +## Custom Validation + +```java +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = UniqueEmailValidator.class) +public @interface UniqueEmail { + String message() default "Email already exists"; + Class[] groups() default {}; + Class[] payload() default {}; +} + +@Component +@RequiredArgsConstructor +public class UniqueEmailValidator implements ConstraintValidator { + private final UserRepository userRepository; + + @Override + public boolean isValid(String email, ConstraintValidatorContext context) { + if (email == null) return true; + return !userRepository.existsByEmail(email); + } +} +``` + +## WebClient for External APIs + +```java +@Configuration +public class WebClientConfig { + @Bean + public WebClient webClient(WebClient.Builder builder) { + return builder + .baseUrl("https://api.example.com") + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .filter(logRequest()) + .build(); + } + + private ExchangeFilterFunction logRequest() { + return ExchangeFilterFunction.ofRequestProcessor(request -> { + log.info("Request: {} {}", request.method(), request.url()); + return Mono.just(request); + }); + } +} + +@Service +@RequiredArgsConstructor +public class ExternalApiService { + private final WebClient webClient; + + public Mono fetchData(String id) { + return webClient + .get() + .uri("/data/{id}", id) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, response -> + Mono.error(new ResourceNotFoundException("External resource not found"))) + .onStatus(HttpStatusCode::is5xxServerError, response -> + Mono.error(new ServiceUnavailableException("External service unavailable"))) + .bodyToMono(ExternalDataResponse.class) + .timeout(Duration.ofSeconds(5)) + .retry(3); + } +} +``` + +## CORS Configuration + +```java +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOrigins("http://localhost:3000", "https://example.com") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } +} +``` + +## Quick Reference + +| Annotation | Purpose | +|------------|---------| +| `@RestController` | Marks class as REST controller (combines @Controller + @ResponseBody) | +| `@RequestMapping` | Maps HTTP requests to handler methods | +| `@GetMapping/@PostMapping` | HTTP method-specific mappings | +| `@PathVariable` | Extracts values from URI path | +| `@RequestParam` | Extracts query parameters | +| `@RequestBody` | Binds request body to method parameter | +| `@Valid` | Triggers validation on request body | +| `@RestControllerAdvice` | Global exception handling for REST controllers | +| `@ResponseStatus` | Sets HTTP status code for method | diff --git a/openhis-server-new/add_missing_fields_to_consultation_request.sql b/openhis-server-new/add_missing_fields_to_consultation_request.sql new file mode 100644 index 00000000..bd0a9c95 --- /dev/null +++ b/openhis-server-new/add_missing_fields_to_consultation_request.sql @@ -0,0 +1,63 @@ +-- 为现有的consultation_request表添加缺失的字段 +DO $$ +BEGIN + -- 检查并添加缺失的字段 + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'consultation_request' AND column_name = 'confirming_physician_name') THEN + ALTER TABLE consultation_request ADD COLUMN confirming_physician_name VARCHAR(100); + COMMENT ON COLUMN consultation_request.confirming_physician_name IS '确认会诊的医生姓名'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'consultation_request' AND column_name = 'confirming_department_name') THEN + ALTER TABLE consultation_request ADD COLUMN confirming_department_name VARCHAR(100); + COMMENT ON COLUMN consultation_request.confirming_department_name IS '代表科室'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'consultation_request' AND column_name = 'confirming_physician_participation') THEN + ALTER TABLE consultation_request ADD COLUMN confirming_physician_participation VARCHAR(100); + COMMENT ON COLUMN consultation_request.confirming_physician_participation IS '会诊确认参加医师'; + END IF; + + -- 检查并更新consultation_status字段的注释 + DROP COMMENT ON COLUMN consultation_request.consultation_status; + COMMENT ON COLUMN consultation_request.consultation_status IS '会诊状态:0-新开,10-已提交,20-已确认,30-已签名,40-已完成,50-已取消'; + + -- 检查并更新consultation_urgency字段的默认值 + ALTER TABLE consultation_request ALTER COLUMN consultation_urgency SET DEFAULT '一般'; + + -- 检查并更新字段注释 + COMMENT ON COLUMN consultation_request.consultation_id IS '会诊申请单号:CS+年月日时分秒+4位随机数'; + COMMENT ON COLUMN consultation_request.consultation_request_date IS '会诊申请时间'; + COMMENT ON COLUMN consultation_request.consultation_date IS '会诊时间'; + COMMENT ON COLUMN consultation_request.consultation_purpose IS '简要病史及会诊目的'; + COMMENT ON COLUMN consultation_request.consultation_opinion IS '会诊意见'; + COMMENT ON COLUMN consultation_request.consultation_status IS '会诊状态:0-新开,10-已提交,20-已确认,30-已签名,40-已完成,50-已取消'; + COMMENT ON COLUMN consultation_request.consultation_urgency IS '是否紧急:一般/紧急'; + COMMENT ON COLUMN consultation_request.confirming_physician IS '提交会诊的医生姓名'; + COMMENT ON COLUMN consultation_request.confirming_physician_id IS '提交会诊的医生ID'; + COMMENT ON COLUMN consultation_request.confirming_date IS '提交会诊日期'; + COMMENT ON COLUMN consultation_request.signature IS '结束会诊医生姓名/签名医生'; + COMMENT ON COLUMN consultation_request.signature_physician_id IS '结束会诊医生ID'; + COMMENT ON COLUMN consultation_request.signature_date IS '结束会诊日期/签名时间'; + COMMENT ON COLUMN consultation_request.cancel_nature_date IS '作废会诊日期'; + COMMENT ON COLUMN consultation_request.cancel_reason IS '作废原因'; + COMMENT ON COLUMN consultation_request.consultation_activity_id IS '会诊项目ID,关联wor_activity_definition表,用于确定会诊类型和价格'; + COMMENT ON COLUMN consultation_request.consultation_activity_name IS '会诊项目名称,如:院内会诊、远程会诊等'; + +END $$; + +-- 为现有表添加必要的索引 +DO $$ +BEGIN + -- 检查并创建索引 + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'consultation_request' AND indexname = 'idx_consultation_request_status_date') THEN + CREATE INDEX idx_consultation_request_status_date ON consultation_request (consultation_status, consultation_request_date); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'consultation_request' AND indexname = 'idx_consultation_request_physician') THEN + CREATE INDEX idx_consultation_request_physician ON consultation_request (requesting_physician_id); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'consultation_request' AND indexname = 'idx_consultation_request_invited_object') THEN + CREATE INDEX idx_consultation_request_invited_object ON consultation_request (invited_object); + END IF; +END $$; \ No newline at end of file diff --git a/openhis-server-new/create_consultation_tables_postgresql.sql b/openhis-server-new/create_consultation_tables_postgresql.sql new file mode 100644 index 00000000..ab7cece4 --- /dev/null +++ b/openhis-server-new/create_consultation_tables_postgresql.sql @@ -0,0 +1,109 @@ +-- 会诊申请表 +CREATE TABLE IF NOT EXISTS consultation_request ( + id BIGSERIAL PRIMARY KEY, + patient_id VARCHAR(50) NOT NULL, -- 患者ID + patient_name VARCHAR(100), -- 患者姓名 + gender VARCHAR(10), -- 患者性别 + age INTEGER, -- 患者年龄 + visit_id BIGINT, -- 门诊就诊流水号 + order_id BIGINT, -- 门诊医嘱表主键 + department_id BIGINT, -- 申请科室ID + department_name VARCHAR(100), -- 申请科室名称 + attending_doctor_id BIGINT, -- 主管医生ID + attending_doctor_name VARCHAR(100), -- 主管医生姓名 + consultation_purpose TEXT, -- 会诊目的 + provisional_diagnosis TEXT, -- 门诊诊断 + current_condition TEXT, -- 目前病情 + consultation_department_ids TEXT, -- 申请会诊科室ID列表,逗号分隔 + consultation_doctor_ids TEXT, -- 申请会诊医生ID列表,逗号分隔 + consultation_type INTEGER DEFAULT 1, -- 会诊类型:1-普通会诊,2-紧急会诊,3-多学科会诊 + priority_level INTEGER DEFAULT 1, -- 优先级:1-低,2-中,3-高 + consultation_status INTEGER DEFAULT 0, -- 会诊状态:0-新开,10-已提交,20-已确认,30-已签名,40-已完成,50-已取消 + consultation_opinion TEXT, -- 会诊意见 + confirming_physician VARCHAR(100), -- 提交会诊的医生 + confirming_physician_id BIGINT, -- 提交会诊的医生ID + confirming_date TIMESTAMP, -- 提交会诊时间 + consultation_urgency VARCHAR(20) DEFAULT '一般', -- 是否紧急 + application_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 申请日期 + consultation_request_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 会诊申请时间 + confirmed_date TIMESTAMP, -- 确认日期 + scheduled_date TIMESTAMP, -- 计划会诊日期 + actual_date TIMESTAMP, -- 实际会诊日期 + location VARCHAR(200), -- 会诊地点 + confirming_physician_name VARCHAR(100), -- 确认会诊的医生姓名 + signature VARCHAR(100), -- 签名医生 + signature_date TIMESTAMP, -- 签名时间 + confirming_department_name VARCHAR(100), -- 代表科室 + confirming_physician_participation VARCHAR(100), -- 会诊确认参加医师 + creator_id BIGINT, -- 创建人ID + creator_name VARCHAR(100), -- 创建人姓名 + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 创建时间 + updater_id BIGINT, -- 更新人ID + updater_name VARCHAR(100), -- 更新人姓名 + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 更新时间 + valid_flag INTEGER DEFAULT 1, -- 有效标志:1-有效,0-无效 + tenant_id INTEGER DEFAULT 1 -- 租户ID +); + +-- 会诊确认表 +CREATE TABLE IF NOT EXISTS consultation_confirmation ( + id BIGSERIAL PRIMARY KEY, + consultation_request_id BIGINT NOT NULL, -- 会诊申请ID + confirming_department_id BIGINT NOT NULL, -- 确认科室ID + confirming_department_name VARCHAR(100) NOT NULL, -- 确认科室名称 + confirming_doctor_id BIGINT NOT NULL, -- 确认医生ID + confirming_doctor_name VARCHAR(100) NOT NULL, -- 确认医生姓名 + confirmation_status INTEGER DEFAULT 1, -- 确认状态:1-待确认,2-同意,3-拒绝 + confirmation_reason TEXT, -- 确认/拒绝理由 + confirmation_date TIMESTAMP, -- 确认日期 + consultation_opinion TEXT, -- 会诊意见 + confirming_physician_participation VARCHAR(100), -- 会诊确认参加医师 + confirming_physician_name VARCHAR(100), -- 所属医生 + confirming_department_name_field VARCHAR(100), -- 代表科室 + signature VARCHAR(100), -- 签名医生 + signature_date TIMESTAMP, -- 签名时间 + creator_id BIGINT NOT NULL, -- 创建人ID + creator_name VARCHAR(100) NOT NULL, -- 创建人姓名 + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 创建时间 + updater_id BIGINT, -- 更新人ID + updater_name VARCHAR(100), -- 更新人姓名 + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 更新时间 + valid_flag INTEGER DEFAULT 1, -- 有效标志:1-有效,0-无效 + tenant_id INTEGER DEFAULT 1, -- 租户ID + FOREIGN KEY (consultation_request_id) REFERENCES consultation_request(id) +); + +-- 会诊记录表 +CREATE TABLE IF NOT EXISTS consultation_record ( + id BIGSERIAL PRIMARY KEY, + consultation_request_id BIGINT NOT NULL, -- 会诊申请ID + participant_doctor_id BIGINT NOT NULL, -- 参与医生ID + participant_doctor_name VARCHAR(100) NOT NULL, -- 参与医生姓名 + participant_department_id BIGINT NOT NULL, -- 参与科室ID + participant_department_name VARCHAR(100) NOT NULL, -- 参与科室名称 + opinion TEXT, -- 会诊意见 + suggestion TEXT, -- 会诊建议 + record_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 记录日期 + creator_id BIGINT NOT NULL, -- 创建人ID + creator_name VARCHAR(100) NOT NULL, -- 创建人姓名 + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 创建时间 + updater_id BIGINT, -- 更新人ID + updater_name VARCHAR(100), -- 更新人姓名 + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 更新时间 + valid_flag INTEGER DEFAULT 1, -- 有效标志:1-有效,0-无效 + tenant_id INTEGER DEFAULT 1, -- 租户ID + FOREIGN KEY (consultation_request_id) REFERENCES consultation_request(id) +); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_consultation_request_patient_id ON consultation_request(patient_id); +CREATE INDEX IF NOT EXISTS idx_consultation_request_dept_id ON consultation_request(department_id); +CREATE INDEX IF NOT EXISTS idx_consultation_request_status ON consultation_request(consultation_status); +CREATE INDEX IF NOT EXISTS idx_consultation_request_date ON consultation_request(application_date); + +CREATE INDEX IF NOT EXISTS idx_consultation_confirmation_req_id ON consultation_confirmation(consultation_request_id); +CREATE INDEX IF NOT EXISTS idx_consultation_confirmation_doctor_id ON consultation_confirmation(confirming_doctor_id); +CREATE INDEX IF NOT EXISTS idx_consultation_confirmation_status ON consultation_confirmation(confirmation_status); + +CREATE INDEX IF NOT EXISTS idx_consultation_record_req_id ON consultation_record(consultation_request_id); +CREATE INDEX IF NOT EXISTS idx_consultation_record_doctor_id ON consultation_record(participant_doctor_id); \ No newline at end of file diff --git a/openhis-server-new/create_consultation_tables_postgresql_final.sql b/openhis-server-new/create_consultation_tables_postgresql_final.sql new file mode 100644 index 00000000..d833221f --- /dev/null +++ b/openhis-server-new/create_consultation_tables_postgresql_final.sql @@ -0,0 +1,135 @@ +-- 会诊申请表 +CREATE TABLE IF NOT EXISTS consultation_request ( + id BIGSERIAL PRIMARY KEY, + patient_id VARCHAR(50) NOT NULL, -- 患者ID + patient_name VARCHAR(100), -- 患者姓名 + gender VARCHAR(10), -- 患者性别 + age INTEGER, -- 患者年龄 + visit_id BIGINT, -- 门诊就诊流水号 + order_id BIGINT, -- 门诊医嘱表主键 + department_id BIGINT, -- 申请科室ID + department_name VARCHAR(100), -- 申请科室名称 + attending_doctor_id BIGINT, -- 主管医生ID + attending_doctor_name VARCHAR(100), -- 主管医生姓名 + consultation_purpose TEXT, -- 会诊目的 + provisional_diagnosis TEXT, -- 门诊诊断 + current_condition TEXT, -- 目前病情 + consultation_department_ids TEXT, -- 申请会诊科室ID列表,逗号分隔 + consultation_doctor_ids TEXT, -- 申请会诊医生ID列表,逗号分隔 + consultation_type INTEGER DEFAULT 1, -- 会诊类型:1-普通会诊,2-紧急会诊,3-多学科会诊 + priority_level INTEGER DEFAULT 1, -- 优先级:1-低,2-中,3-高 + consultation_status INTEGER DEFAULT 0, -- 会诊状态:0-新开,10-已提交,20-已确认,30-已签名,40-已完成,50-已取消 + consultation_opinion TEXT, -- 会诊意见 + confirming_physician VARCHAR(100), -- 提交会诊的医生 + confirming_physician_id BIGINT, -- 提交会诊的医生ID + confirming_date TIMESTAMP, -- 提交会诊时间 + consultation_urgency VARCHAR(20) DEFAULT '一般', -- 是否紧急 + application_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 申请日期 + consultation_request_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 会诊申请时间 + confirmed_date TIMESTAMP, -- 确认日期 + scheduled_date TIMESTAMP, -- 计划会诊日期 + actual_date TIMESTAMP, -- 实际会诊日期 + location VARCHAR(200), -- 会诊地点 + confirming_physician_name VARCHAR(100), -- 确认会诊的医生姓名 + signature VARCHAR(100), -- 签名医生 + signature_date TIMESTAMP, -- 签名时间 + confirming_department_name VARCHAR(100), -- 代表科室 + confirming_physician_participation VARCHAR(100), -- 会诊确认参加医师 + creator_id BIGINT, -- 创建人ID + creator_name VARCHAR(100), -- 创建人姓名 + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 创建时间 + updater_id BIGINT, -- 更新人ID + updater_name VARCHAR(100), -- 更新人姓名 + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 更新时间 + valid_flag INTEGER DEFAULT 1, -- 有效标志:1-有效,0-无效 + tenant_id INTEGER DEFAULT 1 -- 租户ID +); + +-- 会诊确认表 +CREATE TABLE IF NOT EXISTS consultation_confirmation ( + id BIGSERIAL PRIMARY KEY, + consultation_request_id BIGINT, -- 会诊申请ID + confirming_department_id BIGINT NOT NULL, -- 确认科室ID + confirming_department_name VARCHAR(100) NOT NULL, -- 确认科室名称 + confirming_doctor_id BIGINT NOT NULL, -- 确认医生ID + confirming_doctor_name VARCHAR(100) NOT NULL, -- 确认医生姓名 + confirmation_status INTEGER DEFAULT 1, -- 确认状态:1-待确认,2-同意,3-拒绝 + confirmation_reason TEXT, -- 确认/拒绝理由 + confirmation_date TIMESTAMP, -- 确认日期 + consultation_opinion TEXT, -- 会诊意见 + confirming_physician_participation VARCHAR(100), -- 会诊确认参加医师 + confirming_physician_name VARCHAR(100), -- 所属医生 + confirming_department_name_field VARCHAR(100), -- 代表科室 + signature VARCHAR(100), -- 签名医生 + signature_date TIMESTAMP, -- 签名时间 + creator_id BIGINT NOT NULL, -- 创建人ID + creator_name VARCHAR(100) NOT NULL, -- 创建人姓名 + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 创建时间 + updater_id BIGINT, -- 更新人ID + updater_name VARCHAR(100), -- 更新人姓名 + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 更新时间 + valid_flag INTEGER DEFAULT 1, -- 有效标志:1-有效,0-无效 + tenant_id INTEGER DEFAULT 1 -- 租户ID +); + +-- 会诊记录表 +CREATE TABLE IF NOT EXISTS consultation_record ( + id BIGSERIAL PRIMARY KEY, + consultation_request_id BIGINT, -- 会诊申请ID + participant_doctor_id BIGINT NOT NULL, -- 参与医生ID + participant_doctor_name VARCHAR(100) NOT NULL, -- 参与医生姓名 + participant_department_id BIGINT NOT NULL, -- 参与科室ID + participant_department_name VARCHAR(100) NOT NULL, -- 参与科室名称 + opinion TEXT, -- 会诊意见 + suggestion TEXT, -- 会诊建议 + record_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 记录日期 + creator_id BIGINT NOT NULL, -- 创建人ID + creator_name VARCHAR(100) NOT NULL, -- 创建人姓名 + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 创建时间 + updater_id BIGINT, -- 更新人ID + updater_name VARCHAR(100), -- 更新人姓名 + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 更新时间 + valid_flag INTEGER DEFAULT 1, -- 有效标志:1-有效,0-无效 + tenant_id INTEGER DEFAULT 1 -- 租户ID +); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_consultation_request_patient_id ON consultation_request(patient_id); +CREATE INDEX IF NOT EXISTS idx_consultation_request_dept_id ON consultation_request(department_id); +CREATE INDEX IF NOT EXISTS idx_consultation_request_status ON consultation_request(consultation_status); +CREATE INDEX IF NOT EXISTS idx_consultation_request_date ON consultation_request(application_date); + +CREATE INDEX IF NOT EXISTS idx_consultation_confirmation_req_id ON consultation_confirmation(consultation_request_id); +CREATE INDEX IF NOT EXISTS idx_consultation_confirmation_doctor_id ON consultation_confirmation(confirming_doctor_id); +CREATE INDEX IF NOT EXISTS idx_consultation_confirmation_status ON consultation_confirmation(confirmation_status); + +CREATE INDEX IF NOT EXISTS idx_consultation_record_req_id ON consultation_record(consultation_request_id); +CREATE INDEX IF NOT EXISTS idx_consultation_record_doctor_id ON consultation_record(participant_doctor_id); + +-- 添加外键约束 +DO $$ +BEGIN + -- 为consultation_confirmation表添加外键约束 + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE constraint_name = 'fk_consultation_confirmation_request' + AND table_name = 'consultation_confirmation' + ) THEN + ALTER TABLE consultation_confirmation + ADD CONSTRAINT fk_consultation_confirmation_request + FOREIGN KEY (consultation_request_id) REFERENCES consultation_request(id); + END IF; + + -- 为consultation_record表添加外键约束 + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE constraint_name = 'fk_consultation_record_request' + AND table_name = 'consultation_record' + ) THEN + ALTER TABLE consultation_record + ADD CONSTRAINT fk_consultation_record_request + FOREIGN KEY (consultation_request_id) REFERENCES consultation_request(id); + END IF; +END $$; \ No newline at end of file diff --git a/openhis-server-new/create_consultation_tables_postgresql_fixed.sql b/openhis-server-new/create_consultation_tables_postgresql_fixed.sql new file mode 100644 index 00000000..9ef288df --- /dev/null +++ b/openhis-server-new/create_consultation_tables_postgresql_fixed.sql @@ -0,0 +1,141 @@ +-- 会诊申请表 +CREATE TABLE IF NOT EXISTS consultation_request ( + id BIGSERIAL PRIMARY KEY, + patient_id VARCHAR(50) NOT NULL, -- 患者ID + patient_name VARCHAR(100), -- 患者姓名 + gender VARCHAR(10), -- 患者性别 + age INTEGER, -- 患者年龄 + visit_id BIGINT, -- 门诊就诊流水号 + order_id BIGINT, -- 门诊医嘱表主键 + department_id BIGINT, -- 申请科室ID + department_name VARCHAR(100), -- 申请科室名称 + attending_doctor_id BIGINT, -- 主管医生ID + attending_doctor_name VARCHAR(100), -- 主管医生姓名 + consultation_purpose TEXT, -- 会诊目的 + provisional_diagnosis TEXT, -- 门诊诊断 + current_condition TEXT, -- 目前病情 + consultation_department_ids TEXT, -- 申请会诊科室ID列表,逗号分隔 + consultation_doctor_ids TEXT, -- 申请会诊医生ID列表,逗号分隔 + consultation_type INTEGER DEFAULT 1, -- 会诊类型:1-普通会诊,2-紧急会诊,3-多学科会诊 + priority_level INTEGER DEFAULT 1, -- 优先级:1-低,2-中,3-高 + consultation_status INTEGER DEFAULT 0, -- 会诊状态:0-新开,10-已提交,20-已确认,30-已签名,40-已完成,50-已取消 + consultation_opinion TEXT, -- 会诊意见 + confirming_physician VARCHAR(100), -- 提交会诊的医生 + confirming_physician_id BIGINT, -- 提交会诊的医生ID + confirming_date TIMESTAMP, -- 提交会诊时间 + consultation_urgency VARCHAR(20) DEFAULT '一般', -- 是否紧急 + application_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 申请日期 + consultation_request_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 会诊申请时间 + confirmed_date TIMESTAMP, -- 确认日期 + scheduled_date TIMESTAMP, -- 计划会诊日期 + actual_date TIMESTAMP, -- 实际会诊日期 + location VARCHAR(200), -- 会诊地点 + confirming_physician_name VARCHAR(100), -- 确认会诊的医生姓名 + signature VARCHAR(100), -- 签名医生 + signature_date TIMESTAMP, -- 签名时间 + confirming_department_name VARCHAR(100), -- 代表科室 + confirming_physician_participation VARCHAR(100), -- 会诊确认参加医师 + creator_id BIGINT, -- 创建人ID + creator_name VARCHAR(100), -- 创建人姓名 + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 创建时间 + updater_id BIGINT, -- 更新人ID + updater_name VARCHAR(100), -- 更新人姓名 + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 更新时间 + valid_flag INTEGER DEFAULT 1, -- 有效标志:1-有效,0-无效 + tenant_id INTEGER DEFAULT 1 -- 租户ID +); + +-- 会诊确认表 +CREATE TABLE IF NOT EXISTS consultation_confirmation ( + id BIGSERIAL PRIMARY KEY, + consultation_request_id BIGINT NOT NULL, -- 会诊申请ID + confirming_department_id BIGINT NOT NULL, -- 确认科室ID + confirming_department_name VARCHAR(100) NOT NULL, -- 确认科室名称 + confirming_doctor_id BIGINT NOT NULL, -- 确认医生ID + confirming_doctor_name VARCHAR(100) NOT NULL, -- 确认医生姓名 + confirmation_status INTEGER DEFAULT 1, -- 确认状态:1-待确认,2-同意,3-拒绝 + confirmation_reason TEXT, -- 确认/拒绝理由 + confirmation_date TIMESTAMP, -- 确认日期 + consultation_opinion TEXT, -- 会诊意见 + confirming_physician_participation VARCHAR(100), -- 会诊确认参加医师 + confirming_physician_name VARCHAR(100), -- 所属医生 + confirming_department_name_field VARCHAR(100), -- 代表科室 + signature VARCHAR(100), -- 签名医生 + signature_date TIMESTAMP, -- 签名时间 + creator_id BIGINT NOT NULL, -- 创建人ID + creator_name VARCHAR(100) NOT NULL, -- 创建人姓名 + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 创建时间 + updater_id BIGINT, -- 更新人ID + updater_name VARCHAR(100), -- 更新人姓名 + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 更新时间 + valid_flag INTEGER DEFAULT 1, -- 有效标志:1-有效,0-无效 + tenant_id INTEGER DEFAULT 1, -- 租户ID + -- 注意:外键约束将在所有表创建后再添加 + CONSTRAINT fk_consultation_confirmation_request + FOREIGN KEY (consultation_request_id) REFERENCES consultation_request(id) +); + +-- 会诊记录表 +CREATE TABLE IF NOT EXISTS consultation_record ( + id BIGSERIAL PRIMARY KEY, + consultation_request_id BIGINT NOT NULL, -- 会诊申请ID + participant_doctor_id BIGINT NOT NULL, -- 参与医生ID + participant_doctor_name VARCHAR(100) NOT NULL, -- 参与医生姓名 + participant_department_id BIGINT NOT NULL, -- 参与科室ID + participant_department_name VARCHAR(100) NOT NULL, -- 参与科室名称 + opinion TEXT, -- 会诊意见 + suggestion TEXT, -- 会诊建议 + record_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 记录日期 + creator_id BIGINT NOT NULL, -- 创建人ID + creator_name VARCHAR(100) NOT NULL, -- 创建人姓名 + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 创建时间 + updater_id BIGINT, -- 更新人ID + updater_name VARCHAR(100), -- 更新人姓名 + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 更新时间 + valid_flag INTEGER DEFAULT 1, -- 有效标志:1-有效,0-无效 + tenant_id INTEGER DEFAULT 1, -- 租户ID + -- 注意:外键约束将在所有表创建后再添加 + CONSTRAINT fk_consultation_record_request + FOREIGN KEY (consultation_request_id) REFERENCES consultation_request(id) +); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_consultation_request_patient_id ON consultation_request(patient_id); +CREATE INDEX IF NOT EXISTS idx_consultation_request_dept_id ON consultation_request(department_id); +CREATE INDEX IF NOT EXISTS idx_consultation_request_status ON consultation_request(consultation_status); +CREATE INDEX IF NOT EXISTS idx_consultation_request_date ON consultation_request(application_date); + +CREATE INDEX IF NOT EXISTS idx_consultation_confirmation_req_id ON consultation_confirmation(consultation_request_id); +CREATE INDEX IF NOT EXISTS idx_consultation_confirmation_doctor_id ON consultation_confirmation(confirming_doctor_id); +CREATE INDEX IF NOT EXISTS idx_consultation_confirmation_status ON consultation_confirmation(confirmation_status); + +CREATE INDEX IF NOT EXISTS idx_consultation_record_req_id ON consultation_record(consultation_request_id); +CREATE INDEX IF NOT EXISTS idx_consultation_record_doctor_id ON consultation_record(participant_doctor_id); + +-- 添加外键约束(如果尚未添加) +DO $$ +BEGIN + -- 为consultation_confirmation表添加外键约束 + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE constraint_name = 'fk_consultation_confirmation_request' + AND table_name = 'consultation_confirmation' + ) THEN + ALTER TABLE consultation_confirmation + ADD CONSTRAINT fk_consultation_confirmation_request + FOREIGN KEY (consultation_request_id) REFERENCES consultation_request(id); + END IF; + + -- 为consultation_record表添加外键约束 + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE constraint_name = 'fk_consultation_record_request' + AND table_name = 'consultation_record' + ) THEN + ALTER TABLE consultation_record + ADD CONSTRAINT fk_consultation_record_request + FOREIGN KEY (consultation_request_id) REFERENCES consultation_request(id); + END IF; +END $$; \ No newline at end of file diff --git a/openhis-server-new/insert_consultation_menu_fixed.sql b/openhis-server-new/insert_consultation_menu_fixed.sql new file mode 100644 index 00000000..076fe5ef --- /dev/null +++ b/openhis-server-new/insert_consultation_menu_fixed.sql @@ -0,0 +1,70 @@ +-- 插入会诊管理模块的菜单项 +-- 首先插入主菜单项 +INSERT INTO sys_menu +(menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +VALUES +('会诊管理', 0, 10, 'consultationmanagement', '', '', 'ConsultationManagement', 1, 0, 'M', '0', '0', '', 'operation', 'admin', NOW(), 'admin', NOW(), '会诊管理菜单'); + +-- 获取刚插入的菜单ID +DO $$ +DECLARE + consultation_menu_id BIGINT; + consultation_request_menu_id BIGINT; + consultation_confirmation_menu_id BIGINT; + consultation_record_menu_id BIGINT; +BEGIN + -- 获取会诊管理菜单ID + SELECT menu_id INTO consultation_menu_id FROM sys_menu WHERE menu_name = '会诊管理' LIMIT 1; + + -- 插入会诊申请子菜单 + INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + VALUES + ('会诊申请', consultation_menu_id, 1, 'consultationrequest', 'clinicmanagement/consultationrequest/index', '', 'ConsultationRequest', 1, 0, 'C', '0', '0', 'consultation:request:view', 'form', 'admin', NOW(), 'admin', NOW(), '会诊申请菜单'); + + -- 获取会诊申请菜单ID + SELECT menu_id INTO consultation_request_menu_id FROM sys_menu WHERE menu_name = '会诊申请' LIMIT 1; + + -- 插入会诊申请按钮权限 + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + VALUES + ('会诊申请查询', consultation_request_menu_id, 1, '', '', '', '', 1, 0, 'F', '0', '0', 'consultation:request:query', '#', 'admin', NOW(), 'admin', NOW(), ''), + ('会诊申请新增', consultation_request_menu_id, 2, '', '', '', '', 1, 0, 'F', '0', '0', 'consultation:request:add', '#', 'admin', NOW(), 'admin', NOW(), ''), + ('会诊申请修改', consultation_request_menu_id, 3, '', '', '', '', 1, 0, 'F', '0', '0', 'consultation:request:edit', '#', 'admin', NOW(), 'admin', NOW(), ''), + ('会诊申请删除', consultation_request_menu_id, 4, '', '', '', '', 1, 0, 'F', '0', '0', 'consultation:request:remove', '#', 'admin', NOW(), 'admin', NOW(), ''); + + -- 插入会诊确认子菜单 + INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + VALUES + ('会诊确认', consultation_menu_id, 2, 'consultationconfirmation', 'clinicmanagement/consultationconfirmation/index', '', 'ConsultationConfirmation', 1, 0, 'C', '0', '0', 'consultation:confirmation:view', 'form', 'admin', NOW(), 'admin', NOW(), '会诊确认菜单'); + + -- 获取会诊确认菜单ID + SELECT menu_id INTO consultation_confirmation_menu_id FROM sys_menu WHERE menu_name = '会诊确认' LIMIT 1; + + -- 插入会诊确认按钮权限 + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + VALUES + ('会诊确认查询', consultation_confirmation_menu_id, 1, '', '', '', '', 1, 0, 'F', '0', '0', 'consultation:confirmation:query', '#', 'admin', NOW(), 'admin', NOW(), ''), + ('会诊确认新增', consultation_confirmation_menu_id, 2, '', '', '', '', 1, 0, 'F', '0', '0', 'consultation:confirmation:add', '#', 'admin', NOW(), 'admin', NOW(), ''), + ('会诊确认修改', consultation_confirmation_menu_id, 3, '', '', '', '', 1, 0, 'F', '0', '0', 'consultation:confirmation:edit', '#', 'admin', NOW(), 'admin', NOW(), ''), + ('会诊确认删除', consultation_confirmation_menu_id, 4, '', '', '', '', 1, 0, 'F', '0', '0', 'consultation:confirmation:remove', '#', 'admin', NOW(), 'admin', NOW(), ''), + ('会诊确认操作', consultation_confirmation_menu_id, 5, '', '', '', '', 1, 0, 'F', '0', '0', 'consultation:confirmation:confirm', '#', 'admin', NOW(), 'admin', NOW(), ''); + + -- 插入会诊记录子菜单 + INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + VALUES + ('会诊记录', consultation_menu_id, 3, 'consultationrecord', 'clinicmanagement/consultationrecord/index', '', 'ConsultationRecord', 1, 0, 'C', '0', '0', 'consultation:record:view', 'form', 'admin', NOW(), 'admin', NOW(), '会诊记录菜单'); + + -- 获取会诊记录菜单ID + SELECT menu_id INTO consultation_record_menu_id FROM sys_menu WHERE menu_name = '会诊记录' LIMIT 1; + + -- 插入会诊记录按钮权限 + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + VALUES + ('会诊记录查询', consultation_record_menu_id, 1, '', '', '', '', 1, 0, 'F', '0', '0', 'consultation:record:query', '#', 'admin', NOW(), 'admin', NOW(), ''), + ('会诊记录新增', consultation_record_menu_id, 2, '', '', '', '', 1, 0, 'F', '0', '0', 'consultation:record:add', '#', 'admin', NOW(), 'admin', NOW(), ''), + ('会诊记录修改', consultation_record_menu_id, 3, '', '', '', '', 1, 0, 'F', '0', '0', 'consultation:record:edit', '#', 'admin', NOW(), 'admin', NOW(), ''), + ('会诊记录删除', consultation_record_menu_id, 4, '', '', '', '', 1, 0, 'F', '0', '0', 'consultation:record:remove', '#', 'admin', NOW(), 'admin', NOW(), ''); +END $$; \ No newline at end of file diff --git a/openhis-server-new/openhis-application/src/main/java/com/openhis/web/consultation/controller/ConsultationConfirmationController.java b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/consultation/controller/ConsultationConfirmationController.java new file mode 100644 index 00000000..67fcc084 --- /dev/null +++ b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/consultation/controller/ConsultationConfirmationController.java @@ -0,0 +1,95 @@ +package com.openhis.web.consultation.controller; + +import com.core.common.core.controller.BaseController; +import com.core.common.core.domain.AjaxResult; +import com.core.common.annotation.Log; +import com.core.common.enums.BusinessType; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.openhis.consultation.domain.ConsultationConfirmation; +import com.openhis.consultation.service.IConsultationConfirmationService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 会诊确认 Controller + * + * @author his + */ +@RestController +@RequestMapping("/consultation/confirmation") +public class ConsultationConfirmationController extends BaseController { + @Autowired + private IConsultationConfirmationService consultationConfirmationService; + + /** + * 查询会诊确认列表 + */ + @PreAuthorize("@ss.hasPermi('consultation:confirmation:list')") + @GetMapping("/list") + public AjaxResult list(ConsultationConfirmation consultationConfirmation, + @RequestParam(defaultValue = "1") Integer pageNum, + @RequestParam(defaultValue = "10") Integer pageSize) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(consultationConfirmation.getConsultationRequestId() != null, + ConsultationConfirmation::getConsultationRequestId, + consultationConfirmation.getConsultationRequestId()) + .eq(consultationConfirmation.getConfirmationStatus() != null, + ConsultationConfirmation::getConfirmationStatus, + consultationConfirmation.getConfirmationStatus()) + .orderByDesc(ConsultationConfirmation::getCreateTime); + + Page page = new Page<>(pageNum, pageSize); + Page result = consultationConfirmationService.page(page, queryWrapper); + return AjaxResult.success(result); + } + + /** + * 获取会诊确认详细信息 + */ + @PreAuthorize("@ss.hasPermi('consultation:confirmation:query')") + @GetMapping(value = "/{id}") + public AjaxResult getInfo(@PathVariable("id") Long id) { + return AjaxResult.success(consultationConfirmationService.getById(id)); + } + + /** + * 新增会诊确认 + */ + @PreAuthorize("@ss.hasPermi('consultation:confirmation:add')") + @Log(title = "会诊确认", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody ConsultationConfirmation consultationConfirmation) { + consultationConfirmation.setCreatorId(getUserId()); + consultationConfirmation.setCreatorName(getUsername()); + return toAjax(consultationConfirmationService.save(consultationConfirmation)); + } + + /** + * 确认会诊(同意或拒绝) + */ + @PreAuthorize("@ss.hasPermi('consultation:confirmation:confirm')") + @Log(title = "会诊确认", businessType = BusinessType.UPDATE) + @PutMapping("/confirm") + public AjaxResult confirm(@RequestBody ConsultationConfirmation consultationConfirmation) { + // 设置确认日期 + consultationConfirmation.setConfirmationDate(LocalDateTime.now()); + consultationConfirmation.setUpdaterId(getUserId()); + consultationConfirmation.setUpdaterName(getUsername()); + return toAjax(consultationConfirmationService.updateById(consultationConfirmation)); + } + + /** + * 删除会诊确认 + */ + @PreAuthorize("@ss.hasPermi('consultation:confirmation:remove')") + @Log(title = "会诊确认", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public AjaxResult remove(@PathVariable Long[] ids) { + return toAjax(consultationConfirmationService.removeByIds(java.util.Arrays.asList(ids))); + } +} \ No newline at end of file diff --git a/openhis-server-new/openhis-application/src/main/java/com/openhis/web/consultation/controller/ConsultationRecordController.java b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/consultation/controller/ConsultationRecordController.java new file mode 100644 index 00000000..f138e177 --- /dev/null +++ b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/consultation/controller/ConsultationRecordController.java @@ -0,0 +1,92 @@ +package com.openhis.web.consultation.controller; + +import com.core.common.core.controller.BaseController; +import com.core.common.core.domain.AjaxResult; +import com.core.common.annotation.Log; +import com.core.common.enums.BusinessType; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.openhis.consultation.domain.ConsultationRecord; +import com.openhis.consultation.service.IConsultationRecordService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 会诊记录 Controller + * + * @author his + */ +@RestController +@RequestMapping("/consultation/record") +public class ConsultationRecordController extends BaseController { + @Autowired + private IConsultationRecordService consultationRecordService; + + /** + * 查询会诊记录列表 + */ + @PreAuthorize("@ss.hasPermi('consultation:record:list')") + @GetMapping("/list") + public AjaxResult list(ConsultationRecord consultationRecord, + @RequestParam(defaultValue = "1") Integer pageNum, + @RequestParam(defaultValue = "10") Integer pageSize) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(consultationRecord.getConsultationRequestId() != null, + ConsultationRecord::getConsultationRequestId, + consultationRecord.getConsultationRequestId()) + .eq(consultationRecord.getParticipantDoctorId() != null, + ConsultationRecord::getParticipantDoctorId, + consultationRecord.getParticipantDoctorId()) + .orderByDesc(ConsultationRecord::getRecordDate); + + Page page = new Page<>(pageNum, pageSize); + Page result = consultationRecordService.page(page, queryWrapper); + return AjaxResult.success(result); + } + + /** + * 获取会诊记录详细信息 + */ + @PreAuthorize("@ss.hasPermi('consultation:record:query')") + @GetMapping(value = "/{id}") + public AjaxResult getInfo(@PathVariable("id") Long id) { + return AjaxResult.success(consultationRecordService.getById(id)); + } + + /** + * 新增会诊记录 + */ + @PreAuthorize("@ss.hasPermi('consultation:record:add')") + @Log(title = "会诊记录", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody ConsultationRecord consultationRecord) { + consultationRecord.setCreatorId(getUserId()); + consultationRecord.setCreatorName(getUsername()); + return toAjax(consultationRecordService.save(consultationRecord)); + } + + /** + * 修改会诊记录 + */ + @PreAuthorize("@ss.hasPermi('consultation:record:edit')") + @Log(title = "会诊记录", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@RequestBody ConsultationRecord consultationRecord) { + consultationRecord.setUpdaterId(getUserId()); + consultationRecord.setUpdaterName(getUsername()); + return toAjax(consultationRecordService.updateById(consultationRecord)); + } + + /** + * 删除会诊记录 + */ + @PreAuthorize("@ss.hasPermi('consultation:record:remove')") + @Log(title = "会诊记录", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public AjaxResult remove(@PathVariable Long[] ids) { + return toAjax(consultationRecordService.removeByIds(java.util.Arrays.asList(ids))); + } +} \ No newline at end of file diff --git a/openhis-server-new/openhis-application/src/main/java/com/openhis/web/consultation/controller/ConsultationRequestController.java b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/consultation/controller/ConsultationRequestController.java new file mode 100644 index 00000000..8f250e58 --- /dev/null +++ b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/consultation/controller/ConsultationRequestController.java @@ -0,0 +1,170 @@ +package com.openhis.web.consultation.controller; + +import com.core.common.core.controller.BaseController; +import com.core.common.core.domain.AjaxResult; +import com.core.common.annotation.Log; +import com.core.common.enums.BusinessType; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.openhis.consultation.domain.ConsultationRequest; +import com.openhis.consultation.service.IConsultationRequestService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 会诊申请 Controller + * + * @author his + */ +@RestController +@RequestMapping("/consultation/request") +public class ConsultationRequestController extends BaseController { + @Autowired + private IConsultationRequestService consultationRequestService; + + /** + * 查询会诊申请列表 + */ + @PreAuthorize("@ss.hasPermi('consultation:request:list')") + @GetMapping("/list") + public AjaxResult list(ConsultationRequest consultationRequest, + @RequestParam(defaultValue = "1") Integer pageNum, + @RequestParam(defaultValue = "10") Integer pageSize) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.like(consultationRequest.getPatientName() != null, ConsultationRequest::getPatientName, consultationRequest.getPatientName()) + .eq(consultationRequest.getConsultationStatus() != null, ConsultationRequest::getConsultationStatus, consultationRequest.getConsultationStatus()) + .eq(consultationRequest.getConsultationActivityId() != null, ConsultationRequest::getConsultationActivityId, consultationRequest.getConsultationActivityId()) + .orderByDesc(ConsultationRequest::getConsultationRequestDate); + + Page page = new Page<>(pageNum, pageSize); + Page result = consultationRequestService.page(page, queryWrapper); + return AjaxResult.success(result); + } + + /** + * 获取会诊申请详细信息 + */ + @PreAuthorize("@ss.hasPermi('consultation:request:query')") + @GetMapping(value = "/{id}") + public AjaxResult getInfo(@PathVariable("id") Long id) { + return AjaxResult.success(consultationRequestService.getById(id)); + } + + /** + * 新增会诊申请 + */ + @PreAuthorize("@ss.hasPermi('consultation:request:add')") + @Log(title = "会诊申请", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody ConsultationRequest consultationRequest) { + // 自动生成申请单号 + String consultationId = "CS" + LocalDateTime.now().toString().replaceAll("[^0-9]", "").substring(0, 14) + String.format("%04d", (int)(Math.random() * 10000)); + consultationRequest.setConsultationId(consultationId); + + consultationRequest.setCreateBy(getUsername()); + consultationRequest.setConsultationRequestDate(LocalDateTime.now()); + consultationRequest.setConsultationStatus(0); // 新开状态 + return toAjax(consultationRequestService.save(consultationRequest)); + } + + /** + * 修改会诊申请 + */ + @PreAuthorize("@ss.hasPermi('consultation:request:edit')") + @Log(title = "会诊申请", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@RequestBody ConsultationRequest consultationRequest) { + consultationRequest.setUpdateBy(getUsername()); + return toAjax(consultationRequestService.updateById(consultationRequest)); + } + + /** + * 删除会诊申请 + */ + @PreAuthorize("@ss.hasPermi('consultation:request:remove')") + @Log(title = "会诊申请", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public AjaxResult remove(@PathVariable Long[] ids) { + return toAjax(consultationRequestService.removeByIds(java.util.Arrays.asList(ids))); + } + + /** + * 提交会诊申请 + */ + @PreAuthorize("@ss.hasPermi('consultation:request:submit')") + @Log(title = "会诊申请", businessType = BusinessType.UPDATE) + @PutMapping("/submit/{id}") + public AjaxResult submit(@PathVariable Long id) { + int result = consultationRequestService.submitConsultation(id, getUserId(), getUsername()); + return result > 0 ? AjaxResult.success("提交成功") : AjaxResult.error("提交失败"); + } + + /** + * 取消提交会诊申请 + */ + @PreAuthorize("@ss.hasPermi('consultation:request:cancelSubmit')") + @Log(title = "会诊申请", businessType = BusinessType.UPDATE) + @PutMapping("/cancelSubmit/{id}") + public AjaxResult cancelSubmit(@PathVariable Long id) { + int result = consultationRequestService.cancelSubmitConsultation(id); + return result > 0 ? AjaxResult.success("取消提交成功") : AjaxResult.error("取消提交失败"); + } + + /** + * 结束会诊申请 + */ + @PreAuthorize("@ss.hasPermi('consultation:request:end')") + @Log(title = "会诊申请", businessType = BusinessType.UPDATE) + @PutMapping("/end/{id}") + public AjaxResult end(@PathVariable Long id) { + int result = consultationRequestService.endConsultation(id, getUserId(), getUsername()); + return result > 0 ? AjaxResult.success("结束成功") : AjaxResult.error("结束失败,请确保会诊已签名"); + } + + /** + * 作废会诊申请 + */ + @PreAuthorize("@ss.hasPermi('consultation:request:cancel')") + @Log(title = "会诊申请", businessType = BusinessType.UPDATE) + @PutMapping("/cancel/{id}") + public AjaxResult cancel(@PathVariable Long id) { + int result = consultationRequestService.cancelConsultation(id, getUserId(), getUsername()); + return result > 0 ? AjaxResult.success("作废成功") : AjaxResult.error("作废失败"); + } + + /** + * 确认会诊 + */ + @PreAuthorize("@ss.hasPermi('consultation:request:confirm')") + @Log(title = "会诊申请", businessType = BusinessType.UPDATE) + @PutMapping("/confirm") + public AjaxResult confirm(@RequestBody ConsultationRequest request) { + int result = consultationRequestService.confirmConsultation( + request.getId(), + request.getConfirmingPhysicianName(), + String.valueOf(request.getConfirmingPhysicianId()), + request.getConsultationOpinion() + ); + return result > 0 ? AjaxResult.success("确认成功") : AjaxResult.error("确认失败"); + } + + /** + * 签名会诊 + */ + @PreAuthorize("@ss.hasPermi('consultation:request:sign')") + @Log(title = "会诊申请", businessType = BusinessType.UPDATE) + @PutMapping("/sign") + public AjaxResult sign(@RequestBody ConsultationRequest request) { + int result = consultationRequestService.signConsultation( + request.getId(), + request.getSignature(), + getUserId(), + getUsername() + ); + return result > 0 ? AjaxResult.success("签名成功") : AjaxResult.error("签名失败,请确保会诊已确认"); + } +} \ No newline at end of file diff --git a/openhis-server-new/openhis-application/src/main/java/com/openhis/web/paymentmanage/appservice/impl/PaymentRecServiceImpl.java b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/paymentmanage/appservice/impl/PaymentRecServiceImpl.java index 1b34b9f6..4a74d763 100644 --- a/openhis-server-new/openhis-application/src/main/java/com/openhis/web/paymentmanage/appservice/impl/PaymentRecServiceImpl.java +++ b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/paymentmanage/appservice/impl/PaymentRecServiceImpl.java @@ -190,8 +190,15 @@ public class PaymentRecServiceImpl implements IPaymentRecService { @Override public R prePayment(PrePaymentDto prePaymentDto) { logger.info("预结算:参数:" + JSON.toJSONString(prePaymentDto)); - // 查收费项 - List chargeItemList = getChargeItems(prePaymentDto.getChargeItemIds()); + // 查收费项(支持手术计费) + List chargeItemList; + if (prePaymentDto.getGenerateSourceEnum() != null && prePaymentDto.getGenerateSourceEnum() == 2) { + // 手术计费:根据generateSourceEnum和sourceBillNo过滤 + chargeItemList = getChargeItems(prePaymentDto.getChargeItemIds(), prePaymentDto.getGenerateSourceEnum(), prePaymentDto.getSourceBillNo()); + } else { + // 普通门诊划价 + chargeItemList = getChargeItems(prePaymentDto.getChargeItemIds()); + } if (chargeItemList.isEmpty()) { return R.fail("未选择收费项"); } @@ -2093,6 +2100,24 @@ public class PaymentRecServiceImpl implements IPaymentRecService { return chargeItemService.list(new LambdaQueryWrapper().in(ChargeItem::getId, chargeItemIds)); } + /** + * 获取收费项集合(支持手术计费) + * + * @param chargeItemIds 收费项id集合 + * @param generateSourceEnum 账单生成来源(2:手术计费) + * @param sourceBillNo 来源业务单据号(手术单号) + * @return 收费项集合 + */ + private List getChargeItems(List chargeItemIds, Integer generateSourceEnum, String sourceBillNo) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper().in(ChargeItem::getId, chargeItemIds); + // 如果是手术计费,需要额外过滤条件 + if (generateSourceEnum != null && generateSourceEnum == 2 && sourceBillNo != null) { + wrapper.eq(ChargeItem::getGenerateSourceEnum, 2) + .eq(ChargeItem::getSourceBillNo, sourceBillNo); + } + return chargeItemService.list(wrapper); + } + /** * 类型转换:收费项chargeItem转成PaymentedItemModel * diff --git a/openhis-server-new/openhis-application/src/main/java/com/openhis/web/ybmanage/controller/DayEndMedicalInsuranceSettlementController.java b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/ybmanage/controller/DayEndMedicalInsuranceSettlementController.java new file mode 100644 index 00000000..e64a0252 --- /dev/null +++ b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/ybmanage/controller/DayEndMedicalInsuranceSettlementController.java @@ -0,0 +1,94 @@ +package com.openhis.web.ybmanage.controller; + +import com.core.common.core.domain.AjaxResult; +import com.core.common.core.domain.PageResult; +import com.core.common.core.page.TableDataInfo; +import com.core.common.core.controller.BaseController; +import com.openhis.yb.domain.DayEndMedicalInsuranceSettlement; +import com.openhis.yb.service.IDayEndMedicalInsuranceSettlementService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 日结医保结算Controller + * + * @author + * @date 2026-02-02 + */ +@Api(tags = "日结医保结算") +@RestController +@RequestMapping("/ybmanage/dayEndMedicalInsuranceSettlement") +public class DayEndMedicalInsuranceSettlementController extends BaseController { + + @Autowired + private IDayEndMedicalInsuranceSettlementService dayEndMedicalInsuranceSettlementService; + + /** + * 查询日结医保结算列表 + */ + @ApiOperation("查询日结医保结算列表") + @PreAuthorize("@ss.hasPermi('ybmanage:dayEndMedicalInsuranceSettlement:list')") + @GetMapping("/list") + public TableDataInfo list(DayEndMedicalInsuranceSettlement dayEndMedicalInsuranceSettlement) { + startPage(); + List list = dayEndMedicalInsuranceSettlementService.selectDayEndMedicalInsuranceSettlementList(dayEndMedicalInsuranceSettlement); + return getDataTable(list); + } + + /** + * 分页查询日结医保结算列表 + */ + @ApiOperation("分页查询日结医保结算列表") + @PreAuthorize("@ss.hasPermi('ybmanage:dayEndMedicalInsuranceSettlement:list')") + @GetMapping("/page") + public PageResult page(DayEndMedicalInsuranceSettlement dayEndMedicalInsuranceSettlement, + @RequestParam(defaultValue = "1") int pageNum, + @RequestParam(defaultValue = "10") int pageSize) { + return dayEndMedicalInsuranceSettlementService.selectDayEndMedicalInsuranceSettlementPage(dayEndMedicalInsuranceSettlement, pageNum, pageSize); + } + + /** + * 获取日结医保结算详细信息 + */ + @ApiOperation("获取日结医保结算详细信息") + @PreAuthorize("@ss.hasPermi('ybmanage:dayEndMedicalInsuranceSettlement:query')") + @GetMapping(value = "/{id}") + public AjaxResult getInfo(@PathVariable("id") Long id) { + return AjaxResult.success(dayEndMedicalInsuranceSettlementService.selectDayEndMedicalInsuranceSettlementById(id)); + } + + /** + * 新增日结医保结算 + */ + @ApiOperation("新增日结医保结算") + @PreAuthorize("@ss.hasPermi('ybmanage:dayEndMedicalInsuranceSettlement:add')") + @PostMapping + public AjaxResult add(@RequestBody DayEndMedicalInsuranceSettlement dayEndMedicalInsuranceSettlement) { + return toAjax(dayEndMedicalInsuranceSettlementService.insertDayEndMedicalInsuranceSettlement(dayEndMedicalInsuranceSettlement)); + } + + /** + * 修改日结医保结算 + */ + @ApiOperation("修改日结医保结算") + @PreAuthorize("@ss.hasPermi('ybmanage:dayEndMedicalInsuranceSettlement:edit')") + @PutMapping + public AjaxResult edit(@RequestBody DayEndMedicalInsuranceSettlement dayEndMedicalInsuranceSettlement) { + return toAjax(dayEndMedicalInsuranceSettlementService.updateDayEndMedicalInsuranceSettlement(dayEndMedicalInsuranceSettlement)); + } + + /** + * 删除日结医保结算 + */ + @ApiOperation("删除日结医保结算") + @PreAuthorize("@ss.hasPermi('ybmanage:dayEndMedicalInsuranceSettlement:remove')") + @DeleteMapping("/{ids}") + public AjaxResult remove(@PathVariable Long[] ids) { + return toAjax(dayEndMedicalInsuranceSettlementService.deleteDayEndMedicalInsuranceSettlementByIds(ids)); + } +} \ No newline at end of file diff --git a/openhis-server-new/openhis-application/src/main/resources/application-dev.yml b/openhis-server-new/openhis-application/src/main/resources/application-dev.yml index bf984f0d..2c1fc2fe 100644 --- a/openhis-server-new/openhis-application/src/main/resources/application-dev.yml +++ b/openhis-server-new/openhis-application/src/main/resources/application-dev.yml @@ -90,4 +90,15 @@ server: port: 18080 servlet: # 应用的访问路径 - context-path: /openhis \ No newline at end of file + context-path: /openhis + +# 开发环境日志配置 +logging: + level: + com.openhis: debug + com.baomidou.mybatisplus: debug + com.openhis.mapper: debug + com.openhis.domain: debug + org.springframework.jdbc.core: debug + com.alibaba.druid: debug + com.alibaba.druid.sql: debug \ No newline at end of file diff --git a/openhis-server-new/openhis-application/src/main/resources/application.yml b/openhis-server-new/openhis-application/src/main/resources/application.yml index 2b1abad6..057ea6ab 100644 --- a/openhis-server-new/openhis-application/src/main/resources/application.yml +++ b/openhis-server-new/openhis-application/src/main/resources/application.yml @@ -32,16 +32,17 @@ server: # 日志配置 logging: level: - com.openhis: info + com.openhis: debug org.springframework: warn # MyBatis和MyBatis-Plus日志 - com.baomidou.mybatisplus: info - com.openhis.web.regdoctorstation.mapper: info + com.baomidou.mybatisplus: debug + com.openhis.mapper: debug + com.openhis.domain: debug # JDBC日志 - org.springframework.jdbc.core: info + org.springframework.jdbc.core: debug # Druid SQL日志 - com.alibaba.druid: info - com.alibaba.druid.sql: info + com.alibaba.druid: debug + com.alibaba.druid.sql: debug # 用户配置 user: @@ -91,6 +92,9 @@ mybatis-plus: mapperLocations: classpath*:mapper/**/*Mapper.xml # 加载全局的配置文件 configLocation: classpath:mybatis/mybatis-config.xml + configuration: + # 开启 SQL 日志输出 + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: validFlag # 全局逻辑删除的实体字段名 diff --git a/openhis-server-new/openhis-application/src/main/resources/mybatis/mybatis-config.xml b/openhis-server-new/openhis-application/src/main/resources/mybatis/mybatis-config.xml index e239d24b..e98ddd27 100644 --- a/openhis-server-new/openhis-application/src/main/resources/mybatis/mybatis-config.xml +++ b/openhis-server-new/openhis-application/src/main/resources/mybatis/mybatis-config.xml @@ -12,7 +12,7 @@ - + diff --git a/openhis-server-new/openhis-application/src/main/resources/spy.properties b/openhis-server-new/openhis-application/src/main/resources/spy.properties new file mode 100644 index 00000000..4fd5882d --- /dev/null +++ b/openhis-server-new/openhis-application/src/main/resources/spy.properties @@ -0,0 +1,5 @@ +modulelist=com.baomidou.mybatisplus.extension.p6spy.MybatisPlusLogFactory,com.p6spy.engine.outage.P6OutageFactory +logMessageFormat=com.baomidou.mybatisplus.extension.p6spy.P6SpyLogger +outagedetection=true +outagedetectioninterval=2000 +appender=com.baomidou.mybatisplus.extension.p6spy.StdoutLogger \ No newline at end of file diff --git a/openhis-server-new/openhis-domain/src/main/java/com/openhis/administration/domain/ChargeItem.java b/openhis-server-new/openhis-domain/src/main/java/com/openhis/administration/domain/ChargeItem.java index 25a717cd..75de7058 100644 --- a/openhis-server-new/openhis-domain/src/main/java/com/openhis/administration/domain/ChargeItem.java +++ b/openhis-server-new/openhis-domain/src/main/java/com/openhis/administration/domain/ChargeItem.java @@ -244,4 +244,9 @@ public class ChargeItem extends HisBaseEntity { */ private BigDecimal manualAdjustedPrice; + /** + * 来源业务单据号(如手术申请单号) + */ + private String sourceBillNo; + } diff --git a/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/domain/ConsultationConfirmation.java b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/domain/ConsultationConfirmation.java new file mode 100644 index 00000000..15440769 --- /dev/null +++ b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/domain/ConsultationConfirmation.java @@ -0,0 +1,156 @@ +package com.openhis.consultation.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.io.Serializable; +import java.time.LocalDateTime; +import lombok.Data; + +/** + * 会诊确认表 + */ +@TableName(value = "consultation_confirmation") +@Data +public class ConsultationConfirmation implements Serializable { + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 会诊申请ID + */ + @TableField(value = "consultation_request_id") + private Long consultationRequestId; + + /** + * 确认科室ID + */ + @TableField(value = "confirming_department_id") + private Long confirmingDepartmentId; + + /** + * 确认科室名称 + */ + @TableField(value = "confirming_department_name") + private String confirmingDepartmentName; + + /** + * 确认医生ID + */ + @TableField(value = "confirming_doctor_id") + private Long confirmingDoctorId; + + /** + * 确认医生姓名 + */ + @TableField(value = "confirming_doctor_name") + private String confirmingDoctorName; + + /** + * 确认状态:1-待确认,2-同意,3-拒绝 + */ + @TableField(value = "confirmation_status") + private Integer confirmationStatus; + + /** + * 确认/拒绝理由 + */ + @TableField(value = "confirmation_reason") + private String confirmationReason; + + /** + * 确认日期 + */ + @TableField(value = "confirmation_date") + private LocalDateTime confirmationDate; + + /** + * 会诊意见 + */ + @TableField(value = "consultation_opinion") + private String consultationOpinion; + + /** + * 会诊确认参加医师 + */ + @TableField(value = "confirming_physician_participation") + private String confirmingPhysicianParticipation; + + /** + * 所属医生 + */ + @TableField(value = "confirming_physician_name") + private String confirmingPhysicianName; + + /** + * 代表科室 + */ + @TableField(value = "confirming_department_name") + private String confirmingDepartmentNameField; + + /** + * 签名医生 + */ + @TableField(value = "signature") + private String signature; + + /** + * 签名时间 + */ + @TableField(value = "signature_date") + private LocalDateTime signatureDate; + + /** + * 创建人ID + */ + @TableField(value = "creator_id") + private Long creatorId; + + /** + * 创建人姓名 + */ + @TableField(value = "creator_name") + private String creatorName; + + /** + * 创建时间 + */ + @TableField(value = "create_time") + private LocalDateTime createTime; + + /** + * 更新人ID + */ + @TableField(value = "updater_id") + private Long updaterId; + + /** + * 更新人姓名 + */ + @TableField(value = "updater_name") + private String updaterName; + + /** + * 更新时间 + */ + @TableField(value = "update_time") + private LocalDateTime updateTime; + + /** + * 有效标志:1-有效,0-无效 + */ + @TableField(value = "valid_flag") + private Integer validFlag; + + /** + * 租户ID + */ + @TableField(value = "tenant_id") + private Integer tenantId; + + private static final long serialVersionUID = 1L; +} \ No newline at end of file diff --git a/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/domain/ConsultationRecord.java b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/domain/ConsultationRecord.java new file mode 100644 index 00000000..474a6101 --- /dev/null +++ b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/domain/ConsultationRecord.java @@ -0,0 +1,120 @@ +package com.openhis.consultation.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.io.Serializable; +import java.time.LocalDateTime; +import lombok.Data; + +/** + * 会诊记录表 + */ +@TableName(value = "consultation_record") +@Data +public class ConsultationRecord implements Serializable { + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 会诊申请ID + */ + @TableField(value = "consultation_request_id") + private Long consultationRequestId; + + /** + * 参与医生ID + */ + @TableField(value = "participant_doctor_id") + private Long participantDoctorId; + + /** + * 参与医生姓名 + */ + @TableField(value = "participant_doctor_name") + private String participantDoctorName; + + /** + * 参与科室ID + */ + @TableField(value = "participant_department_id") + private Long participantDepartmentId; + + /** + * 参与科室名称 + */ + @TableField(value = "participant_department_name") + private String participantDepartmentName; + + /** + * 会诊意见 + */ + @TableField(value = "opinion") + private String opinion; + + /** + * 会诊建议 + */ + @TableField(value = "suggestion") + private String suggestion; + + /** + * 记录日期 + */ + @TableField(value = "record_date") + private LocalDateTime recordDate; + + /** + * 创建人ID + */ + @TableField(value = "creator_id") + private Long creatorId; + + /** + * 创建人姓名 + */ + @TableField(value = "creator_name") + private String creatorName; + + /** + * 创建时间 + */ + @TableField(value = "create_time") + private LocalDateTime createTime; + + /** + * 更新人ID + */ + @TableField(value = "updater_id") + private Long updaterId; + + /** + * 更新人姓名 + */ + @TableField(value = "updater_name") + private String updaterName; + + /** + * 更新时间 + */ + @TableField(value = "update_time") + private LocalDateTime updateTime; + + /** + * 有效标志:1-有效,0-无效 + */ + @TableField(value = "valid_flag") + private Integer validFlag; + + /** + * 租户ID + */ + @TableField(value = "tenant_id") + private Integer tenantId; + + private static final long serialVersionUID = 1L; +} \ No newline at end of file diff --git a/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/domain/ConsultationRequest.java b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/domain/ConsultationRequest.java new file mode 100644 index 00000000..20d2c3de --- /dev/null +++ b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/domain/ConsultationRequest.java @@ -0,0 +1,270 @@ +package com.openhis.consultation.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.io.Serializable; +import java.time.LocalDateTime; +import lombok.Data; + +/** + * 会诊申请表 + */ +@TableName(value = "consultation_request") +@Data +public class ConsultationRequest implements Serializable { + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 会诊申请单号:CS+年月日时分秒+4位随机数 + */ + @TableField(value = "consultation_id") + private String consultationId; + + /** + * 患者ID(外键:adm_patient.id) + */ + @TableField(value = "patient_id") + private Long patientId; + + /** + * 门诊就诊流水号(外键:adm_encounter.id) + */ + @TableField(value = "encounter_id") + private Long encounterId; + + /** + * 关联的医嘱ID(外键:wor_service_request.id) + */ + @TableField(value = "order_id") + private Long orderId; + + /** + * 患者姓名 + */ + @TableField(value = "patient_name") + private String patientName; + + /** + * 患者病历号 + */ + @TableField(value = "patient_bus_no") + private String patientBusNo; + + /** + * 患者就诊卡号 + */ + @TableField(value = "patient_identifier_no") + private String patientIdentifierNo; + + /** + * 性别 + */ + @TableField(value = "gender_enum") + private Integer genderEnum; + + /** + * 年龄 + */ + @TableField(value = "age") + private Integer age; + + /** + * 申请会诊的科室名称 + */ + @TableField(value = "department") + private String department; + + /** + * 申请科室ID + */ + @TableField(value = "department_id") + private Long departmentId; + + /** + * 申请会诊的医生姓名 + */ + @TableField(value = "requesting_physician") + private String requestingPhysician; + + /** + * 申请医生ID + */ + @TableField(value = "requesting_physician_id") + private Long requestingPhysicianId; + + /** + * 会诊申请时间 + */ + @TableField(value = "consultation_request_date") + private LocalDateTime consultationRequestDate; + + /** + * 会诊邀请对象 + */ + @TableField(value = "invited_object") + private String invitedObject; + + /** + * 会诊时间 + */ + @TableField(value = "consultation_date") + private LocalDateTime consultationDate; + + /** + * 简要病史及会诊目的 + */ + @TableField(value = "consultation_purpose") + private String consultationPurpose; + + /** + * 门诊诊断 + */ + @TableField(value = "provisional_diagnosis") + private String provisionalDiagnosis; + + /** + * 会诊意见 + */ + @TableField(value = "consultation_opinion") + private String consultationOpinion; + + /** + * 会诊状态:0-新开,10-已提交,20-已确认,30-已签名,40-已完成,50-已取消 + */ + @TableField(value = "consultation_status") + private Integer consultationStatus; + + /** + * 是否紧急:一般/紧急 + */ + @TableField(value = "consultation_urgency") + private String consultationUrgency; + + /** + * 提交会诊的医生姓名 + */ + @TableField(value = "confirming_physician") + private String confirmingPhysician; + + /** + * 提交会诊的医生ID + */ + @TableField(value = "confirming_physician_id") + private Long confirmingPhysicianId; + + /** + * 提交会诊日期 + */ + @TableField(value = "confirming_date") + private LocalDateTime confirmingDate; + + /** + * 结束会诊医生姓名/签名医生 + */ + @TableField(value = "signature") + private String signature; + + /** + * 结束会诊医生ID + */ + @TableField(value = "signature_physician_id") + private Long signaturePhysicianId; + + /** + * 结束会诊日期/签名时间 + */ + @TableField(value = "signature_date") + private LocalDateTime signatureDate; + + /** + * 作废会诊日期 + */ + @TableField(value = "cancel_nature_date") + private LocalDateTime cancelNatureDate; + + /** + * 作废原因 + */ + @TableField(value = "cancel_reason") + private String cancelReason; + + /** + * 创建时间 + */ + @TableField(value = "create_time") + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(value = "update_time") + private LocalDateTime updateTime; + + /** + * 创建人 + */ + @TableField(value = "create_by") + private String createBy; + + /** + * 更新人 + */ + @TableField(value = "update_by") + private String updateBy; + + /** + * 租户ID + */ + @TableField(value = "tenant_id") + private Long tenantId; + + /** + * 逻辑删除标识:0-未删除,1-已删除 + */ + @TableField(value = "is_deleted") + private Integer isDeleted; + + /** + * 备注 + */ + @TableField(value = "remark") + private String remark; + + /** + * 会诊项目ID,关联wor_activity_definition表,用于确定会诊类型和价格 + */ + @TableField(value = "consultation_activity_id") + private Long consultationActivityId; + + /** + * 会诊项目名称,如:院内会诊、远程会诊等 + */ + @TableField(value = "consultation_activity_name") + private String consultationActivityName; + + /** + * 确认会诊的医生姓名 + */ + @TableField(value = "confirming_physician_name") + private String confirmingPhysicianName; + + /** + * 代表科室 + */ + @TableField(value = "confirming_department_name") + private String confirmingDepartmentName; + + /** + * 会诊确认参加医师 + */ + @TableField(value = "confirming_physician_participation") + private String confirmingPhysicianParticipation; + + private static final long serialVersionUID = 1L; +} \ No newline at end of file diff --git a/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/mapper/ConsultationConfirmationMapper.java b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/mapper/ConsultationConfirmationMapper.java new file mode 100644 index 00000000..c9da7925 --- /dev/null +++ b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/mapper/ConsultationConfirmationMapper.java @@ -0,0 +1,14 @@ +package com.openhis.consultation.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.openhis.consultation.domain.ConsultationConfirmation; +import org.apache.ibatis.annotations.Mapper; + +/** + * 会诊确认 Mapper接口 + * + * @author his + */ +@Mapper +public interface ConsultationConfirmationMapper extends BaseMapper { +} \ No newline at end of file diff --git a/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/mapper/ConsultationRecordMapper.java b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/mapper/ConsultationRecordMapper.java new file mode 100644 index 00000000..6e7d9c9b --- /dev/null +++ b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/mapper/ConsultationRecordMapper.java @@ -0,0 +1,14 @@ +package com.openhis.consultation.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.openhis.consultation.domain.ConsultationRecord; +import org.apache.ibatis.annotations.Mapper; + +/** + * 会诊记录 Mapper接口 + * + * @author his + */ +@Mapper +public interface ConsultationRecordMapper extends BaseMapper { +} \ No newline at end of file diff --git a/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/mapper/ConsultationRequestMapper.java b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/mapper/ConsultationRequestMapper.java new file mode 100644 index 00000000..73d26323 --- /dev/null +++ b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/mapper/ConsultationRequestMapper.java @@ -0,0 +1,14 @@ +package com.openhis.consultation.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.openhis.consultation.domain.ConsultationRequest; +import org.apache.ibatis.annotations.Mapper; + +/** + * 会诊申请 Mapper接口 + * + * @author his + */ +@Mapper +public interface ConsultationRequestMapper extends BaseMapper { +} \ No newline at end of file diff --git a/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/service/IConsultationConfirmationService.java b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/service/IConsultationConfirmationService.java new file mode 100644 index 00000000..dbfc1528 --- /dev/null +++ b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/service/IConsultationConfirmationService.java @@ -0,0 +1,12 @@ +package com.openhis.consultation.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.openhis.consultation.domain.ConsultationConfirmation; + +/** + * 会诊确认 服务层 + * + * @author his + */ +public interface IConsultationConfirmationService extends IService { +} \ No newline at end of file diff --git a/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/service/IConsultationRecordService.java b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/service/IConsultationRecordService.java new file mode 100644 index 00000000..bc33fb65 --- /dev/null +++ b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/service/IConsultationRecordService.java @@ -0,0 +1,12 @@ +package com.openhis.consultation.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.openhis.consultation.domain.ConsultationRecord; + +/** + * 会诊记录 服务层 + * + * @author his + */ +public interface IConsultationRecordService extends IService { +} \ No newline at end of file diff --git a/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/service/IConsultationRequestService.java b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/service/IConsultationRequestService.java new file mode 100644 index 00000000..a3c08d0e --- /dev/null +++ b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/service/IConsultationRequestService.java @@ -0,0 +1,41 @@ +package com.openhis.consultation.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.openhis.consultation.domain.ConsultationRequest; + +/** + * 会诊申请 服务层 + * + * @author his + */ +public interface IConsultationRequestService extends IService { + /** + * 提交会诊申请 + */ + int submitConsultation(Long id, Long userId, String userName); + + /** + * 取消提交会诊申请 + */ + int cancelSubmitConsultation(Long id); + + /** + * 结束会诊申请 + */ + int endConsultation(Long id, Long userId, String userName); + + /** + * 作废会诊申请 + */ + int cancelConsultation(Long id, Long userId, String userName); + + /** + * 确认会诊 + */ + int confirmConsultation(Long id, String physicianName, String physicianId, String opinion); + + /** + * 签名会诊 + */ + int signConsultation(Long id, String signature, Long userId, String userName); +} \ No newline at end of file diff --git a/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/service/impl/ConsultationConfirmationServiceImpl.java b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/service/impl/ConsultationConfirmationServiceImpl.java new file mode 100644 index 00000000..42bdb7fc --- /dev/null +++ b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/service/impl/ConsultationConfirmationServiceImpl.java @@ -0,0 +1,16 @@ +package com.openhis.consultation.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.openhis.consultation.domain.ConsultationConfirmation; +import com.openhis.consultation.mapper.ConsultationConfirmationMapper; +import com.openhis.consultation.service.IConsultationConfirmationService; +import org.springframework.stereotype.Service; + +/** + * 会诊确认 服务层实现 + * + * @author his + */ +@Service +public class ConsultationConfirmationServiceImpl extends ServiceImpl implements IConsultationConfirmationService { +} \ No newline at end of file diff --git a/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/service/impl/ConsultationRecordServiceImpl.java b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/service/impl/ConsultationRecordServiceImpl.java new file mode 100644 index 00000000..efd33342 --- /dev/null +++ b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/service/impl/ConsultationRecordServiceImpl.java @@ -0,0 +1,16 @@ +package com.openhis.consultation.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.openhis.consultation.domain.ConsultationRecord; +import com.openhis.consultation.mapper.ConsultationRecordMapper; +import com.openhis.consultation.service.IConsultationRecordService; +import org.springframework.stereotype.Service; + +/** + * 会诊记录 服务层实现 + * + * @author his + */ +@Service +public class ConsultationRecordServiceImpl extends ServiceImpl implements IConsultationRecordService { +} \ No newline at end of file diff --git a/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/service/impl/ConsultationRequestServiceImpl.java b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/service/impl/ConsultationRequestServiceImpl.java new file mode 100644 index 00000000..e0f3ba66 --- /dev/null +++ b/openhis-server-new/openhis-domain/src/main/java/com/openhis/consultation/service/impl/ConsultationRequestServiceImpl.java @@ -0,0 +1,93 @@ +package com.openhis.consultation.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.openhis.consultation.domain.ConsultationRequest; +import com.openhis.consultation.mapper.ConsultationRequestMapper; +import com.openhis.consultation.service.IConsultationRequestService; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +/** + * 会诊申请 服务层实现 + * + * @author his + */ +@Service +public class ConsultationRequestServiceImpl extends ServiceImpl implements IConsultationRequestService { + + @Override + public int submitConsultation(Long id, Long userId, String userName) { + ConsultationRequest request = this.getById(id); + if (request != null && request.getConsultationStatus() == 0) { // 仅当状态为"新开"时可提交 + request.setConsultationStatus(10); // 设置为"已提交" + request.setConfirmingPhysician(userName); + request.setConfirmingPhysicianId(userId); + request.setConfirmingDate(LocalDateTime.now()); + return this.updateById(request) ? 1 : 0; + } + return 0; // 提交失败 + } + + @Override + public int cancelSubmitConsultation(Long id) { + ConsultationRequest request = this.getById(id); + if (request != null && request.getConsultationStatus() == 10) { // 仅当状态为"已提交"时可取消提交 + request.setConsultationStatus(0); // 设置为"新开" + request.setConfirmingPhysician(null); + request.setConfirmingPhysicianId(null); + request.setConfirmingDate(null); + return this.updateById(request) ? 1 : 0; + } + return 0; // 取消提交失败 + } + + @Override + public int endConsultation(Long id, Long userId, String userName) { + ConsultationRequest request = this.getById(id); + if (request != null && request.getConsultationStatus() == 30) { // 仅当状态为"已签名"时可结束 + request.setConsultationStatus(40); // 设置为"已完成" + request.setSignature(userName); + request.setSignatureDate(LocalDateTime.now()); + return this.updateById(request) ? 1 : 0; + } + return 0; // 结束失败 + } + + @Override + public int cancelConsultation(Long id, Long userId, String userName) { + ConsultationRequest request = this.getById(id); + if (request != null && request.getConsultationStatus() != 40) { // 未完成的申请可作废 + request.setConsultationStatus(50); // 设置为"已取消" + request.setSignature(userName); + request.setSignatureDate(LocalDateTime.now()); + return this.updateById(request) ? 1 : 0; + } + return 0; // 作废失败 + } + + @Override + public int confirmConsultation(Long id, String physicianName, String physicianId, String opinion) { + ConsultationRequest request = this.getById(id); + if (request != null && request.getConsultationStatus() != 40) { // 未完成的申请可确认 + request.setConsultationStatus(20); // 设置为"已确认" + request.setConfirmingPhysicianName(physicianName); + request.setConsultationOpinion(opinion); + return this.updateById(request) ? 1 : 0; + } + return 0; // 确认失败 + } + + @Override + public int signConsultation(Long id, String signature, Long userId, String userName) { + ConsultationRequest request = this.getById(id); + if (request != null && request.getConsultationStatus() == 20) { // 仅当状态为"已确认"时可签名 + request.setConsultationStatus(30); // 设置为"已签名" + request.setSignature(signature); + request.setSignatureDate(LocalDateTime.now()); + return this.updateById(request) ? 1 : 0; + } + return 0; // 签名失败 + } +} \ No newline at end of file diff --git a/openhis-server-new/openhis-domain/src/main/java/com/openhis/yb/dto/PrePaymentDto.java b/openhis-server-new/openhis-domain/src/main/java/com/openhis/yb/dto/PrePaymentDto.java index 0f95b214..e7427767 100644 --- a/openhis-server-new/openhis-domain/src/main/java/com/openhis/yb/dto/PrePaymentDto.java +++ b/openhis-server-new/openhis-domain/src/main/java/com/openhis/yb/dto/PrePaymentDto.java @@ -42,4 +42,14 @@ public class PrePaymentDto { * 类型 医保挂号时使用 */ private String busiCardInfo;// 社保卡号/身份证号/ecToken + + /** + * 账单生成来源(1:门诊划价,2:手术计费) + */ + private Integer generateSourceEnum; + + /** + * 来源业务单据号(如手术申请单号) + */ + private String sourceBillNo; } diff --git a/openhis-server-new/openhis-domain/src/main/resources/mapper/consultation/ConsultationConfirmationMapper.xml b/openhis-server-new/openhis-domain/src/main/resources/mapper/consultation/ConsultationConfirmationMapper.xml new file mode 100644 index 00000000..ac8afe62 --- /dev/null +++ b/openhis-server-new/openhis-domain/src/main/resources/mapper/consultation/ConsultationConfirmationMapper.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/openhis-server-new/openhis-domain/src/main/resources/mapper/consultation/ConsultationRecordMapper.xml b/openhis-server-new/openhis-domain/src/main/resources/mapper/consultation/ConsultationRecordMapper.xml new file mode 100644 index 00000000..7e1fcdf9 --- /dev/null +++ b/openhis-server-new/openhis-domain/src/main/resources/mapper/consultation/ConsultationRecordMapper.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/openhis-server-new/openhis-domain/src/main/resources/mapper/consultation/ConsultationRequestMapper.xml b/openhis-server-new/openhis-domain/src/main/resources/mapper/consultation/ConsultationRequestMapper.xml new file mode 100644 index 00000000..b12bd06a --- /dev/null +++ b/openhis-server-new/openhis-domain/src/main/resources/mapper/consultation/ConsultationRequestMapper.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/openhis-server-new/optimize_consultation_tables_postgresql.sql b/openhis-server-new/optimize_consultation_tables_postgresql.sql new file mode 100644 index 00000000..74888a23 --- /dev/null +++ b/openhis-server-new/optimize_consultation_tables_postgresql.sql @@ -0,0 +1,80 @@ +-- 优化会诊申请表结构以满足需求文档 (PostgreSQL格式) +-- 添加缺失的字段 +DO $$ +BEGIN + -- 检查并添加字段 + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'consultation_request' AND column_name = 'visit_id') THEN + ALTER TABLE consultation_request ADD COLUMN visit_id BIGINT; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'consultation_request' AND column_name = 'order_id') THEN + ALTER TABLE consultation_request ADD COLUMN order_id BIGINT; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'consultation_request' AND column_name = 'patient_name') THEN + ALTER TABLE consultation_request ADD COLUMN patient_name VARCHAR(100); + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'consultation_request' AND column_name = 'gender') THEN + ALTER TABLE consultation_request ADD COLUMN gender VARCHAR(10); + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'consultation_request' AND column_name = 'age') THEN + ALTER TABLE consultation_request ADD COLUMN age INTEGER; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'consultation_request' AND column_name = 'provisional_diagnosis') THEN + ALTER TABLE consultation_request ADD COLUMN provisional_diagnosis TEXT; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'consultation_request' AND column_name = 'consultation_opinion') THEN + ALTER TABLE consultation_request ADD COLUMN consultation_opinion TEXT; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'consultation_request' AND column_name = 'confirming_physician') THEN + ALTER TABLE consultation_request ADD COLUMN confirming_physician VARCHAR(100); + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'consultation_request' AND column_name = 'confirming_physician_id') THEN + ALTER TABLE consultation_request ADD COLUMN confirming_physician_id BIGINT; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'consultation_request' AND column_name = 'confirming_date') THEN + ALTER TABLE consultation_request ADD COLUMN confirming_date TIMESTAMP; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'consultation_request' AND column_name = 'consultation_urgency') THEN + ALTER TABLE consultation_request ADD COLUMN consultation_urgency VARCHAR(20) DEFAULT '一般'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'consultation_request' AND column_name = 'consultation_status') THEN + ALTER TABLE consultation_request ADD COLUMN consultation_status INTEGER DEFAULT 0; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'consultation_request' AND column_name = 'consultation_request_date') THEN + ALTER TABLE consultation_request ADD COLUMN consultation_request_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'consultation_request' AND column_name = 'confirming_physician_name') THEN + ALTER TABLE consultation_request ADD COLUMN confirming_physician_name VARCHAR(100); + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'consultation_request' AND column_name = 'signature') THEN + ALTER TABLE consultation_request ADD COLUMN signature VARCHAR(100); + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'consultation_request' AND column_name = 'signature_date') THEN + ALTER TABLE consultation_request ADD COLUMN signature_date TIMESTAMP; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'consultation_request' AND column_name = 'confirming_department_name') THEN + ALTER TABLE consultation_request ADD COLUMN confirming_department_name VARCHAR(100); + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'consultation_request' AND column_name = 'confirming_physician_participation') THEN + ALTER TABLE consultation_request ADD COLUMN confirming_physician_participation VARCHAR(100); + END IF; + + -- 更新注释 + COMMENT ON COLUMN consultation_request.consultation_status IS '会诊状态:0-新开,10-已提交,20-已确认,30-已签名,40-已完成,50-已取消'; +END $$; \ No newline at end of file diff --git a/openhis-server-new/sql/add_source_bill_no_to_adm_charge_item.sql b/openhis-server-new/sql/add_source_bill_no_to_adm_charge_item.sql new file mode 100644 index 00000000..d983caf7 --- /dev/null +++ b/openhis-server-new/sql/add_source_bill_no_to_adm_charge_item.sql @@ -0,0 +1,12 @@ +-- 在adm_charge_item表添加来源业务单据字段 +-- 用于追溯费用是来源于手术计费 +-- SourceBillNo = 手术申请单号 + +-- 添加SourceBillNo字段 +ALTER TABLE adm_charge_item ADD COLUMN IF NOT EXISTS source_bill_no VARCHAR(64); + +-- 添加注释 +COMMENT ON COLUMN adm_charge_item.source_bill_no IS '来源业务单据号(如手术申请单号)'; + +-- 创建索引以提高查询性能 +CREATE INDEX IF NOT EXISTS idx_source_bill_no ON adm_charge_item(source_bill_no); diff --git a/openhis-server-new/sql/create_yb_day_end_settlement_table.sql b/openhis-server-new/sql/create_yb_day_end_settlement_table.sql new file mode 100644 index 00000000..8022b380 --- /dev/null +++ b/openhis-server-new/sql/create_yb_day_end_settlement_table.sql @@ -0,0 +1,71 @@ +-- 创建日结医保结算表 +CREATE TABLE IF NOT EXISTS yb_day_end_settlement ( + id BIGSERIAL PRIMARY KEY, + settlement_no VARCHAR(64) NOT NULL, + settlement_date DATE NOT NULL, + settlement_type VARCHAR(20) DEFAULT 'daily', + insurance_type VARCHAR(50), + total_visits INTEGER DEFAULT 0, + total_amount NUMERIC(15,2) DEFAULT 0.00, + insurance_pay_amount NUMERIC(15,2) DEFAULT 0.00, + account_pay_amount NUMERIC(15,2) DEFAULT 0.00, + personal_pay_amount NUMERIC(15,2) DEFAULT 0.00, + fund_pay_sum_amount NUMERIC(15,2) DEFAULT 0.00, + status CHAR(1) DEFAULT '0', + operator VARCHAR(50), + remark VARCHAR(500), + create_by VARCHAR(64), + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_by VARCHAR(64), + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 添加注释 +COMMENT ON TABLE yb_day_end_settlement IS '日结医保结算表'; +COMMENT ON COLUMN yb_day_end_settlement.settlement_no IS '结算单号'; +COMMENT ON COLUMN yb_day_end_settlement.settlement_date IS '结算日期'; +COMMENT ON COLUMN yb_day_end_settlement.settlement_type IS '结算类型 (daily, weekly, monthly)'; +COMMENT ON COLUMN yb_day_end_settlement.insurance_type IS '医保类型 (urbanEmployee, urbanRuralResident, newRuralCooperative, selfPaid)'; +COMMENT ON COLUMN yb_day_end_settlement.total_visits IS '总人次'; +COMMENT ON COLUMN yb_day_end_settlement.total_amount IS '总金额'; +COMMENT ON COLUMN yb_day_end_settlement.insurance_pay_amount IS '医保统筹支付金额'; +COMMENT ON COLUMN yb_day_end_settlement.account_pay_amount IS '个人账户支付金额'; +COMMENT ON COLUMN yb_day_end_settlement.personal_pay_amount IS '个人自付金额'; +COMMENT ON COLUMN yb_day_end_settlement.fund_pay_sum_amount IS '医保基金支付总额'; +COMMENT ON COLUMN yb_day_end_settlement.status IS '状态 (0正常 1停用)'; +COMMENT ON COLUMN yb_day_end_settlement.operator IS '操作员'; +COMMENT ON COLUMN yb_day_end_settlement.remark IS '备注'; +COMMENT ON COLUMN yb_day_end_settlement.create_by IS '创建者'; +COMMENT ON COLUMN yb_day_end_settlement.create_time IS '创建时间'; +COMMENT ON COLUMN yb_day_end_settlement.update_by IS '更新者'; +COMMENT ON COLUMN yb_day_end_settlement.update_time IS '更新时间'; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_settlement_date ON yb_day_end_settlement(settlement_date); +CREATE INDEX IF NOT EXISTS idx_settlement_no ON yb_day_end_settlement(settlement_no); +CREATE INDEX IF NOT EXISTS idx_settlement_type ON yb_day_end_settlement(settlement_type); +CREATE INDEX IF NOT EXISTS idx_insurance_type ON yb_day_end_settlement(insurance_type); + +-- 创建更新时间函数(如果不存在) +CREATE OR REPLACE FUNCTION update_modified_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.update_time = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- 创建更新时间触发器 +DO $$ +BEGIN + DROP TRIGGER IF EXISTS update_yb_day_end_settlement_modtime ON yb_day_end_settlement; + CREATE TRIGGER update_yb_day_end_settlement_modtime + BEFORE UPDATE ON yb_day_end_settlement + FOR EACH ROW EXECUTE FUNCTION update_modified_column(); +END $$; + +-- 插入示例数据 +INSERT INTO yb_day_end_settlement (settlement_no, settlement_date, settlement_type, insurance_type, total_visits, total_amount, insurance_pay_amount, account_pay_amount, personal_pay_amount, fund_pay_sum_amount, status, operator, remark, create_by) VALUES +('YBDS20260202001', '2026-02-02', 'daily', 'urbanEmployee', 150, 150000.00, 120000.00, 15000.00, 15000.00, 135000.00, '0', 'admin', '2026年2月2日城镇职工医保日结', 'admin'), +('YBDS20260202002', '2026-02-02', 'daily', 'urbanRuralResident', 80, 80000.00, 64000.00, 8000.00, 8000.00, 72000.00, '0', 'admin', '2026年2月2日城乡居民医保日结', 'admin'), +('YBDS20260202003', '2026-02-02', 'daily', 'newRuralCooperative', 60, 60000.00, 48000.00, 6000.00, 6000.00, 54000.00, '0', 'admin', '2026年2月2日新农合医保日结', 'admin'); \ No newline at end of file diff --git a/openhis-server-new/sql/insert_yb_day_end_settlement_menu.sql b/openhis-server-new/sql/insert_yb_day_end_settlement_menu.sql new file mode 100644 index 00000000..ba09bd03 --- /dev/null +++ b/openhis-server-new/sql/insert_yb_day_end_settlement_menu.sql @@ -0,0 +1,27 @@ +-- 添加日结医保结算菜单项到系统菜单表 +-- 假设医保管理模块的父级菜单ID为某个值,这里我们先查询医保管理相关的菜单ID + +-- 首先查找医保相关的父菜单ID(如果没有找到,需要手动指定一个合适的父菜单ID) +-- 偌设我们使用一个常见的父级ID,或者创建一个新的医保管理顶级菜单 + +-- 添加医保管理顶级菜单(如果不存在的话) +INSERT INTO sys_menu +(menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +SELECT '医保管理', 0, 7, 'ybmanagement', '', NULL, 'YbManagement', '1', '0', 'M', '0', '0', '', 'medication', 'admin', NOW(), 'admin', NOW(), '医保管理菜单' +WHERE NOT EXISTS ( + SELECT 1 FROM sys_menu WHERE menu_name = '医保管理' AND menu_type = 'M' +); + +-- 获取医保管理菜单ID +DO $$ +DECLARE + yb_management_menu_id BIGINT; +BEGIN + SELECT menu_id INTO yb_management_menu_id FROM sys_menu WHERE menu_name = '医保管理' LIMIT 1; + + -- 添加日结医保结算子菜单 + INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + VALUES + ('日结医保结算', yb_management_menu_id, 1, 'dayEndMedicalInsuranceSettlement', 'ybmanagement/dayEndMedicalInsuranceSettlement/index', NULL, 'DayEndMedicalInsuranceSettlement', '1', '0', 'C', '0', '0', 'ybmanage:dayEndMedicalInsuranceSettlement:view', 'document', 'admin', NOW(), 'admin', NOW(), '日结医保结算菜单'); +END $$; \ No newline at end of file diff --git a/openhis-server-new/update_consultation_menu_postgresql.sql b/openhis-server-new/update_consultation_menu_postgresql.sql new file mode 100644 index 00000000..d6c04f1b --- /dev/null +++ b/openhis-server-new/update_consultation_menu_postgresql.sql @@ -0,0 +1,118 @@ +-- 更新会诊管理模块的菜单项,以满足需求文档要求 (PostgreSQL格式) +DO $$ +DECLARE + consultation_menu_id BIGINT; + consultation_request_menu_id BIGINT; + consultation_confirmation_menu_id BIGINT; + consultation_record_menu_id BIGINT; +BEGIN + -- 获取会诊管理菜单ID,如果不存在则创建 + SELECT menu_id INTO consultation_menu_id FROM sys_menu WHERE menu_name = '会诊管理' LIMIT 1; + IF NOT FOUND THEN + INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + VALUES + ('会诊管理', 0, 10, 'consultationmanagement', '', '', 'ConsultationManagement', '1', '0', 'M', '0', '0', '', 'operation', 'admin', NOW(), 'admin', NOW(), '会诊管理菜单'); + + SELECT menu_id INTO consultation_menu_id FROM sys_menu WHERE menu_name = '会诊管理' LIMIT 1; + END IF; + + -- 获取会诊申请菜单ID,如果不存在则创建 + SELECT menu_id INTO consultation_request_menu_id FROM sys_menu WHERE menu_name = '会诊申请' AND parent_id = consultation_menu_id LIMIT 1; + IF NOT FOUND THEN + INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + VALUES + ('会诊申请', consultation_menu_id, 1, 'consultationrequest', 'clinicmanagement/consultationrequest/index', '', 'ConsultationRequest', '1', '0', 'C', '0', '0', 'consultation:request:view', 'form', 'admin', NOW(), 'admin', NOW(), '会诊申请菜单'); + + SELECT menu_id INTO consultation_request_menu_id FROM sys_menu WHERE menu_name = '会诊申请' AND parent_id = consultation_menu_id LIMIT 1; + END IF; + + -- 更新或插入会诊申请按钮权限 + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊申请查询', consultation_request_menu_id, 1, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:request:query', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:request:query' AND parent_id = consultation_request_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊申请新增', consultation_request_menu_id, 2, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:request:add', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:request:add' AND parent_id = consultation_request_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊申请修改', consultation_request_menu_id, 3, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:request:edit', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:request:edit' AND parent_id = consultation_request_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊申请删除', consultation_request_menu_id, 4, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:request:remove', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:request:remove' AND parent_id = consultation_request_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊申请提交', consultation_request_menu_id, 5, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:request:submit', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:request:submit' AND parent_id = consultation_request_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊申请结束', consultation_request_menu_id, 6, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:request:end', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:request:end' AND parent_id = consultation_request_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊申请作废', consultation_request_menu_id, 7, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:request:cancel', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:request:cancel' AND parent_id = consultation_request_menu_id); + + -- 获取会诊确认菜单ID,如果不存在则创建 + SELECT menu_id INTO consultation_confirmation_menu_id FROM sys_menu WHERE menu_name = '会诊确认' AND parent_id = consultation_menu_id LIMIT 1; + IF NOT FOUND THEN + INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + VALUES + ('会诊确认', consultation_menu_id, 2, 'consultationconfirmation', 'clinicmanagement/consultationconfirmation/index', '', 'ConsultationConfirmation', '1', '0', 'C', '0', '0', 'consultation:confirmation:view', 'form', 'admin', NOW(), 'admin', NOW(), '会诊确认菜单'); + + SELECT menu_id INTO consultation_confirmation_menu_id FROM sys_menu WHERE menu_name = '会诊确认' AND parent_id = consultation_menu_id LIMIT 1; + END IF; + + -- 更新或插入会诊确认按钮权限 + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊确认查询', consultation_confirmation_menu_id, 1, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:confirmation:query', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:confirmation:query' AND parent_id = consultation_confirmation_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊确认新增', consultation_confirmation_menu_id, 2, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:confirmation:add', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:confirmation:add' AND parent_id = consultation_confirmation_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊确认修改', consultation_confirmation_menu_id, 3, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:confirmation:edit', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:confirmation:edit' AND parent_id = consultation_confirmation_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊确认删除', consultation_confirmation_menu_id, 4, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:confirmation:remove', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:confirmation:remove' AND parent_id = consultation_confirmation_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊确认操作', consultation_confirmation_menu_id, 5, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:confirmation:confirm', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:confirmation:confirm' AND parent_id = consultation_confirmation_menu_id); + + -- 获取会诊记录菜单ID,如果不存在则创建 + SELECT menu_id INTO consultation_record_menu_id FROM sys_menu WHERE menu_name = '会诊记录' AND parent_id = consultation_menu_id LIMIT 1; + IF NOT FOUND THEN + INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + VALUES + ('会诊记录', consultation_menu_id, 3, 'consultationrecord', 'clinicmanagement/consultationrecord/index', '', 'ConsultationRecord', '1', '0', 'C', '0', '0', 'consultation:record:view', 'form', 'admin', NOW(), 'admin', NOW(), '会诊记录菜单'); + END IF; + + -- 更新或插入会诊记录按钮权限 + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊记录查询', consultation_record_menu_id, 1, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:record:query', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:record:query' AND parent_id = consultation_record_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊记录新增', consultation_record_menu_id, 2, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:record:add', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:record:add' AND parent_id = consultation_record_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊记录修改', consultation_record_menu_id, 3, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:record:edit', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:record:edit' AND parent_id = consultation_record_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊记录删除', consultation_record_menu_id, 4, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:record:remove', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:record:remove' AND parent_id = consultation_record_menu_id); + +END $$; \ No newline at end of file diff --git a/openhis-server-new/update_consultation_menu_postgresql_corrected.sql b/openhis-server-new/update_consultation_menu_postgresql_corrected.sql new file mode 100644 index 00000000..d6c04f1b --- /dev/null +++ b/openhis-server-new/update_consultation_menu_postgresql_corrected.sql @@ -0,0 +1,118 @@ +-- 更新会诊管理模块的菜单项,以满足需求文档要求 (PostgreSQL格式) +DO $$ +DECLARE + consultation_menu_id BIGINT; + consultation_request_menu_id BIGINT; + consultation_confirmation_menu_id BIGINT; + consultation_record_menu_id BIGINT; +BEGIN + -- 获取会诊管理菜单ID,如果不存在则创建 + SELECT menu_id INTO consultation_menu_id FROM sys_menu WHERE menu_name = '会诊管理' LIMIT 1; + IF NOT FOUND THEN + INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + VALUES + ('会诊管理', 0, 10, 'consultationmanagement', '', '', 'ConsultationManagement', '1', '0', 'M', '0', '0', '', 'operation', 'admin', NOW(), 'admin', NOW(), '会诊管理菜单'); + + SELECT menu_id INTO consultation_menu_id FROM sys_menu WHERE menu_name = '会诊管理' LIMIT 1; + END IF; + + -- 获取会诊申请菜单ID,如果不存在则创建 + SELECT menu_id INTO consultation_request_menu_id FROM sys_menu WHERE menu_name = '会诊申请' AND parent_id = consultation_menu_id LIMIT 1; + IF NOT FOUND THEN + INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + VALUES + ('会诊申请', consultation_menu_id, 1, 'consultationrequest', 'clinicmanagement/consultationrequest/index', '', 'ConsultationRequest', '1', '0', 'C', '0', '0', 'consultation:request:view', 'form', 'admin', NOW(), 'admin', NOW(), '会诊申请菜单'); + + SELECT menu_id INTO consultation_request_menu_id FROM sys_menu WHERE menu_name = '会诊申请' AND parent_id = consultation_menu_id LIMIT 1; + END IF; + + -- 更新或插入会诊申请按钮权限 + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊申请查询', consultation_request_menu_id, 1, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:request:query', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:request:query' AND parent_id = consultation_request_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊申请新增', consultation_request_menu_id, 2, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:request:add', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:request:add' AND parent_id = consultation_request_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊申请修改', consultation_request_menu_id, 3, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:request:edit', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:request:edit' AND parent_id = consultation_request_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊申请删除', consultation_request_menu_id, 4, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:request:remove', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:request:remove' AND parent_id = consultation_request_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊申请提交', consultation_request_menu_id, 5, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:request:submit', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:request:submit' AND parent_id = consultation_request_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊申请结束', consultation_request_menu_id, 6, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:request:end', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:request:end' AND parent_id = consultation_request_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊申请作废', consultation_request_menu_id, 7, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:request:cancel', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:request:cancel' AND parent_id = consultation_request_menu_id); + + -- 获取会诊确认菜单ID,如果不存在则创建 + SELECT menu_id INTO consultation_confirmation_menu_id FROM sys_menu WHERE menu_name = '会诊确认' AND parent_id = consultation_menu_id LIMIT 1; + IF NOT FOUND THEN + INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + VALUES + ('会诊确认', consultation_menu_id, 2, 'consultationconfirmation', 'clinicmanagement/consultationconfirmation/index', '', 'ConsultationConfirmation', '1', '0', 'C', '0', '0', 'consultation:confirmation:view', 'form', 'admin', NOW(), 'admin', NOW(), '会诊确认菜单'); + + SELECT menu_id INTO consultation_confirmation_menu_id FROM sys_menu WHERE menu_name = '会诊确认' AND parent_id = consultation_menu_id LIMIT 1; + END IF; + + -- 更新或插入会诊确认按钮权限 + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊确认查询', consultation_confirmation_menu_id, 1, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:confirmation:query', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:confirmation:query' AND parent_id = consultation_confirmation_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊确认新增', consultation_confirmation_menu_id, 2, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:confirmation:add', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:confirmation:add' AND parent_id = consultation_confirmation_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊确认修改', consultation_confirmation_menu_id, 3, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:confirmation:edit', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:confirmation:edit' AND parent_id = consultation_confirmation_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊确认删除', consultation_confirmation_menu_id, 4, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:confirmation:remove', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:confirmation:remove' AND parent_id = consultation_confirmation_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊确认操作', consultation_confirmation_menu_id, 5, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:confirmation:confirm', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:confirmation:confirm' AND parent_id = consultation_confirmation_menu_id); + + -- 获取会诊记录菜单ID,如果不存在则创建 + SELECT menu_id INTO consultation_record_menu_id FROM sys_menu WHERE menu_name = '会诊记录' AND parent_id = consultation_menu_id LIMIT 1; + IF NOT FOUND THEN + INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + VALUES + ('会诊记录', consultation_menu_id, 3, 'consultationrecord', 'clinicmanagement/consultationrecord/index', '', 'ConsultationRecord', '1', '0', 'C', '0', '0', 'consultation:record:view', 'form', 'admin', NOW(), 'admin', NOW(), '会诊记录菜单'); + END IF; + + -- 更新或插入会诊记录按钮权限 + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊记录查询', consultation_record_menu_id, 1, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:record:query', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:record:query' AND parent_id = consultation_record_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊记录新增', consultation_record_menu_id, 2, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:record:add', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:record:add' AND parent_id = consultation_record_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊记录修改', consultation_record_menu_id, 3, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:record:edit', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:record:edit' AND parent_id = consultation_record_menu_id); + + INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + SELECT '会诊记录删除', consultation_record_menu_id, 4, '', '', '', '', '1', '0', 'F', '0', '0', 'consultation:record:remove', '#', 'admin', NOW(), 'admin', NOW(), '' + WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'consultation:record:remove' AND parent_id = consultation_record_menu_id); + +END $$; \ No newline at end of file diff --git a/openhis-ui-vue3/package-lock.json b/openhis-ui-vue3/package-lock.json index 027c17a8..2f42419a 100644 --- a/openhis-ui-vue3/package-lock.json +++ b/openhis-ui-vue3/package-lock.json @@ -39,6 +39,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "segmentit": "^2.0.3", + "sortablejs": "^1.15.6", "v-region": "^3.3.0", "vue": "^3.5.13", "vue-area-linkage": "^5.1.0", @@ -1638,6 +1639,7 @@ "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/lodash": "*" } @@ -1648,6 +1650,7 @@ "integrity": "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3343,6 +3346,7 @@ "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -5468,7 +5472,7 @@ }, "node_modules/json-bigint": { "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/json-bigint/-/json-bigint-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", "license": "MIT", "dependencies": { @@ -5594,13 +5598,15 @@ "version": "4.17.21", "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash-es": { "version": "4.17.21", "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash-unified": { "version": "1.0.3", @@ -6553,6 +6559,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6793,6 +6800,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -6805,6 +6813,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -7056,6 +7065,7 @@ "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -7234,6 +7244,7 @@ "integrity": "sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -7266,6 +7277,7 @@ "version": "2.0.3", "resolved": "https://registry.npmmirror.com/segmentit/-/segmentit-2.0.3.tgz", "integrity": "sha512-7mn2XL3OdTUQ+AhHz7SbgyxLTaQRzTWQNVwiK+UlTO8aePGbSwvKUzTwE4238+OUY9MoR6ksAg35zl8sfTunQQ==", + "peer": true, "dependencies": { "preval.macro": "^4.0.0" } @@ -7598,6 +7610,12 @@ "node": ">=0.10.0" } }, + "node_modules/sortablejs": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz", + "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", @@ -8465,6 +8483,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8826,6 +8845,7 @@ "integrity": "sha512-RzAr8LSvM8lmhB4tQ5OPcBhpjOZRZjuxv9zO5UcxeoY2bd3kP3Ticd40Qma9/BqZ8JS96Ll/jeBX9u+LJZrhVg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.19.3", "postcss": "^8.4.31", @@ -8930,6 +8950,7 @@ "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.25.tgz", "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.25", "@vue/compiler-sfc": "3.5.25", diff --git a/openhis-ui-vue3/package.json b/openhis-ui-vue3/package.json index eade869e..06ebc9ff 100644 --- a/openhis-ui-vue3/package.json +++ b/openhis-ui-vue3/package.json @@ -49,6 +49,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "segmentit": "^2.0.3", + "sortablejs": "^1.15.6", "v-region": "^3.3.0", "vue": "^3.5.13", "vue-area-linkage": "^5.1.0", diff --git a/openhis-ui-vue3/src/api/consultation/confirmation.js b/openhis-ui-vue3/src/api/consultation/confirmation.js new file mode 100644 index 00000000..cac5da2a --- /dev/null +++ b/openhis-ui-vue3/src/api/consultation/confirmation.js @@ -0,0 +1,53 @@ +import request from '@/utils/request' + +// 查询会诊确认列表 +export function listConfirmation(query) { + return request({ + url: '/consultation/confirmation/list', + method: 'get', + params: query + }) +} + +// 查询会诊确认详细 +export function getConfirmation(id) { + return request({ + url: '/consultation/confirmation/' + id, + method: 'get' + }) +} + +// 新增会诊确认 +export function addConfirmation(data) { + return request({ + url: '/consultation/confirmation', + method: 'post', + data: data + }) +} + +// 修改会诊确认 +export function updateConfirmation(data) { + return request({ + url: '/consultation/confirmation', + method: 'put', + data: data + }) +} + +// 确认会诊 +export function confirmConsultation(data) { + return request({ + url: '/consultation/confirmation/confirm', + method: 'put', + data: data + }) +} + +// 删除会诊确认 +export function delConfirmation(id) { + return request({ + url: '/consultation/confirmation/' + id, + method: 'delete' + }) +} \ No newline at end of file diff --git a/openhis-ui-vue3/src/api/consultation/record.js b/openhis-ui-vue3/src/api/consultation/record.js new file mode 100644 index 00000000..dfa674e1 --- /dev/null +++ b/openhis-ui-vue3/src/api/consultation/record.js @@ -0,0 +1,44 @@ +import request from '@/utils/request' + +// 查询会诊记录列表 +export function listRecord(query) { + return request({ + url: '/consultation/record/list', + method: 'get', + params: query + }) +} + +// 查询会诊记录详细 +export function getRecord(id) { + return request({ + url: '/consultation/record/' + id, + method: 'get' + }) +} + +// 新增会诊记录 +export function addRecord(data) { + return request({ + url: '/consultation/record', + method: 'post', + data: data + }) +} + +// 修改会诊记录 +export function updateRecord(data) { + return request({ + url: '/consultation/record', + method: 'put', + data: data + }) +} + +// 删除会诊记录 +export function delRecord(id) { + return request({ + url: '/consultation/record/' + id, + method: 'delete' + }) +} \ No newline at end of file diff --git a/openhis-ui-vue3/src/api/consultation/request.js b/openhis-ui-vue3/src/api/consultation/request.js new file mode 100644 index 00000000..10a48738 --- /dev/null +++ b/openhis-ui-vue3/src/api/consultation/request.js @@ -0,0 +1,94 @@ +import request from '@/utils/request' + +// 查询会诊申请列表 +export function listRequest(query) { + return request({ + url: '/consultation/request/list', + method: 'get', + params: query + }) +} + +// 查询会诊申请详细 +export function getRequest(id) { + return request({ + url: '/consultation/request/' + id, + method: 'get' + }) +} + +// 新增会诊申请 +export function addRequest(data) { + return request({ + url: '/consultation/request', + method: 'post', + data: data + }) +} + +// 修改会诊申请 +export function updateRequest(data) { + return request({ + url: '/consultation/request', + method: 'put', + data: data + }) +} + +// 删除会诊申请 +export function delRequest(id) { + return request({ + url: '/consultation/request/' + id, + method: 'delete' + }) +} + +// 提交会诊申请 +export function submitRequest(id) { + return request({ + url: '/consultation/request/submit/' + id, + method: 'put' + }) +} + +// 取消提交会诊申请 +export function cancelSubmitRequest(id) { + return request({ + url: '/consultation/request/cancelSubmit/' + id, + method: 'put' + }) +} + +// 结束会诊申请 +export function endRequest(id) { + return request({ + url: '/consultation/request/end/' + id, + method: 'put' + }) +} + +// 作废会诊申请 +export function cancelRequest(id) { + return request({ + url: '/consultation/request/cancel/' + id, + method: 'put' + }) +} + +// 确认会诊 +export function confirmRequest(data) { + return request({ + url: '/consultation/request/confirm', + method: 'put', + data: data + }) +} + +// 签名会诊 +export function signRequest(data) { + return request({ + url: '/consultation/request/sign', + method: 'put', + data: data + }) +} \ No newline at end of file diff --git a/openhis-ui-vue3/src/router/index.js b/openhis-ui-vue3/src/router/index.js index aeba7808..153ce38b 100644 --- a/openhis-ui-vue3/src/router/index.js +++ b/openhis-ui-vue3/src/router/index.js @@ -237,21 +237,6 @@ export const dynamicRoutes = [ }, ], }, - { - path: '/doctorstation', - component: Layout, - redirect: '/doctorstation/index', - name: 'DoctorStation', - meta: { title: '医生工作站', icon: 'operation' }, - children: [ - { - path: 'pending-emr', - component: () => import('@/views/doctorstation/pendingEmr.vue'), - name: 'PendingEmr', - meta: { title: '待写病历', icon: 'document', permissions: ['doctorstation:pending-emr:view'] } - } - ] - }, { path: '/features', component: Layout, @@ -313,20 +298,6 @@ export const dynamicRoutes = [ meta: { title: '门诊日结', icon: 'document' } } ] - }, - { - path: '/medicationmanagement', - component: Layout, - name: 'MedicationManagement', - meta: { title: '药房管理', icon: 'medication' }, - children: [ - { - path: 'dayEndSettlement', - component: () => import('@/views/medicationmanagement/dayEndSettlement/index.vue'), - name: 'DayEndSettlement', - meta: { title: '日结结算单管理', icon: 'document' } - } - ] } ]; diff --git a/openhis-ui-vue3/src/views/charge/surgerycharge/index.vue b/openhis-ui-vue3/src/views/charge/surgerycharge/index.vue new file mode 100644 index 00000000..4369b6e0 --- /dev/null +++ b/openhis-ui-vue3/src/views/charge/surgerycharge/index.vue @@ -0,0 +1,502 @@ + + + + + diff --git a/openhis-ui-vue3/src/views/clinicmanagement/consultationconfirmation/index.vue b/openhis-ui-vue3/src/views/clinicmanagement/consultationconfirmation/index.vue new file mode 100644 index 00000000..78a5a8ca --- /dev/null +++ b/openhis-ui-vue3/src/views/clinicmanagement/consultationconfirmation/index.vue @@ -0,0 +1,556 @@ + + + \ No newline at end of file diff --git a/openhis-ui-vue3/src/views/surgicalschedule/index.vue b/openhis-ui-vue3/src/views/surgicalschedule/index.vue index 4e73b558..6b36f56a 100644 --- a/openhis-ui-vue3/src/views/surgicalschedule/index.vue +++ b/openhis-ui-vue3/src/views/surgicalschedule/index.vue @@ -93,10 +93,11 @@ - + @@ -775,10 +776,11 @@ import download from '@/plugins/download' // API 导入 import { getSurgerySchedulePage, addSurgerySchedule, updateSurgerySchedule, deleteSurgerySchedule, getSurgeryScheduleDetail } from '@/api/surgicalschedule' import { listUser } from '@/api/system/user' -import { deptTreeSelect } from '@/api/system/user' +import { deptTreeSelectSelect } from '@/api/system/user' import { listOperatingRoom } from '@/api/operatingroom' import { getTestResultPage} from '@/views/inpatientDoctor/home/components/applicationShow/api.js' import { getTenantPage } from '@/api/system/tenant' +import SurgeryCharge from './charge/surgerycharge/index.vue' const { proxy } = getCurrentInstance() const loading = ref(true) @@ -1156,6 +1158,40 @@ function handleDelete(row) { }) } +// 手术计费 +function handleChargeCharge(row) { + // 打开手术计费对话框 + // 传递患者信息和手术信息 + const patientInfo = { + encounterId: row.patientId, // 就诊ID + patientId: row.patientId, + patientName: row.patientName, + genderEnum: row.gender, + age: row.age, + organizationName: row.applyDeptName, + receptionTime: row.scheduleDate, + encounterBusNo: row.visitId, + categoryEnum: 1, // 门诊 + }; + + const surgeryInfo = { + surgeryNo: row.operCode, // 手术单号 + surgeryName: row.operName, // 手术名称 + }; + + // 使用el-dialog显示手术计费组件 + proxy.$modal.open({ + title: '手术计费', + component: SurgeryCharge, + props: { + patientInfo, + surgeryInfo + }, + width: '1400px', + fullscreen: false, + }); +} + // 重置表单 function resetForm() { Object.assign(form, {