Files
Autoparts-DB/pos/tests/test_service_order_integration.py
consultoria-as ce66212223 feat(pos/workshop): add lightweight workshop/taller module
- Add DB migration v4.4_workshop.sql (sale_id, service_catalog,
  reserved_quantity, SO_RESERVE/SO_RELEASE operation types).
- Extend service_order_engine with inventory reservation, release,
  convert-to-sale, mechanic assignment, and service catalog CRUD.
- Extend service_order_bp with /reserve, /convert-to-sale,
  /assign-mechanic, and /service-catalog endpoints.
- Create workshop Kanban UI: workshop.html, workshop.js, workshop.css.
- Add /pos/workshop route and sidebar navigation (sidebar.js + inline
  templates).
- Add 11 unit tests with mocked cursors.
- Update FASES_IMPLEMENTADAS.md with FASE 9 documentation.

Tests: 92 passing (61 console + 20 Facturapi + 11 workshop).
2026-06-15 05:34:35 +00:00

239 lines
6.8 KiB
Python

"""Unit tests for service order workshop integration.
These tests use mocked DB cursors and inventory_engine so they do not require
PostgreSQL or network access.
"""
import os
import sys
POS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, POS_DIR)
from unittest import mock # noqa: E402
import pytest # noqa: E402
from services import service_order_engine as engine # noqa: E402
class MockCursor:
"""Simple programmable cursor mock."""
def __init__(self, responses=None):
self.responses = responses or []
self._calls = []
self._response_index = 0
def execute(self, sql, params=None):
self._calls.append((sql, params))
def fetchone(self):
if self._response_index < len(self.responses):
resp = self.responses[self._response_index]
self._response_index += 1
return resp
return None
def fetchall(self):
if self._response_index < len(self.responses):
resp = self.responses[self._response_index]
self._response_index += 1
return resp
return []
def close(self):
pass
class MockConn:
def __init__(self, cursor):
self._cursor = cursor
def cursor(self):
return self._cursor
def commit(self):
pass
def rollback(self):
pass
@pytest.fixture
def conn():
return MockConn(MockCursor())
def test_generate_order_number_first_of_year(conn):
conn._cursor.responses = [(None,)]
number = engine._generate_order_number(conn)
assert number.startswith("SO-")
assert number.endswith("-0001")
def test_generate_order_number_increments(conn):
conn._cursor.responses = [("SO-2026-0042",)]
number = engine._generate_order_number(conn)
assert number.endswith("-0043")
@mock.patch("services.inventory_engine.get_stock", return_value=10)
@mock.patch("services.inventory_engine.record_operation", return_value=123)
def test_reserve_item_inserts_so_reserve_and_updates_quantity(mock_record, mock_stock, conn):
conn._cursor.responses = [
(1, 5, 3, "pending", "SO-2026-0001"), # item lookup
None, # update
]
result = engine.reserve_item(conn, 7, branch_id=2, employee_id=9)
assert result["reserved"] == 3
mock_stock.assert_called_once_with(conn, 5, 2)
mock_record.assert_called_once()
args = mock_record.call_args.args
assert args[3] == "SO_RESERVE"
assert args[4] == -3
@mock.patch("services.inventory_engine.get_stock", return_value=1)
def test_reserve_item_raises_when_insufficient_stock(mock_stock, conn):
conn._cursor.responses = [
(1, 5, 3, "pending", "SO-2026-0001"),
]
with pytest.raises(ValueError, match="Insufficient stock"):
engine.reserve_item(conn, 7, branch_id=2)
@mock.patch("services.inventory_engine.record_operation", return_value=124)
def test_release_item_restores_stock(mock_record, conn):
conn._cursor.responses = [
(1, 5, 2, 2, "SO-2026-0001"), # item lookup (reserved_quantity=2)
None, # update
]
result = engine.release_item(conn, 7, employee_id=9)
assert result["released"] == 2
args = mock_record.call_args.args
assert args[3] == "SO_RELEASE"
assert args[4] == 2
@mock.patch("services.inventory_engine.record_operation", return_value=125)
def test_convert_to_sale_creates_sale_and_consumes_inventory(mock_record, conn):
# Mock get_service_order response
so = {
"id": 1,
"order_number": "SO-2026-0001",
"status": "ready",
"sale_id": None,
"branch_id": 2,
"customer_id": 3,
"items": [
{
"id": 10,
"inventory_id": 5,
"part_number": "BP-123",
"name": "Bujia",
"quantity": 2,
"unit_price": 150.0,
"unit_cost": 80.0,
"status": "pending",
"reserved_quantity": 2,
}
],
"labor": [
{
"description": "Cambio de bujias",
"hours": 1,
"hourly_rate": 250,
"total_cost": 250,
"status": "completed",
}
],
}
cur = conn._cursor
cur.responses = [
(1, "2026-01-01 10:00:00"), # sale insert -> id=1
]
with mock.patch.object(engine, "get_service_order", return_value=so):
result = engine.convert_to_sale(conn, 1, {"payment_method": "efectivo", "sale_type": "cash"}, employee_id=9)
assert result["sale_id"] == 1
assert result["items_count"] == 2
assert result["total"] == pytest.approx(2 * 150 * 1.16 + 250 * 1.16, 0.01)
# inventory operations: SO_RELEASE + SALE
assert mock_record.call_count == 2
first = mock_record.call_args_list[0].args
second = mock_record.call_args_list[1].args
assert first[3] == "SO_RELEASE"
assert first[4] == 2
assert second[3] == "SALE"
assert second[4] == -2
def test_convert_to_sale_raises_when_already_converted(conn):
so = {"status": "ready", "sale_id": 99, "branch_id": 1, "customer_id": 1, "items": [], "labor": []}
conn._cursor.responses = [(1,)]
with (
mock.patch.object(engine, "get_service_order", return_value=so),
pytest.raises(ValueError, match="already converted"),
):
engine.convert_to_sale(conn, 1, {})
def test_convert_to_sale_raises_when_cancelled(conn):
so = {"status": "cancelled", "sale_id": None, "branch_id": 1, "customer_id": 1, "items": [], "labor": []}
conn._cursor.responses = [(1,)]
with (
mock.patch.object(engine, "get_service_order", return_value=so),
pytest.raises(ValueError, match="cancelled"),
):
engine.convert_to_sale(conn, 1, {})
def test_assign_mechanic_updates_employee(conn):
conn._cursor.responses = [(1,), None]
result = engine.assign_mechanic(conn, 1, 7)
assert result["employee_id"] == 7
def test_assign_mechanic_raises_when_order_missing(conn):
conn._cursor.responses = [None]
with pytest.raises(ValueError, match="not found"):
engine.assign_mechanic(conn, 1, 7)
def test_service_catalog_crud(conn):
# create
conn._cursor.responses = [(1,)]
result = engine.create_service_catalog_item(
conn, 1, {"name": "Afinacion", "suggested_hours": 2, "suggested_rate": 300}
)
assert result["id"] == 1
# list
conn._cursor = MockCursor(
[
[(1, 1, "Afinacion", "", 2, 300, True, None, None)],
]
)
items = engine.list_service_catalog(conn)
assert len(items) == 1
assert items[0]["name"] == "Afinacion"
# update
conn._cursor = MockCursor([None])
ok = engine.update_service_catalog_item(conn, 1, {"name": "Afinacion mayor"})
assert ok is True
# delete
conn._cursor = MockCursor([None])
ok = engine.delete_service_catalog_item(conn, 1)
assert ok is True