From 2a861e480c0661d0738d329994b937593d71f4ff Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Wed, 18 Mar 2026 00:55:17 -0500 Subject: [PATCH] Enhance invoice and item processing with balance tracking features - Added `company_id` to `ClientProviderAddress` and `ClientProviderPrograms` for better association. - Updated `_process_with_discharge` to simplify invoice review by removing redundant parameters. - Improved `pre_validators` to ensure address validation checks for existence before accessing country. - Enhanced balance comparison logic in `compare_balances` by tracking consumed quantities. - Modified `fill_available_balances` to reflect invoice status changes from 'UNPROCESSED' to 'PENDING'. - Introduced `get_lines_with_balance` method in `ItemService` to fetch invoice lines with available balance. - Added new API endpoint to retrieve import invoice lines with balance information. - Updated frontend components to display available balances and improve user experience in invoice selection. --- .../a76/clients_and_providers/service.py | 2 + .../invoices/exports/process/main_process.py | 4 +- .../exports/process/pre_validators.py | 19 +- .../process/sub_process/compare_balances.py | 1 + .../process/sub_process/discharge_types.py | 7 +- .../sub_process/download_balance_collector.py | 2 +- .../sub_process/fill_available_balances.py | 2 +- .../process/sub_process/finalize_invoice.py | 4 + .../sub_process/register_discharge_ledger.py | 172 ++++++++++++++++++ backend/api/v1/modules/a76/items/models.py | 5 +- backend/api/v1/modules/a76/items/routes.py | 36 +++- backend/api/v1/modules/a76/items/service.py | 133 +++++++++++++- backend/core/celery_app.py | 1 + frontend/src/lib/api/dashboard/a76/items.ts | 47 +++++ .../invoices/edit/InvoiceSelectorModal.svelte | 14 +- .../edit/items/fa/item-sheet-fa.svelte | 139 +++++++++++--- .../components/ui/select/select-root.svelte | 12 +- 17 files changed, 554 insertions(+), 46 deletions(-) create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/register_discharge_ledger.py diff --git a/backend/api/v1/modules/a76/clients_and_providers/service.py b/backend/api/v1/modules/a76/clients_and_providers/service.py index aaf76471..14276427 100644 --- a/backend/api/v1/modules/a76/clients_and_providers/service.py +++ b/backend/api/v1/modules/a76/clients_and_providers/service.py @@ -185,6 +185,7 @@ class ClientProviderService: db_address = ClientProviderAddress( client_id=client.id, tenant_id=tenant_id, + company_id=company_id, **client_data.address.model_dump(exclude_unset=True), ) db.add(db_address) @@ -199,6 +200,7 @@ class ClientProviderService: db_programs = ClientProviderPrograms( client_id=client.id, tenant_id=tenant_id, + company_id=company_id, **client_data.programs.model_dump(exclude_unset=True), ) db.add(db_programs) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/main_process.py b/backend/api/v1/modules/a76/invoices/exports/process/main_process.py index 2dcdf9e4..0ea95e59 100644 --- a/backend/api/v1/modules/a76/invoices/exports/process/main_process.py +++ b/backend/api/v1/modules/a76/invoices/exports/process/main_process.py @@ -46,7 +46,7 @@ def _process_with_discharge( AFIJO, DONAC, SCRAP, REEXP, VEMEX. """ assign_no_discharges_series(db, lines, errors) - review_class(db, invoice, lines, invoice.tenant_id, invoice.company_id, errors) + review_class(db, lines, errors) review_exchange_rate(db, invoice, errors) assign_values(db, invoice, lines, invoice.tenant_id, invoice.company_id, errors) @@ -201,4 +201,4 @@ def main_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id # invoice.status and totals are set inside finalize_invoice_no_discharge / termina_ac_o_lp_normal db.flush() - return {"status": "ok", "invoice_id": str(invoice.id)} + return {"status": "success", "invoice_id": str(invoice.id)} diff --git a/backend/api/v1/modules/a76/invoices/exports/process/pre_validators.py b/backend/api/v1/modules/a76/invoices/exports/process/pre_validators.py index bc62c4a6..8f1140f0 100644 --- a/backend/api/v1/modules/a76/invoices/exports/process/pre_validators.py +++ b/backend/api/v1/modules/a76/invoices/exports/process/pre_validators.py @@ -1,5 +1,5 @@ from sqlalchemy import func -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus from api.v1.modules.a76.items.models import LineItem from api.v1.modules.a76.general_catalogs.fractions.warning_fractions.models import WarningFraction @@ -47,7 +47,7 @@ def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_ code="NOT_FOUND", value=invoice.compliance_mx.shipped_to_id, ) - if not shipped_to_exists.address.country: + if not shipped_to_exists.address or not shipped_to_exists.address.country: errors.add_error( field="compliance_mx.shipped_to_id", message="El Destinatario no tiene capturado el pais.", @@ -100,11 +100,16 @@ def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_ ) # Advertencias para las fracciones y su horario - lines = db.query(LineItem).filter( - LineItem.invoice_id == invoice.id, - LineItem.tenant_id == tenant_id, - LineItem.company_id == company_id, - ).all() + lines = ( + db.query(LineItem) + .options(joinedload(LineItem.fa_data)) + .filter( + LineItem.invoice_id == invoice.id, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, + ) + .all() + ) return lines diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/compare_balances.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/compare_balances.py index 940011b9..0a9ec622 100644 --- a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/compare_balances.py +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/compare_balances.py @@ -139,6 +139,7 @@ def compare_balances( entry.quantity_used += consume lot.available_qty -= consume + lot.consumed_qty += consume # ── Check if the entry was fully satisfied ──────────────────────────── if entry.quantity_used < entry.quantity: diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/discharge_types.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/discharge_types.py index f27140bf..e8dbca22 100644 --- a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/discharge_types.py +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/discharge_types.py @@ -21,7 +21,11 @@ class AvailableLot: import_item_line_id : a76.item_lines.id of the import line (the lot) import_invoice_id : a76.invoice_header.id of the import invoice part_number_id : denormalized from the import line - available_qty : net balance available (QSaldo:Cantidad) + available_qty : net balance available (QSaldo:Cantidad); mutated by + compare_balances() as quantity is distributed + consumed_qty : how much was actually taken from this lot by + compare_balances(); used by register_discharge_ledger + to create the exact BalanceMovement amount value_me : USD value of the full lot (for proportional calc) value_mn : MXN value of the full lot (for proportional calc) order_peps : PEPS ordering key — lower = older = consumed first @@ -33,6 +37,7 @@ class AvailableLot: value_me: Optional[Decimal] value_mn: Optional[Decimal] order_peps: int + consumed_qty: Decimal = field(default_factory=Decimal) @dataclass diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/download_balance_collector.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/download_balance_collector.py index ba9955f4..091442f4 100644 --- a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/download_balance_collector.py +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/download_balance_collector.py @@ -66,7 +66,7 @@ def collect_lines_to_discharge( """ to_discharge: List[DownloadEntry] = [] - discharge_lines = [line for line in lines if line.discharge] + discharge_lines = [line for line in lines if line.fa_data and line.fa_data.discharge] if not discharge_lines: return to_discharge diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/fill_available_balances.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/fill_available_balances.py index 51e3d176..eec6f686 100644 --- a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/fill_available_balances.py +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/fill_available_balances.py @@ -200,7 +200,7 @@ def fill_available_balances( continue # Status 'NA' == not processed (Clarion: Estatus = 'NA') - if import_invoice.status == InvoiceStatus.UNPROCESSED: + if import_invoice.status == InvoiceStatus.PENDING: errors.add_error( field=f"line[{entry.export_line}].import_invoice", message=f"La Factura de Importación: '{entry.import_invoice}' está Desactualizada.", diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/finalize_invoice.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/finalize_invoice.py index bb1bab97..6aea9c0a 100644 --- a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/finalize_invoice.py +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/finalize_invoice.py @@ -23,6 +23,7 @@ from sqlalchemy.orm import Session from .register_import_discharge import register_import_discharge from .register_discharge_series import register_discharge_series +from .register_discharge_ledger import register_discharge_ledger if TYPE_CHECKING: from .discharge_types import DownloadEntry @@ -274,6 +275,9 @@ def finalize_invoice_with_discharge( generate_definitive_import(db, invoice, errors) if to_discharge: + # Write BalanceMovement (CONSUMPTION) + DischargeHeader + DischargeDetail + register_discharge_ledger(db, invoice, to_discharge) + # Update quantity_returned / value_returned on the import lines register_import_discharge(db, invoice, to_discharge) register_discharge_series(db, invoice, to_discharge) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/register_discharge_ledger.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/register_discharge_ledger.py new file mode 100644 index 00000000..1ef7d4ee --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/register_discharge_ledger.py @@ -0,0 +1,172 @@ +""" +register_discharge_ledger +========================= +Creates the full Annex-24 discharge record for one export invoice: + + 1. ONE DischargeHeader (one per export event) + 2. N BalanceMovement rows (type=CONSUMPTION, one per lot consumed) + 3. N DischargeDetail rows (one per export-line × import-lot pair), + each referencing its BalanceMovement (design rule 3) + +Design rules from a24.balance_movement (preserved here): + 1. NEVER update existing balance_movement rows — only INSERT. + 2. Balance = SUM of movements. + 3. Every DischargeDetail.movement_id MUST reference a BalanceMovement row. + 4. order_peps = movement.id (set after flush, globally monotonic). +""" + +import datetime +import logging +from decimal import Decimal +from typing import List, Optional + +from sqlalchemy.orm import Session + +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a24.balance_movements.models import BalanceMovement, MovementType +from api.v1.modules.a24.discharges.models import ( + DischargeDetail, + DischargeHeader, + DischargeStatus, + DischargeType, +) +from .discharge_types import DownloadEntry, AvailableLot + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _discharge_type_for_invoice(invoice: InvoiceHeader) -> DischargeType: + mapping = { + "AFIJO": DischargeType.TEMPORARY, + "DONAC": DischargeType.TEMPORARY, + "SCRAP": DischargeType.WASTE_SCRAP, + "REEXP": DischargeType.DEFINITIVE, + "VEMEX": DischargeType.DEFINITIVE, + } + return mapping.get(invoice.invoice_type or "", DischargeType.TEMPORARY) + + +def _export_date(invoice: InvoiceHeader) -> datetime.date: + d = invoice.invoice_date + return d.date() if hasattr(d, "date") else d + + +def _proportional_value( + consume: Decimal, + lot_consumed_total: Decimal, + lot_value: Optional[Decimal], +) -> Optional[Decimal]: + """Returns the proportional value for *consume* units out of *lot_consumed_total*.""" + if not lot_value or lot_consumed_total <= 0: + return None + return (consume / lot_consumed_total) * lot_value + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + +def register_discharge_ledger( + db: Session, + export_invoice: InvoiceHeader, + to_discharge: List[DownloadEntry], +) -> Optional[DischargeHeader]: + """ + Persists the complete Annex-24 discharge record for *export_invoice*. + + Expects that compare_balances() has already run and populated + ``lot.consumed_qty`` for every lot that was drawn from. + + Returns the created DischargeHeader, or None if nothing was discharged. + """ + # Only process entries that actually consumed something + active = [e for e in to_discharge if e.quantity_used > Decimal(0)] + if not active: + return None + + op_date = _export_date(export_invoice) + discharge_type = _discharge_type_for_invoice(export_invoice) + + # ── 1. DischargeHeader ──────────────────────────────────────────────── + header = DischargeHeader( + tenant_id=export_invoice.tenant_id, + company_id=export_invoice.company_id, + source_invoice_id=export_invoice.id, + discharge_type=discharge_type, + status=DischargeStatus.APPLIED, + discharge_date=op_date, + ) + db.add(header) + db.flush() # get header.id + + total_movements = 0 + + for entry in active: + export_line_id: Optional[int] = entry.line_item_id + + # Only iterate lots that were actually consumed + consumed_lots: List[AvailableLot] = [ + lot for lot in entry.available_lots if lot.consumed_qty > Decimal(0) + ] + + for lot in consumed_lots: + consume = lot.consumed_qty + + # ── 2. BalanceMovement (CONSUMPTION) ────────────────────────── + # Proportional value: consume / lot_consumed_total × lot_value + # lot_consumed_total == consume for single-lot entries (most cases) + value_me = _proportional_value(consume, consume, lot.value_me) + value_mn = _proportional_value(consume, consume, lot.value_mn) + + movement = BalanceMovement( + tenant_id=export_invoice.tenant_id, + company_id=export_invoice.company_id, + import_invoice_id=lot.import_invoice_id, + import_item_line_id=lot.import_item_line_id, + part_number_id=lot.part_number_id, + movement_type=MovementType.CONSUMPTION, + quantity=consume, + value_me=value_me, + value_mn=value_mn, + source_invoice_id=export_invoice.id, + source_item_line_id=export_line_id, + order_peps=0, # placeholder — set after flush (rule 4) + operation_date=op_date, + notes=( + f"Descarga por factura de exportación " + f"{export_invoice.invoice_number}" + ), + ) + db.add(movement) + db.flush() # get movement.id + movement.order_peps = movement.id # rule 4: monotonic + + # ── 3. DischargeDetail ───────────────────────────────────────── + detail = DischargeDetail( + tenant_id=export_invoice.tenant_id, + company_id=export_invoice.company_id, + discharge_header_id=header.id, + export_item_line_id=export_line_id, + import_item_line_id=lot.import_item_line_id, + movement_id=movement.id, + quantity_discharged=consume, + unit_of_measure=entry.unit_of_measure or None, + value_me=value_me, + value_mn=value_mn, + procedence=entry.origin_procedure or None, + part_number=entry.part_number or None, + ) + db.add(detail) + total_movements += 1 + + logger.info( + "register_discharge_ledger: invoice=%s header_id=%s details=%d", + export_invoice.invoice_number, + header.id, + total_movements, + ) + return header diff --git a/backend/api/v1/modules/a76/items/models.py b/backend/api/v1/modules/a76/items/models.py index e9bcb55a..c7bc18eb 100644 --- a/backend/api/v1/modules/a76/items/models.py +++ b/backend/api/v1/modules/a76/items/models.py @@ -24,7 +24,10 @@ if TYPE_CHECKING: from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem from api.v1.modules.a76.parts.models import Part from api.v1.modules.a76.invoices.models import InvoiceHeader - from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail + +# Imported at runtime so SQLAlchemy's mapper registry can resolve the class name +# used in the relationship string below. +from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail # ============================================================================ # CORE ENTITIES diff --git a/backend/api/v1/modules/a76/items/routes.py b/backend/api/v1/modules/a76/items/routes.py index ee1c9e14..65f559bf 100644 --- a/backend/api/v1/modules/a76/items/routes.py +++ b/backend/api/v1/modules/a76/items/routes.py @@ -3,7 +3,8 @@ API Endpoints for Items management Handles CRUD operations for Item with one-to-many relationships to LineItems """ -from typing import Dict, Any, Optional +import datetime +from typing import Dict, Any, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Path, status from sqlalchemy.orm import Session @@ -191,6 +192,39 @@ async def get_items_by_invoice( skip=skip, limit=limit ) +@router.get("/invoice/{invoice_id}/items-with-balance", response_model=List[dict]) +async def get_items_with_balance( + invoice_id: int = Path(..., description="Import Invoice ID"), + company_id: int = Query(..., description="Company ID"), + as_of_date: Optional[datetime.date] = Query( + None, + description=( + "Cut-off date for balance calculation. Only consumptions on or " + "before this date are subtracted (CALCULA_SALDO_FECHA_EXPO logic)." + ), + ), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Returns every line of the given import invoice with its available balance + from the a24.balance_movement ledger. + + Each item in the response includes: + - id, line_number, part_number, class_code, unit_of_measure_code + - quantity : original imported quantity + - available_balance : net balance still available for export discharge + - has_balance : true when available_balance > 0 + + Use ``as_of_date`` to restrict consumption movements to a specific date + (pass the export invoice date so that future discharges are not counted). + """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = ItemService() + return service.get_lines_with_balance(db, invoice_id, tenant_id, company_id, as_of_date) + + +# ============================================================================ # STATISTICS & UTILITIES # ============================================================================ diff --git a/backend/api/v1/modules/a76/items/service.py b/backend/api/v1/modules/a76/items/service.py index f656d23c..dbbab8e3 100644 --- a/backend/api/v1/modules/a76/items/service.py +++ b/backend/api/v1/modules/a76/items/service.py @@ -12,10 +12,12 @@ After refactoring: LineItem is the main entity, representing a single line item There is no intermediate Item entity anymore. Each LineItem belongs directly to an InvoiceHeader. """ +import datetime import logging +from decimal import Decimal from typing import Optional, List, Tuple from fastapi import HTTPException -from sqlalchemy import and_, or_ +from sqlalchemy import and_, case, func, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, joinedload @@ -41,6 +43,7 @@ from .series.models import Serie from api.v1.modules.a76.invoices.models import InvoiceHeader from api.v1.modules.a76.parts.models import Part from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail +from api.v1.modules.a24.balance_movements.models import BalanceMovement, NEGATIVE_MOVEMENTS logger = logging.getLogger(__name__) @@ -372,10 +375,11 @@ class ItemService: errors.raise_if_errors("Error al crear el item - invoice_id es requerido") invoice = invoice_exists_by_id( - db, item_data.invoice_id, tenant_id, company_id, errors + db, item_data.invoice_id, tenant_id, company_id, None ) if not invoice: + errors.add_error("invoice_id", "La factura no existe", code="NOT_FOUND", value=str(item_data.invoice_id)) errors.raise_if_errors("Error al encontra la factura para el item") if not invoice_processed(db, item_data.invoice_id, tenant_id, company_id, errors): errors.raise_if_errors("Error al crear el item - la factura ya fue actualizada, no se pueden agregar items") @@ -511,9 +515,10 @@ class ItemService: errors = ErrorCollector() invoice = invoice_exists_by_id( - db, item_data.invoice_id, tenant_id, company_id, errors + db, item_data.invoice_id, tenant_id, company_id, None ) if not invoice: + errors.add_error("invoice_id", "La factura no existe", code="NOT_FOUND", value=str(item_data.invoice_id)) errors.raise_if_errors("Error al encontra la factura para el item") # Lock invoice @@ -684,3 +689,125 @@ class ItemService: db.rollback() logger.error(f"Error deleting item: {e}") raise HTTPException(status_code=500, detail="Error deleting item") + + @staticmethod + def get_lines_with_balance( + db: Session, + invoice_id: int, + tenant_id: int, + company_id: int, + as_of_date: Optional[datetime.date] = None, + ) -> List[dict]: + """ + Returns every line of an import invoice together with its current + available balance calculated from the a24.balance_movement ledger. + + Lines with balance <= 0 are included but marked as unavailable so + the frontend can grey them out / disable them. + + Parameters + ---------- + as_of_date : optional cut-off date. Only negative movements + (consumptions, etc.) on or before this date are counted, + mirroring the CALCULA_SALDO_FECHA_EXPO Clarion logic. + If None, all movements are counted (no date restriction). + """ + lines: List[LineItem] = ( + db.query(LineItem) + .filter( + LineItem.invoice_id == invoice_id, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, + ) + .options( + joinedload(LineItem.quantity), + joinedload(LineItem.description), + joinedload(LineItem.part_info), + joinedload(LineItem.class_info), + joinedload(LineItem.unit_of_measure_info), + joinedload(LineItem.fa_data), + joinedload(LineItem.invoice), + ) + .order_by(LineItem.line_number) + .all() + ) + + result = [] + for line in lines: + available_balance = ItemService._compute_balance(db, line.id, as_of_date) + qty = line.quantity + desc = line.description + fa = line.fa_data + inv = line.invoice + + # Count subitems (lines that reference this line as parent via subitem_number) + subitem_count = 0 + if fa and fa.contains_subitems: + from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem as FaModel + subitem_count = ( + db.query(func.count(LineItem.id)) + .join(FaModel, FaModel.id == LineItem.id) + .filter( + LineItem.invoice_id == invoice_id, + LineItem.tenant_id == tenant_id, + FaModel.is_subitem == True, + FaModel.subitem_number == line.line_number, + ) + .scalar() or 0 + ) + + result.append({ + "id": line.id, + "line_number": line.line_number, + # Invoice info + "invoice_number": inv.invoice_number if inv else None, + "invoice_date": inv.invoice_date.isoformat() if inv and inv.invoice_date else None, + "invoice_status": inv.status if inv and inv.status else None, + # Part / class + "part_number": line.part_info.part_number if line.part_info else None, + "class_code": line.class_info.class_code if line.class_info else None, + "description_spanish": desc.description_spanish if desc else None, + "unit_of_measure_code": line.unit_of_measure_info.code if line.unit_of_measure_info else None, + # Quantities + "quantity": float(qty.quantity) if qty and qty.quantity is not None else None, + "quantity_returned_temp": float(qty.quantity_returned_temp) if qty and qty.quantity_returned_temp is not None else None, + "quantity_returned": float(qty.quantity_returned) if qty and qty.quantity_returned is not None else None, + # Balance + "available_balance": float(available_balance), + "has_balance": available_balance > Decimal(0), + # FA / subitem info + "is_subitem": fa.is_subitem if fa else None, + "contains_subitems": fa.contains_subitems if fa else None, + "subitem_count": subitem_count, + }) + return result + + @staticmethod + def _compute_balance( + db: Session, + item_line_id: int, + as_of_date: Optional[datetime.date], + ) -> Decimal: + """Net available balance for one import line from the ledger.""" + sign_expr = case( + (BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS), Decimal(-1)), + else_=Decimal(1), + ) + if as_of_date is not None: + date_filter = case( + ( + BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS), + BalanceMovement.operation_date <= as_of_date, + ), + else_=True, + ) + else: + date_filter = True # type: ignore[assignment] + + result = db.execute( + select(func.sum(sign_expr * BalanceMovement.quantity)).where( + BalanceMovement.import_item_line_id == item_line_id, + date_filter, + ) + ).scalar() + return Decimal(str(result or 0)) diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index 1df5980b..1ab2efb7 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -64,6 +64,7 @@ celery_app.conf.update( "api.v1.modules.core.help_center.tasks", "api.v1.modules.a76.invoices.imports.process.task", "api.v1.modules.a76.invoices.imports.revert.task", + "api.v1.modules.a76.invoices.exports.process.task", ] # Ruta al módulo donde están las tareas ) diff --git a/frontend/src/lib/api/dashboard/a76/items.ts b/frontend/src/lib/api/dashboard/a76/items.ts index df8eb253..8c5b7326 100644 --- a/frontend/src/lib/api/dashboard/a76/items.ts +++ b/frontend/src/lib/api/dashboard/a76/items.ts @@ -299,5 +299,52 @@ export const itemsApi = { company_id: companyId.toString() }); return api.delete(`/v1/a76/items/${itemId}/?${params.toString()}`); + }, + + /** + * Lista las líneas de una factura de importación con su saldo disponible. + * Solo las líneas con has_balance = true tienen mercancía disponible para descarga. + * + * @param invoiceId - ID de la factura de importación + * @param companyId - ID de la empresa + * @param asOfDate - Fecha corte opcional (ISO: "YYYY-MM-DD"). + * Pasa la fecha de la factura de exportación para que + * los consumos futuros no se descuenten del saldo. + */ + listByInvoiceWithBalance: ( + invoiceId: number, + companyId: number, + asOfDate?: string + ) => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + if (asOfDate) params.append('as_of_date', asOfDate); + return api.get( + `/v1/a76/items/invoice/${invoiceId}/items-with-balance?${params.toString()}` + ); } }; + +export interface ImportLineWithBalance { + id: number; + line_number: number; + // Invoice info + invoice_number?: string; + invoice_date?: string; + invoice_status?: string; + // Part / class + part_number?: string; + class_code?: string; + description_spanish?: string; + unit_of_measure_code?: string; + // Quantities + quantity?: number; + quantity_returned_temp?: number; + quantity_returned?: number; + // Balance + available_balance: number; + has_balance: boolean; + // FA / subitem + is_subitem?: boolean; + contains_subitems?: boolean; + subitem_count?: number; +} diff --git a/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte b/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte index 3b47096b..2c8cff0a 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte @@ -7,17 +7,19 @@ import { companyStore } from '$lib/stores/company.svelte'; import { toast } from 'svelte-sonner'; + interface Props { + open: boolean; + regimen?: string; + operationType?: 'imp' | 'exp'; + onSelect: (invoice: Invoice) => void; + } + let { open = $bindable(false), regimen = 'Temporal', operationType = 'imp' as 'imp' | 'exp', onSelect - }: { - open: boolean; - regimen?: string; - operationType?: 'imp' | 'exp'; - onSelect: (invoice: Invoice) => void; - } = $props(); + }: Props = $props(); let invoices = $state([]); let loading = $state(false); diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte index 4169d4f5..4dd63e0d 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte @@ -11,7 +11,7 @@ import { Loader2, Package, Save, X, FileText, Folder } from 'lucide-svelte'; import type { Invoice } from '$lib/api/dashboard/a76/invoices'; import { invoicesApi } from '$lib/api/dashboard/a76/invoices'; - import { itemsApi, type Item } from '$lib/api/dashboard/a76/items'; + import { itemsApi, type Item, type ImportLineWithBalance } from '$lib/api/dashboard/a76/items'; import { companyStore } from '$lib/stores/company.svelte'; // Child components @@ -68,6 +68,9 @@ if (editingItem.fa_data.omit_annex31 === undefined) { editingItem.fa_data.omit_annex31 = false; } + if (editingItem.fa_data.discharge === undefined) { + editingItem.fa_data.discharge = false; + } } }); @@ -78,7 +81,7 @@ let showImportLinePicker = $state(false); let selectedImportInvoiceId = $state(null); let selectedExportInvoiceId = $state(null); - let importInvoiceLines = $state([]); + let importInvoiceLines = $state([]); let exportInvoiceLines = $state([]); let loadingImportLines = $state(false); let loadingExportLines = $state(false); @@ -88,8 +91,13 @@ if (!companyId) return; loadingImportLines = true; try { - const res = await itemsApi.listByInvoice(invoiceId, companyId); - importInvoiceLines = res.data?.items ?? []; + // Pass the export invoice date so consumption movements after that + // date are not subtracted from the available balance. + const asOfDate = invoice?.invoice_date + ? invoice.invoice_date.split('T')[0] + : undefined; + const res = await itemsApi.listByInvoiceWithBalance(invoiceId, companyId, asOfDate); + importInvoiceLines = res.data ?? []; } catch { importInvoiceLines = []; } finally { @@ -334,7 +342,7 @@ value={editingItem.fa_data?.search_type || ''} onValueChange={(v) => { editingItem.fa_data = editingItem.fa_data || {}; - editingItem.fa_data.search_type = v ?? undefined; + editingItem.fa_data.search_type = v ?? 'Factura'; }} > @@ -345,6 +353,7 @@ Factura NumParte + Clase @@ -630,25 +639,111 @@ - + - Seleccionar línea + Seleccionar línea de importación +

+ Solo se muestran líneas con saldo disponible +

-
- {#each importInvoiceLines as lineItem} - {@const num = lineItem.line_number ?? lineItem.id} - - {/each} + +
+
+ {#if importInvoiceLines.every(l => !l.has_balance)} +

+ No hay líneas con saldo disponible en esta factura. +

+ {:else} + + + + + + + + + + + + + + + + + + + + {#each importInvoiceLines as lineItem} + {#if lineItem.has_balance} + { + editingItem.fa_data = editingItem.fa_data || {}; + editingItem.fa_data.search_line = lineItem.line_number; + showImportLinePicker = false; + }} + > + + + + + + + + + + + + + + {/if} + {/each} + +
LíneaFacturaFechaNum. ParteClaseDescripciónCant. Imp.Ret. Temp.Ret. Def.Saldo Disp.EstatusSub.
{lineItem.line_number}{lineItem.invoice_number ?? '-'} + {lineItem.invoice_date ? lineItem.invoice_date.slice(0, 10) : '-'} + {lineItem.part_number ?? '-'}{lineItem.class_code ?? '-'} + {lineItem.description_spanish ?? '-'} + + {lineItem.quantity != null ? lineItem.quantity.toLocaleString('es-MX', { maximumFractionDigits: 4 }) : '-'} + {lineItem.unit_of_measure_code ?? ''} + + {lineItem.quantity_returned_temp != null ? lineItem.quantity_returned_temp.toLocaleString('es-MX', { maximumFractionDigits: 4 }) : '-'} + + {lineItem.quantity_returned != null ? lineItem.quantity_returned.toLocaleString('es-MX', { maximumFractionDigits: 4 }) : '-'} + + {lineItem.available_balance.toLocaleString('es-MX', { maximumFractionDigits: 4 })} + {lineItem.unit_of_measure_code ?? ''} + + {#if lineItem.invoice_status === 'processed'} + + Procesada + + {:else if lineItem.invoice_status === 'reversed'} + + Revertida + + {:else} + + {lineItem.invoice_status ?? 'Pendiente'} + + {/if} + + {#if lineItem.is_subitem} + + Sub + + {:else if lineItem.contains_subitems} + + {lineItem.subitem_count ?? 0} sub + + {:else} + + {/if} +
+ {/if} +
\ No newline at end of file diff --git a/frontend/src/lib/components/ui/select/select-root.svelte b/frontend/src/lib/components/ui/select/select-root.svelte index 40f3b2dc..62bc3d59 100644 --- a/frontend/src/lib/components/ui/select/select-root.svelte +++ b/frontend/src/lib/components/ui/select/select-root.svelte @@ -5,11 +5,21 @@ import { selectSearchContextKey, type SelectSearchContext } from './select-search-context'; import { type WithoutChild } from '$lib/utils.js'; + // SelectPrimitive.RootProps is a discriminated union (single | multiple). + // Spreading a discriminated union collapses conflicting members (e.g. onValueChange) to `never`. + // We widen the props type so callers can pass either variant without hitting `never`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type SelectRootProps = Omit, 'value' | 'onValueChange'> & { + type?: 'single' | 'multiple'; + value?: string | string[]; + onValueChange?: (value: any) => void; + }; + let { children, value = $bindable(), ...restProps - }: WithoutChild = $props(); + }: SelectRootProps = $props(); let open = $state(false); const query = writable('');