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).
This commit is contained in:
238
pos/tests/test_service_order_integration.py
Normal file
238
pos/tests/test_service_order_integration.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user