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:
Benju1
2025-12-05 06:08:10 +01:00
parent 2035da88ce
commit b059d9c40e
4 changed files with 341 additions and 5 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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":

View 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}