- 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).
239 lines
6.8 KiB
Python
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
|