diff --git a/.agents/skills/ai-vibe-slides/SKILL.md b/.agents/skills/ai-vibe-slides/SKILL.md
new file mode 100644
index 00000000..160e1f9a
--- /dev/null
+++ b/.agents/skills/ai-vibe-slides/SKILL.md
@@ -0,0 +1,453 @@
+---
+name: ai-vibe-slides
+description: "Create beautiful, professional HTML or React slide decks ready for fullscreen presentation. Use this skill when the user wants to: create a PPT/slide/presentation from an idea or outline; build a visually stunning slide deck from existing content; generate an HTML presentation that can be projected fullscreen; convert a document into a presentation. Trigger when you hear: 'create slides', 'make a PPT', 'presentation', 'slide deck', 'pitch deck', 'vibe ppt', 'make a talk', or any request to create a presentation. This skill produces a single self-contained HTML/React artifact — no backend, no installation, no dependencies."
+---
+
+# AI Vibe Slides — Beautiful HTML Slide Decks, Ready to Present
+
+## Goal
+
+Produce **a single HTML or React artifact file** containing a complete slide deck that can:
+
+- Present fullscreen directly in the browser
+- Navigate via arrow keys, spacebar, or click
+- Look professional, polished, and stylistically consistent
+- Print or export to PDF when needed
+
+Inspired by [banana-slides](https://github.com/Anionex/banana-slides): a 3-step pipeline of **Idea → Outline → Finished Slides**, but the output is a self-contained HTML file instead of a fullstack application.
+
+---
+
+## Slide Creation Pipeline
+
+### Step 1: Understand the Request → Build an Outline
+
+When the user provides a request, first **build an outline mentally** (no need to display it unless the user asks):
+
+1. Identify the **main topic** and **target audience** (students, business, tech talk...)
+2. Break it into **logical sections**:
+ - Slide 1: Cover (title + subtitle + author)
+ - Slides 2-3: Introduction / context
+ - Middle slides: Core content (one key idea per slide)
+ - Final slide: Conclusion / Call to action / Thank you
+3. Each slide should have: a title, 2-5 bullet points or visual content, and a layout type
+
+If the user only gives a short sentence (e.g., "create slides about AI in healthcare"), automatically expand it into 8-12 slides with a logical structure.
+
+### Step 2: Choose a Design Direction
+
+Based on context, commit to **one clear design direction**:
+
+| Context | Suggested Style |
+| ---------------------- | ----------------------------------------------------- |
+| Startup pitch deck | Bold, dark theme, gradient accents, strong sans-serif |
+| Academic / education | Clean, light, diagram-heavy, readable fonts |
+| Tech talk / conference | Modern dark, code-style typography, neon accents |
+| Corporate / report | Minimal, professional, navy/white, serif headings |
+| Creative / marketing | Colorful, asymmetric layout, bold typography |
+| Kids / early education | Pastel, rounded corners, playful icons, large text |
+
+General rules:
+
+- **Pick 2-3 primary colors** and use them consistently across the entire deck
+- **1 heading font + 1 body font** (use Google Fonts)
+- **Minimal text, generous whitespace** — max 5-6 lines per slide
+- **Clear visual hierarchy**: large title → medium content → small notes
+
+### Step 3: Generate the HTML/React Slide Deck
+
+Create **a single file** (`.html` or `.jsx`) containing everything:
+
+---
+
+## HTML Slide Deck Structure
+
+```html
+
+
+
+
+
+ {Presentation Title}
+
+
+
+
+
+
+
+
Topic
+
Main Title
+
+ Short subtitle description
+
+
+
+ Author — Date
+
+
+
+
+
+
Slide Title
+
+
+ Key point 1
+ Key point 2
+ Key point 3
+
+
+
+
+
+
+
+ 1 /
+
+ ← → or click to navigate
+
+
+
+
+```
+
+---
+
+## Slide Layout Types
+
+Each slide should use the layout that best fits its content:
+
+### 1. Cover Slide
+
+- Centered, extra-large font, gradient background
+- Topic badge + main title + subtitle + author
+
+### 2. Section Divider
+
+- Only section title + number, accent color background
+- Used to separate major sections of the presentation
+
+### 3. Content + Bullets
+
+- Left-aligned title + list of key points
+- Use icons/emoji at the start of each bullet instead of plain dots
+
+### 4. Two-Column
+
+- `.two-column` layout: text on left, visuals/list/cards on right
+- Great for comparisons, before-after, text+illustration
+
+### 5. Cards Grid
+
+- 2-3 column grid, each card containing icon + title + short description
+- Great for features, benefits, team members
+
+### 6. Big Number / Statistic
+
+- Huge number in the center (font-size: 5rem+) + small label below
+- Great for data points, KPIs, impact numbers
+
+### 7. Quote / Highlight
+
+- Large text, centered, with decorative quotation marks
+- Different background (light accent color)
+
+### 8. Timeline / Steps
+
+- Horizontal or vertical flexbox, dots connecting each step
+- Great for processes, roadmaps, history
+
+### 9. Thank You / CTA
+
+- Centered, simple, contact info or call to action
+
+---
+
+## MANDATORY Design Rules
+
+1. **16:9 aspect ratio**: Always use `width: 100vw; height: 100vh` — each slide fills the entire screen
+2. **Minimal text**: Maximum 6 lines per slide. If content is long → split into multiple slides
+3. **Large font sizes**: Heading ≥ 2.4rem, body ≥ 1.3rem — must be readable on a projector
+4. **High contrast**: Text must be clearly legible against its background. Verify visually
+5. **Consistency**: Same fonts, same color palette, same spacing throughout the entire deck
+6. **No placeholder images**: Never use ` `. Replace with CSS shapes, gradients, icons (emoji or Lucide for React), or inline SVGs
+7. **Subtle animation**: Only fadeIn on slide transition. No complex animations that distract
+8. **Responsive fullscreen**: Must work well at all screen sizes
+9. **Print-ready**: Include `@media print` rules so each slide becomes one printed page
+
+---
+
+## When Using React (.jsx) Instead of HTML
+
+If creating a React artifact, use this pattern:
+
+```jsx
+import { useState, useEffect, useCallback } from "react";
+
+const slides = [
+ { type: "cover", title: "...", subtitle: "..." },
+ { type: "content", title: "...", points: ["...", "..."] },
+ { type: "twoColumn", title: "...", left: "...", right: "..." },
+ // ...
+];
+
+export default function SlideDeck() {
+ const [current, setCurrent] = useState(0);
+
+ const next = useCallback(
+ () => setCurrent((c) => (c + 1) % slides.length),
+ [],
+ );
+ const prev = useCallback(
+ () => setCurrent((c) => (c - 1 + slides.length) % slides.length),
+ [],
+ );
+
+ useEffect(() => {
+ const handleKey = (e) => {
+ if (e.key === "ArrowRight" || e.key === " ") next();
+ if (e.key === "ArrowLeft") prev();
+ if (e.key === "f") document.documentElement.requestFullscreen?.();
+ };
+ window.addEventListener("keydown", handleKey);
+ return () => window.removeEventListener("keydown", handleKey);
+ }, [next, prev]);
+
+ const renderSlide = (slide) => {
+ switch (slide.type) {
+ case "cover":
+ return /* cover layout */;
+ case "content":
+ return /* content layout */;
+ case "twoColumn":
+ return /* two-column layout */;
+ // ...
+ }
+ };
+
+ return (
+ (e.clientX > window.innerWidth / 2 ? next() : prev())}
+ style={{ fontFamily: "'Outfit', sans-serif" }}
+ >
+ {renderSlide(slides[current])}
+
+ {current + 1} / {slides.length}
+
+
+ );
+}
+```
+
+Advantages of React: Tailwind CSS utilities, Lucide icons, more complex logic, recharts for charts.
+
+---
+
+## Pre-Delivery Checklist
+
+- [ ] Correct number of slides (cover + content + closing)
+- [ ] Arrow keys ← → work, click navigates
+- [ ] Counter displays correct "X / N"
+- [ ] Press F for fullscreen
+- [ ] All text is readable with sufficient contrast
+- [ ] No broken images or placeholders
+- [ ] Google Fonts load correctly (or good fallback)
+- [ ] Print/PDF exports properly (`@media print`)
+- [ ] Consistent style throughout the entire deck
+- [ ] Each slide has exactly one key idea, not overloaded with text
+
+---
+
+## Natural Language Editing
+
+After initial creation, the user can request modifications:
+
+- "Switch to a light theme" → update CSS variables
+- "Add 2 slides about case studies" → insert slides into the array
+- "Slide 3 has too much text, split it" → refactor content across slides
+- "Change the font to Playfair Display" → update Google Fonts link + CSS
+- "Add a chart to slide 5" → use CSS chart or recharts (React)
+
+On each edit, keep unchanged slides intact and only update what the user requested.
diff --git a/.agents/skills/brainstorming/SKILL.md b/.agents/skills/brainstorming/SKILL.md
new file mode 100644
index 00000000..1dd476ef
--- /dev/null
+++ b/.agents/skills/brainstorming/SKILL.md
@@ -0,0 +1,69 @@
+---
+name: brainstorming
+description: "Use when defining or changing features, components, behavior, or architecture and requirements are still fuzzy. Helps clarify intent, constraints, trade-offs, and success criteria before implementation."
+---
+
+# Brainstorming Ideas Into Designs
+
+## Overview
+
+Help turn ideas into fully formed designs and specs through natural collaborative dialogue.
+
+Start by understanding the current project context, then ask questions one at a time to refine the idea. Once you understand what you're building, present the design in small sections (200-300 words), checking after each section whether it looks right so far.
+
+For Codoo work, end with concrete outputs that can be executed deterministically:
+- feature spec candidate (`docs/features/spec-FEAT-[ID].yaml`)
+- impacted files map
+- validation gate plan (install, API, UI, permissions, evidence)
+
+## The Process
+
+**Understanding the idea:**
+- Check out the current project state first (files, docs, recent commits)
+- Ask questions one at a time to refine the idea
+- Prefer multiple choice questions when possible, but open-ended is fine too
+- Only one question per message - if a topic needs more exploration, break it into multiple questions
+- Focus on understanding: purpose, constraints, success criteria
+
+**Exploring approaches:**
+- Propose 2-3 different approaches with trade-offs
+- Present options conversationally with your recommendation and reasoning
+- Lead with your recommended option and explain why
+- Explicitly call out SaaS constraints when Odoo server-side customization may be limited
+
+**Presenting the design:**
+- Once you believe you understand what you're building, present the design
+- Break it into sections of 200-300 words
+- Ask after each section whether it looks right so far
+- Cover: architecture, components, data flow, error handling, testing
+- Cover: evidence strategy and rollback/fallback strategy
+- Be ready to go back and clarify if something doesn't make sense
+
+## After the Design
+
+**Documentation:**
+- Write the validated design to `docs/plans/YYYY-MM-DD--design.md`
+- Commit the design document to git
+
+**Implementation (if continuing):**
+- Ask: "Ready to set up for implementation?"
+- Use superpowers:using-git-worktrees to create isolated workspace
+- Use superpowers:writing-plans to create detailed implementation plan
+
+## Key Principles
+
+- **One question at a time** - Don't overwhelm with multiple questions
+- **Multiple choice preferred** - Easier to answer than open-ended when possible
+- **YAGNI ruthlessly** - Remove unnecessary features from all designs
+- **Explore alternatives** - Always propose 2-3 approaches before settling
+- **Incremental validation** - Present design in sections, validate each
+- **Be flexible** - Go back and clarify when something doesn't make sense
+- **Execution-ready outputs** - End brainstorming with inputs ready for implementation gates
+
+## Exit Criteria
+
+Brainstorming is complete only when these are explicit:
+- Problem statement and success criteria
+- Preferred approach and rejected alternatives
+- Constraints and assumptions
+- Initial validation plan
\ No newline at end of file
diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md
new file mode 100644
index 00000000..ba2127b1
--- /dev/null
+++ b/.agents/skills/code-review/SKILL.md
@@ -0,0 +1,140 @@
+---
+name: code-review
+description: Use when receiving code review feedback (especially if unclear or technically questionable), when completing tasks or major features requiring review before proceeding, or before making any completion/success claims. Covers three practices - receiving feedback with technical rigor over performative agreement, requesting reviews via code-reviewer subagent, and verification gates requiring evidence before any status claims. Essential for subagent-driven development, pull requests, and preventing false completion claims.
+---
+
+# Code Review
+
+Guide proper code review practices emphasizing technical rigor, evidence-based claims, and verification over performative responses.
+
+## Overview
+
+Code review requires three distinct practices:
+
+1. **Receiving feedback** - Technical evaluation over performative agreement
+2. **Requesting reviews** - Systematic review via code-reviewer subagent
+3. **Verification gates** - Evidence before any completion claims
+
+Each practice has specific triggers and protocols detailed in reference files.
+
+## Core Principle
+
+**Technical correctness over social comfort.** Verify before implementing. Ask before assuming. Evidence before claims.
+
+## When to Use This Skill
+
+### Receiving Feedback
+Trigger when:
+- Receiving code review comments from any source
+- Feedback seems unclear or technically questionable
+- Multiple review items need prioritization
+- External reviewer lacks full context
+- Suggestion conflicts with existing decisions
+
+**Reference:** `references/code-review-reception.md`
+
+### Requesting Review
+Trigger when:
+- Completing tasks in subagent-driven development (after EACH task)
+- Finishing major features or refactors
+- Before merging to main branch
+- Stuck and need fresh perspective
+- After fixing complex bugs
+
+**Reference:** `references/requesting-code-review.md`
+
+### Verification Gates
+Trigger when:
+- About to claim tests pass, build succeeds, or work is complete
+- Before committing, pushing, or creating PRs
+- Moving to next task
+- Any statement suggesting success/completion
+- Expressing satisfaction with work
+
+**Reference:** `references/verification-before-completion.md`
+
+## Quick Decision Tree
+
+```
+SITUATION?
+│
+├─ Received feedback
+│ ├─ Unclear items? → STOP, ask for clarification first
+│ ├─ From human partner? → Understand, then implement
+│ └─ From external reviewer? → Verify technically before implementing
+│
+├─ Completed work
+│ ├─ Major feature/task? → Request code-reviewer subagent review
+│ └─ Before merge? → Request code-reviewer subagent review
+│
+└─ About to claim status
+ ├─ Have fresh verification? → State claim WITH evidence
+ └─ No fresh verification? → RUN verification command first
+```
+
+## Receiving Feedback Protocol
+
+### Response Pattern
+READ → UNDERSTAND → VERIFY → EVALUATE → RESPOND → IMPLEMENT
+
+### Key Rules
+- ❌ No performative agreement: "You're absolutely right!", "Great point!", "Thanks for [anything]"
+- ❌ No implementation before verification
+- ✅ Restate requirement, ask questions, push back with technical reasoning, or just start working
+- ✅ If unclear: STOP and ask for clarification on ALL unclear items first
+- ✅ YAGNI check: grep for usage before implementing suggested "proper" features
+
+### Source Handling
+- **Human partner:** Trusted - implement after understanding, no performative agreement
+- **External reviewers:** Verify technically correct, check for breakage, push back if wrong
+
+**Full protocol:** `references/code-review-reception.md`
+
+## Requesting Review Protocol
+
+### When to Request
+- After each task in subagent-driven development
+- After major feature completion
+- Before merge to main
+
+### Process
+1. Get git SHAs: `BASE_SHA=$(git rev-parse HEAD~1)` and `HEAD_SHA=$(git rev-parse HEAD)`
+2. Dispatch code-reviewer subagent via Task tool with: WHAT_WAS_IMPLEMENTED, PLAN_OR_REQUIREMENTS, BASE_SHA, HEAD_SHA, DESCRIPTION
+3. Act on feedback: Fix Critical immediately, Important before proceeding, note Minor for later
+
+**Full protocol:** `references/requesting-code-review.md`
+
+## Verification Gates Protocol
+
+### The Iron Law
+**NO COMPLETION CLAIMS WITHOUT FRESH VERIFICATION EVIDENCE**
+
+### Gate Function
+IDENTIFY command → RUN full command → READ output → VERIFY confirms claim → THEN claim
+
+Skip any step = lying, not verifying
+
+### Requirements
+- Tests pass: Test output shows 0 failures
+- Build succeeds: Build command exit 0
+- Bug fixed: Test original symptom passes
+- Requirements met: Line-by-line checklist verified
+
+### Red Flags - STOP
+Using "should"/"probably"/"seems to", expressing satisfaction before verification, committing without verification, trusting agent reports, ANY wording implying success without running verification
+
+**Full protocol:** `references/verification-before-completion.md`
+
+## Integration with Workflows
+
+- **Subagent-Driven:** Review after EACH task, verify before moving to next
+- **Pull Requests:** Verify tests pass, request code-reviewer review before merge
+- **General:** Apply verification gates before any status claims, push back on invalid feedback
+
+## Bottom Line
+
+1. Technical rigor over social performance - No performative agreement
+2. Systematic review processes - Use code-reviewer subagent
+3. Evidence before claims - Verification gates always
+
+Verify. Question. Then implement. Evidence. Then claim.
diff --git a/.agents/skills/code-review/references/code-review-reception.md b/.agents/skills/code-review/references/code-review-reception.md
new file mode 100644
index 00000000..c52443d3
--- /dev/null
+++ b/.agents/skills/code-review/references/code-review-reception.md
@@ -0,0 +1,209 @@
+---
+name: receiving-code-review
+description: Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or technically questionable - requires technical rigor and verification, not performative agreement or blind implementation
+---
+
+# Code Review Reception
+
+## Overview
+
+Code review requires technical evaluation, not emotional performance.
+
+**Core principle:** Verify before implementing. Ask before assuming. Technical correctness over social comfort.
+
+## The Response Pattern
+
+```
+WHEN receiving code review feedback:
+
+1. READ: Complete feedback without reacting
+2. UNDERSTAND: Restate requirement in own words (or ask)
+3. VERIFY: Check against codebase reality
+4. EVALUATE: Technically sound for THIS codebase?
+5. RESPOND: Technical acknowledgment or reasoned pushback
+6. IMPLEMENT: One item at a time, test each
+```
+
+## Forbidden Responses
+
+**NEVER:**
+- "You're absolutely right!" (explicit CLAUDE.md violation)
+- "Great point!" / "Excellent feedback!" (performative)
+- "Let me implement that now" (before verification)
+
+**INSTEAD:**
+- Restate the technical requirement
+- Ask clarifying questions
+- Push back with technical reasoning if wrong
+- Just start working (actions > words)
+
+## Handling Unclear Feedback
+
+```
+IF any item is unclear:
+ STOP - do not implement anything yet
+ ASK for clarification on unclear items
+
+WHY: Items may be related. Partial understanding = wrong implementation.
+```
+
+**Example:**
+```
+your human partner: "Fix 1-6"
+You understand 1,2,3,6. Unclear on 4,5.
+
+❌ WRONG: Implement 1,2,3,6 now, ask about 4,5 later
+✅ RIGHT: "I understand items 1,2,3,6. Need clarification on 4 and 5 before proceeding."
+```
+
+## Source-Specific Handling
+
+### From your human partner
+- **Trusted** - implement after understanding
+- **Still ask** if scope unclear
+- **No performative agreement**
+- **Skip to action** or technical acknowledgment
+
+### From External Reviewers
+```
+BEFORE implementing:
+ 1. Check: Technically correct for THIS codebase?
+ 2. Check: Breaks existing functionality?
+ 3. Check: Reason for current implementation?
+ 4. Check: Works on all platforms/versions?
+ 5. Check: Does reviewer understand full context?
+
+IF suggestion seems wrong:
+ Push back with technical reasoning
+
+IF can't easily verify:
+ Say so: "I can't verify this without [X]. Should I [investigate/ask/proceed]?"
+
+IF conflicts with your human partner's prior decisions:
+ Stop and discuss with your human partner first
+```
+
+**your human partner's rule:** "External feedback - be skeptical, but check carefully"
+
+## YAGNI Check for "Professional" Features
+
+```
+IF reviewer suggests "implementing properly":
+ grep codebase for actual usage
+
+ IF unused: "This endpoint isn't called. Remove it (YAGNI)?"
+ IF used: Then implement properly
+```
+
+**your human partner's rule:** "You and reviewer both report to me. If we don't need this feature, don't add it."
+
+## Implementation Order
+
+```
+FOR multi-item feedback:
+ 1. Clarify anything unclear FIRST
+ 2. Then implement in this order:
+ - Blocking issues (breaks, security)
+ - Simple fixes (typos, imports)
+ - Complex fixes (refactoring, logic)
+ 3. Test each fix individually
+ 4. Verify no regressions
+```
+
+## When To Push Back
+
+Push back when:
+- Suggestion breaks existing functionality
+- Reviewer lacks full context
+- Violates YAGNI (unused feature)
+- Technically incorrect for this stack
+- Legacy/compatibility reasons exist
+- Conflicts with your human partner's architectural decisions
+
+**How to push back:**
+- Use technical reasoning, not defensiveness
+- Ask specific questions
+- Reference working tests/code
+- Involve your human partner if architectural
+
+**Signal if uncomfortable pushing back out loud:** "Strange things are afoot at the Circle K"
+
+## Acknowledging Correct Feedback
+
+When feedback IS correct:
+```
+✅ "Fixed. [Brief description of what changed]"
+✅ "Good catch - [specific issue]. Fixed in [location]."
+✅ [Just fix it and show in the code]
+
+❌ "You're absolutely right!"
+❌ "Great point!"
+❌ "Thanks for catching that!"
+❌ "Thanks for [anything]"
+❌ ANY gratitude expression
+```
+
+**Why no thanks:** Actions speak. Just fix it. The code itself shows you heard the feedback.
+
+**If you catch yourself about to write "Thanks":** DELETE IT. State the fix instead.
+
+## Gracefully Correcting Your Pushback
+
+If you pushed back and were wrong:
+```
+✅ "You were right - I checked [X] and it does [Y]. Implementing now."
+✅ "Verified this and you're correct. My initial understanding was wrong because [reason]. Fixing."
+
+❌ Long apology
+❌ Defending why you pushed back
+❌ Over-explaining
+```
+
+State the correction factually and move on.
+
+## Common Mistakes
+
+| Mistake | Fix |
+|---------|-----|
+| Performative agreement | State requirement or just act |
+| Blind implementation | Verify against codebase first |
+| Batch without testing | One at a time, test each |
+| Assuming reviewer is right | Check if breaks things |
+| Avoiding pushback | Technical correctness > comfort |
+| Partial implementation | Clarify all items first |
+| Can't verify, proceed anyway | State limitation, ask for direction |
+
+## Real Examples
+
+**Performative Agreement (Bad):**
+```
+Reviewer: "Remove legacy code"
+❌ "You're absolutely right! Let me remove that..."
+```
+
+**Technical Verification (Good):**
+```
+Reviewer: "Remove legacy code"
+✅ "Checking... build target is 10.15+, this API needs 13+. Need legacy for backward compat. Current impl has wrong bundle ID - fix it or drop pre-13 support?"
+```
+
+**YAGNI (Good):**
+```
+Reviewer: "Implement proper metrics tracking with database, date filters, CSV export"
+✅ "Grepped codebase - nothing calls this endpoint. Remove it (YAGNI)? Or is there usage I'm missing?"
+```
+
+**Unclear Item (Good):**
+```
+your human partner: "Fix items 1-6"
+You understand 1,2,3,6. Unclear on 4,5.
+✅ "Understand 1,2,3,6. Need clarification on 4 and 5 before implementing."
+```
+
+## The Bottom Line
+
+**External feedback = suggestions to evaluate, not orders to follow.**
+
+Verify. Question. Then implement.
+
+No performative agreement. Technical rigor always.
\ No newline at end of file
diff --git a/.agents/skills/code-review/references/requesting-code-review.md b/.agents/skills/code-review/references/requesting-code-review.md
new file mode 100644
index 00000000..a3f5ecf8
--- /dev/null
+++ b/.agents/skills/code-review/references/requesting-code-review.md
@@ -0,0 +1,105 @@
+---
+name: requesting-code-review
+description: Use when completing tasks, implementing major features, or before merging to verify work meets requirements - dispatches code-reviewer subagent to review implementation against plan or requirements before proceeding
+---
+
+# Requesting Code Review
+
+Dispatch code-reviewer subagent to catch issues before they cascade.
+
+**Core principle:** Review early, review often.
+
+## When to Request Review
+
+**Mandatory:**
+- After each task in subagent-driven development
+- After completing major feature
+- Before merge to main
+
+**Optional but valuable:**
+- When stuck (fresh perspective)
+- Before refactoring (baseline check)
+- After fixing complex bug
+
+## How to Request
+
+**1. Get git SHAs:**
+```bash
+BASE_SHA=$(git rev-parse HEAD~1) # or origin/main
+HEAD_SHA=$(git rev-parse HEAD)
+```
+
+**2. Dispatch code-reviewer subagent:**
+
+Use Task tool with `code-reviewer` type, fill template at `code-reviewer.md`
+
+**Placeholders:**
+- `{WHAT_WAS_IMPLEMENTED}` - What you just built
+- `{PLAN_OR_REQUIREMENTS}` - What it should do
+- `{BASE_SHA}` - Starting commit
+- `{HEAD_SHA}` - Ending commit
+- `{DESCRIPTION}` - Brief summary
+
+**3. Act on feedback:**
+- Fix Critical issues immediately
+- Fix Important issues before proceeding
+- Note Minor issues for later
+- Push back if reviewer is wrong (with reasoning)
+
+## Example
+
+```
+[Just completed Task 2: Add verification function]
+
+You: Let me request code review before proceeding.
+
+BASE_SHA=$(git log --oneline | grep "Task 1" | head -1 | awk '{print $1}')
+HEAD_SHA=$(git rev-parse HEAD)
+
+[Dispatch code-reviewer subagent]
+ WHAT_WAS_IMPLEMENTED: Verification and repair functions for conversation index
+ PLAN_OR_REQUIREMENTS: Task 2 from docs/plans/deployment-plan.md
+ BASE_SHA: a7981ec
+ HEAD_SHA: 3df7661
+ DESCRIPTION: Added verifyIndex() and repairIndex() with 4 issue types
+
+[Subagent returns]:
+ Strengths: Clean architecture, real tests
+ Issues:
+ Important: Missing progress indicators
+ Minor: Magic number (100) for reporting interval
+ Assessment: Ready to proceed
+
+You: [Fix progress indicators]
+[Continue to Task 3]
+```
+
+## Integration with Workflows
+
+**Subagent-Driven Development:**
+- Review after EACH task
+- Catch issues before they compound
+- Fix before moving to next task
+
+**Executing Plans:**
+- Review after each batch (3 tasks)
+- Get feedback, apply, continue
+
+**Ad-Hoc Development:**
+- Review before merge
+- Review when stuck
+
+## Red Flags
+
+**Never:**
+- Skip review because "it's simple"
+- Ignore Critical issues
+- Proceed with unfixed Important issues
+- Argue with valid technical feedback
+
+**If reviewer wrong:**
+- Push back with technical reasoning
+- Show code/tests that prove it works
+- Request clarification
+
+See template at: requesting-code-review/code-reviewer.md
\ No newline at end of file
diff --git a/.agents/skills/code-review/references/verification-before-completion.md b/.agents/skills/code-review/references/verification-before-completion.md
new file mode 100644
index 00000000..47389b35
--- /dev/null
+++ b/.agents/skills/code-review/references/verification-before-completion.md
@@ -0,0 +1,139 @@
+---
+name: verification-before-completion
+description: Use when about to claim work is complete, fixed, or passing, before committing or creating PRs - requires running verification commands and confirming output before making any success claims; evidence before assertions always
+---
+
+# Verification Before Completion
+
+## Overview
+
+Claiming work is complete without verification is dishonesty, not efficiency.
+
+**Core principle:** Evidence before claims, always.
+
+**Violating the letter of this rule is violating the spirit of this rule.**
+
+## The Iron Law
+
+```
+NO COMPLETION CLAIMS WITHOUT FRESH VERIFICATION EVIDENCE
+```
+
+If you haven't run the verification command in this message, you cannot claim it passes.
+
+## The Gate Function
+
+```
+BEFORE claiming any status or expressing satisfaction:
+
+1. IDENTIFY: What command proves this claim?
+2. RUN: Execute the FULL command (fresh, complete)
+3. READ: Full output, check exit code, count failures
+4. VERIFY: Does output confirm the claim?
+ - If NO: State actual status with evidence
+ - If YES: State claim WITH evidence
+5. ONLY THEN: Make the claim
+
+Skip any step = lying, not verifying
+```
+
+## Common Failures
+
+| Claim | Requires | Not Sufficient |
+|-------|----------|----------------|
+| Tests pass | Test command output: 0 failures | Previous run, "should pass" |
+| Linter clean | Linter output: 0 errors | Partial check, extrapolation |
+| Build succeeds | Build command: exit 0 | Linter passing, logs look good |
+| Bug fixed | Test original symptom: passes | Code changed, assumed fixed |
+| Regression test works | Red-green cycle verified | Test passes once |
+| Agent completed | VCS diff shows changes | Agent reports "success" |
+| Requirements met | Line-by-line checklist | Tests passing |
+
+## Red Flags - STOP
+
+- Using "should", "probably", "seems to"
+- Expressing satisfaction before verification ("Great!", "Perfect!", "Done!", etc.)
+- About to commit/push/PR without verification
+- Trusting agent success reports
+- Relying on partial verification
+- Thinking "just this once"
+- Tired and wanting work over
+- **ANY wording implying success without having run verification**
+
+## Rationalization Prevention
+
+| Excuse | Reality |
+|--------|---------|
+| "Should work now" | RUN the verification |
+| "I'm confident" | Confidence ≠ evidence |
+| "Just this once" | No exceptions |
+| "Linter passed" | Linter ≠ compiler |
+| "Agent said success" | Verify independently |
+| "I'm tired" | Exhaustion ≠ excuse |
+| "Partial check is enough" | Partial proves nothing |
+| "Different words so rule doesn't apply" | Spirit over letter |
+
+## Key Patterns
+
+**Tests:**
+```
+✅ [Run test command] [See: 34/34 pass] "All tests pass"
+❌ "Should pass now" / "Looks correct"
+```
+
+**Regression tests (TDD Red-Green):**
+```
+✅ Write → Run (pass) → Revert fix → Run (MUST FAIL) → Restore → Run (pass)
+❌ "I've written a regression test" (without red-green verification)
+```
+
+**Build:**
+```
+✅ [Run build] [See: exit 0] "Build passes"
+❌ "Linter passed" (linter doesn't check compilation)
+```
+
+**Requirements:**
+```
+✅ Re-read plan → Create checklist → Verify each → Report gaps or completion
+❌ "Tests pass, phase complete"
+```
+
+**Agent delegation:**
+```
+✅ Agent reports success → Check VCS diff → Verify changes → Report actual state
+❌ Trust agent report
+```
+
+## Why This Matters
+
+From 24 failure memories:
+- your human partner said "I don't believe you" - trust broken
+- Undefined functions shipped - would crash
+- Missing requirements shipped - incomplete features
+- Time wasted on false completion → redirect → rework
+- Violates: "Honesty is a core value. If you lie, you'll be replaced."
+
+## When To Apply
+
+**ALWAYS before:**
+- ANY variation of success/completion claims
+- ANY expression of satisfaction
+- ANY positive statement about work state
+- Committing, PR creation, task completion
+- Moving to next task
+- Delegating to agents
+
+**Rule applies to:**
+- Exact phrases
+- Paraphrases and synonyms
+- Implications of success
+- ANY communication suggesting completion/correctness
+
+## The Bottom Line
+
+**No shortcuts for verification.**
+
+Run the command. Read the output. THEN claim the result.
+
+This is non-negotiable.
\ No newline at end of file
diff --git a/.agents/skills/codoo-methodology/SKILL.md b/.agents/skills/codoo-methodology/SKILL.md
new file mode 100644
index 00000000..76e8552d
--- /dev/null
+++ b/.agents/skills/codoo-methodology/SKILL.md
@@ -0,0 +1,88 @@
+---
+name: codoo-methodology
+description: Use when implementing, validating, or reviewing Codoo features and Odoo instance configuration tasks for Corvanis clients, especially for FEAT specs, execution gates, evidence logs, SaaS constraints, and deterministic delivery.
+---
+
+# Codoo Methodology
+
+Reference workflow for deterministic feature delivery and Odoo configuration work in this repository.
+
+## When to Use
+
+- Building a new FEAT spec implementation
+- Validating readiness of a delivered feature
+- Handling SaaS constraints during addon installation
+- Reviewing whether a task is complete and auditable
+
+## When Not to Use
+
+- Small exploratory tasks with no implementation or validation gates
+- Purely stylistic edits that do not affect behavior
+
+## Core Model
+
+Codoo uses a specs-before-code contract with deterministic execution:
+
+1. Spec contract defines scope and acceptance gates.
+2. Implementation happens in small, reviewable increments.
+3. Validation gates prove behavior (install, API, UI, permissions).
+4. Evidence logs capture pass or fail details.
+5. Feature report summarizes outcomes and limitations.
+
+## Execution Loop (Operator View)
+
+1. Load feature context from `docs/features/spec-FEAT-[ID].yaml`.
+2. Identify impacted zones: `src/codoo/tasks/`, `workspace/data/`, `docs/logs/`, `frontend/`, docs.
+3. Implement in small increments with immediate validation per change.
+4. Run mandatory gates and collect evidence artifacts.
+5. Report outcome with explicit pass/fail per gate and unresolved risks.
+
+See detailed checklist: `references/feature-execution-checklist.md`.
+
+## Gate Checklist
+
+Use this checklist before claiming completion:
+
+- Install or upgrade gate passed
+- API CRUD gate passed
+- UI interaction gate passed
+- Browser console gate passed (no relevant JS errors)
+- Permissions gate passed
+- Evidence files stored in `docs/logs/`
+- Feature report updated in `docs/features/`
+
+## SaaS Limitation Handling
+
+If Odoo SaaS blocks custom Python behavior:
+
+- Record failure evidence and exact error
+- Re-run up to 3 times only when a concrete fix is applied
+- If still blocked, document a supported fallback:
+ - API-first path
+ - Native Odoo configuration alternative
+ - Explicit hard limitation with rationale
+
+Use the report format in `references/saas-limitation-report-template.md`.
+
+## Repository Guardrail
+
+- This workflow assumes a single repository model.
+- Keep changes scoped to the impacted project area (`src/codoo/`, `frontend/`, `docs/`).
+- Prefer small, reviewable commits with explicit evidence references.
+
+## Anti-Patterns to Avoid
+
+- Marking task complete without evidence files.
+- Treating HTTP 405 on XML-RPC GET as outage without `authenticate()` check.
+- Logging credentials or sensitive `.env` values.
+- Skipping permission validation because CRUD succeeded as admin.
+
+## References
+
+- [AGENTS.md](../../../AGENTS.md)
+- [docs/guides/CODOO.md](../../../docs/guides/CODOO.md)
+- [docs/guides/CONTRIBUTING.md](../../../docs/guides/CONTRIBUTING.md)
+- [docs/guides/ARCHITECTURE.md](../../../docs/guides/ARCHITECTURE.md)
+- [docs/guides/ODOO-SAAS-LIMITATIONS.md](../../../docs/guides/ODOO-SAAS-LIMITATIONS.md)
+- [references/feature-execution-checklist.md](references/feature-execution-checklist.md)
+- [references/saas-limitation-report-template.md](references/saas-limitation-report-template.md)
diff --git a/.agents/skills/dtg-base/CLAUDE.md b/.agents/skills/dtg-base/CLAUDE.md
new file mode 100644
index 00000000..896fc0f0
--- /dev/null
+++ b/.agents/skills/dtg-base/CLAUDE.md
@@ -0,0 +1,127 @@
+# DTG Base Development Guide
+
+This file provides guidance to AI agents when working with DTG Base utilities in Odoo 18.
+
+## What is DTG Base?
+
+DTG Base is a custom abstract model (`dtg_base.DTGBase`) that provides common utility methods for Odoo development at DTG. It's inherited by other models to gain access to helpful utilities.
+
+## Location
+
+**Module**: `addons_customs/erp/dtg_base/`
+
+**Main Model**: `dtg_base/models/dtg_base.py`
+
+## When to Use DTG Base
+
+| Task | Method |
+|------|--------|
+| Get first date of month/quarter/year | `find_first_date_of_period(date, 'month')` |
+| Get last date of month/quarter/year | `find_last_date_of_period(date, 'year')` |
+| Convert local datetime to UTC | `convert_local_to_utc(local_dt, 'Asia/Ho_Chi_Minh')` |
+| Convert UTC to local datetime | `convert_utc_to_local(utc_dt, 'Asia/Ho_Chi_Minh')` |
+| Check if barcode exists | `barcode_exists('1234567890123')` |
+| Generate EAN13 barcode | `get_ean13('product_code')` |
+| Process large recordsets in batches | `splittor(limit=100)` |
+| Remove Vietnamese accents | `strip_accents('Tiếng Việt')` |
+| Zip a directory | `zip_dir(source_path, output_path)` |
+| Get file size in readable format | `_get_file_size(file_path)` |
+
+## Inheritance Pattern
+
+```python
+from odoo import models
+
+class MyModel(models.Model):
+ _name = 'my.model'
+ _inherit = ['dtg_base.dtg_base']
+
+ def process_records(self):
+ # Use DTGBase utilities
+ for batch in self.splittor(limit=100):
+ # Process batch
+ pass
+```
+
+## Key Utilities
+
+### Date & Period
+
+```python
+# Get first date of current month
+first_date = self.find_first_date_of_period(fields.Date.today(), 'month')
+
+# Get last date of current quarter
+last_date = self.find_last_date_of_period(fields.Date.today(), 'quarter')
+
+# Iterate over months in a period
+for start, end in self.period_iter('2024-01-01', '2024-12-31', 'month'):
+ print(f"Period: {start} to {end}")
+```
+
+### Timezone Conversion
+
+```python
+# Convert Vietnam local time to UTC
+utc_dt = self.convert_local_to_utc('2024-01-15 10:00:00', 'Asia/Ho_Chi_Minh')
+
+# Convert UTC to Vietnam local time
+local_dt = self.convert_utc_to_local(utc_dt, 'Asia/Ho_Chi_Minh')
+```
+
+### Batch Processing
+
+```python
+# Process 1000 records in batches of 100
+records = self.env['my.model'].search([])
+for batch in records.splittor(limit=100):
+ # Process each batch
+ for record in batch:
+ # Do something
+ pass
+```
+
+### Barcode
+
+```python
+# Check if barcode already exists
+if self.barcode_exists('1234567890123'):
+ raise UserError("Barcode already exists!")
+
+# Generate EAN13 barcode
+ean13 = self.get_ean13('PRODUCT123')
+```
+
+### Vietnamese Text
+
+```python
+# Remove accents for search/comparison
+search_text = self.strip_accents('Tiếng Việt') # -> 'Ties Viet'
+
+# For comparison
+if self.strip_accents(record.name) == self.strip_accents(search_term):
+ # Match
+ pass
+```
+
+## Period Types
+
+| Type | Description |
+|------|-------------|
+| `'month'` | Month period |
+| `'quarter'` | Quarter period |
+| `'year'` | Year period |
+| `'week'` | Week period |
+
+## Common Timezones
+
+| Timezone | UTC Offset |
+|----------|------------|
+| `'Asia/Ho_Chi_Minh'` | UTC+7 |
+| `'UTC'` | UTC+0 |
+| `'Asia/Bangkok'` | UTC+7 |
+| `'Asia/Singapore'` | UTC+8 |
+
+---
+
+**For complete reference, see [odoo-18-dtg-base-guide.md](./odoo-18-dtg-base-guide.md)**
diff --git a/.agents/skills/dtg-base/README.md b/.agents/skills/dtg-base/README.md
new file mode 100644
index 00000000..99197b32
--- /dev/null
+++ b/.agents/skills/dtg-base/README.md
@@ -0,0 +1,44 @@
+# DTG Base Skill
+
+Complete reference for DTG Base module utilities and helpers in Odoo 18.
+
+## Overview
+
+DTG Base is a custom abstract model that provides common utility methods for Odoo development. This skill contains comprehensive documentation for all DTGBase utilities.
+
+## What's Included
+
+- **Date & Period Utilities** - Find first/last dates, iterate over periods
+- **Timezone Conversion** - Convert between local time and UTC
+- **Barcode Utilities** - Validate and generate EAN13 barcodes
+- **Batch Processing** - Split large recordsets into manageable batches
+- **after_commit Decorator** - Execute code after transaction commit
+- **Vietnamese Text** - Strip accents for search/comparison
+- **File Utilities** - Zip directories, get file sizes
+- **Number Utilities** - Round to specific decimal places
+
+## Files
+
+| File | Description |
+|------|-------------|
+| `SKILL.md` | Master index and quick reference |
+| `CLAUDE.md` | AI agent guidance |
+| `odoo-18-dtg-base-guide.md` | Complete DTG Base utilities reference |
+
+## Quick Start
+
+```python
+class MyModel(models.Model):
+ _name = 'my.model'
+ _inherit = ['dtg_base.dtg_base']
+
+ def my_method(self):
+ # Use DTGBase utilities
+ first_date = self.find_first_date_of_period('2024-01-15', 'month')
+ utc_date = self.convert_local_to_utc('2024-01-15 10:00:00')
+```
+
+## Links
+
+- [Full Documentation](./odoo-18-dtg-base-guide.md)
+- [SKILL.md](./SKILL.md) - Quick reference
diff --git a/.agents/skills/dtg-base/SKILL.md b/.agents/skills/dtg-base/SKILL.md
new file mode 100644
index 00000000..ca0f6dc6
--- /dev/null
+++ b/.agents/skills/dtg-base/SKILL.md
@@ -0,0 +1,113 @@
+---
+name: dtg-base
+description: Complete reference for DTG Base module utilities and helpers. Use when working with DTGBase inheritance, date/time utilities, timezone conversion, barcode generation/validation, batch processing, file operations, and Vietnamese text normalization.
+globs: "**/addons_customs/erp/**/*.py"
+license: MIT
+author: UncleCat
+version: 1.0.0
+---
+
+# DTG Base Skill
+
+Complete reference for DTG Base module utilities and helpers in Odoo 18.
+
+## What is DTG Base?
+
+DTG Base is a custom abstract model (`dtg_base.DTGBase`) that provides common utility methods for Odoo development. It's designed to be inherited by other models to gain access to helpful utilities.
+
+## Quick Reference
+
+| Utility | Description |
+|---------|-------------|
+| [Date & Period](#date--period-utilities) | Find first/last date of period, period iteration |
+| [Timezone](#timezone-conversion) | Convert local to UTC, UTC to local |
+| [Barcode](#barcode-utilities) | Check barcode exists, generate EAN13 |
+| [Batch Processing](#batch-processing) | Split large recordsets into batches |
+| [after_commit](#after_commit-decorator) | Execute code after transaction commit |
+| [Vietnamese Text](#string--text-utilities) | Strip accents, convert to non-accent |
+| [File Utilities](#file-utilities) | Zip directories, get file size |
+| [Number Utilities](#number-utilities) | Round to decimal places |
+
+---
+
+## Main Guide
+
+**File**: `odoo-18-dtg-base-guide.md`
+
+### When to use this skill
+
+- Working with DTG Odoo codebase
+- Need date/period calculations
+- Timezone conversions
+- Barcode validation
+- Batch processing large recordsets
+- Vietnamese text processing
+- File zipping utilities
+
+---
+
+## DTGBase Abstract Model
+
+### Inherit from DTGBase
+
+**Location**: `addons_customs/erp/dtg_base/models/dtg_base.py`
+
+```python
+from odoo import models
+
+class MyModel(models.Model):
+ _name = 'my.model'
+ _inherit = ['dtg_base.dtg_base']
+
+ def my_method(self):
+ # Now you have access to all DTGBase utilities
+ first_date = self.find_first_date_of_period('2024-01-15', 'month')
+ utc_date = self.convert_local_to_utc('2024-01-15 10:00:00')
+```
+
+---
+
+## File Structure
+
+```
+agent-skills/skills/dtg-base/
+├── SKILL.md # This file - master index
+├── odoo-18-dtg-base-guide.md # Complete DTG Base utilities reference
+└── README.md # Skill overview
+```
+
+---
+
+## Utilities Overview
+
+### Date & Period Utilities
+- `find_first_date_of_period(date, period_type)` - Get first date of period
+- `find_last_date_of_period(date, period_type)` - Get last date of period
+- `period_iter(start_date, end_date, period_type)` - Iterate over periods
+
+### Timezone Conversion
+- `convert_local_to_utc(local_dt, tz=None)` - Convert local datetime to UTC
+- `convert_utc_to_local(utc_dt, tz=None)` - Convert UTC datetime to local
+
+### Barcode Utilities
+- `barcode_exists(barcode, exclude_id=0)` - Check if barcode already exists
+- `get_ean13 barcode)` - Generate/check EAN13 barcode
+
+### Batch Processing
+- `splittor(limit=None)` - Split recordset into batches for processing
+
+### String & Text Utilities
+- `strip_accents(text)` - Remove Vietnamese accents
+- `_no_accent_vietnamese(text)` - Convert Vietnamese text
+
+### File Utilities
+- `zip_dir(source_dir, output_file)` - Zip a directory
+- `zip_dirs(dirs, output_file)` - Zip multiple directories
+- `_get_file_size(file_path)` - Get human-readable file size
+
+### Number Utilities
+- `round_decimal(value, decimal_places)` - Round to specific decimal places
+
+---
+
+**For detailed documentation, see [odoo-18-dtg-base-guide.md](./odoo-18-dtg-base-guide.md)**
diff --git a/.agents/skills/dtg-base/odoo-18-dtg-base-guide.md b/.agents/skills/dtg-base/odoo-18-dtg-base-guide.md
new file mode 100644
index 00000000..f8cbbf92
--- /dev/null
+++ b/.agents/skills/dtg-base/odoo-18-dtg-base-guide.md
@@ -0,0 +1,778 @@
+---
+name: odoo-18-dtg-base
+description: Complete reference for DTG Base module utilities and helpers. DTGBase is an abstract model providing common utility methods for date/time handling, barcode generation, timezone conversion, file operations, and more.
+globs: "**/addons_customs/erp/**/*.py"
+topics:
+ - DTGBase abstract model inheritance
+ - Date/Period utilities (find_first_date_of_period, find_last_date_of_period, period_iter)
+ - Timezone conversion (convert_local_to_utc, convert_utc_to_local)
+ - Barcode utilities (barcode_exists, get_ean13)
+ - Batch processing (splittor)
+ - after_commit decorator
+ - Vietnamese text utilities (strip_accents, _no_accent_vietnamese)
+ - File utilities (zip_dir, zip_dirs, _get_file_size)
+when_to_use:
+ - Working with DTG Odoo codebase
+ - Need date/period calculations
+ - Timezone conversions
+ - Barcode validation
+ - Batch processing large recordsets
+ - Vietnamese text processing
+---
+
+# Odoo 18 DTG Base Guide
+
+Complete reference for DTG Base module utilities and helpers.
+
+## Table of Contents
+
+1. [DTGBase Abstract Model](#dtgbase-abstract-model)
+2. [Date & Period Utilities](#date--period-utilities)
+3. [Timezone Conversion](#timezone-conversion)
+4. [Barcode Utilities](#barcode-utilities)
+5. [Batch Processing](#batch-processing)
+6. [after_commit Decorator](#after_commit-decorator)
+7. [String & Text Utilities](#string--text-utilities)
+8. [File Utilities](#file-utilities)
+9. [Number Utilities](#number-utilities)
+
+---
+
+## DTGBase Abstract Model
+
+### Inherit from DTGBase
+
+**Location**: `addons_customs/erp/dtg_base/models/dtg_base.py`
+
+```python
+from odoo import models, fields
+
+class MyModel(models.Model):
+ _name = 'my.model'
+ _inherit = ['dtg.base'] # Inherit DTGBase to access all utilities
+
+ name = fields.Char()
+```
+
+**When to inherit**:
+- Need date/period calculation utilities
+- Need timezone conversion
+- Need barcode validation/generation
+- Need batch processing with memory management
+- Need Vietnamese text processing
+- Need file zipping utilities
+
+---
+
+## Date & Period Utilities
+
+### Period Names
+
+Supported periods: `'hourly'`, `'daily'`, `'weekly'`, `'monthly'`, `'quarterly'`, `'biannually'`, `'annually'`
+
+Aliases also work: `'hour'`, `'day'`, `'week'`, `'month'`, `'quarter'`, `'biannual'`, `'year'`, `'annual'`
+
+### find_first_date_of_period()
+
+Find the first date of a period from any date within that period.
+
+```python
+# Get first day of month from any date
+date = fields.Date.to_date('2024-02-15')
+first_day = self.find_first_date_of_period('monthly', date)
+# Result: datetime(2024, 2, 1, 0, 0, 0)
+
+# Get first day of week (Monday)
+first_week_day = self.find_first_date_of_period('weekly', date)
+# Result: datetime(2024, 2, 12, 0, 0, 0) - Monday of that week
+
+# Get first day of quarter
+first_quarter_day = self.find_first_date_of_period('quarterly', date)
+# Result: datetime(2024, 1, 1, 0, 0, 0) - Q1 starts Jan 1
+
+# With offset - start from 5th day
+first_day_offset = self.find_first_date_of_period('monthly', date, start_day_offset=5)
+# Result: datetime(2024, 2, 6, 0, 0, 0)
+```
+
+### find_last_date_of_period()
+
+Find the last date of a period from any date within that period.
+
+```python
+# Get last day of month
+date = fields.Date.to_date('2024-02-15')
+last_day = self.find_last_date_of_period('monthly', date)
+# Result: datetime(2024, 2, 29, 23, 59, 59, 999999) - 2024 is leap year
+
+# Get last day of quarter
+last_quarter_day = self.find_last_date_of_period('quarterly', date)
+# Result: datetime(2024, 3, 31, 23, 59, 59, 999999)
+
+# When given_date is also the start date
+start_date = fields.Date.to_date('2024-02-01')
+last_day_from_start = self.find_last_date_of_period('monthly', start_date, date_is_start_date=True)
+# Result: datetime(2024, 2, 29, 23, 59, 59, 999999)
+
+# Custom cycle value - 2 months
+last_day_2months = self.find_last_date_of_period('monthly', date, cycle_value=2)
+# Result: datetime(2024, 3, 31, 23, 59, 59, 999999) - 2 month period
+```
+
+### period_iter()
+
+Generate sorted dates for periods between two dates.
+
+```python
+# Get all month ends between two dates
+dt_start = fields.Date.to_date('2024-01-15')
+dt_end = fields.Date.to_date('2024-06-20')
+
+period_dates = self.period_iter('monthly', dt_start, dt_end)
+# Result: [
+# date(2024, 1, 15), # start date
+# date(2024, 1, 31), # end of Jan
+# date(2024, 2, 29), # end of Feb
+# date(2024, 3, 31), # end of Mar
+# date(2024, 4, 30), # end of Apr
+# date(2024, 5, 31), # end of May
+# date(2024, 6, 20), # end date
+# ]
+
+# Quarterly with offset
+quarterly_dates = self.period_iter('quarterly', dt_start, dt_end, start_day_offset=5)
+# Result includes dates starting from 5th day of each quarter
+```
+
+### Date Difference Utilities
+
+```python
+# Days between dates
+days = self.get_days_between_dates(date_from, date_to)
+
+# Hours between datetimes
+hours = self.get_hours_between_dates(datetime_from, datetime_to)
+
+# Weeks between dates
+weeks = self.get_weeks_between_dates(date_from, date_to)
+
+# Months between dates (float, respects odd/even months)
+months = self.get_months_between_dates(date_from, date_to)
+# Example: Jan 15 to Feb 14 = 0.9677 months (31 days in Jan)
+
+# Years between dates (float, respects leap years)
+years = self.get_number_of_years_between_dates(date_from, date_to)
+
+# Days in month
+days_in_month = self.get_days_of_month_from_date(date)
+
+# Day of year (1-366)
+day_of_year = self.get_day_of_year_from_date(date)
+# Example: Jan 6 returns 6
+
+# Days in year (365 or 366)
+days_in_year = self.get_days_in_year(date)
+```
+
+### Other Date Utilities
+
+```python
+# Split date into components
+year, month, day = self.split_date(date)
+
+# Next weekday
+next_monday = self.next_weekday(date, weekday=0) # 0=Monday, 6=Sunday
+same_weekday = self.next_weekday(date) # Same weekday next week
+
+# Break time range at midnight
+# 2024-02-02 20:00 to 2024-02-03 04:00
+# -> [2024-02-02 20:00, 2024-02-03 00:00, 2024-02-03 04:00]
+intervals = self.break_timerange_for_midnight(start_dt, end_dt)
+```
+
+### Period Ratio Calculation
+
+```python
+# Calculate ratio between two periods
+# Example: monthly vs daily on Feb 2024 (29 days)
+ratio = self.get_ratio_between_periods('monthly', 1, 'daily', 1, given_date=date(2024, 2, 1))
+# Result: 29/7
+
+# Example: quarterly vs monthly
+ratio = self.get_ratio_between_periods('quarterly', 1, 'monthly', 1)
+# Result: 3.0
+```
+
+---
+
+## Timezone Conversion
+
+### get_company_tz()
+
+Get company timezone.
+
+```python
+# Get current company's timezone
+tz = self.get_company_tz()
+# Returns: 'Asia/Ho_Chi_Minh' or 'UTC' or company's timezone
+
+# Get specific company's timezone
+tz = self.get_company_tz(company=company_record)
+```
+
+### convert_local_to_utc()
+
+Convert local datetime to UTC.
+
+```python
+# Convert local datetime to UTC
+local_dt = datetime(2024, 2, 15, 14, 30, 0)
+utc_dt = self.convert_local_to_utc(local_dt, force_local_tz_name='Asia/Ho_Chi_Minh')
+# Result: datetime(2024, 2, 15, 7, 30, 0) (UTC is 7 hours behind)
+
+# Use context tz or user tz
+utc_dt = self.convert_local_to_utc(local_dt)
+
+# With naive=True (no timezone info in result)
+utc_dt_naive = self.convert_local_to_utc(local_dt, naive=True)
+# Result: datetime(2024, 2, 15, 7, 30, 0) without tzinfo
+
+# Convert date to datetime then to UTC
+date_only = date(2024, 2, 15)
+utc_from_date = self.convert_local_to_utc(date_only)
+```
+
+### convert_utc_to_local()
+
+Convert UTC datetime to local timezone.
+
+```python
+# Convert UTC to local
+utc_dt = datetime(2024, 2, 15, 7, 30, 0)
+local_dt = self.convert_utc_to_local(utc_dt, force_local_tz_name='Asia/Ho_Chi_Minh')
+# Result: datetime(2024, 2, 15, 14, 30, 0)
+
+# With DST handling
+local_dt = self.convert_utc_to_local(utc_dt, is_dst=False)
+```
+
+### Time Conversion Utilities
+
+```python
+# Convert datetime to float hours
+# datetime(2024, 1, 1, 14, 30, 0) -> 14.5
+float_hours = self.time_to_float_hour(datetime)
+
+# Convert float hours to time
+# 14.5 -> time(14, 30, 0)
+time_obj = self.float_hours_to_time(14.5)
+
+# Convert hours to string "HH:MM"
+time_str = self.hours_time_string(14.5) # "14:30"
+time_str = self.hours_time_string(8.5) # "08:30"
+
+# Convert date to datetime (combines with current time)
+dt = self.date_to_datetime(date_value)
+```
+
+---
+
+## Barcode Utilities
+
+### barcode_exists()
+
+Check if barcode exists in a model.
+
+```python
+# Check in current model
+exists = self.barcode_exists('8901234567890')
+
+# Check in specific model
+exists = self.barcode_exists('8901234567890', model_name='product.product')
+
+# Check with custom barcode field
+exists = self.barcode_exists('8901234567890', barcode_field='default_code')
+
+# Check only active records (default)
+exists = self.barcode_exists('8901234567890', inactive_rec=True)
+
+# Check all records including inactive
+exists = self.barcode_exists('8901234567890', inactive_rec=False)
+```
+
+### get_ean13()
+
+Generate EAN-13 barcode checksum.
+
+```python
+# Generate EAN-13 from 12-digit base
+barcode = self.get_ean13('123456789012')
+# Result: '1234567890128' (last digit is checksum)
+
+# Pads with zeros if less than 12 digits
+barcode = self.get_ean13('123')
+# Result: '000000000123X' (padded to 12 digits + checksum)
+```
+
+---
+
+## Batch Processing
+
+### splittor()
+
+Split large recordsets into batches to avoid memory issues.
+
+```python
+# Basic usage - splits into batches of PREFETCH_MAX (1000)
+for batch in self.splittor(large_recordset):
+ # Process batch
+ batch.compute_expensive_field()
+
+# Custom batch size
+for batch in self.splittor(large_recordset, max_rec_in_batch=500):
+ # Process 500 records at a time
+ batch.write({'field': value})
+
+# Maintain order - high priority items first
+for batch in self.splittor(recordset, max_rec_in_batch=100, maintain_order=True):
+ # Batches maintain relative order
+ batch.process()
+
+# No flush - keep in cache
+for batch in self.splittor(recordset, flush=False):
+ # Records stay in cache
+ batch.read_only_operation()
+```
+
+**Key features**:
+- Automatically divides collection into equal-sized batches
+- Invalidates recordset after each batch (default) to free memory
+- Set `flush=False` to keep records in cache
+- Use `maintain_order=True` to preserve order across batches
+
+---
+
+## after_commit Decorator
+
+Execute tasks after database transaction commits.
+
+```python
+from odoo.addons.dtg_base.models.dtg_base import after_commit
+
+class MyModel(models.Model):
+ _name = 'my.model'
+ _inherit = ['dtg.base']
+
+ @after_commit
+ def send_notification_after_commit(self):
+ """Send notification ONLY after transaction commits"""
+ for rec in self:
+ rec.message_post(
+ body=_("Record created successfully"),
+ message_type='notification'
+ )
+
+ def action_process(self):
+ # This will be called after commit
+ self.send_notification_after_commit()
+ return {'type': 'ir.actions.act_window_close'}
+```
+
+**Important**:
+- Function runs AFTER commit, in a new cursor
+- Use for notifications, external API calls, emails
+- If the function raises an exception, it's logged but doesn't rollback the transaction
+
+---
+
+## String & Text Utilities
+
+### strip_accents() & _no_accent_vietnamese()
+
+Remove accents from Vietnamese text.
+
+```python
+# Strip accents (general + Vietnamese specific)
+text = "Tiếng Việt có dấu"
+no_accent = self.strip_accents(text)
+# Result: "Tieng Viet khong dau"
+
+# Direct Vietnamese conversion
+vietnamese = "Xin chào, Đất Việt nước đẹp"
+converted = self._no_accent_vietnamese(vietnamese)
+# Result: "Xin chao, Dat Viet nuoc dep"
+```
+
+---
+
+## File Utilities
+
+### zip_dir()
+
+Zip a directory into bytes for storage in Binary field.
+
+```python
+# Zip a directory
+path = '/path/to/directory'
+zipped_bytes = self.zip_dir(path, incl_dir=False)
+
+# Store in binary field
+self.attachment_data = zipped_bytes
+
+# Include directory name in zip
+zipped_with_dir = self.zip_dir(path, incl_dir=True)
+```
+
+### zip_dirs()
+
+Zip multiple directories into one archive.
+
+```python
+# Zip multiple directories
+paths = ['/path/to/dir1', '/path/to/dir2']
+zipped_bytes = self.zip_dirs(paths)
+
+# Store in attachment
+attachment = self.env['ir.attachment'].create({
+ 'name': 'archives.zip',
+ 'res_id': self.id,
+ 'res_model': self._name,
+ 'datas': zipped_bytes,
+})
+```
+
+### _get_file_size()
+
+Get size of file or directory.
+
+```python
+# Get file size
+file_size = self._get_file_size('/path/to/file.pdf')
+# Returns: size in bytes
+
+# Get directory size (recursive)
+dir_size = self._get_file_size('/path/to/directory')
+# Returns: total size in bytes (excluding symbolic links)
+```
+
+---
+
+## Number Utilities
+
+### sum_digits()
+
+Sum digits until result has specified number of digits.
+
+```python
+# Sum all digits once
+result = self.sum_digits(178)
+# Result: 16 (1 + 7 + 8)
+
+# Sum until single digit
+result = self.sum_digits(178, number_of_digit_return=1)
+# Result: 7 (1 + 6 = 7)
+
+# Sum until 2 digits
+result = self.sum_digits(9999, number_of_digit_return=2)
+# Result: 36 (9 + 9 + 9 + 9 = 36)
+```
+
+### find_nearest_lucky_number()
+
+Find nearest number where digit sum equals 9.
+
+```python
+# Find nearest lucky number
+lucky = self.find_nearest_lucky_number(178)
+# Result: 171 (1 + 7 + 1 = 9)
+
+# With rounding
+lucky = self.find_nearest_lucky_number(178999, rounding=2)
+# Result: 178900 (then adjusted to nearest lucky number)
+
+# Round up
+lucky = self.find_nearest_lucky_number(100, round_up=True)
+# Result: 108 (1 + 0 + 8 = 9)
+```
+
+### calculate_weights()
+
+Calculate weight percentages.
+
+```python
+# Calculate weights as percentages
+weights = self.calculate_weights(2, 6)
+# Result: [0.25, 0.75] (25%, 75%)
+
+# With precision
+weights = self.calculate_weights(2, 6, precision_digits=2)
+# Result: [0.25, 0.75]
+
+# Ensure sum equals 1
+assert sum(weights) == 1.0
+```
+
+### fibonacci()
+
+Generate Fibonacci sequence.
+
+```python
+# Generate 5 terms
+fib = self.fibonacci(5)
+# Result: [0, 1, 1, 2, 3]
+
+# Deduplicate first 1
+fib = self.fibonacci(5, deduplicate_1=True)
+# Result: [0, 1, 2, 3] - removed duplicate 1
+```
+
+---
+
+## Other Utilities
+
+### validate_year()
+
+Validate and convert year to integer.
+
+```python
+# Valid year
+year = self.validate_year('2024') # Returns: 2024
+year = self.validate_year(2024) # Returns: 2024
+
+# Invalid year - raises ValidationError
+year = self.validate_year('abc') # Raises ValidationError
+year = self.validate_year(0) # Raises ValidationError
+year = self.validate_year(10000) # Raises ValidationError
+```
+
+### identical_images()
+
+Compare two Image fields.
+
+```python
+# Compare two images
+is_same = self.identical_images(img1_field, img2_field)
+# Returns: True if identical, False otherwise
+
+# Note: Does not support SVG format (PIL limitation)
+```
+
+### Unit Conversion
+
+```python
+# Miles to kilometers
+km = self.mile2km(10) # Returns: 16.09344
+
+# Kilometers to miles
+miles = self.km2mile(16) # Returns: 9.9419
+```
+
+### Week Utilities
+
+```python
+# Get weekdays for a period (max 7 days)
+weekdays = self.get_weekdays_for_period(date_from, date_to)
+# Returns: {0: date, 1: date, ...} where 0=Monday, 6=Sunday
+```
+
+---
+
+## Common Patterns
+
+### Pattern 1: Date Range by Period
+
+```python
+def _get_period_dates(self, date_from, date_to):
+ """Get all month-end dates in range"""
+ return self.period_iter('monthly', date_from, date_to)
+
+def action_report_by_period(self):
+ date_from = fields.Date.to_date(self.env.context.get('date_from'))
+ date_to = fields.Date.to_date(self.env.context.get('date_to'))
+
+ # Get all period boundaries
+ period_dates = self._get_period_dates(date_from, date_to)
+
+ for i in range(len(period_dates) - 1):
+ period_start = period_dates[i]
+ period_end = period_dates[i + 1]
+ # Process each period
+ self._process_period(period_start, period_end)
+```
+
+### Pattern 2: Safe Timezone Conversion
+
+```python
+def action_schedule_meeting(self):
+ # Get user's local timezone
+ tz = self.get_company_tz()
+
+ # Convert user input (local) to UTC for storage
+ utc_dt = self.convert_local_to_utc(
+ self.meeting_date,
+ force_local_tz_name=tz
+ )
+ self.meeting_date_utc = utc_dt
+
+ # Convert back to local for display
+ local_dt = self.convert_utc_to_local(
+ self.meeting_date_utc,
+ force_local_tz_name=tz
+ )
+ self.meeting_date_display = local_dt
+```
+
+### Pattern 3: Batch Processing Large Recordsets
+
+```python
+def action_recompute_all(self):
+ # Get all records
+ records = self.search([])
+
+ # Process in batches to avoid memory issues
+ for batch in self.splittor(records, max_rec_in_batch=500):
+ # Each batch is automatically invalidated after processing
+ for rec in batch:
+ rec._compute_expensive_field()
+```
+
+### Pattern 4: After-Commit Notification
+
+```python
+@after_commit
+def _send_external_notification(self):
+ """Send to external API after commit"""
+ for rec in self:
+ requests.post(
+ 'https://api.example.com/notify',
+ json={'record_id': rec.id, 'state': rec.state}
+ )
+
+def action_confirm(self):
+ self.state = 'confirmed'
+ # Notification only sent if transaction commits
+ self._send_external_notification()
+```
+
+### Pattern 5: Barcode Validation
+
+```python
+def _check_barcode_unique(self, barcode):
+ """Validate barcode doesn't exist"""
+ if self.barcode_exists(barcode):
+ raise UserError(_("Barcode %s already exists") % barcode)
+
+def create(self, vals):
+ if vals.get('barcode'):
+ self._check_barcode_unique(vals['barcode'])
+ return super().create(vals)
+```
+
+---
+
+## Anti-Patterns
+
+| Anti-Pattern | Why Bad | Correct Approach |
+|--------------|---------|------------------|
+| Manual date calculation for periods | Error-prone, timezone issues | Use `find_first_date_of_period()`, `find_last_date_of_period()` |
+| Processing all records at once | Memory issues with large datasets | Use `splittor()` for batch processing |
+| Sending notifications before commit | Sent even if transaction rolls back | Use `@after_commit` decorator |
+| Manual timezone conversion | DST issues, error-prone | Use `convert_local_to_utc()`, `convert_utc_to_local()` |
+| Checking barcode with search() | Doesn't check inactive records | Use `barcode_exists()` |
+
+---
+
+## Method Reference
+
+### Date/Period Methods
+
+| Method | Description |
+|--------|-------------|
+| `find_first_date_of_period(period, date, offset)` | Get first date of period |
+| `find_last_date_of_period(period, date, is_start, cycle)` | Get last date of period |
+| `period_iter(period, dt_start, dt_end, offset, cycle)` | Get all period dates in range |
+| `get_days_between_dates(dt_from, dt_to)` | Days between dates |
+| `get_months_between_dates(dt_from, dt_to)` | Months between (float) |
+| `get_number_of_years_between_dates(dt_from, dt_to)` | Years between (float) |
+| `get_hours_between_dates(dt_from, dt_to)` | Hours between datetimes |
+| `get_days_of_month_from_date(dt)` | Number of days in month |
+| `get_day_of_year_from_date(dt)` | Day of year (1-366) |
+| `get_days_in_year(dt)` | Days in year (365 or 366) |
+| `split_date(date)` | Split into year, month, day |
+| `next_weekday(date, weekday)` | Get date next week |
+| `break_timerange_for_midnight(start, end)` | Split at midnight |
+| `get_ratio_between_periods(p1, d1, p2, d2, date)` | Ratio between periods |
+
+### Timezone Methods
+
+| Method | Description |
+|--------|-------------|
+| `get_company_tz(company)` | Get company timezone |
+| `convert_local_to_utc(dt, tz, is_dst, naive)` | Local to UTC |
+| `convert_utc_to_local(utc_dt, tz, is_dst, naive)` | UTC to local |
+| `time_to_float_hour(dt)` | Datetime to float hours |
+| `float_hours_to_time(hours, tz)` | Float to time |
+| `hours_time_string(hours)` | Hours to "HH:MM" string |
+| `date_to_datetime(date)` | Date to datetime |
+
+### Barcode Methods
+
+| Method | Description |
+|--------|-------------|
+| `barcode_exists(barcode, model, field, active)` | Check if barcode exists |
+| `get_ean13(base_number)` | Generate EAN-13 checksum |
+
+### Batch Methods
+
+| Method | Description |
+|--------|-------------|
+| `splittor(collection, max, order, flush)` | Split into batches |
+
+### String Methods
+
+| Method | Description |
+|--------|-------------|
+| `strip_accents(s)` | Remove all accents |
+| `_no_accent_vietnamese(s)` | Vietnamese accent removal |
+
+### File Methods
+
+| Method | Description |
+|--------|-------------|
+| `zip_dir(path, incl_dir)` | Zip directory |
+| `zip_dirs(paths)` | Zip multiple directories |
+| `_get_file_size(path)` | Get file/dir size |
+
+### Number Methods
+
+| Method | Description |
+|--------|-------------|
+| `sum_digits(n, digits)` | Sum digits |
+| `find_nearest_lucky_number(n, round, up)` | Find lucky number |
+| `calculate_weights(*weights, ...)` | Calculate percentages |
+| `fibonacci(n, dedup)` | Fibonacci sequence |
+
+### Other Methods
+
+| Method | Description |
+|--------|-------------|
+| `validate_year(year)` | Validate year (1-9999) |
+| `identical_images(img1, img2)` | Compare images |
+| `mile2km(miles)` | Convert to km |
+| `km2mile(km)` | Convert to miles |
+| `get_weekdays_for_period(from, to)` | Get weekdays dict |
+
+---
+
+## Module Info
+
+**Module**: `dtg_base`
+**Version**: 1.0.0
+**Author**: AnhBT
+**Location**: `addons_customs/erp/dtg_base/`
+**License**: OPL-1
+
+**Dependencies**: `base`
+
+**Files**:
+- `models/dtg_base.py` - DTGBase abstract model with all utilities
diff --git a/.agents/skills/odoo-19/AGENTS.md b/.agents/skills/odoo-19/AGENTS.md
new file mode 100644
index 00000000..a2d71bd1
--- /dev/null
+++ b/.agents/skills/odoo-19/AGENTS.md
@@ -0,0 +1,173 @@
+# Odoo 19 Documentation - AI Agents Setup
+
+Setup guide for using Odoo 19 documentation with AI coding assistants (Cursor, Claude Code, Windsurf, Aider, etc.).
+
+## Quick Start
+
+### Install via skills.sh (Recommended)
+
+```bash
+# Add Odoo 19 skill to your project
+npx skills add unclecatvn/agent-skills
+```
+
+Visit [https://skills.sh/](https://skills.sh/) for more installation options.
+
+### Cursor IDE - Remote Rule
+
+Configure once in Cursor settings:
+- `Settings` → `Rules` → `Add Remote Rule`
+- Source: `Git Repository`
+- URL: `git@github.com:unclecatvn/agent-skills.git`
+- Branch: `19.0`
+- Subfolder: `skills/odoo-19.0/`
+
+---
+
+## Documentation Structure
+
+```
+skills/odoo-19.0/
+├── SKILL.md # Master index (all agents)
+├── references/ # Development guides (18 files)
+│ ├── odoo-19-actions-guide.md # ir.actions.*, cron, bindings
+│ ├── odoo-19-controller-guide.md # HTTP, routing, controllers
+│ ├── odoo-19-data-guide.md # XML/CSV data files, records
+│ ├── odoo-19-decorator-guide.md # @api decorators
+│ ├── odoo-19-development-guide.md # Manifest, wizards (overview)
+│ ├── odoo-19-field-guide.md # Field types, parameters
+│ ├── odoo-19-manifest-guide.md # __manifest__.py reference
+│ ├── odoo-19-mixins-guide.md # mail.thread, activities, etc.
+│ ├── odoo-19-model-guide.md # ORM, CRUD, search, domain
+│ ├── odoo-19-migration-guide.md # Migration scripts, hooks
+│ ├── odoo-19-owl-guide.md # OWL components, services
+│ ├── odoo-19-performance-guide.md # N+1 prevention, optimization
+│ ├── odoo-19-reports-guide.md # QWeb reports, PDF/HTML
+│ ├── odoo-19-security-guide.md # ACL, record rules, security
+│ ├── odoo-19-testing-guide.md # Test classes, decorators
+│ ├── odoo-19-transaction-guide.md # Savepoints, errors
+│ ├── odoo-19-translation-guide.md # Translations, i18n
+│ └── odoo-19-view-guide.md # XML views, QWeb
+├── CLAUDE.md # Claude Code specific
+└── AGENTS.md # THIS FILE - setup guide
+```
+
+---
+
+## Guide Reference
+
+| File | Purpose | When to Use |
+|------|---------|-------------|
+| `SKILL.md` | Master index for all guides | Find the right guide for your task |
+| `references/odoo-19-actions-guide.md` | Actions (window, URL, server, cron) | Creating actions, menus, scheduled jobs |
+| `references/odoo-19-controller-guide.md` | HTTP controllers, routing | Writing endpoints |
+| `references/odoo-19-data-guide.md` | XML/CSV data files, records | Creating data files |
+| `references/odoo-19-decorator-guide.md` | @api decorators usage | Using @api decorators |
+| `references/odoo-19-development-guide.md` | Module structure, wizards | Creating new modules |
+| `references/odoo-19-field-guide.md` | Field types, parameters | Defining model fields |
+| `references/odoo-19-manifest-guide.md` | __manifest__.py reference | Configuring module manifest |
+| `references/odoo-19-mixins-guide.md` | mail.thread, activities, mixins | Adding messaging, activities |
+| `references/odoo-19-model-guide.md` | ORM methods, CRUD, domains | Writing model methods |
+| `references/odoo-19-migration-guide.md` | Migration scripts, hooks | Upgrading modules |
+| `references/odoo-19-owl-guide.md` | OWL components, hooks, services | Building OWL UI |
+| `references/odoo-19-performance-guide.md` | Performance optimization | Fixing slow code |
+| `references/odoo-19-reports-guide.md` | QWeb reports, templates | Creating reports |
+| `references/odoo-19-security-guide.md` | ACL, record rules, security | Configuring security |
+| `references/odoo-19-testing-guide.md` | Test classes, decorators, mocking | Writing tests |
+| `references/odoo-19-transaction-guide.md` | Database transactions, error handling | Savepoints, UniqueViolation |
+| `references/odoo-19-translation-guide.md` | Translations, localization, i18n | Adding translations |
+| `references/odoo-19-view-guide.md` | XML views, actions, menus | Writing view XML |
+
+---
+
+## AI Agent Configuration
+
+### Cursor IDE
+
+| Setting | Value |
+|---------|-------|
+| Source | Git Repository |
+| URL | `git@github.com:unclecatvn/agent-skills.git` |
+| Branch | `19.0` |
+| Subfolder | `skills/odoo-19.0/` |
+
+**Globs patterns used by Cursor:**
+
+| File | globs Pattern |
+|------|---------------|
+| `SKILL.md` | `**/*.{py,xml}` |
+| `references/odoo-19-actions-guide.md` | `**/*.{py,xml}` |
+| `references/odoo-19-controller-guide.md` | `**/controllers/**/*.py` |
+| `references/odoo-19-data-guide.md` | `**/*.{xml,csv}` |
+| `references/odoo-19-decorator-guide.md` | `**/models/**/*.py` |
+| `references/odoo-19-development-guide.md` | `**/*.{py,xml,csv}` |
+| `references/odoo-19-field-guide.md` | `**/models/**/*.py` |
+| `references/odoo-19-manifest-guide.md` | `**/__manifest__.py` |
+| `references/odoo-19-mixins-guide.md` | `**/models/**/*.py` |
+| `references/odoo-19-model-guide.md` | `**/models/**/*.py` |
+| `references/odoo-19-migration-guide.md` | `**/migrations/**/*.py` |
+| `references/odoo-19-owl-guide.md` | `static/src/**/*.{js,xml}` |
+| `references/odoo-19-performance-guide.md` | `**/*.{py,xml}` |
+| `references/odoo-19-reports-guide.md` | `**/report/**/*.xml` |
+| `references/odoo-19-security-guide.md` | `**/security/**/*.{csv,xml}` |
+| `references/odoo-19-testing-guide.md` | `**/tests/**/*.py` |
+| `references/odoo-19-transaction-guide.md` | `**/models/**/*.py` |
+| `references/odoo-19-translation-guide.md` | `**/*.{py,js,xml}` |
+| `references/odoo-19-view-guide.md` | `**/views/**/*.xml` |
+
+### Claude Code
+
+```bash
+# Install via skills.sh
+npx skills add unclecatvn/agent-skills
+```
+
+Claude Code reads:
+- `CLAUDE.md` - Project overview and quick reference
+- `SKILL.md` - Master index for all guides
+- Individual guides in `references/` - Detailed information
+
+### Other Agents
+
+| Agent | Setup |
+|-------|-------|
+| Windsurf | Same as Cursor (uses `.mdc` files) |
+| Continue | Place `CLAUDE.md` or `SKILL.md` in root |
+| Aider | Place `CLAUDE.md` or add to prompt |
+| OpenCode | Copy skill folder to project - no additional config needed |
+
+---
+
+## Cursor / Claude Skills Folder
+
+After installing via `npx skills add unclecatvn/agent-skills`, the skill is placed at:
+
+```
+.cursor/skills/
+└── odoo-19/
+ └── SKILL.md
+
+.claude/skills/
+└── odoo-19/
+ └── SKILL.md
+```
+
+---
+
+## Key Odoo 19 Changes
+
+| Change | Old | New |
+|--------|-----|-----|
+| List view tag | `` | `` |
+| Dynamic attributes | `attrs="{'invisible': [...]}"` | `invisible="..."` |
+| Delete validation | Override `unlink()` | `@api.ondelete(at_uninstall=False)` |
+| Field aggregation | `group_operator=` | `aggregator=` |
+| SQL queries | `cr.execute()` | `SQL` class with `execute_query_dict()` |
+
+---
+
+## Repository
+
+**URL**: `git@github.com:unclecatvn/agent-skills.git`
+**Branch**: `19.0`
+**License**: MIT
diff --git a/.agents/skills/odoo-19/CLAUDE.md b/.agents/skills/odoo-19/CLAUDE.md
new file mode 100644
index 00000000..3177c529
--- /dev/null
+++ b/.agents/skills/odoo-19/CLAUDE.md
@@ -0,0 +1,192 @@
+# Odoo 19 Development Guide
+
+This file provides guidance to AI agents when working with Odoo 19 code in this repository.
+
+> **For setup instructions with different AI IDEs, see [AGENTS.md](./AGENTS.md)**
+
+## Documentation Structure
+
+The `skills/odoo-19.0/references/` directory contains modular guides for Odoo 19 development:
+
+```
+skills/odoo-19.0/
+├── SKILL.md # Master index
+├── references/ # Development guides (18 files)
+│ ├── odoo-19-actions-guide.md # ir.actions.*, cron, bindings
+│ ├── odoo-19-controller-guide.md # HTTP, routing, controllers
+│ ├── odoo-19-data-guide.md # XML/CSV data files, records
+│ ├── odoo-19-decorator-guide.md # @api decorators
+│ ├── odoo-19-development-guide.md # Manifest, wizards (overview)
+│ ├── odoo-19-field-guide.md # Field types, parameters
+│ ├── odoo-19-manifest-guide.md # __manifest__.py reference
+│ ├── odoo-19-mixins-guide.md # mail.thread, activities, etc.
+│ ├── odoo-19-model-guide.md # ORM, CRUD, search, domain
+│ ├── odoo-19-migration-guide.md # Migration scripts, hooks
+│ ├── odoo-19-owl-guide.md # OWL components, services
+│ ├── odoo-19-performance-guide.md # N+1 prevention, optimization
+│ ├── odoo-19-reports-guide.md # QWeb reports, PDF/HTML
+│ ├── odoo-19-security-guide.md # ACL, record rules, security
+│ ├── odoo-19-testing-guide.md # Test classes, decorators
+│ ├── odoo-19-transaction-guide.md # Savepoints, errors
+│ ├── odoo-19-translation-guide.md # Translations, i18n
+│ └── odoo-19-view-guide.md # XML views, QWeb
+├── CLAUDE.md # This file
+└── AGENTS.md # AI agents setup
+```
+
+## Which Guide to Use
+
+| Task | Guide |
+| ------------------------------------- | ----------------------------------------- |
+| Creating actions, menus, cron jobs | `references/odoo-19-actions-guide.md` |
+| Creating a new module | `references/odoo-19-development-guide.md` |
+| Configuring **manifest**.py | `references/odoo-19-manifest-guide.md` |
+| Creating XML/CSV data files | `references/odoo-19-data-guide.md` |
+| Writing ORM queries/search | `references/odoo-19-model-guide.md` |
+| Defining model fields | `references/odoo-19-field-guide.md` |
+| Using @api decorators | `references/odoo-19-decorator-guide.md` |
+| Writing XML views | `references/odoo-19-view-guide.md` |
+| Fixing slow code/N+1 queries | `references/odoo-19-performance-guide.md` |
+| Handling database errors | `references/odoo-19-transaction-guide.md` |
+| Creating HTTP endpoints | `references/odoo-19-controller-guide.md` |
+| Building OWL components | `references/odoo-19-owl-guide.md` |
+| Upgrading modules/migrating data | `references/odoo-19-migration-guide.md` |
+| Using mail.thread, activities, mixins | `references/odoo-19-mixins-guide.md` |
+| Creating QWeb reports | `references/odoo-19-reports-guide.md` |
+| Configuring security (ACL, rules) | `references/odoo-19-security-guide.md` |
+| Writing tests | `references/odoo-19-testing-guide.md` |
+| Adding translations/localization | `references/odoo-19-translation-guide.md` |
+
+## Key Odoo 19 Changes
+
+| Change | Old (Odoo 17-) | New (Odoo 19) |
+| ------------------ | ------------------------------ | ------------------------------------------ |
+| List view tag | `` | `` |
+| Dynamic attributes | `attrs="{'invisible': [...]}"` | `invisible="..."` (direct) |
+| Delete validation | Override `unlink()` | `@api.ondelete(at_uninstall=False)` |
+| Field aggregation | `group_operator=` | `aggregator=` |
+| SQL queries | `cr.execute()` | `SQL` class with `execute_query_dict()` |
+| Batch create | Single dict | List of dicts (`create([{...}, {...}])`) |
+| SQL constraints | `_sql_constraints = [...]` | `models.Constraint(...)` |
+| DB indexes | `index=True` only | `models.Index(...)` declarative |
+| Kanban template | `t-name="kanban-box"` | `t-name="card"` |
+| QWeb output | `t-esc` | `t-out` (t-esc deprecated) |
+| Security groups | `category_id` on `res.groups` | `privilege_id` + `res.groups.privilege` |
+| Private methods | `_` prefix convention | `@api.private` decorator (enforced) |
+| Model naming | `_name = 'res.users'` required | CamelCase class → auto-derive `_name` |
+| read_group | `read_group()` | `_read_group()` / `formatted_read_group()` |
+
+## Critical Anti-Patterns
+
+| Anti-Pattern | Why Bad | Correct Approach |
+| -------------------------------------------------------------- | ---------------------------- | -------------------------------------------------- |
+| `attrs="{'invisible': [...]}"` | Deprecated in Odoo 18 | Use `invisible="..."` direct attribute |
+| `@api.depends('partner_id')` then accessing `partner_id.email` | N queries per record | Add `@api.depends('partner_id.email')` |
+| `search()` inside loop | N+1 queries | Use `search()` with `IN` domain or `_read_group()` |
+| `create()` in loop | N INSERT statements | Batch: `create([{...}, {...}])` |
+| Overriding `unlink()` for validation | Breaks module uninstall | Use `@api.ondelete(at_uninstall=False)` |
+| Using `` in Odoo 19 | Deprecated tag | Use `` instead |
+| Using `_sql_constraints` | **Not supported in Odoo 19** | Use `models.Constraint(...)` |
+| Using `t-esc` in templates | Deprecated directive | Use `t-out` instead |
+| Using `category_id` in `res.groups` | Removed in Odoo 19 | Use `privilege_id` + `res.groups.privilege` |
+| Using `read_group()` | Deprecated | Use `_read_group()` or `formatted_read_group()` |
+
+## @api Decorator Decision Tree
+
+```
+Need to define field behavior?
+├── Field computed from other fields → @api.depends
+│ └── CAN use dotted paths: `@api.depends('partner_id.email')`
+├── Validate data → @api.constrains
+│ └── CANNOT use dotted paths: only simple field names
+├── Prevent record deletion → @api.ondelete (Odoo 18+)
+└── Update form UI → @api.onchange
+ └── NO CRUD operations allowed
+
+Need to define method behavior?
+├── Method-level, doesn't depend on self → @api.model
+├── Mark method as non-RPC callable → @api.private
+└── Normal record method → no decorator needed
+```
+
+## Common Patterns Reference
+
+### N+1 Query Prevention
+
+```python
+# BAD: search in loop
+for order in orders:
+ payments = self.env['payment'].search([('order_id', '=', order.id)])
+
+# GOOD: single query
+payments = self.env['payment'].search_read([('order_id', 'in', orders.ids)])
+```
+
+### List View (Odoo 19)
+
+```xml
+
+
+
+
+```
+
+### Delete Validation (Odoo 19)
+
+```python
+@api.ondelete(at_uninstall=False)
+def _unlink_if_not_draft(self):
+ if any(rec.state != 'draft' for rec in self):
+ raise UserError("Cannot delete non-draft records")
+```
+
+## Module Structure
+
+```
+my_module/
+├── __init__.py
+├── __manifest__.py
+├── models/
+│ ├── __init__.py
+│ └── my_model.py
+├── views/
+│ └── my_model_views.xml
+├── security/
+│ ├── ir.model.access.csv
+│ └── my_module_security.xml
+├── data/
+│ └── my_module_data.xml
+├── migrations/
+│ └── 19.0.1.0/
+│ └── post-migration.py
+├── tests/
+│ ├── __init__.py
+│ └── test_my_model.py
+├── wizard/
+│ ├── __init__.py
+│ └── my_wizard.py
+├── controllers/
+│ ├── __init__.py
+│ └── my_controller.py
+└── static/
+ └── src/
+ ├── js/
+ │ └── my_component.js
+ ├── xml/
+ │ └── my_component.xml
+ └── scss/
+ └── my_style.scss
+```
+
+## Base Code Reference
+
+The guides are based on Odoo 19 source code. Reference these files in your Odoo installation:
+
+- `odoo/models.py` - ORM implementation
+- `odoo/fields.py` - Field types
+- `odoo/api.py` - Decorators
+- `odoo/http.py` - HTTP layer
+- `odoo/exceptions.py` - Exception types
+- `odoo/tools/translate.py` - Translation system
+- `odoo/addons/base/models/res_lang.py` - Language model
+- `addons/web/static/src/core/l10n/translation.js` - JS translations
diff --git a/.agents/skills/odoo-19/SKILL.md b/.agents/skills/odoo-19/SKILL.md
new file mode 100644
index 00000000..63a6c362
--- /dev/null
+++ b/.agents/skills/odoo-19/SKILL.md
@@ -0,0 +1,97 @@
+---
+name: odoo-19
+description: >-
+ Odoo 19 development knowledge base with 18 specialized guides covering
+ Actions (ir.actions.*, cron jobs, server actions), Controllers (HTTP
+ routing, endpoints, auth types), Data files (XML/CSV records, shortcuts,
+ noupdate), API Decorators (@api.depends, @api.constrains, @api.ondelete,
+ @api.onchange, @api.model, @api.private), SQL Constraints (models.Constraint
+ replacing _sql_constraints), Database Indexes (models.Index), Module
+ development (manifest, wizards, reports), Field types (Char, Text, Monetary,
+ relational fields), Manifest configuration (__manifest__.py, dependencies,
+ asset bundles), Mixins (mail.thread, mail.activity.mixin, mail.alias.mixin,
+ utm.mixin), ORM Model methods (search, CRUD, domain filters, recordsets,
+ CamelCase model naming), Migration scripts (pre/post/end hooks, data
+ migration), OWL frontend components (hooks, services, lifecycle),
+ Performance optimization (N+1 prevention, batch ops, _read_group), QWeb
+ Reports (PDF/HTML, paper formats, barcodes, t-out), Security/ACL (record
+ rules, field permissions, privilege-based groups, @api.private), Testing
+ (TransactionCase, HttpCase, mocking, query count assertions), Transactions
+ (savepoints, UniqueViolation, serialization failures), Translations (i18n,
+ PO files, translatable fields), XML Views (list/form/search, kanban card
+ templates, xpath inheritance, QWeb templates). Use when writing, reviewing,
+ or debugging any Odoo 19 Python or XML code, creating or modifying modules,
+ fixing performance issues, or looking up Odoo 19 API patterns and best
+ practices.
+---
+
+# Odoo 19 Skill - Master Index
+
+Master index for all Odoo 19 development guides. Read the appropriate guide from `references/` based on your task.
+
+## Quick Reference
+
+| Topic | File | When to Use |
+| -------------- | ----------------------------------------- | ------------------------------------------------------- |
+| Actions | `references/odoo-19-actions-guide.md` | Creating actions, menus, scheduled jobs, server actions |
+| API Decorators | `references/odoo-19-decorator-guide.md` | Using @api decorators, compute fields, validation |
+| Controllers | `references/odoo-19-controller-guide.md` | Writing HTTP endpoints, routes, web controllers |
+| Data Files | `references/odoo-19-data-guide.md` | XML/CSV data files, records, shortcuts |
+| Development | `references/odoo-19-development-guide.md` | Creating modules, manifest, reports, security, wizards |
+| Field Types | `references/odoo-19-field-guide.md` | Defining model fields, choosing field types |
+| Manifest | `references/odoo-19-manifest-guide.md` | **manifest**.py configuration, dependencies, hooks |
+| Migration | `references/odoo-19-migration-guide.md` | Upgrading modules, data migration, version changes |
+| Mixins | `references/odoo-19-mixins-guide.md` | mail.thread, activities, email aliases, tracking |
+| Model Methods | `references/odoo-19-model-guide.md` | Writing ORM queries, CRUD operations, domain filters |
+| OWL Components | `references/odoo-19-owl-guide.md` | Building OWL UI components, hooks, services |
+| Performance | `references/odoo-19-performance-guide.md` | Optimizing queries, fixing slow code, preventing N+1 |
+| Reports | `references/odoo-19-reports-guide.md` | QWeb reports, PDF/HTML, templates, paper formats |
+| Security | `references/odoo-19-security-guide.md` | Access rights, record rules, field permissions |
+| Testing | `references/odoo-19-testing-guide.md` | Writing tests, mocking, assertions, browser testing |
+| Transactions | `references/odoo-19-transaction-guide.md` | Handling database errors, savepoints, UniqueViolation |
+| Translation | `references/odoo-19-translation-guide.md` | Adding translations, localization, i18n |
+| Views & XML | `references/odoo-19-view-guide.md` | Writing XML views, actions, menus, QWeb templates |
+
+## File Structure
+
+```
+skills/odoo-19.0/
+├── SKILL.md # This file - master index
+└── references/ # Development guides
+ ├── odoo-19-actions-guide.md
+ ├── odoo-19-controller-guide.md
+ ├── odoo-19-data-guide.md
+ ├── odoo-19-decorator-guide.md
+ ├── odoo-19-development-guide.md
+ ├── odoo-19-field-guide.md
+ ├── odoo-19-manifest-guide.md
+ ├── odoo-19-migration-guide.md
+ ├── odoo-19-mixins-guide.md
+ ├── odoo-19-model-guide.md
+ ├── odoo-19-owl-guide.md
+ ├── odoo-19-performance-guide.md
+ ├── odoo-19-reports-guide.md
+ ├── odoo-19-security-guide.md
+ ├── odoo-19-testing-guide.md
+ ├── odoo-19-transaction-guide.md
+ ├── odoo-19-translation-guide.md
+ └── odoo-19-view-guide.md
+```
+
+## Base Code Reference (Odoo 19)
+
+All guides are based on analysis of Odoo 19 source code:
+
+- `odoo/models.py` - ORM implementation
+- `odoo/fields.py` - Field types
+- `odoo/api.py` - Decorators
+- `odoo/http.py` - HTTP layer
+- `odoo/exceptions.py` - Exception types
+- `odoo/tools/translate.py` - Translation system
+- `odoo/addons/base/models/res_lang.py` - Language model
+- `addons/web/static/src/core/l10n/translation.js` - JS translations
+
+## External Documentation
+
+- [Odoo 19 Official Documentation](https://github.com/odoo/documentation/tree/19.0)
+- [Odoo 19 Developer Reference](https://github.com/odoo/documentation/blob/19.0/developer/reference/orm.rst)
diff --git a/.agents/skills/odoo-19/references/odoo-19-actions-guide.md b/.agents/skills/odoo-19/references/odoo-19-actions-guide.md
new file mode 100644
index 00000000..404b0698
--- /dev/null
+++ b/.agents/skills/odoo-19/references/odoo-19-actions-guide.md
@@ -0,0 +1,341 @@
+# Odoo 19 Actions Guide
+
+Guide for working with Odoo 19 actions (`ir.actions.*`), scheduled jobs (cron), and action bindings.
+
+## Table of Contents
+- [Action Types](#action-types)
+- [Window Actions](#window-actions)
+- [Server Actions](#server-actions)
+- [Report Actions](#report-actions)
+- [Client Actions](#client-actions)
+- [URL Actions](#url-actions)
+- [Scheduled Actions (Cron)](#scheduled-actions-cron)
+- [Action Bindings](#action-bindings)
+
+---
+
+## Action Types
+
+Actions define the behavior of the system in response to user actions: login, action button, selection of records, etc.
+
+Actions can be stored in the database or returned directly as dictionaries. All actions share two mandatory attributes:
+
+| Attribute | Type | Description |
+|-----------|------|-------------|
+| `type` | string | The category of the current action |
+| `name` | string | Short user-readable description |
+
+A client can get actions in 4 forms:
+- `False` - closes any open action dialog
+- A string - client action tag or number
+- A number - database identifier or external ID
+- A dictionary - client action descriptor
+
+---
+
+## Window Actions
+
+`ir.actions.act_window` - The most common action type, used to present visualizations of a model through views.
+
+### Key Attributes
+
+| Attribute | Type | Description |
+|-----------|------|-------------|
+| `res_model` | string | Model to present views for |
+| `views` | list | List of `(view_id, view_type)` pairs |
+| `res_id` | int | If default view is `form`, specifies the record to load |
+| `search_view_id` | tuple | `(id, name)` pair for specific search view |
+| `target` | string | `current`, `fullscreen`, `new`, or `main` |
+| `context` | dict | Additional context data |
+| `domain` | list | Filtering domain |
+| `limit` | int | Number of records to display (default: 80) |
+
+### Example: Opening customers
+
+```python
+{
+ "type": "ir.actions.act_window",
+ "res_model": "res.partner",
+ "views": [[False, "list"], [False, "form"]],
+ "domain": [["customer", "=", True]],
+}
+```
+
+### Example: Opening specific product in dialog
+
+```python
+{
+ "type": "ir.actions.act_window",
+ "res_model": "product.product",
+ "views": [[False, "form"]],
+ "res_id": a_product_id,
+ "target": "new",
+}
+```
+
+### In-Database Fields
+
+When defining actions from XML data files:
+
+| Attribute | Description |
+|-----------|-------------|
+| `view_mode` | Comma-separated list of view types (e.g., `list,form`) |
+| `view_ids` | Many2many to view objects |
+| `view_id` | Specific view to add to views list |
+
+### Using ir.actions.act_window.view
+
+```xml
+
+
+ list
+
+
+
+```
+
+---
+
+## Server Actions
+
+`ir.actions.server` - Allow triggering complex server code from any valid action location.
+
+### Key Attributes
+
+| Attribute | Type | Description |
+|-----------|------|-------------|
+| `id` | int | In-database identifier |
+| `model_id` | ref | Odoo model linked to the action |
+| `state` | string | Type of action: `code`, `object_create`, `object_write`, `multi` |
+| `code` | string | Python code to execute |
+
+### State: code
+
+```xml
+
+ Res Partner Server Action
+
+ code
+
+ raise Warning(record.name)
+
+
+```
+
+### Returning next action
+
+```xml
+
+ Open Form Action
+
+ code
+
+ if record.some_condition():
+ action = {
+ "type": "ir.actions.act_window",
+ "view_mode": "form",
+ "res_model": record._name,
+ "res_id": record.id,
+ }
+
+
+```
+
+### State: object_create
+
+| Attribute | Description |
+|-----------|-------------|
+| `crud_model_id` | Model in which to create a new record |
+| `link_field_id` | Many2one field on which to set newly created record |
+| `fields_lines` | Fields to override when creating |
+
+### State: object_write
+
+Updates the current record(s) following `fields_lines` specifications.
+
+### State: multi
+
+Executes several actions given through `child_ids`.
+
+### Evaluation Context
+
+Available variables in server actions:
+- `model` - Model object linked to the action
+- `record`/`records` - Record/recordset on which the action is triggered
+- `env` - Odoo Environment
+- `datetime`, `dateutil`, `time`, `timezone` - Python modules
+- `log(message, level='info')` - Logging function
+- `Warning` - Constructor for Warning exception
+
+---
+
+## Report Actions
+
+`ir.actions.report` - Triggers the printing of a report.
+
+### Key Attributes
+
+| Attribute | Type | Description |
+|-----------|------|-------------|
+| `name` | string | Used as file name if `print_report_name` not specified |
+| `model` | string | Model your report will be about |
+| `report_type` | string | `qweb-pdf` or `qweb-html` |
+| `report_name` | string | External ID of the qweb template |
+| `print_report_name` | string | Python expression for report name |
+| `groups_id` | Many2many | Groups allowed to view/use the report |
+| `multi` | boolean | If True, not displayed on form view |
+| `paperformat_id` | Many2one | Paper format to use |
+| `attachment_use` | boolean | Generate once, then reprint from stored report |
+| `attachment` | string | Python expression for attachment name |
+
+### Print Menu Integration
+
+If you define your report through a `` and want it in the Print menu:
+
+```xml
+
+ My Report
+ my.model
+ qweb-pdf
+ my_module.my_template
+
+
+```
+
+---
+
+## Client Actions
+
+`ir.actions.client` - Triggers an action implemented entirely in the client.
+
+### Key Attributes
+
+| Attribute | Type | Description |
+|-----------|------|-------------|
+| `tag` | string | Client-side identifier of the action |
+| `params` | dict | Additional data to send to the client |
+| `target` | string | `current`, `fullscreen`, or `new` |
+
+```python
+{
+ "type": "ir.actions.client",
+ "tag": "pos.ui"
+}
+```
+
+Tells the client to start the Point of Sale interface.
+
+---
+
+## URL Actions
+
+`ir.actions.act_url` - Allow opening a URL (website/web page).
+
+### Key Attributes
+
+| Attribute | Type | Description |
+|-----------|------|-------------|
+| `url` | string | The address to open |
+| `target` | string | `new`, `self`, or `download` |
+
+```python
+{
+ "type": "ir.actions.act_url",
+ "url": "https://odoo.com",
+ "target": "self",
+}
+```
+
+---
+
+## Scheduled Actions (Cron)
+
+`ir.cron` - Actions triggered automatically on a predefined frequency.
+
+### Key Attributes
+
+| Attribute | Type | Description |
+|-----------|------|-------------|
+| `name` | string | Name of the scheduled action |
+| `interval_number` | int | Number of interval_type units between executions |
+| `interval_type` | string | `minutes`, `hours`, `days`, `weeks`, `months` |
+| `model_id` | ref | Model on which this action will be called |
+| `code` | string | Code content of the action |
+| `nextcall` | datetime | Next planned execution date |
+| `priority` | int | Priority when executing multiple actions |
+
+### Writing cron functions
+
+When writing cron functions, batch the progress to avoid blocking workers:
+
+```python
+def _cron_do_something(self, *, limit=300):
+ domain = [('state', '=', 'ready')]
+ records = self.search(domain, limit=limit)
+ records.do_something()
+ # notify progression
+ remaining = 0 if len(records) == limit else self.search_count(domain)
+ self.env['ir.cron']._commit_progress(len(records), remaining=remaining)
+```
+
+### Managing resources between batches
+
+```python
+def _cron_do_something(self):
+ assert self.env.context.get('cron_id'), "Run only inside cron jobs"
+ domain = [('state', '=', 'ready')]
+ records = self.search(domain)
+ self.env['ir.cron']._commit_progress(remaining=len(records))
+
+ with open_some_connection() as conn:
+ for record in records:
+ record = record.try_lock_for_update().filtered_domain(domain)
+ if not record:
+ continue
+ try:
+ record.do_something(conn)
+ if not self.env['ir.cron']._commit_progress(1):
+ break
+ except Exception:
+ self.env.cr.rollback()
+```
+
+### Running cron functions
+
+Do not call cron functions directly. Use:
+- `IrCron.method_direct_trigger()` - for testing
+- `IrCron._trigger()` - for scheduled execution
+
+### Security Measures
+
+- If a scheduled action encounters an error or timeout **3 consecutive times**, it skips current execution
+- If it fails **5 consecutive times** over **7 days**, it is deactivated and notifies the DB admin
+- A hard-limit exists for cron execution at the database level
+
+---
+
+## Action Bindings
+
+Actions can be bound to models to appear in contextual menus.
+
+### Binding Attributes
+
+| Attribute | Type | Description |
+|-----------|------|-------------|
+| `binding_model_id` | Many2one | Model the action is bound to (use `model_id` for Server Actions) |
+| `binding_type` | string | `action` (default) or `report` |
+| `binding_view_types` | string | Comma-separated list: `list`, `form`, or `list,form` (default) |
+
+### Binding Type: action
+
+Action appears in the **Action** contextual menu.
+
+### Binding Type: report
+
+Action appears in the **Print** contextual menu.
+
+---
+
+## References
+
+- Source: Odoo 19 documentation `/doc/developer/reference/backend/actions.rst`
diff --git a/.agents/skills/odoo-19/references/odoo-19-controller-guide.md b/.agents/skills/odoo-19/references/odoo-19-controller-guide.md
new file mode 100644
index 00000000..4efe7cab
--- /dev/null
+++ b/.agents/skills/odoo-19/references/odoo-19-controller-guide.md
@@ -0,0 +1,344 @@
+# Odoo 19 Controller Guide
+
+Guide for creating HTTP controllers and routes in Odoo 19.
+
+## Table of Contents
+
+- [Controllers](#controllers)
+- [Routes](#routes)
+- [Request](#request)
+- [Response](#response)
+- [Authentication](#authentication)
+- [JSON-RPC](#json-rpc)
+
+---
+
+## Controllers
+
+Controllers provide extensibility similar to models, but with a separate mechanism (since database may not be available).
+
+Controllers are created by inheriting from `odoo.http.Controller`:
+
+```python
+from odoo import http
+
+class MyController(http.Controller):
+ @http.route('/some_url', auth='public')
+ def handler(self):
+ return stuff()
+```
+
+### Controller Inheritance
+
+To override a controller, inherit from its class and override methods:
+
+```python
+class Extension(MyController):
+ @http.route()
+ def handler(self):
+ do_before()
+ return super(Extension, self).handler()
+```
+
+**Important**:
+
+- Always re-apply `@http.route()` decorator to keep route visible
+- Without decorator, method is "unpublished"
+- Decorator arguments override previous ones
+
+### Change Authentication
+
+```python
+class Restrict(MyController):
+ @http.route(auth='user')
+ def handler(self):
+ return super(Restrict, self).handler()
+```
+
+This changes `/some_url` from public to user (requires login).
+
+---
+
+## Routes
+
+### Route Decorator
+
+`@http.route()` defines routing for controller methods.
+
+```python
+@http.route('/hello', auth='public', website=True)
+def hello(self):
+ return "Hello World!"
+```
+
+### Route Parameters
+
+| Parameter | Description |
+| --------- | ---------------------------------------------- |
+| `route` | Route path(s) (string or list) |
+| `auth` | Authentication type (`public`, `user`, `none`) |
+| `methods` | Allowed HTTP methods (`GET`, `POST`, etc.) |
+| `type` | Response type (`http`, `json`) |
+| `website` | Boolean: bind to current website |
+| `csrf` | Boolean: CSRF protection (default: True) |
+| `sitemap` | Boolean or sitemap config |
+
+### Multiple Routes
+
+```python
+@http.route(['/hello', '/bonjour'], auth='public')
+def hello_bonjour(self):
+ return "Hello or Bonjour!"
+```
+
+### HTTP Methods
+
+```python
+@http.route('/api/data', methods=['GET'], auth='user', type='json')
+def get_data(self):
+ return {'data': 'value'}
+
+@http.route('/api/data', methods=['POST'], auth='user', type='json')
+def post_data(self, **kwargs):
+ return {'result': 'created'}
+```
+
+---
+
+## Authentication
+
+### Authentication Types
+
+| Type | Description |
+| --------- | ----------------------------- |
+| `public` | No authentication required |
+| `user` | Requires active user session |
+| `none` | No authentication, no session |
+| `website` | Public with website support |
+
+### Examples
+
+```python
+# Public route
+@http.route('/page', auth='public')
+def public_page(self):
+ return "Everyone can see this"
+
+# User-only route
+@http.route('/my-account', auth='user')
+def user_page(self):
+ return "Only logged users can see this"
+
+# No authentication
+@http.route('/api/status', auth='none', type='json')
+def status(self):
+ return {'status': 'ok'}
+```
+
+### Current User
+
+```python
+@http.route('/profile', auth='user')
+def profile(self):
+ # Access current user
+ user = http.request.env.user
+ return f"Hello, {user.name}"
+```
+
+---
+
+## Request
+
+The request object is automatically set on `odoo.http.request` at the start of each request.
+
+### Request Properties
+
+| Property | Description |
+| ------------- | ------------------------------------ |
+| `httprequest` | Original Werkzeug request |
+| `env` | Odoo environment for current request |
+| `db` | Current database |
+| `uid` | Current user id |
+| `context` | Request context |
+| `session` | Session |
+| `cr` | Database cursor |
+| `lang` | Current language |
+| `registry` | Model registry |
+
+### Example
+
+```python
+@http.route('/info', auth='user')
+def info(self):
+ request = http.request
+ user = request.env.user
+ company = request.env.company
+ return f"{user.name} @ {company.name}"
+```
+
+### Session
+
+```python
+@http.route('/set-value', auth='public', methods=['POST'])
+def set_value(self, key, value):
+ http.request.session[key] = value
+ return "OK"
+
+@http.route('/get-value', auth='public')
+def get_value(self, key):
+ return http.request.session.get(key, 'not set')
+```
+
+---
+
+## Response
+
+### HTTP Response
+
+Return string for HTML, dict for JSON:
+
+```python
+# HTML response
+@http.route('/html', auth='public', type='http')
+def html_response(self):
+ return "Hello "
+
+# JSON response
+@http.route('/json', auth='public', type='json')
+def json_response(self):
+ return {'key': 'value'}
+```
+
+### Redirect
+
+```python
+from odoo.http import redirect
+
+@http.route('/old-url', auth='public')
+def old_url(self):
+ return redirect('/new-url')
+```
+
+### File Response
+
+```python
+@http.route('/download', auth='user')
+def download_file(self):
+ file_content = b'file data'
+ headers = [
+ ('Content-Type', 'application/pdf'),
+ ('Content-Disposition', 'attachment; filename="file.pdf"'),
+ ]
+ return request.make_response(
+ file_content,
+ headers
+ )
+```
+
+---
+
+## JSON-RPC
+
+### JSON Controller
+
+```python
+@http.route('/api/search', auth='user', type='json', methods=['POST'])
+def json_search(self, model, domain, fields=None):
+ Model = http.request.env[model]
+ records = Model.search(domain)
+ if fields:
+ records = records.read(fields)
+ else:
+ records = records.read()
+ return {'result': records}
+```
+
+### Call from JavaScript
+
+```javascript
+rpc("/api/search", {
+ model: "res.partner",
+ domain: [["is_company", "=", true]],
+ fields: ["name", "email"],
+}).then(function (result) {
+ console.log(result);
+});
+```
+
+---
+
+## Website Routes
+
+### Website Page
+
+```python
+class WebsiteController(http.Controller):
+ @http.route('/my-page', auth='public', website=True)
+ def my_page(self):
+ return http.request.render('my_module.my_page_template', {
+ 'title': 'My Page',
+ })
+```
+
+### Template
+
+```xml
+
+
+
+
+
+```
+
+---
+
+## Controllers Best Practices
+
+### Always Return a Value
+
+```python
+# BAD: no return
+@http.route('/bad', auth='public')
+def bad(self):
+ pass
+
+# GOOD: return something
+@http.route('/good', auth='public')
+def good(self):
+ return "Response"
+```
+
+### Use Proper Authentication
+
+```python
+# BAD: public for sensitive data
+@http.route('/sensitive', auth='public')
+def sensitive(self):
+ return secret_data()
+
+# GOOD: user authentication
+@http.route('/sensitive', auth='user')
+def sensitive(self):
+ return secret_data()
+```
+
+### CSRF Protection
+
+CSRF is enabled by default for POST. Disable with caution:
+
+```python
+@http.route('/webhook', auth='public', methods=['POST'], csrf=False)
+def webhook(self):
+ # External webhook, no CSRF token
+ return "OK"
+```
+
+---
+
+## References
+
+- Source: Odoo 19 documentation `/doc/developer/reference/backend/http.rst`
diff --git a/.agents/skills/odoo-19/references/odoo-19-data-guide.md b/.agents/skills/odoo-19/references/odoo-19-data-guide.md
new file mode 100644
index 00000000..ee5fcaf0
--- /dev/null
+++ b/.agents/skills/odoo-19/references/odoo-19-data-guide.md
@@ -0,0 +1,298 @@
+# Odoo 19 Data Files Guide
+
+Guide for working with Odoo 19 data files (XML and CSV), records, and shortcuts.
+
+## Table of Contents
+
+- [XML Data Files Structure](#xml-data-files-structure)
+- [Record Tag](#record-tag)
+- [Field Tag](#field-tag)
+- [Delete Tag](#delete-tag)
+- [Function Tag](#function-tag)
+- [Shortcuts](#shortcuts)
+- [CSV Data Files](#csv-data-files)
+
+---
+
+## XML Data Files Structure
+
+The main way to define data in Odoo is via XML data files:
+
+```xml
+
+
+
+ ...
+
+```
+
+### noupdate Flag
+
+If content should only be applied once:
+
+```xml
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+## Record Tag
+
+`record` - Defines or updates a database record.
+
+### Attributes
+
+| Attribute | Type | Required | Description |
+| ------------- | ------ | -------- | ---------------------------------------------------------- |
+| `model` | string | Yes | Name of the model to create/update |
+| `id` | string | No\* | External identifier for this record (strongly recommended) |
+| `context` | dict | No | Context to use when creating |
+| `forcecreate` | bool | No | In update mode, create if doesn't exist (default: True) |
+
+\*Required for record updates; recommended for creation
+
+### Example
+
+```xml
+
+ Odoo
+
+
+
+```
+
+---
+
+## Field Tag
+
+Each `record` can have `field` tags defining values.
+
+### Attributes
+
+| Attribute | Type | Description |
+| --------- | ------ | ----------------------------------------- |
+| `name` | string | **Required**. Name of the field to set |
+| `ref` | string | External ID to look up and set |
+| `search` | domain | Search domain, result set as field value |
+| `eval` | string | Python expression to evaluate |
+| `type` | string | Interpret field content (see types below) |
+
+### Value Methods
+
+#### Nothing (False)
+
+```xml
+
+```
+
+#### search
+
+For relational fields, evaluates a domain and sets the result:
+
+```xml
+
+```
+
+Only first result used for Many2one fields.
+
+#### ref
+
+Look up an external ID:
+
+```xml
+
+
+```
+
+#### type
+
+Available types:
+
+| Type | Description |
+| --------------- | ------------------------------------------------------ |
+| `xml`, `html` | Extract children as document, evaluate external IDs |
+| `file` | Ensure content is valid file path, saves `module,path` |
+| `char` | Set content directly without alterations |
+| `base64` | Base64-encode content (use with `file` attribute) |
+| `int`, `float` | Convert to number |
+| `list`, `tuple` | Contains `value` elements |
+
+```xml
+
+ Hello
+
+
+
+```
+
+#### eval
+
+Evaluate a Python expression:
+
+```xml
+
+
+
+```
+
+Evaluation context:
+
+- `time`, `datetime`, `timedelta`, `relativedelta` modules
+- `ref()` function to resolve external IDs
+- `obj` for current field's model
+
+---
+
+## Delete Tag
+
+`delete` - Removes records.
+
+### Attributes
+
+| Attribute | Type | Required | Description |
+| --------- | ------ | -------- | -------------------------------- |
+| `model` | string | Yes | Model in which to delete |
+| `id` | string | No\* | External ID of record to remove |
+| `search` | domain | No\* | Domain to find records to remove |
+
+\*Exclusive: use either `id` or `search`
+
+### Examples
+
+```xml
+
+
+
+
+
+```
+
+---
+
+## Function Tag
+
+`function` - Calls a method on a model.
+
+### Attributes
+
+| Attribute | Type | Required | Description |
+| --------- | ------ | -------- | ----------------------- |
+| `model` | string | Yes | Model to call method on |
+| `name` | string | Yes | Name of method to call |
+
+### Parameters
+
+Via `eval` (should evaluate to sequence):
+
+```xml
+
+```
+
+Via `value` elements:
+
+```xml
+
+
+
+```
+
+---
+
+## Shortcuts
+
+Because some structural models are complex, data files provide shorter alternatives.
+
+### menuitem
+
+Defines an `ir.ui.menu` record with defaults:
+
+| Attribute | Description |
+| --------- | ------------------------------------------------------------------------- |
+| `parent` | External ID of parent menu, or interpret `name` as `/`-separated sequence |
+| `name` | Menu name (or get from linked action) |
+| `groups` | Comma-separated external IDs for `res.groups` (prefix `-` removes group) |
+| `action` | External ID of action to execute |
+| `id` | External identifier |
+
+```xml
+
+
+```
+
+### template
+
+Creates a QWeb view requiring only the `arch` section:
+
+| Attribute | Description |
+| ------------ | ---------------------------------------------- |
+| `id` | External identifier |
+| `name` | View name |
+| `inherit_id` | External ID of parent view |
+| `priority` | View priority |
+| `primary` | If True with `inherit_id`, defines as primary |
+| `groups` | Comma-separated group external IDs |
+| `active` | Whether view is active (for inheritance views) |
+
+```xml
+
+
+
+
My Content
+
+
+
+```
+
+### asset
+
+Creates an `ir.asset` record:
+
+```xml
+
+ web.assets_frontend
+ website_something/static/src/some_style.scss
+
+```
+
+---
+
+## CSV Data Files
+
+XML is verbose for bulk creation. CSV files are simpler for same-model records.
+
+### Structure
+
+- File name: `{model_name}.csv`
+- First row: fields to write, special field `id` for external IDs
+- Each row: creates a new record
+
+### Example: `res.country.state.csv`
+
+```csv
+id,country_id,name,code
+state_1_us,country_us,Alabama,AL
+state_2_us,country_us,Alaska,AK
+state_3_us,country_us,Arizona,AZ
+```
+
+### Notes
+
+- First column: external ID for creation/update
+- Second column: external ID of country object to link to
+- Third column: `name` field value
+- Fourth column: `code` field value
+
+---
+
+## References
+
+- Source: Odoo 19 documentation `/doc/developer/reference/backend/data.rst`
diff --git a/.agents/skills/odoo-19/references/odoo-19-decorator-guide.md b/.agents/skills/odoo-19/references/odoo-19-decorator-guide.md
new file mode 100644
index 00000000..4b757538
--- /dev/null
+++ b/.agents/skills/odoo-19/references/odoo-19-decorator-guide.md
@@ -0,0 +1,458 @@
+# Odoo 19 Decorator Guide
+
+Guide for using `@api` decorators in Odoo 19: computed fields, validation, onchange, and more.
+
+## Table of Contents
+
+- [Method Decorators](#method-decorators)
+- [@api.depends](#apidepends)
+- [@api.constrains](#apiconstrains)
+- [@api.ondelete](#apiondelete)
+- [@api.onchange](#apionchange)
+- [@api.model](#apimodel)
+- [@api.model_create_multi](#apimodel_create_multi)
+- [@api.autovacuum](#apiautovacuum)
+- [@api.private](#apiprivate)
+- [@api.returns](#apireturns)
+
+---
+
+## Method Decorators
+
+Decorators in Odoo 19 are in `odoo.api` module:
+
+```python
+from odoo import api, models
+
+class MyModel(models.Model):
+ _name = 'my.model'
+
+ @api.depends('field')
+ def _compute_method(self):
+ pass
+```
+
+---
+
+## @api.depends
+
+For computed fields. Specifies dependencies that trigger recomputation.
+
+### Basic Usage
+
+```python
+total = fields.Float(compute='_compute_total', store=True)
+
+@api.depends('value', 'tax')
+def _compute_total(self):
+ for record in self:
+ record.total = record.value + record.value * record.tax
+```
+
+### Dotted Paths
+
+Can use dotted paths for relational fields:
+
+```python
+@api.depends('partner_id.email')
+def _compute_email(self):
+ for record in self:
+ record.email = record.partner_id.email
+```
+
+### Multiple Fields
+
+Compute multiple fields:
+
+```python
+discount_value = fields.Float(compute='_apply_discount')
+total = fields.Float(compute='_apply_discount')
+
+@api.depends('value', 'discount')
+def _apply_discount(self):
+ for record in self:
+ discount = record.value * record.discount
+ record.discount_value = discount
+ record.total = record.value - discount
+```
+
+### Search on Computed Field
+
+```python
+upper_name = fields.Char(compute='_compute_upper', search='_search_upper')
+
+@api.depends('name')
+def _compute_upper(self):
+ for record in self:
+ record.upper_name = record.name.upper() if record.name else False
+
+def _search_upper(self, operator, value):
+ if operator == 'like':
+ operator = 'ilike'
+ return [('name', operator, value)]
+```
+
+### Inverse Method
+
+Allow setting computed field:
+
+```python
+document = fields.Char(compute='_get_document', inverse='_set_document')
+
+@api.depends('document_path')
+def _get_document(self):
+ for record in self:
+ with open(record.document_path) as f:
+ record.document = f.read()
+
+def _set_document(self):
+ for record in self:
+ if not record.document:
+ continue
+ with open(record.document_path) as f:
+ f.write(record.document)
+```
+
+---
+
+## @api.constrains
+
+For validation. Called on create and write.
+
+### Basic Usage
+
+```python
+@api.constrains('email')
+def _check_email(self):
+ for record in self:
+ if not tools.email_validation(record.email):
+ raise ValidationError("Invalid email")
+```
+
+### Multiple Fields
+
+```python
+@api.constrains('date_start', 'date_end')
+def _check_dates(self):
+ for record in self:
+ if record.date_end < record.date_start:
+ raise ValidationError("End date must be after start date")
+```
+
+### No Dotted Paths
+
+Unlike `@api.depends`, **cannot use dotted paths**:
+
+```python
+# BAD: dotted path not supported
+@api.constrains('partner_id.email')
+def _check_email(self):
+ pass
+
+# GOOD: use simple field name
+@api.constrains('partner_id')
+def _check_email(self):
+ for record in self:
+ if not tools.email_validation(record.partner_id.email):
+ raise ValidationError("Invalid email")
+```
+
+---
+
+## @api.ondelete
+
+For delete validation (Odoo 18+).
+
+```python
+@api.ondelete(at_uninstall=False)
+def _unlink_if_not_draft(self):
+ if any(rec.state != 'draft' for rec in self):
+ raise UserError("Cannot delete non-draft records")
+```
+
+### Parameters
+
+| Parameter | Description |
+| -------------- | ------------------------------------------------------ |
+| `at_uninstall` | If `False`, allows deletion when module is uninstalled |
+
+### Why Use @api.ondelete?
+
+- **Better than overriding `unlink()`**: Doesn't break module uninstall
+- **Clear intent**: Explicitly for delete validation
+- **Automatic**: Called before deletion
+
+### Unlink Override (Anti-pattern)
+
+```python
+# BAD: breaks module uninstall
+def unlink(self):
+ if any(rec.state != 'draft' for rec in self):
+ raise UserError("Cannot delete non-draft records")
+ return super().unlink()
+```
+
+---
+
+## @api.onchange
+
+For form UI updates when field values change.
+
+### Basic Usage
+
+```python
+@api.onchange('partner_id')
+def _onchange_partner_id(self):
+ if self.partner_id:
+ self.email = self.partner_id.email
+ self.phone = self.partner_id.phone
+```
+
+### Multiple Fields
+
+```python
+@api.onchange('country_id', 'state_id')
+def _onchange_location(self):
+ if self.country_id:
+ # Update zip format
+ pass
+```
+
+### No CRUD Operations
+
+**Important**: `onchange` methods should **not** perform CRUD operations.
+
+```python
+# BAD: create in onchange
+@api.onchange('field1')
+def _onchange_field1(self):
+ self.env['another.model'].create({'name': 'test'})
+
+# GOOD: only modify current record
+@api.onchange('field1')
+def _onchange_field1(self):
+ self.field2 = 'computed value'
+```
+
+### Return Warning
+
+```python
+@api.onchange('amount')
+def _onchange_amount(self):
+ if self.amount < 0:
+ return {
+ 'warning': {
+ 'title': "Warning",
+ 'message': "Amount cannot be negative",
+ }
+ }
+```
+
+---
+
+## @api.model
+
+For model-level methods that don't depend on `self`.
+
+### Usage
+
+```python
+@api.model
+def get_default_values(self):
+ return {
+ 'field1': 'value1',
+ 'field2': 'value2',
+ }
+```
+
+### Can be called on any recordset
+
+```python
+# Can call on any recordset (self may be empty)
+record = self.env['my.model'].browse([1, 2, 3])
+defaults = record.get_default_values()
+```
+
+---
+
+## @api.model_create_multi
+
+For handling batch create operations.
+
+```python
+@api.model_create_multi
+def create(self, vals_list):
+ # Add default values
+ for vals in vals_list:
+ vals.setdefault('field', 'default')
+ return super().create(vals_list)
+```
+
+### Why Use It?
+
+Odoo 17+ creates records in batches by default. This decorator ensures proper handling.
+
+---
+
+## @api.autovacuum
+
+For methods to run by cron vacuuem.
+
+```python
+@api.autovacuum
+def _gc_entries(self):
+ # Clean old records
+ domain = [('create_date', '<', date.today() - timedelta(days=90)])
+ self.search(domain).unlink()
+```
+
+---
+
+## Decorator Decision Tree
+
+```
+Need to define field behavior?
+├── Field computed from other fields → @api.depends
+│ └── CAN use dotted paths
+├── Validate data → @api.constrains
+│ └── CANNOT use dotted paths
+├── Prevent record deletion → @api.ondelete
+└── Update form UI → @api.onchange
+ └── NO CRUD operations allowed
+
+Need to define method behavior?
+├── Method-level, doesn't depend on self → @api.model
+├── Mark method as non-RPC callable → @api.private
+└── Normal record method → no decorator needed
+```
+
+---
+
+## @api.private
+
+New in Odoo 19. Marks a method as **not callable via RPC** (external API).
+
+### Usage
+
+```python
+from odoo import api, models
+
+class MyModel(models.Model):
+ _name = 'my.model'
+
+ @api.private
+ def _internal_computation(self):
+ """This method cannot be called via XML-RPC/JSON-RPC."""
+ return self._do_heavy_work()
+
+ def public_action(self):
+ """This method CAN be called via RPC."""
+ return self._internal_computation()
+```
+
+### When to Use
+
+- Methods that should only be called internally (not via API/button)
+- Replaces the convention of prefixing with `_` for security-critical methods
+- ORM override methods that you don't want exposed
+
+### @api.private vs Underscore Convention
+
+```python
+# Convention: underscore prefix = private (but NOT enforced by ORM)
+def _do_stuff(self): # Cannot be called from action buttons, but still convention-based
+ pass
+
+# Odoo 19: @api.private = explicitly enforced by framework
+@api.private
+def compute_sensitive_data(self): # Name doesn't need underscore
+ pass
+```
+
+---
+
+---
+
+## @api.returns
+
+**Purpose**: Specify the return model of a method for API compatibility.
+
+```python
+from odoo import api, models
+
+class SaleOrder(models.Model):
+ _name = 'sale.order'
+
+ @api.returns('res.partner')
+ def get_partner(self):
+ """Returns partner record(s)"""
+ return self.mapped('partner_id')
+
+ @api.returns('self')
+ def copy(self, default=None):
+ """Returns new record(s) of same model"""
+ return super().copy(default)
+```
+
+**Common usage in Odoo base**:
+```python
+# Many methods use @api.returns
+@api.returns('mail.message', lambda value: value.id)
+def message_post(self, ...):
+ # Post a message, return the message
+ return message
+```
+
+---
+
+## Common Patterns
+
+### Computed Field with Inverse
+
+```python
+total = fields.Float(compute='_compute_total', inverse='_inverse_total', store=True)
+
+@api.depends('subtotal', 'tax')
+def _compute_total(self):
+ for record in self:
+ record.total = record.subtotal + record.tax
+
+def _inverse_total(self):
+ for record in self:
+ record.subtotal = record.total - record.tax
+```
+
+### Validation with Constraints
+
+```python
+@api.constrains('age')
+def _check_age(self):
+ for record in self:
+ if record.age < 18:
+ raise ValidationError("Must be 18 or older")
+```
+
+### Delete Validation
+
+```python
+@api.ondelete(at_uninstall=False)
+def _unlink_if_not_cancelled(self):
+ if any(rec.state != 'cancel' for rec in self):
+ raise UserError("Only cancelled records can be deleted")
+```
+
+### Onchange for Defaults
+
+```python
+@api.onchange('partner_id')
+def _onchange_partner_id(self):
+ if self.partner_id:
+ self.lang = self.partner_id.lang
+ self.user_id = self.partner_id.user_id
+```
+
+---
+
+## References
+
+- Source: Odoo 19 documentation `/doc/developer/reference/backend/orm.rst`
diff --git a/.agents/skills/odoo-19/references/odoo-19-development-guide.md b/.agents/skills/odoo-19/references/odoo-19-development-guide.md
new file mode 100644
index 00000000..c655aa50
--- /dev/null
+++ b/.agents/skills/odoo-19/references/odoo-19-development-guide.md
@@ -0,0 +1,514 @@
+# Odoo 19 Development Guide
+
+Guide for developing Odoo 19 modules: creating modules, manifest, structure, and common patterns.
+
+## Table of Contents
+- [Module Structure](#module-structure)
+- [Creating a Module](#creating-a-module)
+- [Manifest File](#manifest-file)
+- [Models](#models)
+- [Views](#views)
+- [Security](#security)
+- [Data Files](#data-files)
+- [Assets](#assets)
+- [Wizards](#wizards)
+
+---
+
+## Module Structure
+
+### Standard Structure
+
+```
+my_module/
+├── __init__.py
+├── __manifest__.py
+├── models/
+│ ├── __init__.py
+│ └── my_model.py
+├── views/
+│ └── my_model_views.xml
+├── security/
+│ ├── ir.model.access.csv
+│ └── my_module_security.xml
+├── data/
+│ └── my_module_data.xml
+├── demo/
+│ └── demo_data.xml
+├── migrations/
+│ └── 19.0.1.0/
+│ └── post-migration.py
+├── tests/
+│ ├── __init__.py
+│ └── test_my_model.py
+├── wizard/
+│ ├── __init__.py
+│ └── my_wizard.py
+├── controllers/
+│ ├── __init__.py
+│ └── my_controller.py
+├── static/
+│ ├── src/
+│ │ ├── js/
+│ │ ├── xml/
+│ │ └── scss/
+│ └── description/
+│ └── icon.png
+└── report/
+ └── my_report.xml
+```
+
+---
+
+## Creating a Module
+
+### Step 1: Create Directory
+
+```bash
+mkdir -p my_module/models
+mkdir -p my_module/views
+mkdir -p my_module/security
+mkdir -p my_module/static/src/js
+```
+
+### Step 2: Create __init__.py
+
+```python
+# __init__.py
+
+from . import models
+from . import controllers
+```
+
+```python
+# models/__init__.py
+
+from . import my_model
+```
+
+### Step 3: Create Model
+
+```python
+# models/my_model.py
+
+from odoo import models, fields
+
+class MyModel(models.Model):
+ _name = 'my.model'
+ _description = 'My Model'
+
+ name = fields.Char(string="Name", required=True)
+ description = fields.Text(string="Description")
+ active = fields.Boolean(string="Active", default=True)
+```
+
+### Step 4: Create Manifest
+
+```python
+# __manifest__.py
+
+{
+ 'name': 'My Module',
+ 'version': '1.0.0',
+ 'category': 'Tools',
+ 'summary': 'My awesome module',
+ 'description': """
+ My Module
+ ==========
+ This module does something useful.
+ """,
+ 'author': 'Your Name',
+ 'website': 'https://github.com/yourname/my_module',
+ 'license': 'LGPL-3',
+ 'depends': ['base'],
+ 'data': [
+ 'security/my_module_security.xml',
+ 'views/my_model_views.xml',
+ ],
+ 'demo': [
+ 'demo/demo_data.xml',
+ ],
+ 'assets': {
+ 'web.assets_backend': [
+ 'my_module/static/src/js/my_script.js',
+ ],
+ },
+ 'installable': True,
+ 'application': False,
+}
+```
+
+---
+
+## Manifest File
+
+### Required Fields
+
+```python
+{
+ 'name': 'My Module', # Required
+ 'version': '1.0', # Optional
+ 'depends': ['base'], # Optional but recommended
+}
+```
+
+### Common Fields
+
+```python
+{
+ # Information
+ 'name': 'My Module',
+ 'version': '1.0.0',
+ 'category': 'Tools',
+ 'summary': 'Short description',
+ 'description': 'Long description',
+ 'author': 'Author Name',
+ 'website': 'https://example.com',
+ 'license': 'LGPL-3',
+
+ # Dependencies
+ 'depends': ['base', 'web'],
+ 'data': ['views/views.xml'],
+ 'demo': ['demo/demo.xml'],
+
+ # Assets
+ 'assets': {
+ 'web.assets_backend': [
+ 'my_module/static/src/js/file.js',
+ ],
+ },
+
+ # Other
+ 'application': False,
+ 'installable': True,
+ 'auto_install': False,
+}
+```
+
+---
+
+## Models
+
+### Basic Model
+
+```python
+from odoo import models, fields
+
+class MyModel(models.Model):
+ _name = 'my.model'
+ _description = 'My Model'
+ _order = 'name'
+
+ name = fields.Char(string="Name", required=True)
+ code = fields.Char(string="Code")
+ description = fields.Text(string="Description")
+ active = fields.Boolean(string="Active", default=True)
+```
+
+### Model Inheritance (Extension)
+
+```python
+class Partner(models.Model):
+ _inherit = 'res.partner'
+
+ my_field = fields.Char(string="My Field")
+```
+
+### Model Inheritance (Prototype)
+
+```python
+class NewModel(models.Model):
+ _name = 'new.model'
+ _inherit = 'base.model'
+
+ # Inherits all fields and methods from base.model
+ my_field = fields.Char(string="My Field")
+```
+
+---
+
+## Views
+
+### Create Views
+
+```xml
+
+
+
+
+ my.model.tree
+ my.model
+
+
+
+
+
+
+
+
+
+
+
+ my.model.form
+ my.model
+
+
+
+
+
+
+
+ my.model.search
+ my.model
+
+
+
+
+
+
+
+
+
+
+
+ My Models
+ my.model
+ list,form
+
+
+ Create your first my.model!
+
+
+
+
+
+
+
+
+```
+
+---
+
+## Security
+
+### Access Rights (CSV)
+
+File: `security/ir.model.access.csv`
+
+```csv
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_my_model_user,my.model.user,model_my_model,base.group_user,1,1,1,0
+access_my_model_manager,my.model.manager,model_my_model,group_my_module_manager,1,1,1,1
+```
+
+### Record Rules (XML)
+
+```xml
+
+
+
+
+ My Model: user can see own records
+
+ [('create_uid', '=', user.id)]
+
+
+
+
+
+
+
+
+
+ My Model: manager sees all
+
+ [(1, '=', 1)]
+
+
+
+
+```
+
+---
+
+## Data Files
+
+### Create Records
+
+```xml
+
+
+ Record 1
+ R001
+
+
+
+ Record 2
+ R002
+
+
+```
+
+### noupdate Flag
+
+```xml
+
+
+
+
+ Demo
+
+
+
+
+
+ Core
+
+
+```
+
+---
+
+## Assets
+
+### JavaScript
+
+```python
+'assets': {
+ 'web.assets_backend': [
+ 'my_module/static/src/js/my_widget.js',
+ 'my_module/static/src/js/my_view.js',
+ ],
+ 'web.assets_frontend': [
+ 'my_module/static/src/js/frontend.js',
+ ],
+},
+```
+
+### CSS/SCSS
+
+```python
+'assets': {
+ 'web.assets_backend': [
+ 'my_module/static/src/scss/my_style.scss',
+ ],
+ 'web.assets_frontend': [
+ 'my_module/static/src/scss/frontend.scss',
+ ],
+},
+```
+
+---
+
+## Wizards
+
+### TransientModel
+
+```python
+from odoo import models, fields
+
+class MyWizard(models.TransientModel):
+ _name = 'my.wizard'
+ _description = 'My Wizard'
+
+ date = fields.Date(string="Date", required=True, default=fields.Date.context_today)
+ note = fields.Text(string="Note")
+
+ def action_confirm(self):
+ # Do something
+ return {'type': 'ir.actions.act_window_close'}
+```
+
+### Wizard View
+
+```xml
+
+ my.wizard.form
+ my.wizard
+
+
+
+
+```
+
+### Action to Open Wizard
+
+```python
+def action_open_wizard(self):
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'My Wizard',
+ 'res_model': 'my.wizard',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {
+ 'default_date': fields.Date.context_today(self),
+ }
+ }
+```
+
+---
+
+## Common Patterns
+
+### State Field
+
+```python
+state = fields.Selection([
+ ('draft', 'Draft'),
+ 'confirmed', 'Confirmed'),
+ ('done', 'Done'),
+], string='State', default='draft', tracking=True)
+```
+
+### Create Default from Context
+
+```python
+def default_get(self, fields_list):
+ defaults = super().default_get(fields_list)
+ if 'field' in fields_list:
+ defaults['field'] = self.env.context.get('default_field', 'default')
+ return defaults
+```
+
+### Name Search
+
+```python
+def name_search(self, name='', args=None, operator='ilike', limit=100):
+ args = args or []
+ if name:
+ args = [('name', operator, name)] + args
+ return super().name_search(name, args, operator, limit)
+```
+
+---
+
+## References
+
+- Based on Odoo 19 best practices
diff --git a/.agents/skills/odoo-19/references/odoo-19-field-guide.md b/.agents/skills/odoo-19/references/odoo-19-field-guide.md
new file mode 100644
index 00000000..577fde15
--- /dev/null
+++ b/.agents/skills/odoo-19/references/odoo-19-field-guide.md
@@ -0,0 +1,509 @@
+# Odoo 19 Field Guide
+
+Guide for defining fields in Odoo 19: field types, parameters, computed fields, and relational fields.
+
+## Table of Contents
+- [Field Types](#field-types)
+- [Basic Fields](#basic-fields)
+- [Advanced Fields](#advanced-fields)
+- [Date Fields](#date-fields)
+- [Relational Fields](#relational-fields)
+- [Computed Fields](#computed-fields)
+- [Related Fields](#related-fields)
+- [Field Parameters](#field-parameters)
+
+---
+
+## Field Types
+
+### Type Summary
+
+| Category | Types |
+|----------|-------|
+| **Basic** | Boolean, Char, Float, Integer |
+| **Advanced** | Binary, Html, Image, Monetary, Selection, Text |
+| **Date** | Date, Datetime |
+| **Relational** | Many2one, One2many, Many2many |
+| **Pseudo** | Reference, Many2oneReference |
+
+---
+
+## Basic Fields
+
+### Boolean
+
+True/False value.
+
+```python
+active = fields.Boolean(string="Active", default=True)
+is_company = fields.Boolean("Is Company")
+```
+
+### Char
+
+String with limited length.
+
+```python
+name = fields.Char(string="Name", required=True)
+code = fields.Char("Code", size=10)
+```
+
+### Float
+
+Floating-point number.
+
+```python
+price = fields.Float(string="Price")
+weight = fields.Float(digits="Stock Weight")
+```
+
+#### Digits
+
+```python
+# Using named precision
+price = fields.Float(digits="Product Price")
+
+# Custom precision (12 digits, 2 decimal)
+amount = fields.Float(digits=(12, 2))
+```
+
+### Integer
+
+Whole number.
+
+```python
+count = fields.Integer(string="Count")
+priority = fields.Integer(default=10)
+```
+
+---
+
+## Advanced Fields
+
+### Binary
+
+Binary data (files).
+
+```python
+file = fields.Binary(string="File")
+attachment = fields.Binary("Attachment")
+```
+
+### Html
+
+HTML content (rich text).
+
+```python
+description = fields.Html(string="Description")
+notes = fields.Html("Notes", sanitize=False)
+```
+
+### Image
+
+Enhanced Binary for images with thumbnails.
+
+```python
+image = fields.Image(string="Image")
+logo = fields.Image("Logo", max_width=1024, max_height=1024)
+```
+
+### Monetary
+
+Monetary amount with currency.
+
+```python
+amount = fields.Monetary(string="Amount", currency_field="currency_id")
+```
+
+**Requires** a currency field (default: `currency_id`).
+
+### Selection
+
+Selection from predefined list.
+
+```python
+state = fields.Selection([
+ ('draft', 'Draft'),
+ ('confirmed', 'Confirmed'),
+ ('done', 'Done'),
+], string="State", default='draft')
+
+# Or using model reference
+type = fields.Selection([
+ ('a', 'A'),
+ ('b', 'B'),
+], string="Type")
+```
+
+**Dynamic selection** (from another model):
+
+```python
+type_id = fields.Many2one('my.type', string="Type")
+```
+
+### Text
+
+Long text (unlimited).
+
+```python
+description = fields.Text(string="Description")
+notes = fields.Text("Notes")
+```
+
+---
+
+## Date Fields
+
+### Date
+
+Date without time.
+
+```python
+date = fields.Date(string="Date")
+deadline = fields.Date(default=fields.Date.context_today)
+```
+
+#### Date Methods
+
+```python
+from odoo.fields import Date
+
+# Today
+today = Date.context_today(self)
+
+# Add/subtract
+next_week = Date.add(Date.today(), weeks=1)
+last_month = Date.subtract(Date.today(), months=1)
+
+# Start/end of period
+start_of_month = Date.start_of(Date.today(), 'month')
+end_of_month = Date.end_of(Date.today(), 'month')
+
+# To string
+date_str = Date.to_string(Date.today())
+
+# From string
+date_obj = Date.to_date('2023-01-01')
+```
+
+### Datetime
+
+Date and time.
+
+```python
+datetime = fields.Datetime(string="DateTime")
+create_date = fields.Datetime(default=fields.Datetime.now)
+```
+
+#### Datetime Methods
+
+```python
+from odoo.fields import Datetime
+
+# Now
+now = Datetime.now()
+
+# Context timestamp (user timezone)
+timestamp = Datetime.context_timestamp(self, datetime)
+
+# Add/subtract
+next_hour = Datetime.add(Datetime.now(), hours=1)
+
+# Start/end of period
+start_of_day = Datetime.start_of(Datetime.now(), 'day')
+end_of_day = Datetime.end_of(Datetime.now(), 'day')
+
+# Convert
+date_obj = Datetime.to_datetime('2023-01-01 12:00:00')
+date_str = Datetime.to_string(Datetime.now())
+```
+
+#### Timezone
+
+Datetime fields are stored as UTC. Conversion is client-side.
+
+---
+
+## Relational Fields
+
+### Many2one
+
+Many-to-one relation (foreign key).
+
+```python
+partner_id = fields.Many2one('res.partner', string="Partner")
+user_id = fields.Many2one('res.users', 'User', default=lambda self: self.env.user)
+```
+
+#### Parameters
+
+| Parameter | Description |
+|-----------|-------------|
+| `comodel_name` | Related model name |
+| `string` | Field label |
+| `required` | Whether required |
+| `ondelete` | What to do when related record is deleted (`cascade`, `set null`, `restrict`) |
+| `domain` | Domain filter |
+| `context` | Context for operations |
+| `default` | Default value |
+| `index` | Add database index |
+
+```python
+partner_id = fields.Many2one(
+ 'res.partner',
+ string="Customer",
+ required=True,
+ ondelete='cascade',
+ domain=[('customer_rank', '>', 0)],
+ default=lambda self: self.env.partner,
+)
+```
+
+### One2many
+
+One-to-many relation (inverse of Many2one).
+
+```python
+line_ids = fields.One2many('sale.order.line', 'order_id', string="Order Lines")
+```
+
+#### Parameters
+
+| Parameter | Description |
+|-----------|-------------|
+| `comodel_name` | Related model name |
+| `inverse_name` | Inverse Many2one field |
+| `string` | Field label |
+
+```python
+order_id = fields.Many2one('sale.order', 'Order')
+line_ids = fields.One2many(
+ 'sale.order.line',
+ 'order_id',
+ string="Order Lines",
+)
+```
+
+### Many2many
+
+Many-to-many relation.
+
+```python
+tag_ids = fields.Many2many('crm.tag', string="Tags")
+category_ids = fields.Many2many('product.category', 'product_category_rel', 'product_id', 'category_id')
+```
+
+#### Parameters
+
+| Parameter | Description |
+|-----------|-------------|
+| `comodel_name` | Related model name |
+| `relation` | Relation table name (auto if not specified) |
+| `column1` | Column for this model |
+| `column2` | Column for related model |
+| `string` | Field label |
+
+```python
+# Simple (auto relation table)
+tag_ids = fields.Many2many('crm.tag', string="Tags")
+
+# Custom relation table
+product_ids = fields.Many2many(
+ 'product.product',
+ 'my_rel',
+ 'my_id',
+ 'product_id',
+ string="Products",
+)
+```
+
+### Commands
+
+Use `Command` class for One2many/Many2many operations:
+
+```python
+from odoo.fields import Command
+
+# Create
+{
+ 'line_ids': [
+ Command.create({'product_id': 1, 'qty': 10}),
+ Command.create({'product_id': 2, 'qty': 5}),
+ ]
+}
+
+# Update
+{
+ 'line_ids': [
+ Command.update(line_id, {'qty': 20}),
+ ]
+}
+
+# Delete
+{
+ 'line_ids': [
+ Command.delete(line_id),
+ ]
+}
+
+# Clear all
+{
+ 'line_ids': [Command.clear()]
+}
+
+# Set (replace all)
+{
+ 'line_ids': [
+ Command.set([id1, id2, id3])
+ ]
+}
+
+# Link (add without deleting existing)
+{
+ 'tag_ids': [
+ Command.link(tag_id),
+ ]
+}
+
+# Unlink
+{
+ 'tag_ids': [
+ Command.unlink(tag_id),
+ ]
+}
+```
+
+---
+
+## Computed Fields
+
+Fields computed from other fields.
+
+### Basic Compute
+
+```python
+total = fields.Float(compute='_compute_total')
+
+@api.depends('price', 'qty')
+def _compute_total(self):
+ for record in self:
+ record.total = record.price * record.qty
+```
+
+### Store and Search
+
+```python
+total = fields.Float(
+ compute='_compute_total',
+ store=True,
+ search='_search_total',
+)
+```
+
+### Inverse
+
+Allow setting computed field:
+
+```python
+full_name = fields.Char(
+ compute='_compute_full_name',
+ inverse='_inverse_full_name',
+)
+
+@api.depends('first_name', 'last_name')
+def _compute_full_name(self):
+ for record in self:
+ record.full_name = f"{record.first_name} {record.last_name}"
+
+def _inverse_full_name(self):
+ for record in self:
+ parts = record.full_name.split(' ', 1)
+ record.first_name = parts[0]
+ record.last_name = parts[1] if len(parts) > 1 else ''
+```
+
+---
+
+## Related Fields
+
+Shortcut for computed fields that follow a relation.
+
+```python
+partner_name = fields.Char(related='partner_id.name', string="Partner Name")
+partner_email = fields.Char(related='partner_id.email', readonly=True)
+```
+
+### Store Related
+
+```python
+partner_name = fields.Char(
+ related='partner_id.name',
+ string="Partner Name",
+ store=True,
+)
+```
+
+### Dependencies
+
+```python
+# Only recompute when partner_id changes
+partner_name = fields.Char(
+ related='partner_id.name',
+ store=True,
+ depends=['partner_id'],
+)
+```
+
+---
+
+## Field Parameters
+
+### Common Parameters
+
+| Parameter | Description |
+|-----------|-------------|
+| `string` | Field label |
+| `required` | Whether required (create/write) |
+| `readonly` | Whether read-only |
+| `index` | Add database index |
+| `default` | Default value or callable |
+| `help` | Tooltip text |
+| `groups` | Comma-separated group IDs |
+| `copy` | Copy on duplicate (default: True) |
+| `track_visibilty` | Track changes in chatter (`always`, `onchange`, `never`) |
+
+### Examples
+
+```python
+name = fields.Char(
+ string="Name",
+ required=True,
+ index=True,
+ default='Untitled',
+ help="Enter the name",
+ copy=True,
+ tracking=True, # Equivalent to track_visibility='always'
+)
+```
+
+---
+
+## Reserved Field Names
+
+| Name | Purpose |
+|------|---------|
+| `id` | Record identifier |
+| `display_name` | Display name |
+| `create_date`, `create_uid`, `write_date`, `write_uid` | Access log fields |
+| `name` | Default `rec_name` |
+| `active` | Global visibility toggle |
+| `state` | Lifecycle stages |
+| `parent_id` | Tree structure parent |
+| `parent_path` | Tree structure path |
+| `company_id` | Multi-company field |
+
+---
+
+## References
+
+- Source: Odoo 19 documentation `/doc/developer/reference/backend/orm.rst`
diff --git a/.agents/skills/odoo-19/references/odoo-19-manifest-guide.md b/.agents/skills/odoo-19/references/odoo-19-manifest-guide.md
new file mode 100644
index 00000000..04ab9103
--- /dev/null
+++ b/.agents/skills/odoo-19/references/odoo-19-manifest-guide.md
@@ -0,0 +1,331 @@
+# Odoo 19 Manifest Guide
+
+Guide for configuring `__manifest__.py` in Odoo 19 modules.
+
+## Table of Contents
+- [Manifest File](#manifest-file)
+- [Required Fields](#required-fields)
+- [Module Information](#module-information)
+- [Dependencies](#dependencies)
+- [Data Files](#data-files)
+- [Assets](#assets)
+- [Hooks](#hooks)
+- [External Dependencies](#external-dependencies)
+- [Auto-Install](#auto-install)
+
+---
+
+## Manifest File
+
+The manifest file declares a python package as an Odoo module and specifies module metadata.
+
+File: `__manifest__.py`
+
+```python
+{
+ 'name': "A Module",
+ 'version': '1.0',
+ 'depends': ['base'],
+ 'author': "Author Name",
+ 'category': 'Category',
+ 'description': """
+ Description text
+ """,
+ 'data': [
+ 'views/mymodule_view.xml',
+ ],
+ 'demo': [
+ 'demo/demo_data.xml',
+ ],
+}
+```
+
+---
+
+## Required Fields
+
+### name (str, required)
+
+The human-readable name of the module.
+
+```python
+'name': "My Module",
+```
+
+---
+
+## Module Information
+
+### version (str)
+
+Module version, should follow semantic versioning rules.
+
+```python
+'version': '1.0.0',
+```
+
+### description (str)
+
+Extended description in reStructuredText.
+
+```python
+'description': """
+This module does something useful.
+""",
+```
+
+### author (str)
+
+Name of the module author.
+
+```python
+'author': "UncleCat",
+```
+
+### website (str)
+
+Website URL for the module author.
+
+```python
+'website': "https://github.com/unclecat",
+```
+
+### license (str, default: LGPL-3)
+
+Distribution license.
+
+Possible values:
+- `GPL-2`
+- `GPL-2 or any later version`
+- `GPL-3`
+- `GPL-3 or any later version`
+- `AGPL-3`
+- `LGPL-3`
+- `Other OSI approved licence`
+- `OEEL-1` (Odoo Enterprise Edition License v1.0)
+- `OPL-1` (Odoo Proprietary License v1.0)
+- `Other proprietary`
+
+```python
+'license': 'LGPL-3',
+```
+
+### category (str, default: Uncategorized)
+
+Classification category within Odoo.
+
+Use existing categories or create hierarchies with `/`:
+
+```python
+'category': 'Tools / My Category',
+```
+
+### application (bool, default: False)
+
+Whether the module should be considered a fully-fledged application.
+
+```python
+'application': True, # Appears in Apps menu
+```
+
+### installable (bool, default: True)
+
+Whether a user can install the module from the Web UI.
+
+```python
+'installable': False, # Hidden from Apps menu
+```
+
+### maintainer (str)
+
+Person or entity in charge of maintenance.
+
+```python
+'maintainer': "UncleCat",
+```
+
+---
+
+## Dependencies
+
+### depends (list(str))
+
+Odoo modules which must be loaded before this one.
+
+```python
+'depends': ['base', 'web', 'sale'],
+```
+
+**Important**: Module `base` is always installed, but you should still specify it as a dependency to ensure your module is updated when `base` is updated.
+
+When a module is installed, all dependencies are installed first.
+
+---
+
+## Data Files
+
+### data (list(str))
+
+Data files always loaded at installation and update.
+
+```python
+'data': [
+ 'security/my_module_security.xml',
+ 'views/my_model_views.xml',
+ 'data/my_module_data.xml',
+],
+```
+
+### demo (list(str))
+
+Data files only loaded in demonstration mode.
+
+```python
+'demo': [
+ 'demo/demo_data.xml',
+],
+```
+
+---
+
+## Assets
+
+### assets (dict)
+
+Definition of how static files are loaded in asset bundles.
+
+```python
+'assets': {
+ 'web.assets_backend': [
+ 'my_module/static/src/js/my_script.js',
+ 'my_module/static/src/scss/my_style.scss',
+ ],
+ 'web.assets_frontend': [
+ 'my_module/static/src/js/frontend.js',
+ ],
+},
+```
+
+---
+
+## Hooks
+
+### {pre_init, post_init, uninstall}_hook (str)
+
+Hooks for module installation/uninstallation.
+
+```python
+# In __init__.py
+def pre_init_hook(env):
+ """Executed prior to module installation"""
+ pass
+
+def post_init_hook(env):
+ """Executed right after module installation"""
+ pass
+
+def uninstall_hook(env):
+ """Executed after module uninstallation"""
+ pass
+```
+
+```python
+# In __manifest__.py
+'pre_init_hook': 'pre_init_hook',
+'post_init_hook': 'post_init_hook',
+'uninstall_hook': 'uninstall_hook',
+```
+
+**Usage**: Only when setup/cleanup is extremely difficult or impossible through the API.
+
+---
+
+## External Dependencies
+
+### external_dependencies (dict(key=list(str)))
+
+Dictionary of Python and binary dependencies.
+
+```python
+'external_dependencies': {
+ 'python': ['requests', 'openpyxl'],
+ 'bin': ['zip', 'unzip'],
+},
+```
+
+The module won't be installed if dependencies are not available.
+
+---
+
+## Auto-Install
+
+### auto_install (bool or list(str), default: False)
+
+If `True`, automatically installs if all dependencies are installed.
+
+Used for "link modules" implementing integration between independent modules.
+
+```python
+'auto_install': True, # Install when all dependencies are present
+```
+
+If it is a list, must contain a subset of dependencies:
+
+```python
+'auto_install': ['sale', 'crm'], # Install when both sale and crm are present
+```
+
+If the list is empty, always auto-install regardless of dependencies.
+
+```python
+'auto_install': [], # Always install
+```
+
+---
+
+## Complete Example
+
+```python
+{
+ 'name': "My Awesome Module",
+ 'version': '1.0.0',
+ 'category': 'Tools',
+ 'summary': 'Does something awesome',
+ 'description': """
+My Awesome Module
+=================
+
+This module adds awesome functionality to Odoo.
+""",
+ 'author': "UncleCat",
+ 'website': "https://github.com/unclecat/my_module",
+ 'license': 'LGPL-3',
+ 'depends': ['base', 'web'],
+ 'data': [
+ 'security/my_module_security.xml',
+ 'views/my_model_views.xml',
+ 'data/ir_cron_data.xml',
+ ],
+ 'demo': [
+ 'demo/demo_data.xml',
+ ],
+ 'assets': {
+ 'web.assets_backend': [
+ 'my_module/static/src/js/my_widget.js',
+ 'my_module/static/src/scss/my_style.scss',
+ ],
+ },
+ 'external_dependencies': {
+ 'python': ['requests'],
+ },
+ 'application': True,
+ 'installable': True,
+ 'auto_install': False,
+}
+```
+
+---
+
+## References
+
+- Source: Odoo 19 documentation `/doc/developer/reference/backend/module.rst`
diff --git a/.agents/skills/odoo-19/references/odoo-19-migration-guide.md b/.agents/skills/odoo-19/references/odoo-19-migration-guide.md
new file mode 100644
index 00000000..2927ab0d
--- /dev/null
+++ b/.agents/skills/odoo-19/references/odoo-19-migration-guide.md
@@ -0,0 +1,244 @@
+# Odoo 19 Migration Guide
+
+Guide for migrating modules from Odoo 17/18 to Odoo 19.
+
+## Table of Contents
+- [Migration Overview](#migration-overview)
+- [Key Changes](#key-changes)
+- [Migration Scripts](#migration-scripts)
+- [Module Hooks](#module-hooks)
+- [Common Migrations](#common-migrations)
+- [Testing](#testing)
+
+---
+
+## Migration Overview
+
+### When to Migrate
+
+- **Major version upgrade**: Odoo 17 → 18 → 19
+- **Module dependencies changed**
+- **API breaking changes**
+
+### Migration Strategy
+
+1. Review changelog and breaking changes
+2. Update `__manifest__.py` version
+3. Run migration scripts
+4. Test thoroughly
+5. Update documentation
+
+---
+
+## Key Changes
+
+### Odoo 19 Key Changes
+
+| Area | Change |
+|------|--------|
+| **List view** | Use `` instead of `` |
+| **Dynamic attributes** | Use direct attributes instead of `attrs` |
+| **Delete validation** | Use `@api.ondelete` instead of overriding `unlink()` |
+| **Field aggregation** | Use `aggregator=` instead of `group_operator=` |
+| **SQL queries** | Use `odoo.tools.SQL` class |
+| **Batch create** | Use list of dicts instead of single dict |
+
+### Odoo 18 Key Changes
+
+| Area | Change |
+|------|--------|
+| **Views** | Use `` instead of `` |
+| **attrs** | Deprecated, use direct attributes |
+| **ondelete** | New `@api.ondelete` decorator |
+
+---
+
+## Migration Scripts
+
+### Migration Script Location
+
+```
+my_module/
+└── migrations/
+ └── 19.0.1.0/
+ ├── pre-migration.py
+ ├── end-migration.py
+ └── post-migration.py
+```
+
+### Migration Script Naming
+
+| Script | When it runs |
+|--------|--------------|
+| `pre-migration.py` | Before module update |
+| `post-migration.py` | After module update |
+| `end-migration.py` | After all migrations |
+
+### Migration Script Template
+
+```python
+def migrate(cr, version):
+ """
+ Migration script for Odoo 19
+ """
+ # Your migration code here
+ pass
+```
+
+---
+
+## Common Migrations
+
+### Tree to List View
+
+**Odoo 17 and earlier**:
+
+```xml
+
+
+
+```
+
+**Odoo 18+**:
+
+```xml
+
+
+
+```
+
+### attrs to Direct Attributes
+
+**Odoo 17 and earlier**:
+
+```xml
+
+```
+
+**Odoo 18+**:
+
+```xml
+
+```
+
+### Delete Validation
+
+**Odoo 17 and earlier**:
+
+```python
+def unlink(self):
+ for record in self:
+ if record.state != 'draft':
+ raise UserError("Cannot delete non-draft records")
+ return super().unlink()
+```
+
+**Odoo 18+**:
+
+```python
+@api.ondelete(at_uninstall=False)
+def _unlink_if_not_draft(self):
+ if any(rec.state != 'draft' for rec in self):
+ raise UserError("Cannot delete non-draft records")
+```
+
+### Field Aggregation
+
+**Odoo 17 and earlier**:
+
+```python
+amount_total = fields.Monetary(group_operator="sum")
+```
+
+**Odoo 18+**:
+
+```python
+amount_total = fields.Monetary(aggregator="sum")
+```
+
+---
+
+## Module Hooks
+
+### pre_init_hook
+
+```python
+def pre_init_hook(env):
+ """Called before module installation"""
+ # Create custom tables, etc.
+ pass
+```
+
+### post_init_hook
+
+```python
+def post_init_hook(env):
+ """Called after module installation"""
+ # Set default values, create records, etc.
+ pass
+```
+
+### uninstall_hook
+
+```python
+def uninstall_hook(env):
+ """Called after module uninstallation"""
+ # Clean up custom tables, files, etc.
+ pass
+```
+
+### Register Hooks in Manifest
+
+```python
+{
+ ...
+ 'pre_init_hook': 'my_module.pre_init_hook',
+ 'post_init_hook': 'my_module.post_init_hook',
+ 'uninstall_hook': 'my_module.uninstall_hook',
+}
+```
+
+---
+
+## Migration Checklist
+
+- [ ] Review Odoo 19 changelog
+- [ ] Update `__manifest__.py` version
+- [ ] Update dependencies
+- [ ] Rename `` to ``
+- [ ] Replace `attrs` with direct attributes
+- [ ] Replace `unlink()` override with `@api.ondelete`
+- [ ] Update field aggregators
+- [ ] Update SQL queries to use `odoo.tools.SQL`
+- [ ] Run migration scripts
+- [ ] Test all functionality
+- [ ] Update documentation
+
+---
+
+## Testing Migrations
+
+### Test Migration Script
+
+```python
+from odoo.tests import TransactionCase
+
+class TestMigration(TransactionCase):
+ def test_migration(self):
+ # Test migration script
+ pass
+```
+
+### Manual Testing
+
+1. Install previous version with sample data
+2. Update to Odoo 19
+3. Verify all data migrated correctly
+4. Test all features
+
+---
+
+## References
+
+- Odoo 19 changelog
+- Odoo 18 changelog
diff --git a/.agents/skills/odoo-19/references/odoo-19-mixins-guide.md b/.agents/skills/odoo-19/references/odoo-19-mixins-guide.md
new file mode 100644
index 00000000..56ea7d4a
--- /dev/null
+++ b/.agents/skills/odoo-19/references/odoo-19-mixins-guide.md
@@ -0,0 +1,384 @@
+# Odoo 19 Mixins Guide
+
+Guide for using Odoo 19 mixins: mail.thread, activities, email aliases, and other useful mixins.
+
+## Table of Contents
+- [Messaging Features](#messaging-features)
+- [Activities](#activities)
+- [Email Aliases](#email-aliases)
+- [UTM Mixin](#utm-mixin)
+- [Website Mixins](#website-mixins)
+- [Rating Mixin](#rating-mixin)
+
+---
+
+## Messaging Features
+
+### Basic Messaging Integration
+
+Add `mail.thread` mixin to your model:
+
+```python
+class BusinessTrip(models.Model):
+ _name = 'business.trip'
+ _inherit = ['mail.thread']
+ _description = 'Business Trip'
+
+ name = fields.Char()
+ partner_id = fields.Many2one('res.partner', 'Responsible')
+ guest_ids = fields.Many2many('res.partner', 'Participants')
+```
+
+Add chatter to form view:
+
+```xml
+
+```
+
+### Chatter Options
+
+| Option | Description |
+|--------|-------------|
+| `open_attachments` | Shows attachment section expanded |
+| `reload_on_attachment` | Reload form on attachment change |
+| `reload_on_follower` | Reload form on follower update |
+| `reload_on_post` | Reload form on message post |
+
+---
+
+### Posting Messages
+
+#### message_post
+
+Post a new message in an existing thread:
+
+```python
+record.message_post(
+ body='This is a message',
+ subject='Subject',
+ message_type='notification',
+ subtype_xmlid='mail.mt_comment',
+)
+```
+
+**Parameters**:
+- `body` (str | Markup): Message body (escaped if str, use Markup for HTML)
+- `subject` (str): Message subject
+- `message_type` (str): `notification`, `comment`, `email`
+- `subtype` (str/xmlid): Message subtype
+- `parent_id` (int): Reply to message
+- `attachments` (list): List of `(name, content)` tuples
+- `**kwargs`: Extra mail.message field values
+
+#### message_post_with_view
+
+Send message using a QWeb template:
+
+```python
+record.message_post_with_view(
+ 'my_module.my_template',
+ additional_context={'val': value},
+)
+```
+
+#### message_post_with_template
+
+Send message using an email template:
+
+```python
+record.message_post_with_template(
+ template_id,
+ composition_mode='comment',
+)
+```
+
+---
+
+### Receiving Messages
+
+#### message_new
+
+Called when new email arrives for an alias:
+
+```python
+def message_new(self, msg_dict, custom_values=None):
+ # Extract data from email
+ name = msg_dict.get('subject', 'New')
+ # Create record
+ return super().message_new(msg_dict, {
+ 'name': name,
+ **(custom_values or {}),
+ })
+```
+
+#### message_update
+
+Called when email reply arrives:
+
+```python
+def message_update(self, msg_dict, update_vals=None):
+ # Update record from email
+ return super().message_update(msg_dict, {
+ 'description': msg_dict.get('body'),
+ **(update_vals or {}),
+ })
+```
+
+---
+
+### Followers Management
+
+#### message_subscribe
+
+Add partners/channels as followers:
+
+```python
+# Subscribe partners
+record.message_subscribe(partner_ids=[pid1, pid2])
+
+# Subscribe channels
+record.message_subscribe(channel_ids=[cid1])
+
+# With specific subtypes
+record.message_subscribe(
+ partner_ids=[pid1],
+ subtype_ids=[subtype_id],
+)
+```
+
+#### message_unsubscribe
+
+Remove followers:
+
+```python
+# Unsubscribe partners
+record.message_unsubscribe(partner_ids=[pid1, pid2])
+
+# Unsubscribe current user
+record.message_unsubscribe_users()
+```
+
+---
+
+### Logging Changes (Tracking)
+
+Enable field tracking in `mail.thread`:
+
+```python
+class MyModel(models.Model):
+ _name = 'my.model'
+ _inherit = ['mail.thread']
+
+ name = fields.Char(tracking=True)
+ state = fields.Selection([
+ ('draft', 'Draft'),
+ ('done', 'Done'),
+ ], tracking=True)
+
+ # Track changes in relational field
+ partner_id = fields.Many2one('res.partner', tracking=1)
+```
+
+Track changes in specific subfields:
+
+```python
+# Track all partner_id subfields
+partner_id = fields.Many2one('res.partner', tracking=True)
+
+# Track only name
+partner_id = fields.Many2one('res.partner', tracking='name')
+```
+
+---
+
+## Activities
+
+### mail.activity.mixin
+
+Add activity support:
+
+```python
+class MyModel(models.Model):
+ _name = 'my.model'
+ _inherit = ['mail.activity.mixin']
+
+ name = fields.Char()
+```
+
+### Activity Methods
+
+```python
+# Schedule activity
+record.activity_schedule(
+ 'mail.mail_activity_data_todo',
+ user_id=user.id,
+ summary='Review this',
+)
+
+# Mark as done
+activities = record.activity_ids
+activities.action_done()
+
+# Feedback
+activities.action_feedback(feedback='Completed')
+```
+
+---
+
+## Email Aliases
+
+### mail.alias.mixin
+
+Add email alias support:
+
+```python
+class MyModel(models.Model):
+ _name = 'my.model'
+ _inherit = ['mail.alias.mixin', 'mail.thread']
+
+ name = fields.Char()
+ alias_id = fields.Many2one(
+ 'mail.alias',
+ string='Alias',
+ ondelete="cascade",
+ required=True,
+ )
+
+ def get_alias_model_name(self, vals):
+ return self._name
+
+ def get_alias_values(self):
+ values = super().get_alias_values()
+ values.update({
+ 'alias_defaults': 'name',
+ })
+ return values
+```
+
+Create alias in data file:
+
+```xml
+
+ my-model
+
+
+
+```
+
+---
+
+## UTM Mixin
+
+### utm.mixin
+
+Add campaign tracking:
+
+```python
+class MyModel(models.Model):
+ _name = 'my.model'
+ _inherit = ['utm.mixin']
+
+ name = fields.Char()
+ campaign_id = fields.Many2one('utm.campaign', 'Campaign')
+ source_id = fields.Many2one('utm.source', 'Source')
+ medium_id = fields.Many2one('utm.medium', 'Medium')
+```
+
+This adds tracking for marketing campaigns.
+
+---
+
+## Website Mixins
+
+### website.published.mixin
+
+Add website publishing:
+
+```python
+class MyModel(models.Model):
+ _name = 'my.model'
+ _inherit = ['website.published.mixin']
+
+ name = fields.Char()
+ website_published = fields.Boolean('Visible on Website')
+```
+
+### website.seo.metadata
+
+Add SEO metadata:
+
+```python
+class MyModel(models.Model):
+ _name = 'my.model'
+ _inherit = ['website.seo.metadata']
+
+ name = fields.Char()
+ website_meta_title = fields.Char('Meta Title')
+ website_meta_description = fields.Text('Meta Description')
+```
+
+---
+
+## Rating Mixin
+
+### rating.mixin
+
+Add customer rating:
+
+```python
+class MyModel(models.Model):
+ _name = 'my.model'
+ _inherit = ['rating.mixin', 'mail.thread']
+
+ name = fields.Char()
+```
+
+### Rating Methods
+
+```python
+# Send rating request
+record.rating_send_request(
+ rating_template='mail.mail_template_data_rating',
+)
+
+# Get rating stats
+avg_rating = record.rating_get_stats()
+```
+
+---
+
+## Portal Access
+
+### portal.mixin
+
+Add customer portal access:
+
+```python
+class MyModel(models.Model):
+ _name = 'my.model'
+ _inherit = ['portal.mixin']
+
+ name = fields.Char()
+ partner_id = fields.Many2one('res.partner', 'Customer')
+```
+
+Override access:
+
+```python
+def _compute_access_url(self):
+ super()._compute_access_url()
+ for record in self:
+ record.access_url = '/my/model/%s' % record.id
+
+def _get_portal_return_action(self):
+ return self.env.ref('my_module.my_model_action')
+```
+
+---
+
+## References
+
+- Source: Odoo 19 documentation `/doc/developer/reference/backend/mixins.rst`
diff --git a/.agents/skills/odoo-19/references/odoo-19-model-guide.md b/.agents/skills/odoo-19/references/odoo-19-model-guide.md
new file mode 100644
index 00000000..2fbf5579
--- /dev/null
+++ b/.agents/skills/odoo-19/references/odoo-19-model-guide.md
@@ -0,0 +1,642 @@
+# Odoo 19 Model Guide
+
+Guide for working with Odoo 19 ORM, recordsets, CRUD operations, and domain filters.
+
+## Table of Contents
+
+- [Models](#models)
+- [Fields](#fields)
+- [SQL Constraints](#sql-constraints)
+- [Database Indexes](#database-indexes)
+- [Recordsets](#recordsets)
+- [CRUD Operations](#crud-operations)
+- [Search Domains](#search-domains)
+- [Environment](#environment)
+- [SQL Execution](#sql-execution)
+- [Inheritance](#inheritance)
+
+---
+
+## Models
+
+### Defining a Model
+
+> **Odoo 19 Change**: `_name` is now **optional**. Odoo derives it automatically from the CamelCase class name (each capital letter → `.` separator). E.g. `ResPartner` → `res.partner`, `SaleOrder` → `sale.order`.
+
+```python
+from odoo import models, fields
+
+# Odoo 19: _name auto-derived from class name
+class MyModel(models.Model):
+ # _name = 'my.model' ← auto-derived, can be omitted
+ _description = 'My Model'
+
+ field1 = fields.Char()
+ field2 = fields.Integer(string="Field Label")
+```
+
+```python
+# When _name differs from class name convention, specify explicitly:
+class CustomNameModel(models.Model):
+ _name = 'custom.different.name'
+ _description = 'Custom Named Model'
+
+ name = fields.Char()
+```
+
+### Model Attributes
+
+| Attribute | Description |
+| --------------- | ----------------------------------------------------------------------------- |
+| `_name` | Model name (**optional in Odoo 19** — auto-derived from CamelCase class name) |
+| `_description` | Model description |
+| `_order` | Default sort order |
+| `_rec_name` | Field to use as name representation |
+| `_inherit` | Model(s) to inherit from |
+| `_inherits` | Delegation inheritance |
+| `_table` | Database table name |
+| `_log_access` | Enable create_date, write_date, create_uid, write_uid |
+| `_auto` | Auto-create database table |
+| `_abstract` | Abstract model |
+| `_transient` | Transient model |
+| `_parent_store` | Enable parent_path field |
+| `_fold_name` | Field for kanban fold |
+
+### Model Types
+
+| Class | Description |
+| ----------------------- | ---------------------------------- |
+| `models.Model` | Regular database model |
+| `models.TransientModel` | Temporary/wizard model |
+| `models.AbstractModel` | Abstract model (no database table) |
+
+---
+
+## Fields
+
+### Field Definition
+
+Fields are defined as class attributes on the model.
+
+```python
+from odoo import models, fields
+
+class MyModel(models.Model):
+ _name = 'my.model'
+
+ name = fields.Char(required=True)
+ description = fields.Text()
+ active = fields.Boolean(default=True)
+ count = fields.Integer()
+ price = fields.Float(digits='Product Price')
+```
+
+### Default Values
+
+```python
+# Value
+name = fields.Char(default="A value")
+
+# Function
+def _default_name(self):
+ return self.get_value()
+
+name = fields.Char(default=lambda self: self._default_name())
+```
+
+### Field Types
+
+| Type | Class | Description |
+| ----------------- | ---------------------------- | --------------------------- |
+| **Basic** | | |
+| Boolean | `fields.Boolean()` | True/False |
+| Char | `fields.Char()` | String (limited length) |
+| Float | `fields.Float()` | Floating-point number |
+| Integer | `fields.Integer()` | Integer |
+| **Advanced** | | |
+| Binary | `fields.Binary()` | Binary data (files) |
+| Html | `fields.Html()` | HTML content |
+| Image | `fields.Image()` | Image (enhanced Binary) |
+| Monetary | `fields.Monetary()` | Monetary amount |
+| Selection | `fields.Selection()` | Selection from list |
+| Text | `fields.Text()` | Long text |
+| **Date** | | |
+| Date | `fields.Date()` | Date (no time) |
+| Datetime | `fields.Datetime()` | Date and time |
+| **Relational** | | |
+| Many2one | `fields.Many2one()` | Many-to-one |
+| One2many | `fields.One2many()` | One-to-many |
+| Many2many | `fields.Many2many()` | Many-to-many |
+| **Pseudo** | | |
+| Reference | `fields.Reference()` | Reference to any model |
+| Many2oneReference | `fields.Many2oneReference()` | Many2one with dynamic model |
+
+### Computed Fields
+
+```python
+from odoo import api
+
+total = fields.Float(compute='_compute_total', store=True)
+
+@api.depends('value', 'tax')
+def _compute_total(self):
+ for record in self:
+ record.total = record.value + record.value * record.tax
+```
+
+### Related Fields
+
+```python
+nickname = fields.Char(related='partner_id.name', store=True)
+```
+
+### Automatic Fields
+
+| Field | Type | Description |
+| -------------- | -------- | --------------------- |
+| `id` | int | Identifier |
+| `display_name` | char | Display name |
+| `create_date` | datetime | Creation timestamp |
+| `create_uid` | Many2one | Creator |
+| `write_date` | datetime | Last update timestamp |
+| `write_uid` | Many2one | Last modifier |
+
+### Reserved Field Names
+
+| Name | Type | Purpose |
+| ------------- | --------- | ------------------------- |
+| `name` | Char | Default `rec_name` |
+| `active` | Boolean | Toggles global visibility |
+| `state` | Selection | Lifecycle stages |
+| `parent_id` | Many2one | Tree structure parent |
+| `parent_path` | Char | Tree structure path |
+| `company_id` | Many2one | Multi-company field |
+
+---
+
+## SQL Constraints
+
+> **Odoo 19 Breaking Change**: `_sql_constraints` is **no longer supported**. Use `models.Constraint` instead.
+
+### models.Constraint (Odoo 19)
+
+SQL constraints are now defined as model attributes using `models.Constraint`:
+
+```python
+from odoo import models, fields
+
+class MyModel(models.Model):
+ _name = 'my.model'
+ _description = 'My Model'
+
+ name = fields.Char(required=True)
+ code = fields.Char()
+ quantity = fields.Integer()
+
+ # UNIQUE constraint
+ _unique_name = models.Constraint(
+ 'UNIQUE(name)',
+ 'Name must be unique!',
+ )
+
+ # UNIQUE on multiple fields
+ _unique_name_code = models.Constraint(
+ 'UNIQUE(name, code)',
+ 'The combination of name and code must be unique!',
+ )
+
+ # CHECK constraint
+ _check_quantity = models.Constraint(
+ 'CHECK(quantity > 0)',
+ 'Quantity must be positive!',
+ )
+```
+
+### Constraint Naming
+
+If you omit the `_name` in the constraint, Odoo auto-generates a unique name based on model + attribute name.
+
+### Migration from `_sql_constraints`
+
+```python
+# ❌ OLD (Odoo 18 and earlier) — NO LONGER WORKS in Odoo 19
+class MyModel(models.Model):
+ _name = 'my.model'
+ _sql_constraints = [
+ ('name_uniq', 'UNIQUE(name)', 'Name must be unique!'),
+ ('check_qty', 'CHECK(quantity > 0)', 'Quantity must be positive!'),
+ ]
+
+# ✅ NEW (Odoo 19)
+class MyModel(models.Model):
+ _name = 'my.model'
+
+ _name_uniq = models.Constraint(
+ 'UNIQUE(name)',
+ 'Name must be unique!',
+ )
+
+ _check_qty = models.Constraint(
+ 'CHECK(quantity > 0)',
+ 'Quantity must be positive!',
+ )
+```
+
+### Overriding Constraints in Inherited Models
+
+Constraints can be overridden or removed in inherited models:
+
+```python
+class ExtendedModel(models.Model):
+ _inherit = 'my.model'
+
+ # Override constraint with different check
+ _check_qty = models.Constraint(
+ 'CHECK(quantity >= 0)',
+ 'Quantity cannot be negative!',
+ )
+```
+
+---
+
+## Database Indexes
+
+### Field-Level Index
+
+```python
+name = fields.Char(index=True) # Simple btree index
+```
+
+### Declarative Index (Odoo 19)
+
+For composite or custom indexes, use `models.Index`:
+
+```python
+class MyModel(models.Model):
+ _name = 'my.model'
+
+ name = fields.Char()
+ code = fields.Char()
+ date = fields.Date()
+
+ # Composite index on multiple fields
+ _name_code_idx = models.Index('(name, code)')
+
+ # Index with specific method
+ _date_idx = models.Index('(date DESC)')
+```
+
+> **Warning**: Don't over-index — indexes consume space and impact INSERT/UPDATE/DELETE performance.
+
+---
+
+## Recordsets
+
+### Active Record Interface
+
+```python
+# Read field
+record.name
+record.company_id.name
+
+# Write field
+record.name = "Bob"
+
+# Dynamic field access
+field = "name"
+record[field]
+```
+
+### Iteration
+
+```python
+def do_operation(self):
+ for record in self:
+ # record is a single record
+ print(record.name)
+```
+
+### Record Cache and Prefetching
+
+Odoo maintains a cache and prefetches records/fields following heuristics.
+
+```python
+# Without prefetching: 2000 queries
+for partner in partners:
+ print(partner.name)
+ print(partner.lang)
+
+# With prefetching: 1 query
+for partner in partners:
+ print(partner.name)
+ print(partner.lang)
+```
+
+---
+
+## CRUD Operations
+
+### Create
+
+```python
+# Single record
+record = self.env['model.name'].create({'field': 'value'})
+
+# Multiple records (batch)
+records = self.env['model.name'].create([
+ {'field': 'value1'},
+ {'field': 'value2'},
+])
+```
+
+### Read
+
+```python
+# Browse
+record = self.env['model.name'].browse(record_id)
+records = self.env['model.name'].browse([id1, id2, id3])
+
+# Read
+data = records.read(['field1', 'field2'])
+```
+
+### Write
+
+```python
+# Single record
+record.write({'field': 'value'})
+
+# Multiple records
+records.write({'field': 'value'})
+```
+
+### Unlink (Delete)
+
+```python
+# Single record
+record.unlink()
+
+# Multiple records
+records.unlink()
+```
+
+---
+
+## Search Domains
+
+A search domain is a first-order logical predicate for filtering.
+
+### Domain Condition
+
+```python
+# Simple condition
+domain = [('name', '=', 'ABC')]
+
+# Multiple conditions
+domain = [('name', '=', 'ABC'), ('phone', 'like', '7620')]
+```
+
+### Operators
+
+| Operator | Description |
+| ------------------------------------ | ------------------ |
+| `=` | equals |
+| `!=` | not equals |
+| `>`, `>=`, `<`, `<=` | comparison |
+| `=?` | unset or equals |
+| `=like`, `like`, `ilike`, `=ilike` | pattern matching |
+| `in`, `not in` | in list |
+| `child_of`, `parent_of` | tree traversal |
+| `any`, `any!`, `not any`, `not any!` | relation traversal |
+
+### Logical Operators
+
+```python
+# AND (implicit)
+domain = [('name', '=', 'ABC'), ('state', '=', 'draft')]
+
+# OR
+domain = '|', [('name', '=', 'ABC')], [('name', '=', 'XYZ')]
+
+# NOT
+domain = '!', [('state', '=', 'draft')]
+```
+
+### Domain Class
+
+```python
+from odoo.fields import Domain
+
+# Create domain
+d1 = Domain('name', '=', 'abc')
+d2 = Domain('phone', 'like', '7620')
+
+# Combine
+d3 = d1 & d2 # AND
+d4 = d1 | d2 # OR
+d5 = ~d1 # NOT
+
+# Parse from list
+domain = Domain([('name', '=', 'abc'), ('phone', 'like', '7620')])
+
+# Serialize to list
+domain_list = list(domain)
+```
+
+### Search Methods
+
+```python
+# Search
+records = self.env['model'].search(domain)
+
+# Search with limit
+records = self.env['model'].search(domain, limit=10)
+
+# Search with offset
+records = self.env['model'].search(domain, offset=20)
+
+# Search with order
+records = self.env['model'].search(domain, order='name ASC')
+
+# Search count
+count = self.env['model'].search_count(domain)
+
+# Search and read
+records = self.env['model'].search_read(domain, ['field1', 'field2'])
+
+# Search fetch (Odoo 19+)
+records = self.env['model'].search_fetch(domain, ['field1', 'field2'])
+
+# Name search
+records = self.env['model'].name_search('keyword', operator='ilike')
+```
+
+---
+
+## Environment
+
+The environment holds:
+
+- Database cursor (`cr`)
+- Current user (`user`, `uid`)
+- Context (`context`)
+- Record cache
+
+### Accessing Environment
+
+```python
+# From recordset
+env = record.env
+
+# Create new recordset in another model
+model = env['another.model']
+
+# Access properties
+env.uid # Current user id
+env.user # Current user recordset
+env.company # Current company
+env.companies # Allowed companies
+env.lang # Current language
+```
+
+### Altering Environment
+
+```python
+# Change context
+records.with_context(lang='fr_FR')
+
+# Change user
+records.with_user(user_id)
+
+# Change company
+records.with_company(company_id)
+
+# Change environment completely
+records.with_env(new_env)
+
+# Sudo (superuser mode)
+records.sudo()
+```
+
+---
+
+## SQL Execution
+
+### Raw SQL
+
+```python
+# Execute query
+self.env.cr.execute("SELECT id FROM table WHERE field = %s", (value,))
+
+# Fetch results
+results = self.env.cr.fetchall()
+row = self.env.cr.fetchone()
+```
+
+### SQL Class (Recommended)
+
+```python
+from odoo.tools import SQL
+
+# Build query
+query = SQL("SELECT id FROM table WHERE field = %s", value)
+
+# Execute
+self.env.cr.execute(query)
+```
+
+### Flush and Invalidate
+
+Before SQL queries, flush pending data:
+
+```python
+# Flush all records of a model
+self.env['model'].flush_model(['field1', 'field2'])
+
+# Flush specific recordset
+records.flush_recordset(['field1', 'field2'])
+```
+
+After SQL modifications, invalidate cache:
+
+```python
+# Invalidate all records of a model
+self.env['model'].invalidate_model(['field1', 'field2'])
+
+# Invalidate specific recordset
+records.invalidate_recordset(['field1', 'field2'])
+
+# Notify field modification
+records.modified(['field1', 'field2'])
+```
+
+---
+
+## Inheritance
+
+### Classical Inheritance
+
+Create new model from existing one:
+
+```python
+class Inheritance1(models.Model):
+ _name = 'inheritance.1'
+ _description = 'Inheritance One'
+
+ name = fields.Char()
+
+class Inheritance2(models.Model):
+ _name = 'inheritance.2'
+ _inherit = ['inheritance.1']
+ _description = 'Inheritance Two'
+
+ # Inherits name field from inheritance.1
+ # Adds new fields/methods
+```
+
+### Extension
+
+Extend existing model in-place:
+
+```python
+class Extension0(models.Model):
+ _name = 'extension.0'
+ _description = 'Extension zero'
+
+ name = fields.Char(default="A")
+
+class Extension0(models.Model):
+ _inherit = 'extension.0'
+
+ description = fields.Char(default="Extended")
+```
+
+### Delegation
+
+Delegate fields to child records:
+
+```python
+class Screen(models.Model):
+ _name = 'delegation.screen'
+
+ size = fields.Float(string='Screen Size')
+
+class Laptop(models.Model):
+ _name = 'delegation.laptop'
+
+ _inherits = {
+ 'delegation.screen': 'screen_id',
+ }
+
+ name = fields.Char(string='Name')
+ screen_id = fields.Many2one('delegation.screen', required=True, ondelete="cascade")
+
+# Can access size directly on laptop
+laptop.size
+```
+
+---
+
+## References
+
+- Source: Odoo 19 documentation `/doc/developer/reference/backend/orm.rst`
diff --git a/.agents/skills/odoo-19/references/odoo-19-owl-guide.md b/.agents/skills/odoo-19/references/odoo-19-owl-guide.md
new file mode 100644
index 00000000..0f089f1a
--- /dev/null
+++ b/.agents/skills/odoo-19/references/odoo-19-owl-guide.md
@@ -0,0 +1,421 @@
+# Odoo 19 OWL Guide
+
+Guide for building OWL (Owl Web Library) components in Odoo 19.
+
+## Table of Contents
+
+- [OWL Overview](#owl-overview)
+- [Component Structure](#component-structure)
+- [Hooks](#hooks)
+- [Services](#services)
+- [State Management](#state-management)
+- [QWeb Templates](#qweb-templates)
+- [Translations](#translations)
+
+---
+
+## OWL Overview
+
+OWL is a JavaScript framework for building web UI components in Odoo.
+
+### Key Concepts
+
+| Concept | Description |
+| ------------- | -------------------------- |
+| **Component** | Reusable UI building block |
+| **State** | Reactive data |
+| **Props** | Component properties |
+| **Hooks** | Lifecycle functions |
+| **Template** | QWeb template |
+
+---
+
+## Component Structure
+
+### Basic Component
+
+```javascript
+import { Component } from "@odoo/owl";
+
+export class MyComponent extends Component {
+ static template = "my_module.MyComponent";
+ static props = {
+ value: { type: String, optional: true },
+ };
+
+ setup() {
+ // Component setup
+ }
+}
+```
+
+### Register Component
+
+```javascript
+import { registry } from "@web/core/registry";
+
+registry.category("actions").add("my_component", MyComponent);
+```
+
+### Use in View
+
+```xml
+
+```
+
+---
+
+## Hooks
+
+### Setup Hook
+
+Called when component is created:
+
+```javascript
+setup() {
+ // Initialize state
+ this.state = useState({ count: 0 });
+
+ // Call services
+ this.rpc = useService("rpc");
+ this.orm = useService("orm");
+ this.action = useService("action");
+}
+```
+
+### Lifecycle Hooks
+
+| Hook | When |
+| ----------------- | --------------------- |
+| `setup()` | Component creation |
+| `onWillStart()` | Before render (async) |
+| `onMounted()` | After render |
+| `onWillUnmount()` | Before destroy |
+| `onWillPatch()` | Before update |
+| `onPatched()` | After update |
+
+### Example
+
+```javascript
+setup() {
+ onWillStart(this.onWillStart);
+ onMounted(this.onMounted);
+ onWillUnmount(this.onWillUnmount);
+}
+
+async onWillStart() {
+ // Load data before render
+}
+
+onMounted() {
+ // After render
+}
+
+onWillUnmount() {
+ // Cleanup
+}
+```
+
+---
+
+## Services
+
+### Common Services
+
+| Service | Description |
+| -------------- | ------------------- |
+| `orm` | Database operations |
+| `rpc` | RPC calls |
+| `action` | Execute actions |
+| `dialog` | Show dialogs |
+| `notification` | Show notifications |
+| `router` | Navigation |
+| `user` | Current user |
+| `company` | Current company |
+
+### Use Service
+
+```javascript
+setup() {
+ this.orm = useService("orm");
+ this.rpc = useService("rpc");
+ this.action = useService("action");
+ this.dialog = useService("dialog");
+ this.notification = useService("notification");
+}
+```
+
+### ORM Service
+
+```javascript
+// Search
+const records = await this.orm.search("my.model", [["active", "=", true]]);
+
+// Read
+const data = await this.orm.read("my.model", ids, ["name", "value"]);
+
+// Create
+const id = await this.orm.create("my.model", { name: "Test" });
+
+// Write
+await this.orm.write("my.model", [id], { name: "Updated" });
+
+// Unlink
+await this.orm.unlink("my.model", [id]);
+```
+
+### RPC Service
+
+```javascript
+// Call controller
+const result = await this.rpc("/my/controller", { param: "value" });
+```
+
+### Action Service
+
+```javascript
+// Do action
+await this.action.doAction({
+ type: "ir.actions.act_window",
+ res_model: "my.model",
+ views: [
+ [false, "list"],
+ [false, "form"],
+ ],
+});
+```
+
+### Dialog Service
+
+```javascript
+// Add dialog
+this.dialog.add(MyDialog, {
+ title: "My Dialog",
+ confirm: () => {...},
+});
+```
+
+### Notification Service
+
+```javascript
+// Show notification
+this.notification.notify({
+ message: "Success!",
+ type: "success",
+});
+```
+
+---
+
+## State Management
+
+### useState
+
+```javascript
+setup() {
+ this.state = useState({
+ count: 0,
+ name: "",
+ });
+}
+
+increment() {
+ this.state.count++;
+}
+```
+
+### useState in Template
+
+```xml
+
++
+```
+
+### Computed State
+
+```javascript
+setup() {
+ this.state = useState({count: 0});
+ this.double = computed(() => this.state.count * 2);
+}
+```
+
+---
+
+## QWeb Templates
+
+### Basic Template
+
+```xml
+
+
+
+
+
+
+```
+
+### Event Handlers
+
+```xml
+Increment
+
+```
+
+### Loops and Conditions
+
+```xml
+
+
+
+
+
+
+Visible when true
+Visible when false
+```
+
+---
+
+## Translations
+
+### Translate in JavaScript
+
+```javascript
+import { _t } from "@web/core/l10n/translation";
+
+this.message = _t("Hello World");
+```
+
+### Translate with Parameters
+
+```javascript
+this.message = _t("Hello %(name)s", { name: "John" });
+```
+
+### Translate in Template
+
+```xml
+
+```
+
+---
+
+## Examples
+
+### Counter Component
+
+```javascript
+import { Component, useState } from "@odoo/owl";
+import { _t } from "@web/core/l10n/translation";
+
+export class Counter extends Component {
+ static template = "my_module.Counter";
+
+ setup() {
+ this.state = useState({ count: 0 });
+ }
+
+ increment() {
+ this.state.count++;
+ }
+
+ decrement() {
+ this.state.count--;
+ }
+}
+```
+
+```xml
+
+
+
+ -
+
+ +
+
+
+
+```
+
+### Data Loading Component
+
+```javascript
+import { Component, useState, onWillStart } from "@odoo/owl";
+import { useService } from "@web/core/utils/hooks";
+
+export class DataComponent extends Component {
+ static template = "my_module.DataComponent";
+
+ setup() {
+ this.orm = useService("orm");
+ this.state = useState({
+ records: [],
+ loading: true,
+ });
+
+ onWillStart(this.loadData);
+ }
+
+ async loadData() {
+ this.state.records = await this.orm.search("my.model", [], {
+ limit: 10,
+ });
+ this.state.loading = false;
+ }
+}
+```
+
+---
+
+## Best Practices
+
+### Use Hooks for Side Effects
+
+```javascript
+setup() {
+ onMounted(() => {
+ // Side effects here
+ });
+}
+```
+
+### Cleanup Resources
+
+```javascript
+setup() {
+ onWillUnmount(() => {
+ // Cleanup here
+ });
+}
+```
+
+### Avoid Direct DOM Manipulation
+
+Use templates and reactive state instead.
+
+### Split Components
+
+Keep components small and focused.
+
+```javascript
+// Good: Small focused component
+export class UserName extends Component {
+ static template = "my_module.UserName";
+}
+
+// Bad: Large monolithic component
+export class Everything extends Component {
+ static template = "my_module.Everything";
+}
+```
+
+---
+
+## References
+
+- OWL documentation
+- Odoo 19 Web framework docs
diff --git a/.agents/skills/odoo-19/references/odoo-19-performance-guide.md b/.agents/skills/odoo-19/references/odoo-19-performance-guide.md
new file mode 100644
index 00000000..b8dd61db
--- /dev/null
+++ b/.agents/skills/odoo-19/references/odoo-19-performance-guide.md
@@ -0,0 +1,357 @@
+# Odoo 19 Performance Guide
+
+Guide for optimizing Odoo 19 code: preventing N+1 queries, reducing database queries, and using profiler.
+
+## Table of Contents
+
+- [Profiling](#profiling)
+- [Batch Operations](#batch-operations)
+- [Algorithmic Complexity](#algorithmic-complexity)
+- [Indexes](#indexes)
+- [Performance Pitfalls](#performance-pitfalls)
+
+---
+
+## Profiling
+
+Odoo provides an integrated profiling tool to record SQL queries and stack traces.
+
+### Enable from User Interface
+
+1. Enable developer mode
+2. Toggle **Enable profiling** button
+3. Choose expiry time
+4. Toggle **Enable profiling** again to start session profiling
+
+Options:
+
+- **Record sql** - Saves all SQL queries with stack trace
+- **Record traces** - Saves stack trace periodically (default: 10ms interval)
+
+### Enable from Python Code
+
+```python
+from odoo.tools.profiler import Profiler
+
+# Basic profiling
+with Profiler():
+ do_stuff()
+
+# With custom collectors
+with Profiler(collectors=['sql', PeriodicCollector(interval=0.1)]):
+ do_stuff()
+
+# In tests
+with self.profile():
+ do_stuff()
+```
+
+### Collectors
+
+| Collector | Key | Description |
+| ------------------ | -------------- | ------------------------------------------------ |
+| SQL collector | `sql` | Saves SQL queries with stack trace |
+| Periodic collector | `traces_async` | Saves stack trace periodically (separate thread) |
+| QWeb collector | `qweb` | Saves QWeb directive execution |
+| Sync collector | `traces_sync` | Saves every function call/return (high overhead) |
+
+### Execution Context
+
+Add context to identify calls in speedscope:
+
+```python
+for index in range(max_index):
+ with ExecutionContext(current_index=index):
+ do_stuff()
+```
+
+### Performance Pitfalls
+
+- Randomness can lead to different results (garbage collector, etc.)
+- Blocking calls may cause unexpected long frames
+- Cache state affects results (view/assets in cache)
+- Profiler overhead can impact performance (especially SQL collector)
+- Large profiles may cause memory issues
+
+---
+
+## Batch Operations
+
+### Avoid Loop Queries
+
+**BAD**: Search in loop (N queries)
+
+```python
+def _compute_count(self):
+ for record in self:
+ domain = [('related_id', '=', record.id)]
+ record.count = other_model.search_count(domain)
+```
+
+**GOOD**: Use `_read_group` (1 query)
+
+> **Odoo 19**: `read_group()` is **deprecated**. Use `_read_group()` (internal) or `formatted_read_group()` (public API).
+
+```python
+def _compute_count(self):
+ domain = [('related_id', 'in', self.ids)]
+ counts_data = other_model._read_group(domain, ['related_id'], ['__count'])
+ mapped_data = {r['related_id'][0]: r['__count'] for r in counts_data}
+ for record in self:
+ record.count = mapped_data.get(record.id, 0)
+```
+
+### Batch Creates
+
+**BAD**: Create in loop
+
+```python
+for name in ['foo', 'bar']:
+ model.create({'name': name})
+```
+
+**GOOD**: Batch create
+
+```python
+create_values = [{'name': name} for name in ['foo', 'bar']]
+records = model.create(create_values)
+```
+
+### Prefetch Records
+
+**BAD**: Browse one at a time
+
+```python
+for record_id in record_ids:
+ record = model.browse(record_id)
+ record.foo # One query per record
+```
+
+**GOOD**: Browse all together
+
+```python
+records = model.browse(record_ids)
+for record in records:
+ record.foo # One query for entire recordset
+```
+
+### Disable Prefetching (when needed)
+
+```python
+for values in values_list:
+ message = self.browse(values['id']).with_prefetch(self.ids)
+```
+
+---
+
+## Algorithmic Complexity
+
+### Reduce Nested Loops
+
+**BAD**: O(n²) complexity
+
+```python
+for record in self:
+ for result in results:
+ if result['id'] == record.id:
+ record.foo = result['foo']
+ break
+```
+
+**GOOD**: Use dictionary (O(n))
+
+```python
+mapped_result = {result['id']: result['foo'] for result in results}
+for record in self:
+ record.foo = mapped_result.get(record.id)
+```
+
+### Use Set Operations
+
+**BAD**: List-like `in` check (quadratic)
+
+```python
+invalid_ids = self.search(domain).ids
+for record in self:
+ if record.id in invalid_ids: # O(n) for each record
+ ...
+```
+
+**GOOD**: Use set (O(n) total)
+
+```python
+invalid_ids = set(self.search(domain).ids)
+for record in self:
+ if record.id in invalid_ids:
+ ...
+```
+
+**ALTERNATIVE**: Recordset operations
+
+```python
+invalid_ids = self.search(domain)
+for record in self - invalid_ids:
+ ...
+```
+
+---
+
+## Indexes
+
+Database indexes speed up search operations.
+
+```python
+name = fields.Char(string="Name", index=True)
+```
+
+**Warning**: Don't index every field - indexes consume space and impact INSERT/UPDATE/DELETE performance.
+
+### Using Indexes
+
+```python
+# Field-level index
+name = fields.Char(index=True)
+records = self.search([('name', '=', 'value')]) # Uses index scan
+```
+
+### Declarative Index (Odoo 19)
+
+For composite indexes, use `models.Index` as a model attribute:
+
+```python
+class MyModel(models.Model):
+ _name = 'my.model'
+
+ name = fields.Char()
+ code = fields.Char()
+
+ _name_code_idx = models.Index('(name, code)')
+```
+
+---
+
+## Performance Pitfalls
+
+### N+1 Query Problem
+
+Occurs when you:
+
+1. Fetch a list of records
+2. Loop through them
+3. Execute a query for each record
+
+**Detection**: Use `--log-sql` CLI parameter or profiler
+
+**Solution**: Fetch related data in one query using:
+
+- `_read_group()`
+- `search_fetch()`
+- `fetch()`
+- `mapped()`
+
+### Large Recordsets
+
+Processing large recordsets can cause memory issues.
+
+**Solution**: Process in batches
+
+```python
+def _process_large_dataset(self):
+ limit = 1000
+ offset = 0
+ while True:
+ records = self.search([], limit=limit, offset=offset)
+ if not records:
+ break
+ records.process()
+ offset += limit
+```
+
+### Computed Field Dependencies
+
+Missing dependencies cause recomputation at wrong time.
+
+**BAD**: Missing dotted dependency
+
+```python
+@api.depends('partner_id')
+def _compute_email(self):
+ for record in self:
+ record.email = record.partner_id.email # N queries!
+```
+
+**GOOD**: Include dotted path
+
+```python
+@api.depends('partner_id.email')
+def _compute_email(self):
+ for record in self:
+ record.email = record.partner_id.email # 1 query for all
+```
+
+### Context Pollution
+
+Excessive context changes can cause issues.
+
+```python
+# BAD: Too many with_context calls
+records.with_context(lang='fr').with_context(active_test=False).with_context(...)
+```
+
+**Solution**: Consolidate context changes
+
+```python
+# GOOD: Single with_context call
+records.with_context(lang='fr', active_test=False)
+```
+
+### Unnecessary invalidate_cache
+
+Calling `invalidate_cache()` too frequently defeats the purpose of caching.
+
+**Solution**: Only invalidate fields that actually changed
+
+```python
+# GOOD: Invalidate only changed fields
+records.invalidate_recordset(['field1', 'field2'])
+```
+
+---
+
+## Query Count Testing
+
+Use `assertQueryCount` in tests to establish query limits.
+
+```python
+with self.assertQueryCount(11):
+ do_something()
+```
+
+Combine with profiler for analysis:
+
+```python
+with self.profile():
+ with self.assertQueryCount(__system__=1211):
+ do_stuff()
+```
+
+---
+
+## Good Practices Summary
+
+| Practice | Description |
+| -------------------------- | --------------------------------------------- |
+| **Batch operations** | Accumulate operations, execute in batch |
+| **Use \_read_group** | Replace search/search_count in loops |
+| **Prefetch records** | Browse all records together |
+| **Reduce complexity** | Use dictionaries/sets instead of nested loops |
+| **Add indexes** | On frequently searched fields |
+| **Use fetch/search_fetch** | For targeted data loading |
+| **Profile first** | Use profiler before optimizing |
+| **Test query counts** | Use assertQueryCount in tests |
+
+---
+
+## References
+
+- Source: Odoo 19 documentation `/doc/developer/reference/backend/performance.rst`
diff --git a/.agents/skills/odoo-19/references/odoo-19-reports-guide.md b/.agents/skills/odoo-19/references/odoo-19-reports-guide.md
new file mode 100644
index 00000000..6f3172eb
--- /dev/null
+++ b/.agents/skills/odoo-19/references/odoo-19-reports-guide.md
@@ -0,0 +1,343 @@
+# Odoo 19 Reports Guide
+
+Guide for creating QWeb reports in Odoo 19: PDF/HTML reports, templates, and paper formats.
+
+## Table of Contents
+
+- [Report Basics](#report-basics)
+- [Report Declaration](#report-declaration)
+- [Report Templates](#report-templates)
+- [Custom Reports](#custom-reports)
+- [Paper Formats](#paper-formats)
+- [Translatable Reports](#translatable-reports)
+- [Barcodes](#barcodes)
+
+---
+
+## Report Basics
+
+Reports are written in HTML/QWeb. PDF rendering is performed by wkhtmltopdf.
+
+Reports consist of:
+
+1. A **report action** (`ir.actions.report`)
+2. A **QWeb template** for the report content
+
+---
+
+## Report Declaration
+
+### Using report Tag (Simple)
+
+```xml
+
+```
+
+### Using record Tag (Advanced)
+
+```xml
+
+ My Report
+ my.model
+ qweb-pdf
+ my_module.my_template
+ 'My Report - %s' % (object.name)
+
+
+
+```
+
+### Report Attributes
+
+| Attribute | Description |
+| ------------------- | ----------------------------------------- |
+| `id` | External identifier |
+| `string` | Report name |
+| `model` | Model for the report |
+| `report_type` | `qweb-pdf` or `qweb-html` |
+| `name` | External ID of QWeb template |
+| `file` | Same as name (for backward compatibility) |
+| `print_report_name` | Python expression for filename |
+| `paperformat_id` | Paper format |
+| `binding_model_id` | Model to show in Print menu |
+| `groups_id` | Groups allowed to use report |
+| `multi` | If True, don't show on form view |
+| `attachment_use` | Generate once, reprint from stored |
+| `attachment` | Python expression for attachment |
+
+---
+
+## Report Templates
+
+### Template Variables
+
+Report templates always have access to:
+
+| Variable | Description |
+| ------------------- | --------------------------------------------- |
+| `docs` | Records for the current report |
+| `doc_ids` | List of ids for `docs` |
+| `doc_model` | Model name for `docs` |
+| `time` | Python `time` module |
+| `user` | User printing the report |
+| `res_company` | Current user's company |
+| `website` | Current website (if any) |
+| `web_base_url` | Base URL for webserver |
+| `context_timestamp` | Function to convert datetime to user timezone |
+
+### Minimal Template
+
+```xml
+
+
+
+
+
+
Invoice
+
Customer:
+
Total:
+
+
+
+
+
+```
+
+### External Layout
+
+Calling `web.external_layout` adds default header and footer.
+
+```xml
+
+
+
+
+
+```
+
+#### Layout Variants
+
+| Layout | Description |
+| -------------------------------- | ------------------------------------ |
+| `web.external_layout` | Standard layout (with header/footer) |
+| `web.external_layout_background` | With background |
+| `web.external_layout_clean` | Clean layout (minimal) |
+| `web.external_layout_boxed` | Boxed layout |
+
+---
+
+## Custom Reports
+
+For custom reports, create a report class that overrides `_get_report_values`:
+
+```python
+from odoo import models
+
+class MyReport(models.AbstractModel):
+ _name = 'report.my_module.my_template'
+ _description = 'My Custom Report'
+
+ def _get_report_values(self, docids, data=None):
+ docs = self.env['my.model'].browse(docids)
+
+ return {
+ 'doc_ids': docids,
+ 'doc_model': 'my.model',
+ 'docs': docs,
+ 'data': data,
+ 'my_custom_value': self._compute_custom(docs),
+ }
+
+ def _compute_custom(self, docs):
+ # Custom computation
+ return sum(doc.amount for doc in docs)
+```
+
+Use custom values in template:
+
+```xml
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+## Paper Formats
+
+### Built-in Paper Formats
+
+| Format | Size |
+| ---------------------- | ------ |
+| `paperformat_euro` | A4 |
+| `paperformat_us` | Letter |
+| `paperformat_us_legal` | Legal |
+
+### Custom Paper Format
+
+```xml
+
+ My Custom Format
+
+ A4
+ 297
+ 210
+ Portrait
+ 40
+ 20
+ 7
+ 7
+
+ 35
+ 90
+
+```
+
+### Format Attributes
+
+| Attribute | Description |
+| ---------------- | --------------------------------------- |
+| `format` | Paper size (A4, Letter, etc.) or Custom |
+| `page_height` | Page height in mm |
+| `page_width` | Page width in mm |
+| `orientation` | Portrait or Landscape |
+| `margin_top` | Top margin in mm |
+| `margin_bottom` | Bottom margin in mm |
+| `margin_left` | Left margin in mm |
+| `margin_right` | Right margin in mm |
+| `header_line` | Show header line |
+| `header_spacing` | Header spacing in mm |
+| `dpi` | Resolution (default: 90) |
+
+---
+
+## Translatable Reports
+
+### Two-Template Approach
+
+1. Main template (wrapper)
+2. Translatable document template
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### Language Attribute
+
+Use `t-lang` to set language for a template section:
+
+```xml
+
+
+
+
+
+
+
+```
+
+**Note**: Works only with `t-call`, not on regular XML nodes.
+
+---
+
+## Barcodes
+
+### Embed Barcode
+
+```xml
+
+```
+
+### Barcode Controller Parameters
+
+| Parameter | Values |
+| --------------- | ------------------------------------------------ |
+| `type` | `QR`, `Code128`, `EAN13`, `EAN8`, `UPCA`, `UPCE` |
+| `value` | Barcode value |
+| `width` | Width in pixels |
+| `height` | Height in pixels |
+| `humanreadable` | `1` or `0` (show text below) |
+
+### Example
+
+```xml
+
+
Product:
+
+
+```
+
+---
+
+## QWeb Tips
+
+### Directives
+
+| Directive | Description |
+| ----------- | ----------------------------------------------------- |
+| `t-out` | Escape and output value (replaces deprecated `t-esc`) |
+| `t-field` | Output field value (formatted) |
+| `t-if` | Conditional display |
+| `t-elif` | Else if condition |
+| `t-else` | Else condition |
+| `t-foreach` | Loop over collection |
+| `t-as` | Variable name for foreach |
+| `t-set` | Set variable |
+| `t-call` | Call another template |
+| `t-lang` | Set language for template |
+
+### t-field Options
+
+```xml
+
+
+
+```
+
+---
+
+## References
+
+- Source: Odoo 19 documentation `/doc/developer/reference/backend/reports.rst`
diff --git a/.agents/skills/odoo-19/references/odoo-19-security-guide.md b/.agents/skills/odoo-19/references/odoo-19-security-guide.md
new file mode 100644
index 00000000..9d3e006f
--- /dev/null
+++ b/.agents/skills/odoo-19/references/odoo-19-security-guide.md
@@ -0,0 +1,381 @@
+# Odoo 19 Security Guide
+
+Guide for Odoo 19 security: access rights, record rules, field permissions, and security pitfalls.
+
+## Table of Contents
+
+- [Security Overview](#security-overview)
+- [Groups](#groups)
+- [Access Rights (ACL)](#access-rights-acl)
+- [Record Rules](#record-rules)
+- [Field Access](#field-access)
+- [Security Pitfalls](#security-pitfalls)
+
+---
+
+## Security Overview
+
+Odoo provides two main data-driven mechanisms to manage access:
+
+1. **Access Rights (ACL)** - Grants access to an entire model for operations
+2. **Record Rules** - Conditions that must be satisfied for operations
+
+Both are linked to users through **groups**.
+
+---
+
+## Groups
+
+`res.groups` - Users belong to groups, security mechanisms are associated to groups.
+
+> **Odoo 19 Breaking Change**: `category_id` has been **removed** from `res.groups`. Replaced by a new **privilege-based system** using `res.groups.privilege`.
+
+### Key Attributes
+
+| Attribute | Description |
+| -------------- | ------------------------------------------------------------------------- |
+| `name` | User-readable identification (role/purpose) |
+| `privilege_id` | **Odoo 19**: Reference to `res.groups.privilege` (replaces `category_id`) |
+| `implied_ids` | Other groups to set on the user alongside this one |
+| `comment` | Additional notes |
+
+### 3-Tier Security Architecture (Odoo 19)
+
+```
+ir.module.category → res.groups.privilege → res.groups → res.users
+```
+
+### Example
+
+```xml
+
+
+ My Module Access
+
+
+
+
+
+ My Module User
+
+
+ Users can access my module features.
+
+```
+
+> **Migration**: Replace `category_id` with `privilege_id` in `security.xml` and create corresponding `res.groups.privilege` records.
+
+---
+
+## Access Rights (ACL)
+
+Access rights **grant** access to an entire model for operations. If no access rights matches an operation, the user doesn't have access.
+
+Access rights are **additive** - a user's accesses are the union of all groups.
+
+`ir.model.access` - Access control list entries.
+
+### Key Attributes
+
+| Attribute | Description |
+| ------------- | ----------------------------------------- |
+| `name` | Purpose or role of the group |
+| `model_id` | Model whose access the ACL controls |
+| `group_id` | Group granted access (empty = every user) |
+| `perm_create` | Grant create access |
+| `perm_read` | Grant read access |
+| `perm_write` | Grant write access |
+| `perm_unlink` | Grant unlink (delete) access |
+
+### Example CSV (Common Format)
+
+File: `security/ir.model.access.csv`
+
+```csv
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_my_model_user,my.model.user,model_my_model,group_my_module_user,1,1,0,0
+access_my_model_manager,my.model.manager,model_my_model,group_my_module_manager,1,1,1,1
+```
+
+### Example XML
+
+```xml
+
+ my.model.user
+
+
+
+
+
+
+
+```
+
+---
+
+## Record Rules
+
+Record rules are **conditions** which must be satisfied for operations. They are evaluated record-by-record, following access rights.
+
+Record rules are **default-allow**: if access rights grant access and no rule applies, access is granted.
+
+`ir.rule` - Record rules.
+
+### Key Attributes
+
+| Attribute | Description |
+| ------------------------------------------------------- | -------------------------------------------------- |
+| `name` | Description of the rule |
+| `model_id` | Model to which the rule applies |
+| `groups` | Groups to which access is granted (empty = global) |
+| `domain_force` | Domain predicate (Python expression) |
+| `perm_read`, `perm_write`, `perm_create`, `perm_unlink` | Operations the rule applies to (all by default) |
+
+### Domain Force Variables
+
+| Variable | Description |
+| ------------- | ----------------------------------------------- |
+| `time` | Python's `time` module |
+| `user` | Current user (singleton recordset) |
+| `company_id` | Current user's selected company (single id) |
+| `company_ids` | All companies the user has access (list of ids) |
+
+### Example: Multi-Company Rule
+
+```xml
+
+ My Module: multi-company
+
+ ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]
+
+```
+
+### Example: User-Only Records
+
+```xml
+
+ My Module: user records
+
+ [('user_id', '=', user.id)]
+
+
+```
+
+### Global vs Group Rules
+
+There's a large difference between global and group rules:
+
+| Rule Type | Behavior |
+| ---------------------- | -------------------------------------------------- |
+| **Global** (no groups) | **Intersect** - all global rules must be satisfied |
+| **Group** rules | **Unify** - any group rule can be satisfied |
+| **Global + Group** | **Intersect** - first group rule restricts access |
+
+> **Danger**: Creating multiple global rules is risky - possible to create non-overlapping rulesets which will remove all access.
+
+---
+
+## Field Access
+
+An ORM field can have a `groups` attribute providing a list of groups (comma-separated external identifiers).
+
+If the current user is not in one of the listed groups:
+
+- Restricted fields are removed from views
+- Restricted fields are removed from `fields_get()` responses
+- Attempts to read/write restricted fields result in an access error
+
+```python
+notes = fields.Text('Internal Notes', groups='base.group_system')
+
+# Only users in base.group_system can see/edit this field
+```
+
+---
+
+## Security Pitfalls
+
+### Unsafe Public Methods
+
+Any public method can be executed via RPC call. Methods starting with `_` are not callable from action buttons or external API.
+
+> **Odoo 19**: Use `@api.private` decorator to explicitly prevent RPC access on any method.
+
+```python
+# BAD: this method is public and arguments can not be trusted
+def action_done(self):
+ if self.state == "draft" and self.env.user.has_group('base.manager'):
+ self._set_state("done")
+
+# GOOD: use @api.private (Odoo 19) or underscore prefix
+@api.private
+def _set_state(self, new_state):
+ self.sudo().write({"state": new_state})
+```
+
+### Bypassing the ORM
+
+**Never** use the database cursor directly when the ORM can do the same thing!
+
+#### Wrong (very bad)
+
+```python
+# SQL injection vulnerability
+self.env.cr.execute('SELECT id FROM auction_lots WHERE auction_id in (' +
+ ','.join(map(str, ids)) + ') AND state=%s AND obj_price > 0',
+ ('draft',))
+```
+
+#### Better (no injection, but still wrong)
+
+```python
+self.env.cr.execute('SELECT id FROM auction_lots WHERE auction_id in %s '
+ 'AND state=%s AND obj_price > 0',
+ (tuple(ids), 'draft',))
+```
+
+#### Best (use ORM)
+
+```python
+auction_lots_ids = self.search([
+ ('auction_id', 'in', ids),
+ ('state', '=', 'draft'),
+ ('obj_price', '>', 0)
+]).ids
+```
+
+#### Using SQL class (recommended)
+
+```python
+from odoo.tools import SQL
+
+auction_lots_ids = self.env.execute_query(SQL("""
+ SELECT id FROM auction_lots
+ WHERE auction_id IN %s AND state = %s AND obj_price > 0
+""", tuple(ids), 'draft'))
+```
+
+### SQL Injections
+
+**Never** use Python string concatenation (+) or interpolation (%) for SQL queries.
+
+Use psycopg2 parameter passing or `odoo.tools.SQL` wrapper.
+
+```python
+# BAD: SQL injection vulnerability
+self.env.cr.execute('SELECT distinct child_id FROM account_account_consol_rel '
+ 'WHERE parent_id IN ('+','.join(map(str, ids))+')')
+
+# GOOD: use parameter passing
+self.env.cr.execute('SELECT DISTINCT child_id '
+ 'FROM account_account_consol_rel '
+ 'WHERE parent_id IN %s',
+ (tuple(ids),))
+
+# BETTER: use SQL wrapper
+from odoo.tools import SQL
+self.env.cr.execute(SQL("""
+ SELECT DISTINCT child_id
+ FROM account_account_consol_rel
+ WHERE parent_id IN %s
+""", tuple(ids)))
+```
+
+### Building Domains
+
+Use `odoo.fields.Domain` to handle domain manipulation safely.
+
+```python
+# BAD: the user can pass ['|', ('id', '>', 0)] to access all
+domain = ... # passed by user
+security_domain = [('user_id', '=', self.env.uid)]
+domain += security_domain # can have side effects
+self.search(domain)
+
+# GOOD: use Domain class
+from odoo.fields import Domain
+domain = Domain(...)
+domain &= Domain('user_id', '=', self.env.uid)
+self.search(domain)
+```
+
+### Unescaped Field Content
+
+Avoid using `t-raw` for rich-text content - it's an XSS vector.
+
+#### Bad XML
+
+```xml
+
+```
+
+#### Good XML
+
+```xml
+
+```
+
+### Escaping vs Sanitizing
+
+**Escaping** (always mandatory) converts TEXT to CODE.
+
+```python
+from markupsafe import Markup
+
+# data is TEXT
+code = html_escape(data) # Convert to CODE
+self.website_description = Markup("%s ") % code
+```
+
+**Sanitizing** converts CODE to SAFER CODE (but not necessarily safe).
+
+```python
+from odoo.tools import html_sanitize
+
+code = Markup("Important
")
+html_sanitize(code, strip_classes=True) # => Markup('Important
')
+```
+
+### Evaluating Content
+
+Avoid `eval()` - use `ast.literal_eval()` for parsing.
+
+```python
+# BAD: very bad
+domain = eval(self.filter_domain)
+
+# BETTER: still not recommended
+from odoo.tools import safe_eval
+domain = safe_eval(self.filter_domain)
+
+# GOOD: use literal_eval
+from ast import literal_eval
+domain = literal_eval(self.filter_domain)
+```
+
+### Accessing Object Attributes
+
+Use `__getitem__` instead of `getattr()` for dynamic field access.
+
+```python
+# BAD: unsafe, can access any attribute
+def _get_state_value(self, res_id, state_field):
+ record = self.sudo().browse(res_id)
+ return getattr(record, state_field, False)
+
+# GOOD: safer
+def _get_state_value(self, res_id, state_field):
+ record = self.sudo().browse(res_id)
+ return record[state_field]
+```
+
+---
+
+## References
+
+- Source: Odoo 19 documentation `/doc/developer/reference/backend/security.rst`
diff --git a/.agents/skills/odoo-19/references/odoo-19-testing-guide.md b/.agents/skills/odoo-19/references/odoo-19-testing-guide.md
new file mode 100644
index 00000000..6aa47494
--- /dev/null
+++ b/.agents/skills/odoo-19/references/odoo-19-testing-guide.md
@@ -0,0 +1,481 @@
+# Odoo 19 Testing Guide
+
+Guide for testing Odoo 19: Python unit tests, JS tests, and tours (integration tests).
+
+## Table of Contents
+- [Test Types](#test-types)
+- [Python Tests](#python-tests)
+- [Test Classes](#test-classes)
+- [Test Decorators](#test-decorators)
+- [Test Selection](#test-selection)
+- [JS Tests](#js-tests)
+- [Integration Tests (Tours)](#integration-tests-tours)
+- [Performance Testing](#performance-testing)
+
+---
+
+## Test Types
+
+Odoo has three kinds of tests:
+
+| Type | Purpose |
+|------|---------|
+| **Python unit tests** | Test model business logic |
+| **JS unit tests** | Test JavaScript code in isolation |
+| **Tours** | Integration testing (Python + JS together) |
+
+---
+
+## Python Tests
+
+### Test Structure
+
+Create a `tests` sub-package in your module:
+
+```
+your_module/
+├── ...
+├── tests/
+│ ├── __init__.py
+│ ├── test_bar.py
+│ └── test_foo.py
+```
+
+**Important**: Import test modules from `tests/__init__.py`
+
+```python
+# tests/__init__.py
+from . import test_foo, test_bar
+```
+
+### Test Methods
+
+Test methods must start with `test_`:
+
+```python
+class TestModelA(TransactionCase):
+ def test_some_action(self):
+ record = self.env['model.a'].create({'field': 'value'})
+ record.some_action()
+ self.assertEqual(record.field, expected_value)
+```
+
+---
+
+## Test Classes
+
+### TransactionCase
+
+Most common test class. Each test runs in its own transaction, rolled back at the end.
+
+```python
+from odoo.tests import TransactionCase
+
+class TestMyModel(TransactionCase):
+ def test_create_record(self):
+ record = self.env['my.model'].create({'name': 'Test'})
+ self.assertTrue(record.id)
+ self.assertEqual(record.name, 'Test')
+```
+
+### SingleTransactionCase
+
+Runs all tests in a single transaction (not rolled back). Faster, but tests can affect each other.
+
+```python
+from odoo.tests import SingleTransactionCase
+
+class TestMyModel(SingleTransactionCase):
+ def test_1_first(self):
+ # Creates data
+ pass
+
+ def test_2_second(self):
+ # Can use data from test_1_first
+ pass
+```
+
+### HttpCase
+
+For web-related tests. Starts a browser (headless Chrome by default).
+
+```python
+from odoo.tests import HttpCase
+
+class TestWebsite(HttpCase):
+ def test_homepage(self):
+ self.url_open('/hello')
+```
+
+---
+
+## Test Decorators
+
+### tagged
+
+Add or remove tags from test classes.
+
+```python
+from odoo.tests import TransactionCase, tagged
+
+@tagged('-standard', 'nice')
+class NiceTest(TransactionCase):
+ pass
+```
+
+### Default Tags
+
+- `standard` - Default tag (selected by `--test-tags`)
+- `at_install` - Run after module installation (default)
+- `post_install` - Run after all modules installed
+
+```python
+@tagged('-at_install', 'post_install')
+class WebsiteVisitorTests(HttpCase):
+ def test_create_visitor(self):
+ pass
+```
+
+---
+
+## Test Selection
+
+### By Tag
+
+```bash
+# Run only nice tests
+odoo-bin --test-tags nice
+
+# Run nice and standard tests
+odoo-bin --test-tags nice,standard
+
+# Run standard except slow
+odoo-bin --test-tags standard,-slow
+```
+
+### By Module
+
+```bash
+# Run only sale module tests
+odoo-bin --test-tags /sale
+
+# Run sale module but not slow tests
+odoo-bin --test-tags '/sale,-slow'
+
+# Run stock or slow tests
+odoo-bin --test-tags '-standard, slow, /stock'
+```
+
+### By Specific Test
+
+```bash
+# Run specific test function
+odoo-bin --test-tags .test_supplier_invoice
+
+# Equivalent (full path)
+odoo-bin --test-tags /account:TestAccount.test_supplier_invoice
+```
+
+### Tag Format
+
+```
+[-][tag][/module][:class][.method]
+```
+
+| Prefix | Meaning |
+|--------|---------|
+| `-` | Deselect/remove tag |
+| `+` | Select/add tag (implicit, optional) |
+| `/module` | Specific module |
+| `:class` | Specific class |
+| `.method` | Specific method |
+
+---
+
+## Test Utilities
+
+### browse_ref
+
+Browse an external ID:
+
+```python
+record = self.browse_ref('base.user_admin')
+self.assertEqual(record.login, 'admin')
+```
+
+### ref
+
+Get database ID from external ID:
+
+```python
+user_id = self.ref('base.user_admin')
+```
+
+### Form
+
+Helper for testing form views:
+
+```python
+from odoo.tests import Form
+
+with Form(self.env['sale.order']) as form:
+ form.partner_id = self.partner
+ form.date_order = '2023-01-01'
+ with form.order_line.new() as line:
+ line.product_id = self.product
+ line.product_uom_qty = 10
+
+order = form.save()
+```
+
+---
+
+## JS Tests
+
+Odoo uses Hoot for JS unit testing. See the frontend testing documentation.
+
+Test files go in `static/tests/`:
+
+```
+your_module/
+└── static/
+ └── tests/
+ └── my_test.js
+```
+
+```javascript
+import { start } from '@mail/utils/test_utils';
+
+QUnit.module('My Module', {
+ beforeEach() {
+ this.data = {
+ records: {
+ 'my.model': [{id: 1, name: 'Test'}],
+ },
+ };
+ },
+}, function () {
+ QUnit.test('my test', async function (assert) {
+ // Test code here
+ });
+});
+```
+
+---
+
+## Integration Tests (Tours)
+
+Tours simulate real user scenarios in the browser.
+
+### Tour Structure
+
+```
+your_module/
+├── static/
+│ └── tests/
+│ └── tours/
+│ └── my_tour.js
+├── tests/
+│ ├── __init__.py
+│ └── test_my_tour.py
+└── __manifest__.py
+```
+
+### Register Tour (JavaScript)
+
+```javascript
+import tour from 'web_tour.tour';
+
+tour.register('my_tour', {
+ url: '/web',
+}, [
+ // Step 1: Show apps menu
+ tour.stepUtils.showAppsMenuItem(),
+ // Step 2: Click on app
+ {
+ trigger: '.o_app[data-menu-xmlid="my_module.menu_root"]',
+ run: "click",
+ },
+ // Step 3: Fill form
+ {
+ trigger: 'input[name="name"]',
+ run: "text",
+ },
+ // Step 4: Verify
+ {
+ trigger: '.o_data_row:first',
+ run: function () {
+ // Assertions here
+ },
+ },
+]);
+```
+
+### Add to Manifest
+
+```python
+'assets': {
+ 'web.assets_tests': [
+ 'your_module/static/tests/tours/my_tour.js',
+ ],
+},
+```
+
+### Start Tour (Python)
+
+```python
+from odoo.tests import HttpCase
+
+class TestMyTour(HttpCase):
+ def test_my_tour(self):
+ self.start_tour("/web", "my_tour", login="admin")
+```
+
+### Tour Step Options
+
+| Option | Description |
+|--------|-------------|
+| `trigger` | Selector/element to run action on |
+| `run` | Action to perform (see helpers below) |
+| `isActive` | Array of conditions (mobile, enterprise, auto/manual) |
+| `content` | Tooltip content |
+| `tooltipPosition` | `top`, `right`, `bottom`, or `left` |
+| `timeout` | Wait time in ms (default: 10000) |
+
+### Run Actions
+
+| Action | Description |
+|--------|-------------|
+| `click` | Clicks the element |
+| `dblclick` | Double-clicks the element |
+| `drag_and_drop {target}` | Drags to target |
+| `edit {content}` | Clears and fills |
+| `editor {content}` | WYSIWYG editor fill |
+| `fill {content}` | Fills the element |
+| `hover` | Hovers over element |
+| `press {content}` | Keyboard input |
+| `range {content}` | Range slider value |
+| `select {value}` | Select by value |
+| `selectByIndex {index}` | Select by index |
+| `selectByLabel {label}` | Select by label |
+| `check` | Checks checkbox |
+| `uncheck` | Unchecks checkbox |
+| `clear` | Clears input |
+
+### Run Tour from Browser
+
+```javascript
+odoo.startTour("tour_name");
+```
+
+Or use `?debug=tests` URL parameter.
+
+### Debug Tours
+
+```python
+# Watch mode (opens Chrome window)
+self.start_tour("/web", "my_tour", watch=True)
+
+# Debug mode (opens devtools)
+self.start_tour("/web", "my_tour", debug=True)
+```
+
+### Onboarding Tours
+
+Onboarding tours are interactive guides for users.
+
+Create `web_tour.tour` record:
+
+```xml
+
+ my_tour
+ 10
+ Great job!
+
+```
+
+Add to manifest:
+
+```python
+'data': [
+ 'data/my_tour.xml',
+],
+'assets': {
+ 'web.assets_backend': [
+ 'your_module/static/src/js/tours/my_tour.js',
+ ],
+},
+```
+
+---
+
+## Performance Testing
+
+### Query Count Testing
+
+Use `assertQueryCount` to establish query limits:
+
+```python
+with self.assertQueryCount(11):
+ do_something()
+```
+
+### Per-System Limits
+
+```python
+with self.assertQueryCount(__system__=1211):
+ do_something()
+```
+
+---
+
+## Running Tests
+
+Enable tests when starting Odoo:
+
+```bash
+# Enable tests
+odoo-bin --test-enable
+
+# With specific tags
+odoo-bin --test-tags post_install
+
+# Specific module
+odoo-bin -i my_module --test-enable
+
+# Update and test
+odoo-bin -u my_module --test-enable
+```
+
+---
+
+## Screenshot and Screencast
+
+When `HttpCase.browser_js` tests fail:
+
+- Screenshot saved to: `/tmp/odoo_tests/{db_name}/screenshots/`
+
+CLI options:
+
+```bash
+odoo-bin --screenshots /tmp/screenshots
+odoo-bin --screencasts /tmp/screencasts
+```
+
+---
+
+## Special Tags Reference
+
+| Tag | Description |
+|-----|-------------|
+| `standard` | Default tag for BaseCase subclasses |
+| `at_install` | Run after module installation (default) |
+| `post_install` | Run after all modules installed |
+| `-standard` | Remove from default |
+| `-at_install` | Don't run at install (use with `post_install`) |
+
+---
+
+## References
+
+- Source: Odoo 19 documentation `/doc/developer/reference/backend/testing.rst`
diff --git a/.agents/skills/odoo-19/references/odoo-19-transaction-guide.md b/.agents/skills/odoo-19/references/odoo-19-transaction-guide.md
new file mode 100644
index 00000000..41587bc4
--- /dev/null
+++ b/.agents/skills/odoo-19/references/odoo-19-transaction-guide.md
@@ -0,0 +1,261 @@
+# Odoo 19 Transaction Guide
+
+Guide for handling database transactions in Odoo 19: errors, savepoints, and serialization failures.
+
+## Table of Contents
+- [Transaction Overview](#transaction-overview)
+- [Database Errors](#database-errors)
+- [Savepoints](#savepoints)
+- [Error Handling](#error-handling)
+- [Serialization Failures](#serialization-failures)
+- [Best Practices](#best-practices)
+
+---
+
+## Transaction Overview
+
+Odoo uses database transactions to ensure data consistency.
+
+### Transaction Properties
+
+| Property | Description |
+|-----------|-------------|
+| **Atomicity** | All or nothing |
+| **Consistency** | Data remains valid |
+| **Isolation** | Concurrent transactions don't interfere |
+| **Durability** | Committed data persists |
+
+### Transaction Flow
+
+```
+Begin Transaction
+├── Execute Operations
+├── (Commit or Rollback)
+└── End Transaction
+```
+
+---
+
+## Database Errors
+
+### Common Errors
+
+| Error | When |
+|-------|------|
+| `UniqueViolation` | Duplicate unique constraint |
+| `NotNullViolation` | NULL in NOT NULL column |
+| `ForeignKeyViolation` | Invalid foreign key |
+| `CheckViolation` | CHECK constraint failed |
+| `SerializationFailure` | Concurrent modification |
+
+### Catch Database Errors
+
+```python
+from odoo.exceptions import ValidationError, UserError
+from psycopg2 import errors
+
+try:
+ record.write({'field': 'value'})
+except errors.UniqueViolation as e:
+ raise ValidationError("Duplicate value!")
+except errors.NotNullViolation as e:
+ raise ValidationError("Required field missing!")
+```
+
+---
+
+## Savepoints
+
+Savepoints isolate errors within a transaction.
+
+### Using Savepoints
+
+```python
+def process_records(self):
+ for record in self:
+ # Create savepoint before each record
+ self.env.cr.execute("SAVEPOINT my_savepoint")
+
+ try:
+ record.process()
+ except Exception as e:
+ # Rollback to savepoint on error
+ self.env.cr.execute("ROLLBACK TO SAVEPOINT my_savepoint")
+ _logger.warning("Failed to process %s: %s", record, e)
+```
+
+### Release Savepoint
+
+```python
+try:
+ record.process()
+finally:
+ # Release savepoint
+ self.env.cr.execute("RELEASE SAVEPOINT my_savepoint")
+```
+
+---
+
+## Error Handling
+
+### Retry on Serialization Failure
+
+```python
+from odoo.exceptions import UserError
+from psycopg2 import OperationalError
+
+def retry_on_failure(max_retries=3):
+ def decorator(func):
+ def wrapper(self, *args, **kwargs):
+ for attempt in range(max_retries):
+ try:
+ return func(self, *args, **kwargs)
+ except OperationalError as e:
+ if e.pgcode == '40001': # Serialization failure
+ if attempt < max_retries - 1:
+ self.env.cr.rollback()
+ self.env.cr.execute("SAVEPOINT retry_savepoint")
+ continue
+ raise
+ return wrapper
+ return decorator
+```
+
+### Handle Validation Errors
+
+```python
+from odoo.exceptions import ValidationError
+
+@api.constrains('email')
+def _check_email(self):
+ for record in self:
+ if not tools.email_validation(record.email):
+ raise ValidationError("Invalid email: %s" % record.email)
+```
+
+---
+
+## Serialization Failures
+
+### What is Serialization Failure?
+
+Occurs when two transactions try to modify the same data concurrently.
+
+### Avoid Serialization Failures
+
+```python
+# BAD: Loop with search and write
+def process(self):
+ for record in self.search([('state', '=', 'draft')]):
+ record.write({'state': 'done'})
+
+# GOOD: Single write
+def process(self):
+ self.search([('state', '=', 'draft')]).write({'state': 'done'})
+```
+
+### Use SQL FOR UPDATE
+
+```python
+self.env.cr.execute("SELECT id FROM my_model WHERE id IN %s FOR UPDATE", (tuple(self.ids),))
+# Process records
+```
+
+---
+
+## Commit and Rollback
+
+### Auto Commit
+
+Odoo auto-commits after successful operations.
+
+```python
+# Transaction is auto-committed
+record.write({'field': 'value'})
+```
+
+### Manual Rollback
+
+```python
+try:
+ # Multiple operations
+ record1.write({'field': 'value'})
+ record2.write({'field': 'value'})
+except Exception as e:
+ # Rollback entire transaction
+ self.env.cr.rollback()
+ raise
+```
+
+---
+
+## Best Practices
+
+### Batch Operations
+
+```python
+# GOOD: Batch create
+def create_records(self, values_list):
+ return self.create(values_list)
+
+# BAD: Create in loop
+def create_records(self, values_list):
+ for values in values_list:
+ self.create(values)
+```
+
+### Use Context for Special Cases
+
+```python
+# Skip tracking for bulk update
+records.with_context(tracking_disable=True).write({'field': 'value'})
+```
+
+### Validate Before Writing
+
+```python
+@api.constrains('email')
+def _check_email(self):
+ # Validate before write
+ for record in self:
+ if not tools.email_validation(record.email):
+ raise ValidationError("Invalid email")
+```
+
+---
+
+## Common Patterns
+
+### Safe Update Pattern
+
+```python
+def safe_update(self, values):
+ try:
+ self.write(values)
+ except errors.UniqueViolation:
+ raise UserError("Duplicate entry!")
+ except errors.NotNullViolation:
+ raise UserError("Required field missing!")
+```
+
+### Bulk Processing with Savepoints
+
+```python
+def bulk_process(self, records):
+ for record in records:
+ self.env.cr.execute("SAVEPOINT process_savepoint")
+ try:
+ record.process()
+ except Exception as e:
+ self.env.cr.execute("ROLLBACK TO SAVEPOINT process_savepoint")
+ _logger.warning("Failed: %s", e)
+ finally:
+ self.env.cr.execute("RELEASE SAVEPOINT process_savepoint")
+```
+
+---
+
+## References
+
+- PostgreSQL documentation on transactions
+- Odoo 19 ORM documentation
diff --git a/.agents/skills/odoo-19/references/odoo-19-translation-guide.md b/.agents/skills/odoo-19/references/odoo-19-translation-guide.md
new file mode 100644
index 00000000..eb551e64
--- /dev/null
+++ b/.agents/skills/odoo-19/references/odoo-19-translation-guide.md
@@ -0,0 +1,328 @@
+# Odoo 19 Translation Guide
+
+Guide for adding translations and localization in Odoo 19: Python, JavaScript, and QWeb templates.
+
+## Table of Contents
+
+- [Translation Overview](#translation-overview)
+- [Python Translations](#python-translations)
+- [JavaScript Translations](#javascript-translations)
+- [QWeb Translations](#qweb-translations)
+- [Translated Fields](#translated-fields)
+- [Export/Import](#exportimport)
+- [Languages](#languages)
+
+---
+
+## Translation Overview
+
+Odoo supports multi-language through:
+
+- Python `_()` function
+- JavaScript `_t()` function
+- Translatable fields
+- QWeb translation mechanisms
+
+### Supported File Formats
+
+| Format | Use |
+| ------ | ----------------------------- |
+| `.po` | Portable Object (main format) |
+| `.pot` | Portable Object Template |
+| `.csv` | For some data imports |
+
+---
+
+## Python Translations
+
+### Basic Translation
+
+```python
+from odoo import _
+
+def my_method(self):
+ message = _("Hello World")
+ return message
+```
+
+### Translation with Parameters
+
+```python
+# Old style (still works)
+message = _("Hello %s") % name
+
+# New style (recommended)
+message = _("Hello %(name)s") % {'name': name}
+```
+
+### Lazy Translation
+
+```python
+from odoo import _
+
+# Lazy translation (evaluated when displayed, not when imported)
+ERROR_MESSAGE = _("Error occurred")
+
+def my_method(self):
+ # ERROR_MESSAGE is translated when needed
+ return {'error': ERROR_MESSAGE}
+```
+
+### Multi-Line Translation
+
+```python
+message = _(
+ "This is a long message "
+ "that spans multiple lines"
+)
+```
+
+### Context Translation
+
+```python
+# Provide context for translators
+message = _("Cancel", default_code="refund_cancel")
+```
+
+---
+
+## JavaScript Translations
+
+### Basic Translation
+
+```javascript
+import { _t } from "@web/core/l10n/translation";
+
+const message = _t("Hello World");
+```
+
+### Translation with Parameters
+
+```javascript
+const message = _t("Hello %(name)s", { name: "John" });
+```
+
+### Lazy Translation
+
+```javascript
+import { lazyTranslation } from "@web/core/l10n/translation";
+
+const lt = lazyTranslation(() => _t("Error occurred"));
+```
+
+### Class Translation
+
+```javascript
+import { _lt } from "@web/core/l10n/translation";
+
+class MyClass {
+ errorMessage = _lt("Error occurred");
+}
+```
+
+---
+
+## QWeb Translations
+
+### Translate Static Text
+
+```xml
+
+ Hello World
+
+```
+
+### Translate in Code
+
+```xml
+
+
+
+```
+
+### Translate Field Content
+
+```xml
+
+
+
+```
+
+### Translate Template Content
+
+```xml
+
+
+
This content is translatable
+
+
+```
+
+---
+
+## Translated Fields
+
+### Define Translated Field
+
+```python
+name = fields.Char(translate=True)
+description = fields.Text(translate=True)
+```
+
+### Translation Options
+
+```python
+# Enable translation
+name = fields.Char(translate=True)
+
+# Translation with context (Odoo 19+)
+notes = fields.Text(translate=True, translation_modifiable=True)
+```
+
+### Read Translated Field
+
+```python
+# Record is fetched in user's language
+record = self.env['my.model'].browse(record_id)
+print(record.name) # Translated name
+```
+
+### Read in Specific Language
+
+```python
+# Read in French
+record_fr = record.with_context(lang='fr_FR')
+print(record_fr.name) # French name
+```
+
+---
+
+## Export/Import
+
+### Export Translations
+
+**Via CLI**:
+
+```bash
+odoo-bin -d mydb -l fr --i18n-export=fr --stop-after-init
+```
+
+**Via UI**:
+
+1. Settings → Translations → Export Translations
+2. Select language
+3. Choose file format (PO)
+4. Export
+
+### Import Translations
+
+**Via CLI**:
+
+```bash
+odoo-bin -d mydb -l fr --i18n-import=/path/to/fr.po --stop-after-init
+```
+
+**Via UI**:
+
+1. Settings → Translations → Import Translations
+2. Select language
+3. Upload PO file
+4. Import
+
+### Update Translations
+
+**Via CLI**:
+
+```bash
+odoo-bin -d mydb -l fr --i18n-overwrite --stop-after-init
+```
+
+---
+
+## Languages
+
+### Install Language
+
+```python
+# Load language
+language = self.env['res.lang'].load_lang(self.env.cr, self._uid, 'fr_FR')
+```
+
+### Available Languages
+
+```python
+languages = self.env['res.lang'].search([])
+for lang in languages:
+ print(lang.code, lang.name)
+```
+
+### Get User Language
+
+```python
+user_lang = self.env.user.lang
+context_lang = self.env.context.get('lang', 'en_US')
+```
+
+---
+
+## Translation Best Practices
+
+### Always Use Translation Functions
+
+```python
+# GOOD
+message = _("Hello World")
+
+# BAD
+message = "Hello World"
+```
+
+### Use Parameters for Dynamic Content
+
+```python
+# GOOD
+message = _("Hello %(name)s") % {'name': name}
+
+# BAD
+message = _("Hello ") + name
+```
+
+### Provide Context When Needed
+
+```python
+# GOOD (with context)
+message = _("Cancel", default_code="refund_cancel")
+
+# BAD (ambiguous)
+message = _("Cancel")
+```
+
+### Don't Concatenate Translations
+
+```python
+# BAD
+message = _("Hello ") + name + _("!")
+
+# GOOD
+message = _("Hello %(name)s!") % {'name': name}
+```
+
+---
+
+## Common Translation Terms
+
+| English | French | German | Spanish |
+| ------- | ----------- | ---------- | -------- |
+| Save | Enregistrer | Speichern | Guardar |
+| Cancel | Annuler | Abbrechen | Cancelar |
+| Delete | Supprimer | Löschen | Eliminar |
+| Edit | Modifier | Bearbeiten | Editar |
+| Create | Créer | Erstellen | Crear |
+| Search | Rechercher | Suchen | Buscar |
+
+---
+
+## References
+
+- Odoo 19 translation documentation
+- GNU gettext documentation
diff --git a/.agents/skills/odoo-19/references/odoo-19-view-guide.md b/.agents/skills/odoo-19/references/odoo-19-view-guide.md
new file mode 100644
index 00000000..9e44aaaa
--- /dev/null
+++ b/.agents/skills/odoo-19/references/odoo-19-view-guide.md
@@ -0,0 +1,454 @@
+# Odoo 19 View Guide
+
+Guide for creating views in Odoo 19: list, form, search, kanban, calendar, graph, pivot, and QWeb templates.
+
+## Table of Contents
+
+- [View Types](#view-types)
+- [List Views](#list-views)
+- [Form Views](#form-views)
+- [Search Views](#search-views)
+- [Kanban Views](#kanban-views)
+- [Calendar Views](#calendar-views)
+- [Graph Views](#graph-views)
+- [Pivot Views](#pivot-views)
+- [View Inheritance](#view-inheritance)
+- [QWeb Templates](#qweb-templates)
+
+---
+
+## View Types
+
+| Type | Tag | Description |
+| -------- | ------------ | ------------------------------ |
+| List | `` | Table view (formerly ``) |
+| Form | `