194 lines
7.9 KiB
Python
194 lines
7.9 KiB
Python
from importlib.util import module_from_spec, spec_from_file_location
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
from app.work_center_action_policy import (
|
|
effective_action_code,
|
|
reconstructed_review_status,
|
|
)
|
|
|
|
|
|
def _ensure_real_sqlalchemy():
|
|
module = sys.modules.get("sqlalchemy")
|
|
if module is not None and not hasattr(module, "__path__"):
|
|
for key in list(sys.modules):
|
|
if key == "sqlalchemy" or key.startswith("sqlalchemy."):
|
|
sys.modules.pop(key, None)
|
|
|
|
|
|
def _normalise(items):
|
|
_ensure_real_sqlalchemy()
|
|
from app.operations_service import _normalise_work_item_intent
|
|
return _normalise_work_item_intent(items)
|
|
|
|
|
|
def _physical_status(sale, pickings, productions):
|
|
_ensure_real_sqlalchemy()
|
|
from app.odoo_service import _derive_physical_status
|
|
return _derive_physical_status(sale, pickings, productions)
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def _load_script():
|
|
_ensure_real_sqlalchemy()
|
|
path = ROOT / "scripts" / "apply_v132_operational_coherence.py"
|
|
spec = spec_from_file_location("apply_v132_operational_coherence", path)
|
|
module = module_from_spec(spec)
|
|
assert spec and spec.loader
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def test_title_sem_oportunidade_does_not_invent_association_blocker():
|
|
item = {
|
|
"action_code": "FOLLOW_UP_PAYMENT",
|
|
"opportunity_title": "Orçamento Jasmin sem oportunidade · ORC.1",
|
|
"opportunity_linking_status": "",
|
|
"opportunity_metadata": {},
|
|
}
|
|
result = _normalise([item])[0]
|
|
assert result["action_code"] == "FOLLOW_UP_PAYMENT"
|
|
|
|
|
|
def test_explicit_association_blocker_still_wins():
|
|
item = {
|
|
"action_code": "FOLLOW_UP_PAYMENT",
|
|
"opportunity_linking_status": "ambiguous",
|
|
"opportunity_metadata": {},
|
|
}
|
|
result = _normalise([item])[0]
|
|
assert result["action_code"] == "ASSOCIATE_OPPORTUNITY"
|
|
|
|
|
|
def test_explicit_reconstructed_review_blocks_physical_validation():
|
|
metadata = {
|
|
"clientflow_record_mode": "reconstructed_invoice_review",
|
|
"reconstructed_review_status": "required",
|
|
}
|
|
item = {
|
|
"action_code": "VALIDATE_PHYSICAL_ORDER",
|
|
"opportunity_metadata": metadata,
|
|
}
|
|
result = _normalise([item])[0]
|
|
assert result["action_code"] == "REVIEW_RECONSTRUCTED_PROCESS"
|
|
assert effective_action_code("VALIDATE_PHYSICAL_ORDER", metadata=metadata) == "REVIEW_RECONSTRUCTED_PROCESS"
|
|
|
|
|
|
def test_validated_reconstructed_review_does_not_reopen_from_title():
|
|
metadata = {
|
|
"clientflow_record_mode": "reconstructed_invoice_review",
|
|
"reconstructed_review_status": "validated",
|
|
}
|
|
item = {
|
|
"action_code": "SEND_INVOICE",
|
|
"opportunity_title": "Processo reconstruído · Cliente",
|
|
"opportunity_metadata": metadata,
|
|
}
|
|
result = _normalise([item])[0]
|
|
assert result["action_code"] == "SEND_INVOICE"
|
|
assert reconstructed_review_status(metadata) == "validated"
|
|
|
|
|
|
def test_board_prefers_pending_task_and_explicit_review():
|
|
source = (ROOT / "app" / "admin_ui" / "pages" / "opportunities.py").read_text(encoding="utf-8")
|
|
function = source[source.index("def _opportunity_card_next_action"):source.index("def _is_noise_opportunity")]
|
|
assert function.index("reconstructed_review_required(metadata)") < function.index("central = _central_next_action_for_card")
|
|
assert function.index("pending_primary_action_code") < function.index("central = _central_next_action_for_card")
|
|
assert 'return "Validar processo reconstruído"' in function
|
|
|
|
|
|
def test_odoo_assigned_is_reserved_not_ready_to_ship():
|
|
result = _physical_status(
|
|
{"state": "sale"},
|
|
[{"name": "WH/OUT/1", "state": "assigned", "picking_type_id": [2, "Delivery Orders"]}],
|
|
[],
|
|
)
|
|
assert result["physical_status"] == "picking_assigned"
|
|
assert result["stage"] == "ORDER_PREPARATION"
|
|
assert result["ready_to_ship"] is False
|
|
assert result["picking_reserved"] is True
|
|
|
|
|
|
def test_migration_plan_rewinds_assigned_and_requires_explicit_review():
|
|
module = _load_script()
|
|
opportunities = [{
|
|
"id": "opp-1",
|
|
"title": "Fwd cliente",
|
|
"customer_name": "NOLTIA SYSTEM, LDA",
|
|
"fiscal_name": "NOLTIA SYSTEM, LDA",
|
|
"stage": "SHIPMENT_CREATED",
|
|
"last_action_code": "VALIDATE_PHYSICAL_ORDER",
|
|
"metadata": {"clientflow_record_mode": "reconstructed_invoice_review"},
|
|
}]
|
|
links = [{
|
|
"opportunity_id": "opp-1", "system": "odoo", "external_type": "physical_status",
|
|
"status": "picking_assigned", "payload": {"physical_status": "picking_assigned", "pickings": [{"state": "assigned"}]},
|
|
}]
|
|
tasks = [{
|
|
"id": "task-1", "opportunity_id": "opp-1", "action_code": "VALIDATE_PHYSICAL_ORDER",
|
|
"status": "pending", "metadata": {},
|
|
}]
|
|
plans = module._plan_opportunities(opportunities, links, tasks, [])
|
|
assert len(plans) == 1
|
|
plan = plans[0]
|
|
assert plan.safe is True
|
|
assert plan.target_stage == "ORDER_PREPARATION"
|
|
assert plan.reconstructed_after == "required"
|
|
assert plan.task_operation == "convert_sensitive_to_review"
|
|
|
|
|
|
def test_completed_review_is_persisted_as_validated_not_reopened():
|
|
module = _load_script()
|
|
opportunities = [{
|
|
"id": "opp-2", "title": "Processo reconstruído", "customer_name": "RICARDO",
|
|
"fiscal_name": "RICARDO", "stage": "PAYMENT_CONFIRMED", "last_action_code": "SEND_INVOICE",
|
|
"metadata": {"clientflow_record_mode": "reconstructed_invoice_review", "reconstructed_review_required": True},
|
|
}]
|
|
tasks = [
|
|
{"id": "review-done", "opportunity_id": "opp-2", "action_code": "REVIEW_RECONSTRUCTED_PROCESS", "status": "done", "metadata": {}},
|
|
{"id": "invoice", "opportunity_id": "opp-2", "action_code": "SEND_INVOICE", "status": "pending", "metadata": {}},
|
|
]
|
|
plans = module._plan_opportunities(opportunities, [], tasks, [])
|
|
assert len(plans) == 1
|
|
assert plans[0].reconstructed_after == "validated"
|
|
assert plans[0].task_operation == "none"
|
|
|
|
|
|
def test_stale_candidate_requires_identity_unless_name_or_nif_matches():
|
|
module = _load_script()
|
|
items = [
|
|
{"id": "i1", "external_id": "318", "document_number": "S00318", "customer_name": "DUNAS INVENCÍVEIS, LDA", "customer_tax_id": "", "payload": {}},
|
|
{"id": "i2", "external_id": "323", "document_number": "S00323", "customer_name": "BBKW, LDA", "customer_tax_id": "", "payload": {}},
|
|
]
|
|
links = [
|
|
{"opportunity_id": "o1", "external_id": "318", "external_name": "S00318"},
|
|
{"opportunity_id": "o2", "external_id": None, "external_name": "S00323"},
|
|
]
|
|
opportunities = {
|
|
"o1": {"fiscal_name": "DUNAS INVENCÍVEIS, UNIPESSOAL, LDA", "customer_name": "", "title": "", "fiscal_tax_id": ""},
|
|
"o2": {"fiscal_name": "", "customer_name": "", "title": "", "fiscal_tax_id": ""},
|
|
}
|
|
plans = module._plan_candidates(items, links, opportunities, allow_unknown_identity=False)
|
|
by_ref = {plan.reference: plan for plan in plans}
|
|
assert by_ref["S00318"].safe is True
|
|
assert by_ref["S00323"].safe is False
|
|
assert by_ref["S00323"].reason == "identity_unknown_requires_review"
|
|
|
|
|
|
def test_reconciliation_no_longer_creates_validation_from_delivery_ready():
|
|
source = (ROOT / "app" / "reconciliation_service.py").read_text(encoding="utf-8")
|
|
assert 'if fulfilment.get("delivery_done"):' in source
|
|
assert 'if fulfilment.get("delivery_done") or fulfilment.get("delivery_ready")' not in source
|
|
assert "DELETE FROM operation_links" in source
|
|
|
|
|
|
def test_review_action_is_catalogued_and_completion_persists_validation():
|
|
catalog = (ROOT / "app" / "action_catalog.py").read_text(encoding="utf-8")
|
|
service = (ROOT / "app" / "opportunity_service.py").read_text(encoding="utf-8")
|
|
assert '"REVIEW_RECONSTRUCTED_PROCESS": {' in catalog
|
|
assert 'if action_code == "REVIEW_RECONSTRUCTED_PROCESS":' in service
|
|
assert 'reconstructed_process_review_validated' in service
|