feature/reportes
This commit is contained in:
48
.github/copilot-instructions.md
vendored
Normal file
48
.github/copilot-instructions.md
vendored
Normal 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
133
.github/skills/caveman-ultra/SKILL.md
vendored
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Downloaded parts report module."""
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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}"'},
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -507,6 +507,10 @@ export function getSidebarData(): SidebarData {
|
||||
title: "Facturas Impo/Expo",
|
||||
url: "/dashboard/reports/invoices",
|
||||
},
|
||||
{
|
||||
title: "Partes descargadas",
|
||||
url: "/dashboard/reports/partes-descargadas",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user