Merge pull request 'feature/balance_by_item' (#220) from feature/balance_by_item into development

Reviewed-on: ADUANASOFT/anexo76#220
This commit is contained in:
2026-03-18 14:25:27 +00:00
17 changed files with 621 additions and 46 deletions

View File

@@ -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)

View File

@@ -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)}

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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.",

View File

@@ -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)

View File

@@ -0,0 +1,239 @@
"""
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 api.v1.modules.a76.invoices.models import InvoiceHeader as A76InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
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
def _proportional_qty(consume: Decimal, base_qty: Optional[Decimal], base_total: Optional[Decimal]) -> Optional[Decimal]:
"""
Proratea un valor (peso/valor) en proporción a lo consumido.
- consume: cantidad consumida del lote
- base_qty: valor total del lote (ej. peso neto total del lote)
- base_total: cantidad total del lote (ej. quantity del lote)
"""
if base_qty is None:
return None
if base_total is None or base_total <= 0:
return None
return (consume / base_total) * base_qty
# ---------------------------------------------------------------------------
# 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)
# Caches to avoid N+1 queries in loops
export_line_cache: dict[int, LineItem] = {}
import_line_cache: dict[int, LineItem] = {}
import_invoice_number_cache: dict[int, str] = {}
# ── 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
export_line_obj: Optional[LineItem] = None
if export_line_id:
export_line_obj = export_line_cache.get(export_line_id)
if export_line_obj is None:
export_line_obj = db.get(LineItem, export_line_id)
if export_line_obj is not None:
export_line_cache[export_line_id] = export_line_obj
# 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
# Load import-line object for denormalized customs/weights fields
import_line_obj = import_line_cache.get(lot.import_item_line_id)
if import_line_obj is None:
import_line_obj = db.get(LineItem, lot.import_item_line_id)
if import_line_obj is not None:
import_line_cache[lot.import_item_line_id] = import_line_obj
# Import invoice number (for origin_import_invoice in DischargeDetail)
origin_import_invoice: Optional[str] = None
if lot.import_invoice_id:
origin_import_invoice = import_invoice_number_cache.get(lot.import_invoice_id)
if origin_import_invoice is None:
inv = db.get(A76InvoiceHeader, lot.import_invoice_id)
origin_import_invoice = inv.invoice_number if inv else None
if origin_import_invoice:
import_invoice_number_cache[lot.import_invoice_id] = origin_import_invoice
# ── 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 ─────────────────────────────────────────
# Denormalized fields expected by reports:
imp_cust = import_line_obj.customs if import_line_obj else None
imp_qty = import_line_obj.quantity if import_line_obj else None
imp_total_qty = imp_qty.quantity if imp_qty else None
net_weight = _proportional_qty(consume, imp_qty.net_weight if imp_qty else None, imp_total_qty)
gross_weight = _proportional_qty(consume, imp_qty.gross_weight if imp_qty else None, imp_total_qty)
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,
net_weight=net_weight,
gross_weight=gross_weight,
tariff_fraction=imp_cust.fraction if imp_cust else None,
fraction_type=imp_cust.fraction_type if imp_cust else None,
ad_valorem=imp_cust.advalorem if imp_cust else None,
country_of_origin=imp_cust.origin_country if imp_cust else None,
sector=imp_cust.sector if imp_cust else None,
procedence=entry.origin_procedure or None,
part_number=entry.part_number or None,
export_part_number=(
export_line_obj.part_info.part_number
if export_line_obj and export_line_obj.part_info and export_line_obj.part_info.part_number
else None
),
origin_import_invoice=origin_import_invoice,
)
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

View File

@@ -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

View File

@@ -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
# ============================================================================

View File

@@ -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))

View File

@@ -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
)

View File

@@ -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<ImportLineWithBalance[]>(
`/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;
}

View File

@@ -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<Invoice[]>([]);
let loading = $state(false);

View File

@@ -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<number | null>(null);
let selectedExportInvoiceId = $state<number | null>(null);
let importInvoiceLines = $state<Item[]>([]);
let importInvoiceLines = $state<ImportLineWithBalance[]>([]);
let exportInvoiceLines = $state<Item[]>([]);
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';
}}
>
<Select.Trigger id="fa_rep_search_type" class="h-8 text-sm">
@@ -345,6 +353,7 @@
<Select.Content>
<Select.Item value="Factura">Factura</Select.Item>
<Select.Item value="NumParte">NumParte</Select.Item>
<Select.Item value="Clase">Clase</Select.Item>
</Select.Content>
</Select.Root>
</div>
@@ -630,25 +639,111 @@
<!-- Diálogo para elegir línea (Impo) -->
<Dialog.Root bind:open={showImportLinePicker}>
<Dialog.Content class="max-w-sm">
<Dialog.Content class="max-w-4xl">
<Dialog.Header>
<Dialog.Title class="text-sm">Seleccionar línea</Dialog.Title>
<Dialog.Title class="text-sm">Seleccionar línea de importación</Dialog.Title>
<p class="text-xs text-muted-foreground mt-0.5">
Solo se muestran líneas con saldo disponible
</p>
</Dialog.Header>
<div class="max-h-[280px] overflow-y-auto py-2">
{#each importInvoiceLines as lineItem}
{@const num = lineItem.line_number ?? lineItem.id}
<button
type="button"
class="w-full px-3 py-2 text-left text-sm hover:bg-muted rounded-md"
onclick={() => {
editingItem.fa_data = editingItem.fa_data || {};
editingItem.fa_data.search_line = typeof num === 'number' ? num : undefined;
showImportLinePicker = false;
}}
>
Línea {num}
</button>
{/each}
<!-- overflow-x on a wrapper that does NOT also do overflow-y.
The inner div handles vertical scroll so sticky columns work
independently from the horizontal scrollbar. -->
<div class="overflow-x-auto">
<div class="overflow-y-auto max-h-[500px]">
{#if importInvoiceLines.every(l => !l.has_balance)}
<p class="px-3 py-8 text-xs text-muted-foreground text-center">
No hay líneas con saldo disponible en esta factura.
</p>
{:else}
<table class="text-xs border-collapse" style="min-width: max-content; width: 100%;">
<thead class="sticky top-0 z-20">
<tr class="border-b border-border bg-muted">
<!-- sticky cols 1-3: left offsets match td widths below -->
<th class="sticky left-0 z-20 bg-muted px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap w-[48px]">Línea</th>
<th class="sticky left-[48px] z-20 bg-muted px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap w-[120px]">Factura</th>
<th class="sticky left-[168px] z-20 bg-muted px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap w-[88px]">Fecha</th>
<th class="px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap">Num. Parte</th>
<th class="px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap">Clase</th>
<th class="px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap w-[180px]">Descripción</th>
<th class="px-2 py-2 text-right font-semibold text-muted-foreground whitespace-nowrap">Cant. Imp.</th>
<th class="px-2 py-2 text-right font-semibold text-muted-foreground whitespace-nowrap">Ret. Temp.</th>
<th class="px-2 py-2 text-right font-semibold text-muted-foreground whitespace-nowrap">Ret. Def.</th>
<th class="px-2 py-2 text-right font-semibold text-muted-foreground whitespace-nowrap">Saldo Disp.</th>
<th class="px-2 py-2 text-center font-semibold text-muted-foreground whitespace-nowrap">Estatus</th>
<th class="px-2 py-2 text-center font-semibold text-muted-foreground whitespace-nowrap">Sub.</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
{#each importInvoiceLines as lineItem}
{#if lineItem.has_balance}
<tr
class="hover:bg-muted/50 cursor-pointer transition-colors group"
onclick={() => {
editingItem.fa_data = editingItem.fa_data || {};
editingItem.fa_data.search_line = lineItem.line_number;
showImportLinePicker = false;
}}
>
<td class="sticky left-0 z-10 bg-background group-hover:bg-muted/50 px-2 py-1.5 font-semibold text-primary whitespace-nowrap w-[48px]">{lineItem.line_number}</td>
<td class="sticky left-[48px] z-10 bg-background group-hover:bg-muted/50 px-2 py-1.5 font-mono whitespace-nowrap w-[120px]">{lineItem.invoice_number ?? '-'}</td>
<td class="sticky left-[168px] z-10 bg-background group-hover:bg-muted/50 px-2 py-1.5 text-muted-foreground whitespace-nowrap w-[88px]">
{lineItem.invoice_date ? lineItem.invoice_date.slice(0, 10) : '-'}
</td>
<td class="px-2 py-1.5 font-mono whitespace-nowrap">{lineItem.part_number ?? '-'}</td>
<td class="px-2 py-1.5 whitespace-nowrap">{lineItem.class_code ?? '-'}</td>
<td class="px-2 py-1.5 w-[180px] max-w-[180px] truncate text-muted-foreground" title={lineItem.description_spanish ?? ''}>
{lineItem.description_spanish ?? '-'}
</td>
<td class="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
{lineItem.quantity != null ? lineItem.quantity.toLocaleString('es-MX', { maximumFractionDigits: 4 }) : '-'}
<span class="text-muted-foreground">{lineItem.unit_of_measure_code ?? ''}</span>
</td>
<td class="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-amber-600 dark:text-amber-400">
{lineItem.quantity_returned_temp != null ? lineItem.quantity_returned_temp.toLocaleString('es-MX', { maximumFractionDigits: 4 }) : '-'}
</td>
<td class="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-blue-600 dark:text-blue-400">
{lineItem.quantity_returned != null ? lineItem.quantity_returned.toLocaleString('es-MX', { maximumFractionDigits: 4 }) : '-'}
</td>
<td class="px-2 py-1.5 text-right tabular-nums font-semibold whitespace-nowrap text-emerald-600 dark:text-emerald-400">
{lineItem.available_balance.toLocaleString('es-MX', { maximumFractionDigits: 4 })}
<span class="font-normal text-muted-foreground">{lineItem.unit_of_measure_code ?? ''}</span>
</td>
<td class="px-2 py-1.5 text-center whitespace-nowrap">
{#if lineItem.invoice_status === 'processed'}
<span class="inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/40 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 dark:text-emerald-300">
Procesada
</span>
{:else if lineItem.invoice_status === 'reversed'}
<span class="inline-flex items-center rounded-full bg-red-100 dark:bg-red-900/40 px-1.5 py-0.5 text-[10px] font-medium text-red-700 dark:text-red-300">
Revertida
</span>
{:else}
<span class="inline-flex items-center rounded-full bg-zinc-100 dark:bg-zinc-800 px-1.5 py-0.5 text-[10px] font-medium text-zinc-600 dark:text-zinc-400">
{lineItem.invoice_status ?? 'Pendiente'}
</span>
{/if}
</td>
<td class="px-2 py-1.5 text-center whitespace-nowrap">
{#if lineItem.is_subitem}
<span class="inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/40 px-1.5 py-0.5 text-[10px] font-medium text-purple-700 dark:text-purple-300">
Sub
</span>
{:else if lineItem.contains_subitems}
<span class="inline-flex items-center rounded-full bg-indigo-100 dark:bg-indigo-900/40 px-1.5 py-0.5 text-[10px] font-medium text-indigo-700 dark:text-indigo-300" title="{lineItem.subitem_count} subpartida(s)">
{lineItem.subitem_count ?? 0} sub
</span>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
{/if}
</div>
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -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<WithoutChild<SelectPrimitive.RootProps>, 'value' | 'onValueChange'> & {
type?: 'single' | 'multiple';
value?: string | string[];
onValueChange?: (value: any) => void;
};
let {
children,
value = $bindable(),
...restProps
}: WithoutChild<SelectPrimitive.RootProps> = $props();
}: SelectRootProps = $props();
let open = $state(false);
const query = writable('');