mirror of
https://github.com/latinogino/dolibarr-mcp.git
synced 2026-04-21 17:52:39 +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
|
||||
|
||||
It is recommended to use a virtual environment to avoid conflicts with system packages (especially on Linux systems with externally managed environments).
|
||||
|
||||
```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
|
||||
pip install -e .`[dev`]
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Run the test suite
|
||||
|
||||
Once your virtual environment is active:
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
@@ -28,6 +35,12 @@ To gather coverage metrics:
|
||||
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
|
||||
|
||||
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]:
|
||||
"""Delete an invoice."""
|
||||
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
|
||||
|
||||
@@ -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
|
||||
Tool(
|
||||
name="get_orders",
|
||||
@@ -1099,6 +1260,40 @@ async def handle_call_tool(name: str, arguments: dict):
|
||||
|
||||
elif name == "delete_invoice":
|
||||
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
|
||||
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