mirror of
https://github.com/latinogino/dolibarr-mcp.git
synced 2026-05-01 13:55:35 +02:00
feat(invoice): implement atomic invoice tools (REFACTOR-3)
- Add create_invoice_draft, add_invoice_line, update_invoice_line, delete_invoice_line, set_invoice_project, validate_invoice tools - Update DolibarrClient with corresponding methods - Add tests for atomic invoice operations - Update development docs with venv instructions
This commit is contained in:
@@ -6,18 +6,25 @@ kept separate in `docker/`.
|
|||||||
|
|
||||||
## Install development dependencies
|
## Install development dependencies
|
||||||
|
|
||||||
|
It is recommended to use a virtual environment to avoid conflicts with system packages (especially on Linux systems with externally managed environments).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -e '.[dev]'
|
# Create a virtual environment
|
||||||
```
|
python3 -m venv .venv
|
||||||
|
|
||||||
### Windows PowerShell
|
# Activate the virtual environment
|
||||||
|
source .venv/bin/activate # On Linux/macOS
|
||||||
|
# .venv\Scripts\activate # On Windows
|
||||||
|
|
||||||
```powershell
|
# Install dependencies
|
||||||
pip install -e .`[dev`]
|
pip install -r requirements.txt
|
||||||
|
pip install -e .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run the test suite
|
## Run the test suite
|
||||||
|
|
||||||
|
Once your virtual environment is active:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest
|
pytest
|
||||||
```
|
```
|
||||||
@@ -28,6 +35,12 @@ To gather coverage metrics:
|
|||||||
pytest --cov=src/dolibarr_mcp --cov-report=term-missing
|
pytest --cov=src/dolibarr_mcp --cov-report=term-missing
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you encounter "command not found" errors, ensure your virtual environment is activated or run via python module:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m pytest
|
||||||
|
```
|
||||||
|
|
||||||
## Formatting and linting
|
## Formatting and linting
|
||||||
|
|
||||||
The project intentionally avoids heavy linting dependencies. Follow the coding
|
The project intentionally avoids heavy linting dependencies. Follow the coding
|
||||||
|
|||||||
@@ -430,6 +430,44 @@ class DolibarrClient:
|
|||||||
async def delete_invoice(self, invoice_id: int) -> Dict[str, Any]:
|
async def delete_invoice(self, invoice_id: int) -> Dict[str, Any]:
|
||||||
"""Delete an invoice."""
|
"""Delete an invoice."""
|
||||||
return await self.request("DELETE", f"invoices/{invoice_id}")
|
return await self.request("DELETE", f"invoices/{invoice_id}")
|
||||||
|
|
||||||
|
async def add_invoice_line(
|
||||||
|
self,
|
||||||
|
invoice_id: int,
|
||||||
|
data: Optional[Dict[str, Any]] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Add a line to an invoice."""
|
||||||
|
payload = self._merge_payload(data, **kwargs)
|
||||||
|
|
||||||
|
# Map product_id to fk_product if present
|
||||||
|
if "product_id" in payload:
|
||||||
|
payload["fk_product"] = payload.pop("product_id")
|
||||||
|
|
||||||
|
return await self.request("POST", f"invoices/{invoice_id}/lines", data=payload)
|
||||||
|
|
||||||
|
async def update_invoice_line(
|
||||||
|
self,
|
||||||
|
invoice_id: int,
|
||||||
|
line_id: int,
|
||||||
|
data: Optional[Dict[str, Any]] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Update a line in an invoice."""
|
||||||
|
payload = self._merge_payload(data, **kwargs)
|
||||||
|
return await self.request("PUT", f"invoices/{invoice_id}/lines/{line_id}", data=payload)
|
||||||
|
|
||||||
|
async def delete_invoice_line(self, invoice_id: int, line_id: int) -> Dict[str, Any]:
|
||||||
|
"""Delete a line from an invoice."""
|
||||||
|
return await self.request("DELETE", f"invoices/{invoice_id}/lines/{line_id}")
|
||||||
|
|
||||||
|
async def validate_invoice(self, invoice_id: int, warehouse_id: int = 0, not_trigger: int = 0) -> Dict[str, Any]:
|
||||||
|
"""Validate an invoice."""
|
||||||
|
payload = {
|
||||||
|
"idwarehouse": warehouse_id,
|
||||||
|
"not_trigger": not_trigger
|
||||||
|
}
|
||||||
|
return await self.request("POST", f"invoices/{invoice_id}/validate", data=payload)
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# ORDER MANAGEMENT
|
# ORDER MANAGEMENT
|
||||||
|
|||||||
@@ -596,6 +596,167 @@ async def handle_list_tools():
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
|
Tool(
|
||||||
|
name="create_invoice_draft",
|
||||||
|
description=(
|
||||||
|
"Create a new invoice draft (header only). "
|
||||||
|
"Use this to start a new invoice, then use add_invoice_line to add items. "
|
||||||
|
"Returns the new invoice_id."
|
||||||
|
),
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"customer_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Customer ID (Dolibarr socid)",
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Invoice date (YYYY-MM-DD)",
|
||||||
|
},
|
||||||
|
"project_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Linked project ID (optional)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["customer_id", "date"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="add_invoice_line",
|
||||||
|
description="Add a line item to an existing draft invoice.",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"invoice_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Invoice ID",
|
||||||
|
},
|
||||||
|
"desc": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Line description",
|
||||||
|
},
|
||||||
|
"qty": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Quantity",
|
||||||
|
},
|
||||||
|
"subprice": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Unit price (net)",
|
||||||
|
},
|
||||||
|
"product_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Product ID (optional)",
|
||||||
|
},
|
||||||
|
"product_type": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Type (0=Product, 1=Service)",
|
||||||
|
"default": 0,
|
||||||
|
},
|
||||||
|
"vat": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "VAT rate (optional)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["invoice_id", "desc", "qty", "subprice"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="update_invoice_line",
|
||||||
|
description="Update an existing line in a draft invoice.",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"invoice_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Invoice ID",
|
||||||
|
},
|
||||||
|
"line_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Line ID to update",
|
||||||
|
},
|
||||||
|
"desc": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "New description",
|
||||||
|
},
|
||||||
|
"qty": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "New quantity",
|
||||||
|
},
|
||||||
|
"subprice": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "New unit price",
|
||||||
|
},
|
||||||
|
"vat": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "New VAT rate",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["invoice_id", "line_id"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="delete_invoice_line",
|
||||||
|
description="Delete a line from a draft invoice.",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"invoice_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Invoice ID",
|
||||||
|
},
|
||||||
|
"line_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Line ID to delete",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["invoice_id", "line_id"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="set_invoice_project",
|
||||||
|
description="Link an invoice to a project.",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"invoice_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Invoice ID",
|
||||||
|
},
|
||||||
|
"project_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Project ID",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["invoice_id", "project_id"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="validate_invoice",
|
||||||
|
description="Validate a draft invoice (change status to unpaid).",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"invoice_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Invoice ID",
|
||||||
|
},
|
||||||
|
"warehouse_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Warehouse ID for stock decrease (optional)",
|
||||||
|
"default": 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["invoice_id"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
# Order Management CRUD
|
# Order Management CRUD
|
||||||
Tool(
|
Tool(
|
||||||
name="get_orders",
|
name="get_orders",
|
||||||
@@ -1099,6 +1260,40 @@ async def handle_call_tool(name: str, arguments: dict):
|
|||||||
|
|
||||||
elif name == "delete_invoice":
|
elif name == "delete_invoice":
|
||||||
result = await client.delete_invoice(arguments['invoice_id'])
|
result = await client.delete_invoice(arguments['invoice_id'])
|
||||||
|
|
||||||
|
elif name == "create_invoice_draft":
|
||||||
|
# Map customer_id to socid for the API
|
||||||
|
if "customer_id" in arguments:
|
||||||
|
arguments["socid"] = arguments.pop("customer_id")
|
||||||
|
|
||||||
|
# Map project_id to fk_project if present
|
||||||
|
if "project_id" in arguments:
|
||||||
|
arguments["fk_project"] = arguments.pop("project_id")
|
||||||
|
|
||||||
|
result = await client.create_invoice(**arguments)
|
||||||
|
|
||||||
|
elif name == "add_invoice_line":
|
||||||
|
invoice_id = arguments.pop("invoice_id")
|
||||||
|
result = await client.add_invoice_line(invoice_id, **arguments)
|
||||||
|
|
||||||
|
elif name == "update_invoice_line":
|
||||||
|
invoice_id = arguments.pop("invoice_id")
|
||||||
|
line_id = arguments.pop("line_id")
|
||||||
|
result = await client.update_invoice_line(invoice_id, line_id, **arguments)
|
||||||
|
|
||||||
|
elif name == "delete_invoice_line":
|
||||||
|
invoice_id = arguments.pop("invoice_id")
|
||||||
|
line_id = arguments.pop("line_id")
|
||||||
|
result = await client.delete_invoice_line(invoice_id, line_id)
|
||||||
|
|
||||||
|
elif name == "set_invoice_project":
|
||||||
|
invoice_id = arguments.pop("invoice_id")
|
||||||
|
project_id = arguments.pop("project_id")
|
||||||
|
result = await client.update_invoice(invoice_id, fk_project=project_id)
|
||||||
|
|
||||||
|
elif name == "validate_invoice":
|
||||||
|
invoice_id = arguments.pop("invoice_id")
|
||||||
|
result = await client.validate_invoice(invoice_id, **arguments)
|
||||||
|
|
||||||
# Order Management
|
# Order Management
|
||||||
elif name == "get_orders":
|
elif name == "get_orders":
|
||||||
|
|||||||
90
tests/test_invoice_atomic.py
Normal file
90
tests/test_invoice_atomic.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
from dolibarr_mcp.config import Config
|
||||||
|
from dolibarr_mcp.dolibarr_client import DolibarrClient
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestInvoiceAtomic:
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self):
|
||||||
|
config = Config(
|
||||||
|
dolibarr_url="https://test.dolibarr.com/api/index.php",
|
||||||
|
api_key="test_key"
|
||||||
|
)
|
||||||
|
return DolibarrClient(config)
|
||||||
|
|
||||||
|
@patch('aiohttp.ClientSession.request')
|
||||||
|
async def test_add_invoice_line(self, mock_request, client):
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.text.return_value = '123' # Returns line ID usually
|
||||||
|
mock_request.return_value.__aenter__.return_value = mock_response
|
||||||
|
|
||||||
|
async with client:
|
||||||
|
await client.add_invoice_line(
|
||||||
|
invoice_id=1,
|
||||||
|
desc="Test Line",
|
||||||
|
qty=1,
|
||||||
|
subprice=100,
|
||||||
|
product_id=99
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify call
|
||||||
|
args, kwargs = mock_request.call_args
|
||||||
|
assert args[0] == "POST"
|
||||||
|
assert args[1] == "https://test.dolibarr.com/api/index.php/invoices/1/lines"
|
||||||
|
assert kwargs['json'] == {
|
||||||
|
"desc": "Test Line",
|
||||||
|
"qty": 1,
|
||||||
|
"subprice": 100,
|
||||||
|
"fk_product": 99
|
||||||
|
}
|
||||||
|
|
||||||
|
@patch('aiohttp.ClientSession.request')
|
||||||
|
async def test_update_invoice_line(self, mock_request, client):
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.text.return_value = '{"success": 1}'
|
||||||
|
mock_request.return_value.__aenter__.return_value = mock_response
|
||||||
|
|
||||||
|
async with client:
|
||||||
|
await client.update_invoice_line(
|
||||||
|
invoice_id=1,
|
||||||
|
line_id=10,
|
||||||
|
qty=5
|
||||||
|
)
|
||||||
|
|
||||||
|
args, kwargs = mock_request.call_args
|
||||||
|
assert args[0] == "PUT"
|
||||||
|
assert args[1] == "https://test.dolibarr.com/api/index.php/invoices/1/lines/10"
|
||||||
|
assert kwargs['json'] == {"qty": 5}
|
||||||
|
|
||||||
|
@patch('aiohttp.ClientSession.request')
|
||||||
|
async def test_delete_invoice_line(self, mock_request, client):
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.text.return_value = '{"success": 1}'
|
||||||
|
mock_request.return_value.__aenter__.return_value = mock_response
|
||||||
|
|
||||||
|
async with client:
|
||||||
|
await client.delete_invoice_line(invoice_id=1, line_id=10)
|
||||||
|
|
||||||
|
args, kwargs = mock_request.call_args
|
||||||
|
assert args[0] == "DELETE"
|
||||||
|
assert args[1] == "https://test.dolibarr.com/api/index.php/invoices/1/lines/10"
|
||||||
|
|
||||||
|
@patch('aiohttp.ClientSession.request')
|
||||||
|
async def test_validate_invoice(self, mock_request, client):
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.text.return_value = '{"success": 1}'
|
||||||
|
mock_request.return_value.__aenter__.return_value = mock_response
|
||||||
|
|
||||||
|
async with client:
|
||||||
|
await client.validate_invoice(invoice_id=1, warehouse_id=5)
|
||||||
|
|
||||||
|
args, kwargs = mock_request.call_args
|
||||||
|
assert args[0] == "POST"
|
||||||
|
assert args[1] == "https://test.dolibarr.com/api/index.php/invoices/1/validate"
|
||||||
|
assert kwargs['json'] == {"idwarehouse": 5, "not_trigger": 0}
|
||||||
Reference in New Issue
Block a user