feature/reportes

This commit is contained in:
2026-04-16 15:59:18 -06:00
parent 11db840a11
commit de8f944a35
16 changed files with 2321 additions and 45 deletions

48
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,48 @@
# Caveman Ultra Default
Apply these rules to every task in this repository unless the user explicitly asks for explanation or a different format.
## Output Mode
- Zero prose by default.
- No greetings.
- No apologies.
- No pleasantries.
- Prefer exact terminal commands when the user asks for commands.
- Prefer code blocks only when the user asks for code.
- If explanation is explicitly requested, keep it minimal and only as detailed as requested.
- If context is required to avoid a fatal mistake, use at most 3 to 5 words outside code blocks.
## Response Rules
- Do not restate the request.
- Do not add summaries unless requested.
- Do not add rationale unless requested.
- Do not add transition phrases or filler text.
- Do not wrap commands in explanatory prose.
- Do not describe what code does unless requested.
- Keep code complete, accurate, and production-ready.
## Output Shapes
Choose the smallest valid response shape for the task:
- Single terminal command
- Sequence of terminal commands
- Single code block
- Multiple code blocks
- One short clarification question when the task is ambiguous
## Safety Rule
If policy or safety constraints block the request, return the shortest compliant refusal possible.
## Final Check
Before responding, verify:
- No filler words remain.
- No unnecessary explanation remains.
- Output shape matches the request.
- Commands are copy-paste safe.
- Code is directly usable.

133
.github/skills/caveman-ultra/SKILL.md vendored Normal file
View File

@@ -0,0 +1,133 @@
---
name: caveman-ultra
description: Enforce an ultra-terse response mode for every task in this repository. Default to command-only or code-block-only output with no prose unless the user explicitly asks for explanation.
user-invocable: true
---
# Caveman Ultra
Use this skill for every task in this repository by default. Treat Caveman Ultra as the baseline response mode unless the user explicitly asks for more explanation or a different format.
## Goal
Produce responses with these constraints:
- Zero prose.
- No greetings.
- No apologies.
- No pleasantries.
- Output only code blocks or exact terminal commands.
- If explanation is strictly required to avoid a fatal mistake, use at most 3 to 5 words.
- Keep code complete, accurate, and production-ready.
## Workflow
1. Detect activation.
Activate for every task by default.
1. Classify the required output.
Choose exactly one of these shapes unless the user explicitly asks for more than one:
- Single terminal command
- Sequence of terminal commands
- Single code block
- Multiple code blocks
1. Remove non-essential text.
Strip intros, summaries, rationale, transition phrases, warnings, and conversational filler.
1. Preserve critical safety.
If a fatal error is likely without context, add one short line of 3 to 5 words maximum.
1. Validate the final output.
Ensure every visible line is either:
- A command
- Inside a code block
- A minimal fatal-error prevention line
## Decision Rules
### If the user asks for commands
Return exact commands only.
### If the user asks for code
Return only code blocks.
### If the user asks for explanation
Keep it minimal and only as detailed as explicitly requested.
### If the task is ambiguous
Ask one short question using the same mode.
Example:
```text
repo or personal?
```
### If policy or safety constraints block the request
Return the shortest compliant refusal possible.
## Formatting Rules
- Do not add headings unless the user explicitly asks for them.
- Do not add bullets unless the user explicitly asks for a checklist.
- Do not wrap terminal commands in explanation text.
- Do not mix prose paragraphs with code blocks.
- Do not restate the request.
- Do not describe what the code does unless the user explicitly asks.
## Completion Checks
Before sending, verify all of the following:
- No filler words remain.
- No explanatory paragraph remains.
- Output shape matches the request.
- Code is runnable or directly usable.
- Commands are copy-paste safe.
- Any required warning is 3 to 5 words maximum.
## Examples
### Example prompt
```text
Use Caveman Ultra. Give pnpm commands to run frontend tests.
```
### Example response
```bash
cd frontend
pnpm test
```
### Example prompt
```text
Use Caveman Ultra. Write a Svelte loading component.
```
### Example response
```svelte
<script lang="ts">
export let label = 'Loading';
</script>
<div class="loading" aria-live="polite">{label}...</div>
<style>
.loading {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
</style>
```
## Scope Notes
- This skill is intended to apply repository-wide by default.
- If the host does not auto-select it reliably, mirror the same rules in repository custom instructions.

View File

@@ -1,6 +1,7 @@
from decimal import Decimal
from sqlalchemy.orm import Session
from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion
from api.v1.modules.a76.general_catalogs.equivalencies.models import EquivalencyItem
def _get_unit_equivalence(
db: Session,
@@ -13,11 +14,15 @@ def _get_unit_equivalence(
Busca una conversión entre dos unidades de medida.
Paridad: REVEQUIVALENCIA (Clarion SCAII).
Busca primero en el catálogo de Conversiones (unit_conversions) y,
si no encuentra, en el catálogo de Equivalencias (equivalency_items).
Retorna (multi_divide, factor_conv):
- ('M', factor) → multiplicar cantidad por factor
- ('D', factor) → dividir cantidad por factor
- ('', 0) → no existe equivalencia
"""
# ── 1. Catálogo de Conversiones ──────────────────────────────────────────
conv = (
db.query(UnitConversion)
.filter(
@@ -44,4 +49,33 @@ def _get_unit_equivalence(
if conv_inv and conv_inv.conversion_factor:
return "D", conv_inv.conversion_factor
# ── 2. Catálogo de Equivalencias (fallback) ──────────────────────────────
eq = (
db.query(EquivalencyItem)
.filter(
EquivalencyItem.tenant_id == tenant_id,
EquivalencyItem.company_id == company_id,
EquivalencyItem.original_field == from_unit,
EquivalencyItem.external_field == to_unit,
)
.first()
)
if eq:
factor = eq.conversion_factor if eq.conversion_factor else Decimal(1)
return "M", factor
eq_inv = (
db.query(EquivalencyItem)
.filter(
EquivalencyItem.tenant_id == tenant_id,
EquivalencyItem.company_id == company_id,
EquivalencyItem.original_field == to_unit,
EquivalencyItem.external_field == from_unit,
)
.first()
)
if eq_inv:
factor = eq_inv.conversion_factor if eq_inv.conversion_factor else Decimal(1)
return "D", factor
return "", Decimal(0)

View File

@@ -0,0 +1 @@
"""Downloaded parts report module."""

View File

@@ -0,0 +1,43 @@
from typing import Any
from fastapi import APIRouter, Body, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from .schemas import DownloadedPartsReportBootstrap, DownloadedPartsReportRequest
from .service import DownloadedPartsReportService
router = APIRouter(tags=["Reports - Downloaded Parts"])
@router.get(
"/bootstrap",
summary="Get downloaded parts report bootstrap",
description="Returns the base metadata required to render the downloaded parts report screen.",
)
def get_downloaded_parts_report_bootstrap(
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Any = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
service = DownloadedPartsReportService()
return service.build_bootstrap(company_id=company_id, tenant_id=tenant_id)
@router.post(
"/generate",
summary="Generate downloaded parts report CSV",
)
def generate_downloaded_parts_report(
company_id: int = Query(..., description="Company ID"),
request: DownloadedPartsReportRequest = Body(...),
db: Session = Depends(get_core_db),
current_user: Any = Depends(get_current_user),
) -> StreamingResponse:
tenant_id = validate_access_to_resource(db, company_id, current_user)
service = DownloadedPartsReportService()
return service.generate_csv(db=db, req=request, company_id=company_id, tenant_id=tenant_id)

View File

@@ -0,0 +1,56 @@
from datetime import date
from typing import Literal, Optional
from pydantic import BaseModel, Field
class DownloadedPartsReportRequest(BaseModel):
date_from: date
date_to: date
class_from: Optional[str] = None
class_to: Optional[str] = None
print_class_mode: Literal['exported', 'downloaded'] = 'downloaded'
exchange_rate_mode: Literal['invoice', 'pedimento_payment'] = 'invoice'
currency_mode: Literal['dollars', 'pesos', 'both'] = 'both'
temporality_mode: Literal['temporales', 'definitivos', 'ambos'] = 'temporales'
weight_type_mode: Literal['kilos', 'libras', 'ambos'] = 'kilos'
operation_mode: Literal['importacion', 'exportacion'] = 'importacion'
# Optional filters
material_type: Optional[str] = None
invoice_type: Optional[str] = None
parts: Optional[list[str]] = None
pedimento_key: Optional[str] = None
provider_id: Optional[int] = None
sold_to_id: Optional[int] = None
shipped_to_id: Optional[int] = None
destination_customs: Optional[str] = None
# Option flags
include_series: bool = False
print_class_total: bool = False
include_totals_by_fraction: bool = False
julian_date: bool = False
show_item_description: bool = True
include_exempt_fraction: bool = False
show_export_fraction: bool = False
include_rule_octava: bool = False
include_american_fraction_and_country: bool = False
respect_import_invoice_value_in_pesos: bool = False
show_all_temporary_balances: bool = False
class DownloadedPartsReportSection(BaseModel):
id: str
title: str
description: str
class DownloadedPartsReportBootstrap(BaseModel):
report_key: str = Field(default="downloaded_parts")
title: str = Field(default="Partes descargadas")
description: str
company_id: int
tenant_id: int
status: str = Field(default="draft")
available_filters: list[str]
next_steps: list[str]
sections: list[DownloadedPartsReportSection]

View File

@@ -0,0 +1,954 @@
import csv
import io
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Optional
from fastapi import HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session, aliased, selectinload
from api.v1.modules.a24.discharges.models import DischargeDetail, DischargeHeader, DischargeType
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from api.v1.modules.a76.app_settings.service import AppSettingsService
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
from api.v1.modules.a76.invoices.models import InvoiceComplianceMx, InvoiceHeader, InvoiceStatus
from api.v1.modules.a76.items.line_customs.models import LineCustom
from api.v1.modules.a76.items.line_descriptions.models import LineDescription
from api.v1.modules.a76.items.line_financials.models import LineFinancial
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.items.series.models import Serie
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
from .schemas import DownloadedPartsReportBootstrap, DownloadedPartsReportRequest, DownloadedPartsReportSection
LBS_PER_KG = Decimal('2.20462')
class DownloadedPartsReportService:
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _build_pedimento_str(
self,
year: Optional[str],
customs: Optional[str],
license_: Optional[str],
number: Optional[str],
) -> str:
if not all([year, customs, license_, number]):
return ''
return f"{year}/{customs}/{license_}/{number}"
def _build_pedimento_18(
self,
year: Optional[str],
customs: Optional[str],
license_: Optional[str],
number: Optional[str],
code: Optional[str],
) -> str:
if not all([year, customs, license_, number, code]):
return ''
return f"{year}{customs}{license_}{number}{code}"
def _format_date(self, d: Optional[date | datetime], julian: bool = False) -> str:
if d is None:
return ''
if isinstance(d, datetime):
d = d.date()
if julian:
return str(d.timetuple().tm_yday).zfill(3)
return d.strftime('%d/%m/%Y')
def _decimal_str(self, val, decimals: int = 2) -> str:
if val is None:
return ''
return f"{Decimal(str(val)):.{decimals}f}"
def _to_decimal(self, value) -> Decimal:
if value is None:
return Decimal('0')
if isinstance(value, Decimal):
return value
return Decimal(str(value))
def _clean_text(self, value: Optional[str]) -> str:
if not value:
return ''
return value.replace(',', ' ').replace('\r', ' ').replace('\n', ' ').strip()
def _as_date(self, value: Optional[date | datetime]) -> Optional[date]:
if value is None:
return None
if isinstance(value, datetime):
return value.date()
return value
def _adjust_payment_date(
self,
value: Optional[date | datetime],
use_previous_day: bool,
) -> Optional[date]:
resolved = self._as_date(value)
if resolved is None:
return None
if use_previous_day:
return resolved - timedelta(days=1)
return resolved
def _pedimento_headers(self, company: Optional[Company], ped_type: str) -> tuple[str, str]:
if company and company.rfc == 'MWE220512359':
if ped_type == 'export':
return ('PEDIMENTO DE EXPORTACIÓN', 'PEDIMENTO DE EXPORTACIÓN ORIGINAL RECTIFICADO')
return ('PEDIMENTO DE IMPORTACIÓN', 'PEDIMENTO DE IMPORTACIÓN ORIGINAL RECTIFICADO')
if ped_type == 'export':
return ('PEDIMENTO EXPORTACIÓN', 'PED. EXPO R1')
return ('PEDIMENTO IMPORTACIÓN', 'PED. IMPO R1')
def _pedimento_values(
self,
company: Optional[Company],
pedimento: str,
pedimento_r1: str,
) -> tuple[str, str]:
if company and company.rfc == 'MWE220512359':
if pedimento_r1:
return pedimento_r1, pedimento
return pedimento, ''
return pedimento, pedimento_r1
def _build_headers(self, req: DownloadedPartsReportRequest, company: Optional[Company]) -> list[str]:
export_headers = self._pedimento_headers(company, 'export')
import_headers = self._pedimento_headers(company, 'import')
headers = [
export_headers[0],
export_headers[1],
'FECHA PAGO PED EXPO',
'CLAVE',
'FECHA DE DESCARGA',
'FACTURA EXPO',
'FECHA EMISION',
'CLASE',
'DESCRIPCION',
'FRACCION ARANCELARIA',
'CANTIDAD',
'U.M.',
import_headers[0],
import_headers[1],
'FECHA PAGO PED IMPO',
'FACTURA IMPORTACION',
'FECHA EMISION IMPO',
'PESO NETO',
'VALOR TOTAL (DOLARES)',
'VALOR TOTAL (MONEDA NACIONAL)',
'TIPO DE CAMBIO',
'NO. DE PARTE',
'DESCRIPCION PARTE',
'TIPOEXPO',
'FRACCION CLASE',
'PEDIMENTO IMPO 18',
'PEDIMENTO EXPO 18',
'U.M. TARIFA',
]
if req.include_american_fraction_and_country:
headers.extend(['FRACCION AMERICANA', 'PAIS DE ORIGEN'])
return headers
def _write_company_header(
self,
writer: csv.writer,
company: Optional[Company],
settings: dict,
) -> None:
writer.writerow(['REPORTE DE CLASES EXPORTADAS/DESCARGADAS'])
if not company:
writer.writerow([
f"Fecha Generación: {datetime.now().strftime('%d/%m/%Y')} Hora Generación: {datetime.now().strftime('%H:%M:%S')}"
])
writer.writerow(['PROVEEDOR DE SOFTWARE: ADUANASOFT'])
writer.writerow([])
return
if company.name:
writer.writerow([company.name])
main_address = next((addr for addr in company.addresses if addr.address_type == 'main'), None)
if main_address:
fiscal_line = 'Domicilio Fiscal: ' + (main_address.street or '')
if main_address.exterior_number:
fiscal_line += f" Ext. Num: {main_address.exterior_number}"
if main_address.interior_number:
fiscal_line += f" Int. Num: {main_address.interior_number}"
writer.writerow([fiscal_line.strip()])
colony_line = (main_address.neighborhood or '').strip()
if main_address.postal_code:
colony_line = (colony_line + f" Código Postal: {main_address.postal_code}").strip()
if colony_line:
writer.writerow([colony_line])
city_line = ' '.join(filter(None, [main_address.city, main_address.state]))
if city_line:
writer.writerow([city_line])
industrial_address = next(
(addr for addr in company.addresses if addr.address_type == 'industrial'),
None,
)
if industrial_address:
industrial_line = 'Domicilio Industrial: ' + (industrial_address.street or '')
if industrial_address.exterior_number:
industrial_line += f" Ext. Num: {industrial_address.exterior_number}"
if industrial_address.interior_number:
industrial_line += f" Int. Num: {industrial_address.interior_number}"
writer.writerow([industrial_line.strip()])
industrial_colony = (industrial_address.neighborhood or '').strip()
if industrial_address.postal_code:
industrial_colony = (
industrial_colony + f" Código Postal: {industrial_address.postal_code}"
).strip()
if industrial_colony:
writer.writerow([industrial_colony])
industrial_city = ' '.join(filter(None, [industrial_address.city, industrial_address.state]))
if industrial_city:
writer.writerow([industrial_city])
if company.rfc:
writer.writerow([f"R.F.C: {company.rfc}"])
if settings.get('mostrarprogramaimmexprosec'):
if company.program_number:
if company.program == 'Maquila':
writer.writerow([f"SICEX: {company.program_number}"])
else:
writer.writerow([f"{company.program or 'Programa'}: {company.program_number}"])
if company.prosec_authorization:
writer.writerow([f"Autorización PROSEC: {company.prosec_authorization}"])
writer.writerow([
f"Fecha Generación: {datetime.now().strftime('%d/%m/%Y')} Hora Generación: {datetime.now().strftime('%H:%M:%S')}"
])
writer.writerow(['PROVEEDOR DE SOFTWARE: ADUANASOFT'])
writer.writerow([])
def _get_settings(self, db: Session, tenant_id: int, company_id: int) -> dict:
return AppSettingsService.get_resolved_settings(db, tenant_id, company_id) or {}
def _collect_rate_dates(
self,
rows: list,
req: DownloadedPartsReportRequest,
use_previous_payment_day: bool,
) -> set[date]:
dates: set[date] = set()
for row in rows:
if req.exchange_rate_mode == 'invoice':
export_invoice_date = self._as_date(row.expo_invoice_date)
import_invoice_date = self._as_date(row.impo_invoice_date)
if export_invoice_date:
dates.add(export_invoice_date)
if req.operation_mode == 'importacion' and import_invoice_date:
dates.add(import_invoice_date)
continue
target_date = row.impo_payment_date if req.operation_mode == 'importacion' else row.expo_payment_date
adjusted = self._adjust_payment_date(target_date, use_previous_payment_day)
if adjusted:
dates.add(adjusted)
return dates
def _load_exchange_rates(
self,
db: Session,
dates: set[date],
company_id: int,
tenant_id: int,
) -> dict[date, Decimal]:
if not dates:
return {}
rows = db.execute(
select(func.date(ExchangeRate.date), ExchangeRate.value).where(
ExchangeRate.tenant_id == tenant_id,
ExchangeRate.company_id == company_id,
func.date(ExchangeRate.date).in_(sorted(dates)),
)
).fetchall()
return {
self._as_date(rate_date): self._to_decimal(rate_value)
for rate_date, rate_value in rows
if rate_date is not None
}
def _resolve_selected_rate(
self,
row,
req: DownloadedPartsReportRequest,
rate_lookup: dict[date, Decimal],
use_previous_payment_day: bool,
) -> Optional[Decimal]:
export_invoice_date = self._as_date(row.expo_invoice_date)
import_invoice_date = self._as_date(row.impo_invoice_date)
export_payment_date = self._adjust_payment_date(row.expo_payment_date, use_previous_payment_day)
import_payment_date = self._adjust_payment_date(row.impo_payment_date, use_previous_payment_day)
if req.exchange_rate_mode == 'invoice':
if req.operation_mode == 'importacion':
return rate_lookup.get(import_invoice_date) or rate_lookup.get(export_invoice_date)
return rate_lookup.get(export_invoice_date)
if req.operation_mode == 'importacion':
return rate_lookup.get(import_payment_date)
return rate_lookup.get(export_payment_date)
def _find_missing_rate_dates(
self,
rows: list,
req: DownloadedPartsReportRequest,
rate_lookup: dict[date, Decimal],
use_previous_payment_day: bool,
) -> list[str]:
missing: set[date] = set()
for row in rows:
if req.exchange_rate_mode == 'invoice':
export_invoice_date = self._as_date(row.expo_invoice_date)
import_invoice_date = self._as_date(row.impo_invoice_date)
if export_invoice_date and export_invoice_date not in rate_lookup:
missing.add(export_invoice_date)
if req.operation_mode == 'importacion' and import_invoice_date and import_invoice_date not in rate_lookup:
missing.add(import_invoice_date)
continue
target_date = row.impo_payment_date if req.operation_mode == 'importacion' else row.expo_payment_date
adjusted = self._adjust_payment_date(target_date, use_previous_payment_day)
if adjusted and adjusted not in rate_lookup:
missing.add(adjusted)
return [d.strftime('%d/%m/%Y') for d in sorted(missing)]
def _resolve_export_fraction(self, row, req: DownloadedPartsReportRequest) -> str:
if req.include_rule_octava and row.export_octave_fraction:
return row.export_octave_fraction
return row.export_fraction or ''
def _resolve_class_fraction(self, row, req: DownloadedPartsReportRequest) -> str:
export_fraction = self._resolve_export_fraction(row, req)
import_fraction = row.import_fraction or ''
if req.include_rule_octava and row.import_octave_fraction:
import_fraction = row.import_octave_fraction
if req.show_export_fraction:
return row.class_fraction or ''
if req.print_class_mode == 'downloaded':
return import_fraction or row.class_fraction or ''
return export_fraction or row.class_fraction or ''
def _resolve_description(self, row, req: DownloadedPartsReportRequest) -> str:
line_description = row.export_line_description or row.export_line_part_description
class_description = row.export_line_class_description or row.class_description
part_description = row.part_description or row.export_line_part_description
if req.show_item_description:
return self._clean_text(line_description or part_description or class_description)
if part_description and class_description:
return self._clean_text(f"{part_description} / {class_description}")
return self._clean_text(part_description or class_description or line_description)
def _resolve_part_description(self, row) -> str:
return self._clean_text(row.export_line_part_description or row.part_description)
def _resolve_base_values(self, row) -> tuple[Decimal, Decimal]:
qty = self._to_decimal(row.quantity)
detail_mn = self._to_decimal(row.value_mn)
detail_usd = self._to_decimal(row.value_me)
import_qty = self._to_decimal(row.import_quantity_total)
if row.import_is_subitem:
return detail_mn, detail_usd
if row.import_unit_cost_mxn is not None or row.import_unit_cost_usd is not None:
return (
qty * self._to_decimal(row.import_unit_cost_mxn),
qty * self._to_decimal(row.import_unit_cost_usd),
)
if import_qty > 0:
ratio = qty / import_qty
customs_mxn = self._to_decimal(row.import_customs_value_mxn)
customs_usd = self._to_decimal(row.import_customs_value_usd)
if customs_mxn > 0 or customs_usd > 0:
return customs_mxn * ratio, customs_usd * ratio
return detail_mn, detail_usd
def _resolve_values(
self,
row,
req: DownloadedPartsReportRequest,
rate_lookup: dict[date, Decimal],
use_previous_payment_day: bool,
) -> tuple[Decimal, Decimal, Optional[Decimal]]:
detail_mn = self._to_decimal(row.value_mn)
detail_usd = self._to_decimal(row.value_me)
base_mn, base_usd = self._resolve_base_values(row)
selected_rate = self._resolve_selected_rate(row, req, rate_lookup, use_previous_payment_day)
if req.print_class_mode == 'downloaded':
source_mn = base_mn if base_mn > 0 else detail_mn
source_usd = base_usd if base_usd > 0 else detail_usd
else:
source_mn = detail_mn if detail_mn > 0 else base_mn
source_usd = detail_usd if detail_usd > 0 else base_usd
if req.operation_mode == 'importacion' and req.respect_import_invoice_value_in_pesos:
value_mn = source_mn
value_usd = source_mn / selected_rate if selected_rate and source_mn > 0 else source_usd
return value_mn, value_usd, selected_rate
if source_usd > 0 and selected_rate:
return source_usd * selected_rate, source_usd, selected_rate
if source_mn > 0 and selected_rate:
return source_mn, source_mn / selected_rate, selected_rate
return source_mn, source_usd, selected_rate
def _build_series_map(self, db: Session, line_ids: list[int], tenant_id: int) -> dict[int, list[Serie]]:
if not line_ids:
return {}
series_rows = (
db.query(Serie)
.filter(
Serie.tenant_id == tenant_id,
Serie.line_item_id.in_(line_ids),
)
.order_by(Serie.line_item_id, Serie.row, Serie.id)
.all()
)
series_map: dict[int, list[Serie]] = {}
for series in series_rows:
series_map.setdefault(series.line_item_id, []).append(series)
return series_map
# ------------------------------------------------------------------
# Bootstrap
# ------------------------------------------------------------------
def build_bootstrap(self, company_id: int, tenant_id: int) -> DownloadedPartsReportBootstrap:
return DownloadedPartsReportBootstrap(
description=(
"Base inicial para construir el reporte de partes descargadas desde exportacion. "
"Incluye metadatos, filtros sugeridos y bloques base para la vista."
),
company_id=company_id,
tenant_id=tenant_id,
available_filters=[
"fecha_inicio",
"fecha_fin",
"parte",
"pedimento",
"factura_exportacion",
"cliente",
],
next_steps=[
"Definir origen exacto de datos para descargas por parte.",
"Agregar filtros funcionales y tabla de resultados.",
"Conectar exportacion a Excel o CSV cuando el layout quede definido.",
],
sections=[
DownloadedPartsReportSection(
id="filters",
title="Filtros",
description="Contenedor para criterios de busqueda del reporte.",
),
DownloadedPartsReportSection(
id="results",
title="Resultados",
description="Espacio reservado para tabla o listado de partes descargadas.",
),
DownloadedPartsReportSection(
id="exports",
title="Exportacion",
description="Zona para acciones futuras de descarga y generacion de archivos.",
),
],
)
# ------------------------------------------------------------------
# Exchange rate validation
# ------------------------------------------------------------------
def validate_exchange_rates(
self,
db: Session,
req: DownloadedPartsReportRequest,
company_id: int,
tenant_id: int,
) -> list[str]:
settings = self._get_settings(db, tenant_id, company_id)
use_previous_payment_day = bool(settings.get('utilizarfechapagopeddeundiaanterior'))
rows = self.query_discharge_data(db, req, company_id, tenant_id)
rate_dates = self._collect_rate_dates(rows, req, use_previous_payment_day)
rate_lookup = self._load_exchange_rates(db, rate_dates, company_id, tenant_id)
return self._find_missing_rate_dates(rows, req, rate_lookup, use_previous_payment_day)
# ------------------------------------------------------------------
# Main data query
# ------------------------------------------------------------------
def query_discharge_data(
self,
db: Session,
req: DownloadedPartsReportRequest,
company_id: int,
tenant_id: int,
) -> list:
ExportLine = aliased(LineItem, name='export_line')
ImportLine = aliased(LineItem, name='import_line')
ExportInvoice = aliased(InvoiceHeader, name='export_invoice')
ImportInvoice = aliased(InvoiceHeader, name='import_invoice')
ExportCompliance = aliased(InvoiceComplianceMx, name='export_compliance')
ImportCompliance = aliased(InvoiceComplianceMx, name='import_compliance')
ExportPedimento = aliased(Pedimentos, name='export_pedimento')
ImportPedimento = aliased(Pedimentos, name='import_pedimento')
ExportPedR1 = aliased(Pedimentos, name='export_ped_r1')
ImportPedR1 = aliased(Pedimentos, name='import_ped_r1')
ExportPedDates = aliased(PedimentoDates, name='export_ped_dates')
ImportPedDates = aliased(PedimentoDates, name='import_ped_dates')
ExportPart = aliased(Part, name='export_part')
ExportCustom = aliased(LineCustom, name='export_custom')
ImportCustom = aliased(LineCustom, name='import_custom')
ExportDescription = aliased(LineDescription, name='export_description')
ImportFinancial = aliased(LineFinancial, name='import_financial')
ImportQuantity = aliased(LineQuantity, name='import_quantity')
ImportFa = aliased(FaLineItem, name='import_fa')
ExportTariffFraction = aliased(TariffFraction, name='export_tariff_fraction')
stmt = (
select(
ExportLine.id.label('export_line_id'),
ExportPedimento.year.label('expo_ped_year'),
ExportPedimento.customs_office.label('expo_ped_customs'),
ExportPedimento.license.label('expo_ped_license'),
ExportPedimento.pedimento_number.label('expo_ped_number'),
ExportPedimento.pedimento_code.label('expo_ped_code'),
ExportPedR1.year.label('expo_r1_year'),
ExportPedR1.customs_office.label('expo_r1_customs'),
ExportPedR1.license.label('expo_r1_license'),
ExportPedR1.pedimento_number.label('expo_r1_number'),
ExportPedDates.payment_date.label('expo_payment_date'),
ExportPedimento.pedimento_code.label('expo_clave'),
DischargeHeader.discharge_date.label('discharge_date'),
ExportInvoice.invoice_number.label('expo_invoice_number'),
ExportInvoice.invoice_date.label('expo_invoice_date'),
Class.class_code.label('class_code'),
Class.description_es.label('class_description'),
Class.material_key.label('material_key'),
Class.fraction.label('class_fraction'),
ExportCustom.fraction.label('export_fraction'),
ExportCustom.american_fraction.label('american_fraction'),
ExportCustom.octave_fraction.label('export_octave_fraction'),
ImportCustom.fraction.label('import_fraction'),
ImportCustom.octave_fraction.label('import_octave_fraction'),
DischargeDetail.quantity_discharged.label('quantity'),
DischargeDetail.unit_of_measure.label('unit_of_measure'),
DischargeDetail.value_me.label('value_me'),
DischargeDetail.value_mn.label('value_mn'),
DischargeDetail.net_weight.label('net_weight'),
DischargeDetail.origin_import_invoice.label('import_invoice_str'),
DischargeDetail.part_number.label('part_number_str'),
DischargeDetail.country_of_origin.label('country_of_origin'),
ImportPedimento.year.label('impo_ped_year'),
ImportPedimento.customs_office.label('impo_ped_customs'),
ImportPedimento.license.label('impo_ped_license'),
ImportPedimento.pedimento_number.label('impo_ped_number'),
ImportPedimento.pedimento_code.label('impo_ped_code'),
ImportPedR1.year.label('impo_r1_year'),
ImportPedR1.customs_office.label('impo_r1_customs'),
ImportPedR1.license.label('impo_r1_license'),
ImportPedR1.pedimento_number.label('impo_r1_number'),
ImportPedDates.payment_date.label('impo_payment_date'),
ImportInvoice.invoice_number.label('import_invoice_number'),
ImportInvoice.invoice_date.label('impo_invoice_date'),
ImportInvoice.invoice_type.label('import_invoice_type'),
ExportPart.description_spanish.label('part_description'),
ExportDescription.description_spanish.label('export_line_description'),
ExportDescription.part_description.label('export_line_part_description'),
ExportDescription.class_description.label('export_line_class_description'),
ExportDescription.brand.label('export_brand'),
ExportDescription.model.label('export_model'),
ImportFinancial.unit_cost_mxn.label('import_unit_cost_mxn'),
ImportFinancial.unit_cost_usd.label('import_unit_cost_usd'),
ImportFinancial.customs_value_mxn.label('import_customs_value_mxn'),
ImportFinancial.customs_value_usd.label('import_customs_value_usd'),
ImportQuantity.quantity.label('import_quantity_total'),
ImportLine.payment_method.label('import_payment_method'),
ImportFa.is_subitem.label('import_is_subitem'),
ExportTariffFraction.umt.label('tariff_uom'),
)
.select_from(DischargeDetail)
.join(DischargeHeader, DischargeDetail.discharge_header_id == DischargeHeader.id)
.join(ExportLine, DischargeDetail.export_item_line_id == ExportLine.id)
.join(ImportLine, DischargeDetail.import_item_line_id == ImportLine.id)
.join(ExportInvoice, ExportLine.invoice_id == ExportInvoice.id)
.join(ImportInvoice, ImportLine.invoice_id == ImportInvoice.id)
.outerjoin(ExportCompliance, ExportCompliance.invoice_id == ExportInvoice.id)
.outerjoin(ImportCompliance, ImportCompliance.invoice_id == ImportInvoice.id)
.outerjoin(ExportPedimento, ExportPedimento.id == ExportCompliance.pedimento_id)
.outerjoin(ImportPedimento, ImportPedimento.id == ImportCompliance.pedimento_id)
.outerjoin(ExportPedR1, ExportPedR1.id == ExportCompliance.pedimento_r1)
.outerjoin(ImportPedR1, ImportPedR1.id == ImportCompliance.pedimento_r1)
.outerjoin(ExportPedDates, ExportPedDates.pedimento_id == ExportPedimento.id)
.outerjoin(ImportPedDates, ImportPedDates.pedimento_id == ImportPedimento.id)
.outerjoin(Class, Class.id == ExportLine.class_id)
.outerjoin(ExportPart, ExportPart.id == ExportLine.part_number_id)
.outerjoin(ExportCustom, ExportCustom.item_line_id == ExportLine.id)
.outerjoin(ImportCustom, ImportCustom.item_line_id == ImportLine.id)
.outerjoin(ExportDescription, ExportDescription.item_line_id == ExportLine.id)
.outerjoin(ImportFinancial, ImportFinancial.item_line_id == ImportLine.id)
.outerjoin(ImportQuantity, ImportQuantity.item_line_id == ImportLine.id)
.outerjoin(ImportFa, ImportFa.id == ImportLine.id)
.outerjoin(
ExportTariffFraction,
ExportTariffFraction.code == func.substr(
func.replace(func.coalesce(ExportCustom.fraction, ''), '.', ''),
1,
8,
),
)
.where(
DischargeHeader.tenant_id == tenant_id,
DischargeHeader.company_id == company_id,
ExportInvoice.status == InvoiceStatus.PROCESSED,
)
)
# Date filter
if req.print_class_mode == 'exported':
stmt = stmt.where(
ExportInvoice.invoice_date.between(req.date_from, req.date_to)
)
else:
stmt = stmt.where(
ExportInvoice.invoice_type != 'NODES',
or_(
ExportPedDates.payment_date.between(req.date_from, req.date_to),
and_(
ExportPedDates.payment_date.is_(None),
ExportInvoice.invoice_date.between(req.date_from, req.date_to),
),
),
)
# Temporality
if req.temporality_mode == 'temporales':
stmt = stmt.where(DischargeHeader.discharge_type == DischargeType.TEMPORARY)
elif req.temporality_mode == 'definitivos':
stmt = stmt.where(DischargeHeader.discharge_type == DischargeType.DEFINITIVE)
# Class range
if req.class_from:
stmt = stmt.where(Class.class_code >= req.class_from)
if req.class_to:
stmt = stmt.where(Class.class_code <= req.class_to)
if req.material_type:
stmt = stmt.where(Class.material_key == req.material_type)
if req.invoice_type:
stmt = stmt.where(ExportInvoice.invoice_type == req.invoice_type)
if req.parts:
stmt = stmt.where(DischargeDetail.part_number.in_(req.parts))
if req.pedimento_key:
stmt = stmt.where(ExportPedimento.pedimento_code == req.pedimento_key)
if req.provider_id:
stmt = stmt.where(ExportCompliance.provider_id == req.provider_id)
if req.sold_to_id:
stmt = stmt.where(ExportCompliance.sold_to_id == req.sold_to_id)
if getattr(req, 'shipped_to_id', None):
stmt = stmt.where(ExportCompliance.shipped_to_id == req.shipped_to_id)
if req.destination_customs:
stmt = stmt.where(ExportCompliance.aduana == req.destination_customs)
if not req.include_exempt_fraction:
stmt = stmt.where(
or_(
Class.iva_exempt_fraction.is_(None),
Class.iva_exempt_fraction != 'Si',
)
)
if req.print_class_mode == 'downloaded' and not req.show_all_temporary_balances:
stmt = stmt.where(
or_(
ImportLine.payment_method.is_(None),
ImportLine.payment_method != '2',
)
)
stmt = stmt.order_by(
Class.class_code.nullslast(),
ExportInvoice.invoice_date,
DischargeDetail.id,
)
return db.execute(stmt).fetchall()
# ------------------------------------------------------------------
# CSV generation
# ------------------------------------------------------------------
def generate_csv(
self,
db: Session,
req: DownloadedPartsReportRequest,
company_id: int,
tenant_id: int,
) -> StreamingResponse:
settings = self._get_settings(db, tenant_id, company_id)
use_previous_payment_day = bool(settings.get('utilizarfechapagopeddeundiaanterior'))
company = (
db.query(Company)
.options(selectinload(Company.addresses))
.filter(Company.id == company_id, Company.tenant_id == tenant_id)
.first()
)
rows = self.query_discharge_data(db, req, company_id, tenant_id)
rate_dates = self._collect_rate_dates(rows, req, use_previous_payment_day)
rate_lookup = self._load_exchange_rates(db, rate_dates, company_id, tenant_id)
missing = self._find_missing_rate_dates(rows, req, rate_lookup, use_previous_payment_day)
if missing:
raise HTTPException(status_code=422, detail={'missing_dates': missing})
headers = self._build_headers(req, company)
output = io.StringIO()
writer = csv.writer(output)
self._write_company_header(writer, company, settings)
writer.writerow(headers)
current_class: Optional[str] = None
class_qty = Decimal('0')
class_weight_kgs = Decimal('0')
class_weight_lbs = Decimal('0')
class_mn = Decimal('0')
class_me = Decimal('0')
fraction_totals: dict[str, dict[str, Decimal]] = {}
series_map = self._build_series_map(
db,
[row.export_line_id for row in rows if row.export_line_id is not None],
tenant_id,
)
def flush_class_total() -> None:
if req.print_class_total and current_class is not None:
writer.writerow(['TOTAL DE LA CLASE'])
writer.writerow([
self._decimal_str(class_qty, 4),
self._decimal_str(class_weight_kgs, 4),
self._decimal_str(class_weight_lbs, 4),
self._decimal_str(class_mn),
self._decimal_str(class_me),
])
for row in rows:
class_code = row.class_code or ''
if req.print_class_total and class_code != current_class:
flush_class_total()
current_class = class_code
class_qty = Decimal('0')
class_weight_kgs = Decimal('0')
class_weight_lbs = Decimal('0')
class_mn = Decimal('0')
class_me = Decimal('0')
export_fraction = self._resolve_export_fraction(row, req)
class_fraction = self._resolve_class_fraction(row, req)
description_value = self._resolve_description(row, req)
part_description = self._resolve_part_description(row)
value_mn, value_me, selected_rate = self._resolve_values(
row,
req,
rate_lookup,
use_previous_payment_day,
)
quantity = self._to_decimal(row.quantity)
weight_kgs = self._to_decimal(row.net_weight)
weight_lbs = weight_kgs * LBS_PER_KG
if req.print_class_total:
class_qty += quantity
class_weight_kgs += weight_kgs
class_weight_lbs += weight_lbs
class_mn += value_mn
class_me += value_me
if req.include_totals_by_fraction:
fraction_key = export_fraction or ''
if fraction_key not in fraction_totals:
fraction_totals[fraction_key] = {'mn': Decimal('0'), 'me': Decimal('0')}
fraction_totals[fraction_key]['mn'] += value_mn
fraction_totals[fraction_key]['me'] += value_me
export_ped = self._build_pedimento_str(
row.expo_ped_year,
row.expo_ped_customs,
row.expo_ped_license,
row.expo_ped_number,
)
export_r1 = self._build_pedimento_str(
row.expo_r1_year,
row.expo_r1_customs,
row.expo_r1_license,
row.expo_r1_number,
)
import_ped = self._build_pedimento_str(
row.impo_ped_year,
row.impo_ped_customs,
row.impo_ped_license,
row.impo_ped_number,
)
import_r1 = self._build_pedimento_str(
row.impo_r1_year,
row.impo_r1_customs,
row.impo_r1_license,
row.impo_r1_number,
)
export_ped_18 = self._build_pedimento_18(
row.expo_ped_year,
row.expo_ped_customs,
row.expo_ped_license,
row.expo_ped_number,
row.expo_ped_code,
)
import_ped_18 = self._build_pedimento_18(
row.impo_ped_year,
row.impo_ped_customs,
row.impo_ped_license,
row.impo_ped_number,
row.impo_ped_code,
)
export_ped_values = self._pedimento_values(company, export_ped, export_r1)
import_ped_values = self._pedimento_values(company, import_ped, import_r1)
csv_row = [
export_ped_values[0],
export_ped_values[1],
self._format_date(row.expo_payment_date, req.julian_date),
row.expo_clave or '',
self._format_date(row.discharge_date, req.julian_date),
row.expo_invoice_number or '',
self._format_date(row.expo_invoice_date, req.julian_date),
class_code,
description_value,
export_fraction,
self._decimal_str(quantity, 4),
row.unit_of_measure or '',
import_ped_values[0],
import_ped_values[1],
self._format_date(row.impo_payment_date, req.julian_date),
row.import_invoice_number or row.import_invoice_str or '',
self._format_date(row.impo_invoice_date, req.julian_date),
self._decimal_str(weight_kgs, 4),
self._decimal_str(value_me),
self._decimal_str(value_mn),
self._decimal_str(selected_rate, 6) if selected_rate else '',
row.part_number_str or '',
part_description,
row.material_key or '',
class_fraction,
import_ped_18,
export_ped_18,
row.tariff_uom or '',
]
if req.include_american_fraction_and_country:
csv_row.extend([
row.american_fraction or '',
row.country_of_origin or '',
])
writer.writerow(csv_row)
if req.include_series and row.export_line_id in series_map:
writer.writerow([
'RENGLON',
'SERIE',
'MODELO',
'SUBMODELO',
'PARTE',
'NUM ID EXPO',
'MARCA PARTIDA',
'MODELO PARTIDA',
])
for series in series_map[row.export_line_id]:
writer.writerow([
series.row or '',
series.serial_numbers or '',
series.model or '',
series.sub_model or '',
row.part_number_str or '',
series.number_id or '',
row.export_brand or '',
row.export_model or '',
])
flush_class_total()
if req.include_totals_by_fraction and fraction_totals:
writer.writerow([])
writer.writerow(['TOTALES POR FRACCION'])
writer.writerow(['FRACCION', 'VALOR MN', 'VALOR ME'])
total_mn = Decimal('0')
total_me = Decimal('0')
for fraction, totals in sorted(fraction_totals.items()):
writer.writerow([
fraction,
self._decimal_str(totals['mn']),
self._decimal_str(totals['me']),
])
total_mn += totals['mn']
total_me += totals['me']
writer.writerow(['TOTAL'])
writer.writerow(['', self._decimal_str(total_mn), self._decimal_str(total_me)])
csv_content = output.getvalue()
filename = f"partes_descargadas_{req.date_from}_{req.date_to}.csv"
return StreamingResponse(
iter([csv_content.encode('utf-8-sig')]),
media_type='text/csv',
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
)

View File

@@ -41,6 +41,7 @@ from .reports.importacion.facturas.routes import router as invoices_reports_rout
from .reports.importacion.consolidados.routes import router as consolidated_reports_router
from .reports.importacion.packing_list.routes import router as packing_list_router
from .reports.exportacion.aviso_consolidado.routes import router as aviso_consolidado_export_router
from .reports.exportacion.partes_descargadas.routes import router as downloaded_parts_reports_router
from .reports.movements.invoices.routes import router as movement_invoices_router
from .reports.movements.saldos.routes import router as movement_saldos_router
from .reports.exportacion.descargo.routes import router as discharge_reports_router
@@ -118,6 +119,12 @@ router.include_router(
tags=["a76 / reports"]
)
router.include_router(
downloaded_parts_reports_router,
prefix="/a76/reports/exportacion/partes-descargadas",
tags=["a76 / reports"]
)
router.include_router(
movement_invoices_router,

View File

@@ -519,6 +519,12 @@ async function fetchBlob(endpoint: string, options: RequestInit = {}): Promise<B
export const api = {
get: <T = any>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
getBlob: (endpoint: string) => fetchBlob(endpoint, { method: 'GET' }),
postBlob: (endpoint: string, body: any) =>
fetchBlob(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}),
post: <T = any>(endpoint: string, body: any, options: RequestInit = {}) =>
fetchApi<T>(endpoint, {

View File

@@ -0,0 +1,76 @@
import { api } from '$lib/api';
export interface DownloadedPartsReportSection {
id: string;
title: string;
description: string;
}
export interface DownloadedPartsReportBootstrap {
report_key: string;
title: string;
description: string;
company_id: number;
tenant_id: number;
status: string;
available_filters: string[];
next_steps: string[];
sections: DownloadedPartsReportSection[];
}
export interface DownloadedPartsReportRequest {
date_from: string;
date_to: string;
class_from?: string;
class_to?: string;
print_class_mode: 'exported' | 'downloaded';
exchange_rate_mode: 'invoice' | 'pedimento_payment';
currency_mode: 'dollars' | 'pesos' | 'both';
temporality_mode: 'temporales' | 'definitivos' | 'ambos';
weight_type_mode: 'kilos' | 'libras' | 'ambos';
operation_mode: 'importacion' | 'exportacion';
material_type?: string;
invoice_type?: string;
parts?: string[];
pedimento_key?: string;
provider_id?: number;
sold_to_id?: number;
shipped_to_id?: number;
destination_customs?: string;
include_series: boolean;
print_class_total: boolean;
include_totals_by_fraction: boolean;
julian_date: boolean;
show_item_description: boolean;
include_exempt_fraction: boolean;
show_export_fraction: boolean;
include_rule_octava: boolean;
include_american_fraction_and_country: boolean;
respect_import_invoice_value_in_pesos: boolean;
show_all_temporary_balances: boolean;
}
export const downloadedPartsReportsApi = {
getBootstrap: (companyId: number) =>
api.get<DownloadedPartsReportBootstrap>(
`/v1/a76/reports/exportacion/partes-descargadas/bootstrap?company_id=${companyId}`
),
generate: async (
companyId: number,
params: DownloadedPartsReportRequest
): Promise<void> => {
const blob = await api.postBlob(
`/v1/a76/reports/exportacion/partes-descargadas/generate?company_id=${companyId}`,
params
);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `partes_descargadas_${params.date_from}_${params.date_to}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
};

View File

@@ -3,13 +3,15 @@
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { FolderSearch, Scale } from 'lucide-svelte';
import UnitMeasureSelectorDialog from '$lib/components/dashboard/goods/modales/unit-measure-dialog.svelte';
import { companyStore } from '$lib/stores/company.svelte';
import { Scale } from 'lucide-svelte';
import {
createEquivalencyItem,
updateEquivalencyItem,
type EquivalencyItem
} from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
import type { UnitOfMeasure } from '$lib/api/dashboard/a76/general_catalogs/units-of-measure';
let {
open = $bindable(false),
@@ -37,25 +39,33 @@
let loading = $state(false);
let error = $state<string | null>(null);
let showOriginalModal = $state(false);
let showExternalModal = $state(false);
$effect(() => {
if (!open) return;
if (item) {
formData = {
original_field: item.original_field || '',
external_field: item.external_field || ''
};
formData.original_field = item.original_field || '';
formData.external_field = item.external_field || '';
} else {
formData = {
original_field: defaultOriginalField ?? '',
external_field: ''
};
formData.original_field = defaultOriginalField ?? '';
formData.external_field = '';
}
error = null;
});
function handleSelectOriginal(unit: UnitOfMeasure) {
formData.original_field = unit.code;
showOriginalModal = false;
}
function handleSelectExternal(unit: UnitOfMeasure) {
formData.external_field = unit.code;
showExternalModal = false;
}
async function handleSubmit() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
@@ -113,40 +123,64 @@
<div class="grid gap-4 py-4">
<div class="grid gap-2">
<Label for="from_unit_code">
<Label for="original_field">
Campo Original <span class="text-destructive">*</span>
</Label>
<div class="flex gap-2">
<div class="relative w-full">
<Scale class="absolute top-2.5 left-3 h-4 w-4 text-muted-foreground" />
<Input
id="from_unit_code"
bind:value={formData.original_field}
class="pl-9 font-mono"
placeholder="Ej: PZA, KGM..."
disabled={loading}
required
/>
<Input
id="original_field"
bind:value={formData.original_field}
readonly
onclick={() => (showOriginalModal = true)}
class="cursor-pointer pl-9 font-mono"
placeholder="Seleccione..."
disabled={loading}
required
/>
</div>
<Button
variant="outline"
size="icon"
type="button"
onclick={() => (showOriginalModal = true)}
disabled={loading}
class="shrink-0"
>
<FolderSearch class="h-4 w-4" />
</Button>
</div>
</div>
<div class="grid gap-2">
<Label for="to_unit_code">
<Label for="external_field">
Campo Exterior <span class="text-destructive">*</span>
</Label>
<div class="flex gap-2">
<div class="relative w-full">
<Scale class="absolute top-2.5 left-3 h-4 w-4 text-muted-foreground" />
<Input
id="to_unit_code"
bind:value={formData.external_field}
class="pl-9 font-mono"
placeholder="Ej: PIEZAS, KGS..."
disabled={loading}
required
/>
<Input
id="external_field"
bind:value={formData.external_field}
readonly
onclick={() => (showExternalModal = true)}
class="cursor-pointer pl-9 font-mono"
placeholder="Seleccione..."
disabled={loading}
required
/>
</div>
<Button
variant="outline"
size="icon"
type="button"
onclick={() => (showExternalModal = true)}
disabled={loading}
class="shrink-0"
>
<FolderSearch class="h-4 w-4" />
</Button>
</div>
</div>
</div>
@@ -160,3 +194,6 @@
</form>
</Dialog.Content>
</Dialog.Root>
<UnitMeasureSelectorDialog bind:open={showOriginalModal} onSelect={handleSelectOriginal} />
<UnitMeasureSelectorDialog bind:open={showExternalModal} onSelect={handleSelectExternal} />

View File

@@ -33,13 +33,9 @@
operation_type: operationType,
invoice_number: searchTerm || undefined
};
if (operationType === 'imp' && regimen) {
if (regimen === 'Temporal' || regimen === 'TEMPORAL SCAF') {
filters.invoice_type = 'TEM';
} else if (regimen === 'Definitiva' || regimen === 'DEFINITIVO SCAF') {
filters.invoice_type = 'DEF';
}
}
// Do NOT filter by invoice_type here: restricting to TEM or DEF based on
// the current movement_type_import value would hide valid invoices of the
// other type. Let the user search freely and pick the right one.
const res = await invoicesApi.list(companyStore.activeCompany.id, 1, 50, filters);
if (res.data) {
@@ -134,7 +130,8 @@
{invoice.invoice_number}
</td>
<td class="max-w-[400px] truncate p-3 text-muted-foreground italic">
{invoice.compliance_mx?.pedimento_r1 ||
{invoice.compliance_mx?.pedimento?.pedimento_number ||
invoice.compliance_mx?.pedimento_r1 ||
invoice.compliance_mx?.pedimento_id ||
'-'}
</td>

View File

@@ -164,10 +164,12 @@
if (showLinkToImportBlock && num && !selectedImportInvoiceId && !loadingImportLines) {
(async () => {
try {
const res = await invoicesApi.list(companyStore.activeCompany!.id, 1, 5, {
// Do NOT filter by invoice_type: if movement_type_import is null or
// mismatched the invoice won't be found, leaving the line picker
// permanently disabled. Exact match is enforced by .find() below.
const res = await invoicesApi.list(companyStore.activeCompany!.id, 1, 50, {
operation_type: 'imp',
invoice_number: num,
invoice_type: movementType === 'DEF' ? 'DEF' : 'TEM'
invoice_number: num
});
const items = res.data?.items ?? [];
const inv = items.find((i: Invoice) => i.invoice_number === num);
@@ -183,7 +185,7 @@
if (showRepairBlock && num && !selectedExportInvoiceId && !loadingExportLines) {
(async () => {
try {
const res = await invoicesApi.list(companyStore.activeCompany!.id, 1, 5, {
const res = await invoicesApi.list(companyStore.activeCompany!.id, 1, 50, {
operation_type: 'exp',
invoice_number: num
});
@@ -735,7 +737,7 @@
<Dialog.Header>
<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
Líneas sin saldo disponible se muestran en gris.
</p>
</Dialog.Header>
<!-- overflow-x on a wrapper that does NOT also do overflow-y.
@@ -743,9 +745,9 @@
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)}
{#if importInvoiceLines.length === 0}
<p class="px-3 py-8 text-xs text-muted-foreground text-center">
No hay líneas con saldo disponible en esta factura.
No hay líneas en esta factura.
</p>
{:else}
<table class="text-xs border-collapse" style="min-width: max-content; width: 100%;">
@@ -768,9 +770,8 @@
</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"
class="{lineItem.has_balance ? 'hover:bg-muted/50 cursor-pointer' : 'opacity-50 cursor-default'} transition-colors group"
onclick={() => {
editingItem.fa_data = editingItem.fa_data || {};
editingItem.fa_data.search_line = lineItem.line_number;
@@ -830,7 +831,6 @@
{/if}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>

View File

@@ -507,6 +507,10 @@ export function getSidebarData(): SidebarData {
title: "Facturas Impo/Expo",
url: "/dashboard/reports/invoices",
},
{
title: "Partes descargadas",
url: "/dashboard/reports/partes-descargadas",
},
],
},
{

View File

@@ -0,0 +1,15 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import { getAuthTokens } from '$lib/server/api';
export const load: PageServerLoad = async ({ cookies }) => {
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) {
throw redirect(302, '/login');
}
return {
title: 'Partes descargadas'
};
};

View File

@@ -0,0 +1,865 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import {
FileSearch,
Filter,
Boxes,
Search,
Printer,
X,
Package,
Scale,
BadgeDollarSign,
Clock3
} from 'lucide-svelte';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Separator } from '$lib/components/ui/separator';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as RadioGroup from '$lib/components/ui/radio-group';
import * as Select from '$lib/components/ui/select';
import { companyStore } from '$lib/stores/company.svelte';
import { clientsProvidersApi, type ClientProvider } from '$lib/api/dashboard/a76/clients-providers';
import { partsApi, type Part } from '$lib/api/dashboard/a76/parts';
import { classesApi, type A76Class } from '$lib/api/dashboard/a76/classes';
import { invoiceTypesApi, type InvoiceType } from '$lib/api/dashboard/reference_data/invoice_types';
import { pedimentoCodesApi, type PedimentoCode } from '$lib/api/dashboard/reference_data/pedimento_codes';
import { materialTypesApi, type MaterialType } from '$lib/api/dashboard/reference_data/material_types';
import { customsSectionsApi, type CustomsSection } from '$lib/api/dashboard/reference_data/customs_sections';
import {
downloadedPartsReportsApi,
type DownloadedPartsReportBootstrap,
type DownloadedPartsReportRequest
} from '$lib/api/dashboard/a76/reports/reports-partes-descargadas';
type FilterFieldKey =
| 'materialType'
| 'invoiceType'
| 'parts'
| 'pedimentoKey'
| 'provider'
| 'destinationCustoms'
| 'soldTo'
| 'shippedTo';
type OptionKey =
| 'sendEmailSelection'
| 'respectImportInvoiceValueInPesos'
| 'showAllTemporaryBalances'
| 'shelterOption'
| 'showExportFraction'
| 'includeRuleOctava'
| 'includeAmericanFractionAndCountry'
| 'includeSeries'
| 'printClassTotal'
| 'includeTotalsByFraction'
| 'julianDate'
| 'showItemDescription'
| 'includeExemptFraction';
let bootstrap = $state<DownloadedPartsReportBootstrap | null>(null);
let isLoading = $state(false);
let isGenerating = $state(false);
let isCatalogLoading = $state(false);
let lastCompanyId = $state<number | null>(null);
let clientsProviders = $state<ClientProvider[]>([]);
let invoiceTypes = $state<InvoiceType[]>([]);
let pedimentoCodes = $state<PedimentoCode[]>([]);
let materialTypes = $state<MaterialType[]>([]);
let customsSections = $state<CustomsSection[]>([]);
let partsCatalog = $state<Part[]>([]);
let classesCatalog = $state<A76Class[]>([]);
let dates = $state({ from: '', to: '' });
let classRange = $state({ from: '', to: '' });
let filters = $state<Record<FilterFieldKey, string>>({
materialType: '',
invoiceType: '',
parts: '',
pedimentoKey: '',
provider: '',
destinationCustoms: '',
soldTo: '',
shippedTo: ''
});
let printClassMode = $state<'exported' | 'downloaded'>('downloaded');
let currencyMode = $state<'dollars' | 'pesos' | 'both'>('both');
let exchangeRateMode = $state<'invoice' | 'pedimento_payment'>('invoice');
let temporalityMode = $state<'temporales' | 'definitivos' | 'ambos'>('temporales');
let weightTypeMode = $state<'kilos' | 'libras' | 'ambos'>('kilos');
let operationMode = $state<'importacion' | 'exportacion'>('importacion');
let options = $state<Record<OptionKey, boolean>>({
sendEmailSelection: false,
respectImportInvoiceValueInPesos: false,
showAllTemporaryBalances: false,
shelterOption: false,
showExportFraction: false,
includeRuleOctava: false,
includeAmericanFractionAndCountry: false,
includeSeries: false,
printClassTotal: false,
includeTotalsByFraction: false,
julianDate: false,
showItemDescription: true,
includeExemptFraction: false
});
const leftOptions: Array<{ id: string; label: string; key: OptionKey }> = [
{
id: 'email-selection',
label: 'Seleccione para enviar correo electrónico',
key: 'sendEmailSelection'
},
{
id: 'respect-pesos',
label: 'Respetar Valor en Pesos Facturas de Importación',
key: 'respectImportInvoiceValueInPesos'
},
{
id: 'show-temp-balances',
label: 'Mostrar todos los saldos temporales',
key: 'showAllTemporaryBalances'
},
{ id: 'shelter-option', label: 'Opción Shelter', key: 'shelterOption' },
{
id: 'show-export-fraction',
label: 'Mostrar Fraccion de Exportación',
key: 'showExportFraction'
},
{ id: 'include-rule-8', label: 'Incluir Regla Octava', key: 'includeRuleOctava' },
{
id: 'include-american-fraction',
label: 'Incluir Fracción Americana y País de Origen',
key: 'includeAmericanFractionAndCountry'
}
];
const rightOptions: Array<{ id: string; label: string; key: OptionKey }> = [
{ id: 'include-series', label: 'Incluir Series', key: 'includeSeries' },
{ id: 'print-total-class', label: 'Imprimir Total Clase', key: 'printClassTotal' },
{
id: 'include-totals-fraction',
label: 'Incluir Totales x Fracción',
key: 'includeTotalsByFraction'
},
{ id: 'julian-date', label: 'Fecha Juliana', key: 'julianDate' },
{
id: 'show-item-description',
label: 'Mostrar Descripcion de Partida',
key: 'showItemDescription'
},
{
id: 'include-exempt-fraction',
label: 'Incluir Fraccion Exenta',
key: 'includeExemptFraction'
}
];
const allOptions = [...leftOptions, ...rightOptions];
const CLEAR_SELECT_VALUE = '__clear__';
async function loadBootstrap(companyId?: number) {
const activeCompanyId = companyId ?? companyStore.activeCompany?.id;
if (!activeCompanyId) {
bootstrap = null;
return;
}
isLoading = true;
const response = await downloadedPartsReportsApi.getBootstrap(activeCompanyId);
if (response.data) {
bootstrap = response.data;
} else {
toast.error(response.error || 'No se pudo cargar la base del reporte');
}
isLoading = false;
}
async function loadCatalogs(companyId: number) {
isCatalogLoading = true;
try {
const [
clientsProvidersResponse,
invoiceTypesResponse,
pedimentoCodesResponse,
materialTypesResponse,
customsSectionsResponse,
partsResponse,
classesResponse
] = await Promise.all([
clientsProvidersApi.list(companyId, 1, 1000),
invoiceTypesApi.list(1, 1000),
pedimentoCodesApi.list(1, 1000),
materialTypesApi.list(1, 1000),
customsSectionsApi.list(1, 1000),
partsApi.list({ company_id: companyId, page: 1, page_size: 1000 }),
classesApi.getWithFAData({ company_id: companyId, page: 1, page_size: 1000 })
]);
clientsProviders = clientsProvidersResponse.data?.items || [];
invoiceTypes = invoiceTypesResponse.data?.items || [];
pedimentoCodes = pedimentoCodesResponse.data?.items || [];
materialTypes = materialTypesResponse.data?.items || [];
customsSections = customsSectionsResponse.data?.items || [];
partsCatalog = partsResponse.data?.items || [];
classesCatalog = classesResponse.data || [];
} catch (error) {
console.error('Error cargando catálogos para Partes descargadas:', error);
toast.error('No se pudieron cargar algunos catálogos');
} finally {
isCatalogLoading = false;
}
}
let providerOptions = $derived.by(() =>
clientsProviders.filter(
(item) => item.client_or_provider === 'provider' || item.client_or_provider === 'both'
)
);
let soldToOptions = $derived.by(() =>
clientsProviders.filter(
(item) => item.client_or_provider === 'client' || item.client_or_provider === 'both'
)
);
let shippedToOptions = $derived.by(() => {
const unique = new Map<number, ClientProvider>();
clientsProviders.forEach((item) => unique.set(item.id, item));
return Array.from(unique.values());
});
let filteredInvoiceTypes = $derived.by(() => {
const op = operationMode === 'importacion' ? 'imp' : 'exp';
return invoiceTypes.filter((item) => !item.operation || item.operation === 'both' || item.operation === op);
});
$effect(() => {
const companyId = companyStore.activeCompany?.id ?? null;
if (!companyId) {
lastCompanyId = null;
bootstrap = null;
clientsProviders = [];
invoiceTypes = [];
pedimentoCodes = [];
materialTypes = [];
customsSections = [];
partsCatalog = [];
classesCatalog = [];
return;
}
if (companyId === lastCompanyId) {
return;
}
lastCompanyId = companyId;
void loadBootstrap(companyId);
void loadCatalogs(companyId);
});
$effect(() => {
if (!filters.invoiceType) {
return;
}
const stillExists = filteredInvoiceTypes.some((item) => item.key === filters.invoiceType);
if (!stillExists) {
filters.invoiceType = '';
}
});
function selectPlaceholder() {
return isCatalogLoading ? 'Cargando...' : 'Selecciona...';
}
function normalizeSelectValue(value?: string) {
return value === CLEAR_SELECT_VALUE ? '' : value ?? '';
}
async function runReport() {
if (!dates.from) {
toast.error('Es necesario asignar la fecha inicial para generar el reporte');
return;
}
if (!dates.to) {
toast.error('Es necesario asignar la fecha final para generar el reporte');
return;
}
if (dates.to < dates.from) {
toast.error('La fecha inicial no puede ser superior a la final');
return;
}
if (classRange.from && !classRange.to) {
toast.error('Es necesario asignar la clase final para generar el reporte');
return;
}
if (!classRange.from && classRange.to) {
toast.error('Es necesario asignar la clase inicial para generar el reporte');
return;
}
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
toast.error('No hay empresa activa seleccionada');
return;
}
const params: DownloadedPartsReportRequest = {
date_from: dates.from,
date_to: dates.to,
class_from: classRange.from || undefined,
class_to: classRange.to || undefined,
print_class_mode: printClassMode,
exchange_rate_mode: exchangeRateMode,
currency_mode: currencyMode,
temporality_mode: temporalityMode,
weight_type_mode: weightTypeMode,
operation_mode: operationMode,
material_type: filters.materialType || undefined,
invoice_type: filters.invoiceType || undefined,
parts: filters.parts ? [filters.parts] : undefined,
pedimento_key: filters.pedimentoKey || undefined,
provider_id: filters.provider ? Number(filters.provider) : undefined,
sold_to_id: filters.soldTo ? Number(filters.soldTo) : undefined,
shipped_to_id: filters.shippedTo ? Number(filters.shippedTo) : undefined,
destination_customs: filters.destinationCustoms || undefined,
include_series: options.includeSeries,
print_class_total: options.printClassTotal,
include_totals_by_fraction: options.includeTotalsByFraction,
julian_date: options.julianDate,
show_item_description: options.showItemDescription,
include_exempt_fraction: options.includeExemptFraction,
show_export_fraction: options.showExportFraction,
include_rule_octava: options.includeRuleOctava,
include_american_fraction_and_country: options.includeAmericanFractionAndCountry,
respect_import_invoice_value_in_pesos: options.respectImportInvoiceValueInPesos,
show_all_temporary_balances: options.showAllTemporaryBalances
};
isGenerating = true;
try {
await downloadedPartsReportsApi.generate(companyId, params);
} catch (err: any) {
const msg: string = err?.message || '';
try {
const parsed = JSON.parse(msg);
if (parsed?.detail?.missing_dates?.length) {
toast.error(
'Faltan tipos de cambio para: ' + parsed.detail.missing_dates.join(', ')
);
return;
}
} catch {
// not JSON
}
toast.error(msg || 'Error al generar el reporte');
} finally {
isGenerating = false;
}
}
function closeView() {
toast.info('Acción cancelar pendiente');
}
</script>
<div
class="animate-in fade-in slide-in-from-bottom-4 flex min-h-full flex-col gap-2 pb-4 duration-500"
>
<div class="flex shrink-0 items-center justify-between px-1">
<div class="flex items-center gap-3">
<h1 class="flex items-center gap-2 text-xl font-bold tracking-tight text-foreground">
<Boxes class="h-6 w-6 text-primary" />
Reporte de clases exportadas / descargadas
</h1>
<span
class="rounded-full border bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground"
>
{bootstrap ? 'CONECTADO' : 'BASE'}
</span>
</div>
<div class="text-xs text-muted-foreground">Reportes de Control Fiscal</div>
</div>
<Separator />
<div class="grid min-h-0 flex-1 grid-cols-1 content-start items-start gap-3 text-foreground xl:grid-cols-3">
<Card.Root class="flex h-full flex-col gap-0 py-0">
<Card.Header class="shrink-0 border-b bg-muted/20 px-3 py-2">
<Card.Title class="flex items-center gap-2 text-sm font-semibold text-primary">
<FileSearch class="h-4 w-4" /> Rango de Fechas y Clases
</Card.Title>
</Card.Header>
<Card.Content class="flex-1 space-y-3 p-3">
<div class="space-y-2 rounded-md border p-3">
<p class="text-xs font-bold text-muted-foreground uppercase">Rango de fechas</p>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Del</Label>
<Input
type="date"
class="h-8 cursor-pointer"
bind:value={dates.from}
onclick={(e) => {
const input = e.currentTarget;
if (input && typeof input.showPicker === 'function') input.showPicker();
}}
/>
</div>
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Al</Label>
<Input
type="date"
class="h-8 cursor-pointer"
bind:value={dates.to}
onclick={(e) => {
const input = e.currentTarget;
if (input && typeof input.showPicker === 'function') input.showPicker();
}}
/>
</div>
</div>
</div>
<div class="space-y-2 rounded-md border p-3">
<p class="text-xs font-bold text-muted-foreground uppercase">Rango de clases</p>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">De la</Label>
<Select.Root type="single" value={classRange.from} onValueChange={(v) => (classRange.from = normalizeSelectValue(v))}>
<Select.Trigger class="h-8 w-full text-xs">
<span class="truncate">
{#if classRange.from}
{@const selectedClass = classesCatalog.find((item) => item.class_code === classRange.from)}
{selectedClass ? `${selectedClass.class_code} - ${selectedClass.description_es || ''}` : classRange.from}
{:else}
{selectPlaceholder()}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
<Select.Item value={CLEAR_SELECT_VALUE}>Sin límite</Select.Item>
{#if classesCatalog.length}
{#each classesCatalog as item}
<Select.Item value={item.class_code}>
{item.class_code} - {item.description_es || item.description_en || ''}
</Select.Item>
{/each}
{:else}
<div class="px-3 py-2 text-xs text-muted-foreground">Sin opciones</div>
{/if}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">A la</Label>
<Select.Root type="single" value={classRange.to} onValueChange={(v) => (classRange.to = normalizeSelectValue(v))}>
<Select.Trigger class="h-8 w-full text-xs">
<span class="truncate">
{#if classRange.to}
{@const selectedClass = classesCatalog.find((item) => item.class_code === classRange.to)}
{selectedClass ? `${selectedClass.class_code} - ${selectedClass.description_es || ''}` : classRange.to}
{:else}
{selectPlaceholder()}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
<Select.Item value={CLEAR_SELECT_VALUE}>Sin límite</Select.Item>
{#if classesCatalog.length}
{#each classesCatalog as item}
<Select.Item value={item.class_code}>
{item.class_code} - {item.description_es || item.description_en || ''}
</Select.Item>
{/each}
{:else}
<div class="px-3 py-2 text-xs text-muted-foreground">Sin opciones</div>
{/if}
</Select.Content>
</Select.Root>
</div>
</div>
</div>
</Card.Content>
</Card.Root>
<Card.Root class="flex h-full flex-col gap-0 py-0">
<Card.Header class="shrink-0 border-b bg-muted/20 px-3 py-2">
<Card.Title class="flex items-center gap-2 text-sm font-semibold text-primary">
<Filter class="h-4 w-4" /> Filtrar por
</Card.Title>
</Card.Header>
<Card.Content class="flex-1 space-y-3 p-3">
<div class="grid gap-2 md:grid-cols-3">
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Tipo de material</Label>
<Select.Root type="single" value={filters.materialType} onValueChange={(v) => (filters.materialType = normalizeSelectValue(v))}>
<Select.Trigger class="h-8 w-full text-xs">
<span class="truncate">
{#if filters.materialType}
{@const selectedMaterial = materialTypes.find((item) => item.key === filters.materialType)}
{selectedMaterial ? `${selectedMaterial.key} - ${selectedMaterial.description}` : filters.materialType}
{:else}
{selectPlaceholder()}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
<Select.Item value={CLEAR_SELECT_VALUE}>Todos</Select.Item>
{#if materialTypes.length}
{#each materialTypes as item}
<Select.Item value={item.key}>{item.key} - {item.description}</Select.Item>
{/each}
{:else}
<div class="px-3 py-2 text-xs text-muted-foreground">Sin opciones</div>
{/if}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Clave pedimento</Label>
<Select.Root type="single" value={filters.pedimentoKey} onValueChange={(v) => (filters.pedimentoKey = normalizeSelectValue(v))}>
<Select.Trigger class="h-8 w-full text-xs">
<span class="truncate">
{#if filters.pedimentoKey}
{@const selectedCode = pedimentoCodes.find((item) => item.code === filters.pedimentoKey)}
{selectedCode ? `${selectedCode.code} - ${selectedCode.description}` : filters.pedimentoKey}
{:else}
{selectPlaceholder()}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
<Select.Item value={CLEAR_SELECT_VALUE}>Todos</Select.Item>
{#if pedimentoCodes.length}
{#each pedimentoCodes as item}
<Select.Item value={item.code}>{item.code} - {item.description}</Select.Item>
{/each}
{:else}
<div class="px-3 py-2 text-xs text-muted-foreground">Sin opciones</div>
{/if}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Vendido a</Label>
<Select.Root type="single" value={filters.soldTo} onValueChange={(v) => (filters.soldTo = normalizeSelectValue(v))}>
<Select.Trigger class="h-8 w-full text-xs">
<span class="truncate">
{#if filters.soldTo}
{@const selectedClient = soldToOptions.find((item) => String(item.id) === filters.soldTo)}
{selectedClient?.name || selectPlaceholder()}
{:else}
{selectPlaceholder()}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
<Select.Item value={CLEAR_SELECT_VALUE}>Todos</Select.Item>
{#if soldToOptions.length}
{#each soldToOptions as item}
<Select.Item value={String(item.id)}>{item.name}</Select.Item>
{/each}
{:else}
<div class="px-3 py-2 text-xs text-muted-foreground">Sin opciones</div>
{/if}
</Select.Content>
</Select.Root>
</div>
</div>
<div class="grid gap-2 md:grid-cols-3">
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Tipo de factura</Label>
<Select.Root type="single" value={filters.invoiceType} onValueChange={(v) => (filters.invoiceType = normalizeSelectValue(v))}>
<Select.Trigger class="h-8 w-full text-xs">
<span class="truncate">
{#if filters.invoiceType}
{@const selectedType = filteredInvoiceTypes.find((item) => item.key === filters.invoiceType)}
{selectedType ? `${selectedType.key} - ${selectedType.description}` : filters.invoiceType}
{:else}
{selectPlaceholder()}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
<Select.Item value={CLEAR_SELECT_VALUE}>Todos</Select.Item>
{#if filteredInvoiceTypes.length}
{#each filteredInvoiceTypes as item}
<Select.Item value={item.key}>{item.key} - {item.description}</Select.Item>
{/each}
{:else}
<div class="px-3 py-2 text-xs text-muted-foreground">Sin opciones</div>
{/if}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Proveedor</Label>
<Select.Root type="single" value={filters.provider} onValueChange={(v) => (filters.provider = normalizeSelectValue(v))}>
<Select.Trigger class="h-8 w-full text-xs">
<span class="truncate">
{#if filters.provider}
{@const selectedProvider = providerOptions.find((item) => String(item.id) === filters.provider)}
{selectedProvider?.name || selectPlaceholder()}
{:else}
{selectPlaceholder()}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
<Select.Item value={CLEAR_SELECT_VALUE}>Todos</Select.Item>
{#if providerOptions.length}
{#each providerOptions as item}
<Select.Item value={String(item.id)}>{item.name}</Select.Item>
{/each}
{:else}
<div class="px-3 py-2 text-xs text-muted-foreground">Sin opciones</div>
{/if}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Enviado a</Label>
<Select.Root type="single" value={filters.shippedTo} onValueChange={(v) => (filters.shippedTo = normalizeSelectValue(v))}>
<Select.Trigger class="h-8 w-full text-xs">
<span class="truncate">
{#if filters.shippedTo}
{@const selectedShippedTo = shippedToOptions.find((item) => String(item.id) === filters.shippedTo)}
{selectedShippedTo?.name || selectPlaceholder()}
{:else}
{selectPlaceholder()}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
<Select.Item value={CLEAR_SELECT_VALUE}>Todos</Select.Item>
{#if shippedToOptions.length}
{#each shippedToOptions as item}
<Select.Item value={String(item.id)}>{item.name}</Select.Item>
{/each}
{:else}
<div class="px-3 py-2 text-xs text-muted-foreground">Sin opciones</div>
{/if}
</Select.Content>
</Select.Root>
</div>
</div>
<div class="grid gap-2 md:grid-cols-2">
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Partes</Label>
<Select.Root type="single" value={filters.parts} onValueChange={(v) => (filters.parts = normalizeSelectValue(v))}>
<Select.Trigger class="h-8 w-full text-xs">
<span class="truncate">
{#if filters.parts}
{@const selectedPart = partsCatalog.find((item) => item.part_number === filters.parts)}
{selectedPart ? `${selectedPart.part_number} - ${selectedPart.description_spanish || ''}` : filters.parts}
{:else}
{selectPlaceholder()}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
<Select.Item value={CLEAR_SELECT_VALUE}>Todos</Select.Item>
{#if partsCatalog.length}
{#each partsCatalog as item}
<Select.Item value={item.part_number}>{item.part_number} - {item.description_spanish || item.description_english || ''}</Select.Item>
{/each}
{:else}
<div class="px-3 py-2 text-xs text-muted-foreground">Sin opciones</div>
{/if}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Destino aduanero</Label>
<Select.Root type="single" value={filters.destinationCustoms} onValueChange={(v) => (filters.destinationCustoms = normalizeSelectValue(v))}>
<Select.Trigger class="h-8 w-full text-xs">
<span class="truncate">
{#if filters.destinationCustoms}
{@const selectedSection = customsSections.find((item) => item.customs_code === filters.destinationCustoms)}
{selectedSection ? `${selectedSection.customs_code} - ${selectedSection.section_name}` : filters.destinationCustoms}
{:else}
{selectPlaceholder()}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
<Select.Item value={CLEAR_SELECT_VALUE}>Todos</Select.Item>
{#if customsSections.length}
{#each customsSections as item}
<Select.Item value={item.customs_code}>{item.customs_code} - {item.section_name}</Select.Item>
{/each}
{:else}
<div class="px-3 py-2 text-xs text-muted-foreground">Sin opciones</div>
{/if}
</Select.Content>
</Select.Root>
</div>
</div>
<Separator />
<div class="space-y-3">
<div>
<Label class="mb-2 block text-xs font-bold text-muted-foreground uppercase">Imprimir clases</Label>
<RadioGroup.Root bind:value={printClassMode} class="grid grid-cols-2 gap-2">
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
<RadioGroup.Item value="exported" id="print-exported" class="h-4 w-4" />
<Label for="print-exported" class="cursor-pointer text-sm">Exportadas</Label>
</div>
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
<RadioGroup.Item value="downloaded" id="print-downloaded" class="h-4 w-4" />
<Label for="print-downloaded" class="cursor-pointer text-sm">Descargadas</Label>
</div>
</RadioGroup.Root>
</div>
<div>
<Label class="mb-2 block text-xs font-bold text-muted-foreground uppercase">Temporalidad</Label>
<RadioGroup.Root bind:value={temporalityMode} class="grid grid-cols-3 gap-2">
{#each [
{ value: 'temporales', label: 'Temporales' },
{ value: 'definitivos', label: 'Definitivos' },
{ value: 'ambos', label: 'Ambos' }
] as item}
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
<RadioGroup.Item value={item.value} id={`temporality-${item.value}`} class="h-4 w-4" />
<Label for={`temporality-${item.value}`} class="cursor-pointer text-sm">{item.label}</Label>
</div>
{/each}
</RadioGroup.Root>
</div>
</div>
</Card.Content>
</Card.Root>
<Card.Root class="flex h-full flex-col gap-0 py-0">
<Card.Header class="shrink-0 border-b bg-muted/20 px-3 py-2">
<Card.Title class="flex items-center gap-2 text-sm font-semibold text-primary">
<Search class="h-4 w-4" /> Configuración y Salida
</Card.Title>
</Card.Header>
<Card.Content class="space-y-3 p-3">
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="space-y-2">
<Label class="flex items-center gap-2 text-xs font-bold text-muted-foreground uppercase">
<BadgeDollarSign class="h-3.5 w-3.5" /> Tipo Moneda
</Label>
<RadioGroup.Root bind:value={currencyMode} class="flex flex-col gap-2">
{#each [
{ value: 'dollars', label: 'Dólares' },
{ value: 'pesos', label: 'Pesos' },
{ value: 'both', label: 'Ambos' }
] as item}
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
<RadioGroup.Item value={item.value} id={`currency-${item.value}`} class="h-4 w-4" />
<Label for={`currency-${item.value}`} class="cursor-pointer text-sm">{item.label}</Label>
</div>
{/each}
</RadioGroup.Root>
</div>
<div class="space-y-2">
<Label class="flex items-center gap-2 text-xs font-bold text-muted-foreground uppercase">
<Scale class="h-3.5 w-3.5" /> Tipo Cambio
</Label>
<RadioGroup.Root bind:value={exchangeRateMode} class="flex flex-col gap-2">
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
<RadioGroup.Item value="invoice" id="exchange-invoice" class="h-4 w-4" />
<Label for="exchange-invoice" class="cursor-pointer text-sm">Factura</Label>
</div>
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
<RadioGroup.Item value="pedimento_payment" id="exchange-payment" class="h-4 w-4" />
<Label for="exchange-payment" class="cursor-pointer text-sm">Pago de Pedimento</Label>
</div>
</RadioGroup.Root>
</div>
<div class="space-y-2">
<Label class="flex items-center gap-2 text-xs font-bold text-muted-foreground uppercase">
<Package class="h-3.5 w-3.5" /> Tipo Peso
</Label>
<RadioGroup.Root bind:value={weightTypeMode} class="flex flex-col gap-2">
{#each [
{ value: 'kilos', label: 'Kilos' },
{ value: 'libras', label: 'Libras' },
{ value: 'ambos', label: 'Ambos' }
] as item}
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
<RadioGroup.Item value={item.value} id={`weight-${item.value}`} class="h-4 w-4" />
<Label for={`weight-${item.value}`} class="cursor-pointer text-sm">{item.label}</Label>
</div>
{/each}
</RadioGroup.Root>
</div>
<div class="space-y-2">
<Label class="flex items-center gap-2 text-xs font-bold text-muted-foreground uppercase">
<Clock3 class="h-3.5 w-3.5" /> Operación
</Label>
<RadioGroup.Root bind:value={operationMode} class="flex flex-col gap-2">
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
<RadioGroup.Item value="importacion" id="operation-import" class="h-4 w-4" />
<Label for="operation-import" class="cursor-pointer text-sm">Importación</Label>
</div>
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
<RadioGroup.Item value="exportacion" id="operation-export" class="h-4 w-4" />
<Label for="operation-export" class="cursor-pointer text-sm">Exportación</Label>
</div>
</RadioGroup.Root>
</div>
</div>
</Card.Content>
</Card.Root>
<Card.Root class="gap-0 py-0 xl:col-span-3">
<Card.Header class="shrink-0 border-b bg-muted/20 px-3 py-2">
<Card.Title class="text-sm font-semibold text-primary">Opciones</Card.Title>
</Card.Header>
<Card.Content class="p-3">
<div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
{#each allOptions as option}
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
<Checkbox id={option.id} bind:checked={options[option.key]} class="h-4 w-4" />
<Label for={option.id} class="cursor-pointer text-sm">{option.label}</Label>
</div>
{/each}
</div>
</Card.Content>
<Card.Footer class="gap-2 border-t bg-muted/10 p-2.5">
<Button class="h-9 flex-1 text-sm shadow-sm" size="default" onclick={runReport} disabled={isGenerating}>
<Printer class="mr-2 h-3.5 w-3.5" />
{isGenerating ? 'Generando...' : 'Imprimir'}
</Button>
<Button
variant="ghost"
size="icon"
class="h-9 w-9 shrink-0 text-muted-foreground hover:text-destructive"
onclick={closeView}
>
<X class="h-4 w-4" />
</Button>
</Card.Footer>
</Card.Root>
</div>
</div>