diff --git a/docs/development.md b/docs/development.md index 6262687..bed6fb1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 diff --git a/src/dolibarr_mcp/dolibarr_client.py b/src/dolibarr_mcp/dolibarr_client.py index ae491e2..3d1c8f7 100644 --- a/src/dolibarr_mcp/dolibarr_client.py +++ b/src/dolibarr_mcp/dolibarr_client.py @@ -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 diff --git a/src/dolibarr_mcp/dolibarr_mcp_server.py b/src/dolibarr_mcp/dolibarr_mcp_server.py index 9ec235a..a7b90fe 100644 --- a/src/dolibarr_mcp/dolibarr_mcp_server.py +++ b/src/dolibarr_mcp/dolibarr_mcp_server.py @@ -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": diff --git a/tests/test_invoice_atomic.py b/tests/test_invoice_atomic.py new file mode 100644 index 0000000..902bd2c --- /dev/null +++ b/tests/test_invoice_atomic.py @@ -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}