From de8f944a350a65a0ebb49be22dbfc800d5bffbda Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 16 Apr 2026 15:59:18 -0600 Subject: [PATCH 1/7] feature/reportes --- .github/copilot-instructions.md | 48 + .github/skills/caveman-ultra/SKILL.md | 133 +++ .../common/process/review_equivalence.py | 34 + .../partes_descargadas/__init__.py | 1 + .../exportacion/partes_descargadas/routes.py | 43 + .../exportacion/partes_descargadas/schemas.py | 56 + .../exportacion/partes_descargadas/service.py | 954 ++++++++++++++++++ backend/api/v1/modules/a76/router.py | 7 + frontend/src/lib/api.ts | 6 + .../a76/reports/reports-partes-descargadas.ts | 76 ++ .../equivalencies/create-edit-dialog.svelte | 91 +- .../invoices/edit/InvoiceSelectorModal.svelte | 13 +- .../edit/items/fa/item-sheet-fa.svelte | 20 +- .../src/lib/components/sidebar/modules.ts | 4 + .../partes-descargadas/+page.server.ts | 15 + .../reports/partes-descargadas/+page.svelte | 865 ++++++++++++++++ 16 files changed, 2321 insertions(+), 45 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 .github/skills/caveman-ultra/SKILL.md create mode 100644 backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/__init__.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/routes.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/schemas.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/service.py create mode 100644 frontend/src/lib/api/dashboard/a76/reports/reports-partes-descargadas.ts create mode 100644 frontend/src/routes/dashboard/reports/partes-descargadas/+page.server.ts create mode 100644 frontend/src/routes/dashboard/reports/partes-descargadas/+page.svelte diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..58fcd1df --- /dev/null +++ b/.github/copilot-instructions.md @@ -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. \ No newline at end of file diff --git a/.github/skills/caveman-ultra/SKILL.md b/.github/skills/caveman-ultra/SKILL.md new file mode 100644 index 00000000..3f73e2c6 --- /dev/null +++ b/.github/skills/caveman-ultra/SKILL.md @@ -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 + + +
{label}...
+ + +``` + +## 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. \ No newline at end of file diff --git a/backend/api/v1/modules/a76/invoices/common/process/review_equivalence.py b/backend/api/v1/modules/a76/invoices/common/process/review_equivalence.py index 1d0825b6..7c0320d2 100644 --- a/backend/api/v1/modules/a76/invoices/common/process/review_equivalence.py +++ b/backend/api/v1/modules/a76/invoices/common/process/review_equivalence.py @@ -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) diff --git a/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/__init__.py b/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/__init__.py new file mode 100644 index 00000000..20433b71 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/__init__.py @@ -0,0 +1 @@ +"""Downloaded parts report module.""" \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/routes.py b/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/routes.py new file mode 100644 index 00000000..007b17c2 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/routes.py @@ -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) diff --git a/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/schemas.py b/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/schemas.py new file mode 100644 index 00000000..923371ae --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/schemas.py @@ -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] diff --git a/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/service.py b/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/service.py new file mode 100644 index 00000000..7dc41b6c --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/service.py @@ -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}"'}, + ) diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index ed0a6600..57e17486 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -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, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index da8b2b71..6e265dc7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -519,6 +519,12 @@ async function fetchBlob(endpoint: string, options: RequestInit = {}): Promise(endpoint: string) => fetchApi(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: (endpoint: string, body: any, options: RequestInit = {}) => fetchApi(endpoint, { diff --git a/frontend/src/lib/api/dashboard/a76/reports/reports-partes-descargadas.ts b/frontend/src/lib/api/dashboard/a76/reports/reports-partes-descargadas.ts new file mode 100644 index 00000000..4f64f5ad --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-partes-descargadas.ts @@ -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( + `/v1/a76/reports/exportacion/partes-descargadas/bootstrap?company_id=${companyId}` + ), + + generate: async ( + companyId: number, + params: DownloadedPartsReportRequest + ): Promise => { + 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); + } +}; diff --git a/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/create-edit-dialog.svelte index efcd9974..f6628eee 100644 --- a/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/create-edit-dialog.svelte @@ -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(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 @@
-
-
@@ -160,3 +194,6 @@ + + + diff --git a/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte b/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte index 2c8cff0a..67fdf73e 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte @@ -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} - {invoice.compliance_mx?.pedimento_r1 || + {invoice.compliance_mx?.pedimento?.pedimento_number || + invoice.compliance_mx?.pedimento_r1 || invoice.compliance_mx?.pedimento_id || '-'} diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte index 3a413d51..1c491569 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte @@ -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 @@ Seleccionar línea de importación

- Solo se muestran líneas con saldo disponible + Líneas sin saldo disponible se muestran en gris.

- {#if importInvoiceLines.every(l => !l.has_balance)} + {#if importInvoiceLines.length === 0}

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

{:else} @@ -768,9 +770,8 @@ {#each importInvoiceLines as lineItem} - {#if lineItem.has_balance} { editingItem.fa_data = editingItem.fa_data || {}; editingItem.fa_data.search_line = lineItem.line_number; @@ -830,7 +831,6 @@ {/if} - {/if} {/each}
diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 14bf9a57..ec4ef527 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -507,6 +507,10 @@ export function getSidebarData(): SidebarData { title: "Facturas Impo/Expo", url: "/dashboard/reports/invoices", }, + { + title: "Partes descargadas", + url: "/dashboard/reports/partes-descargadas", + }, ], }, { diff --git a/frontend/src/routes/dashboard/reports/partes-descargadas/+page.server.ts b/frontend/src/routes/dashboard/reports/partes-descargadas/+page.server.ts new file mode 100644 index 00000000..2361c927 --- /dev/null +++ b/frontend/src/routes/dashboard/reports/partes-descargadas/+page.server.ts @@ -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' + }; +}; diff --git a/frontend/src/routes/dashboard/reports/partes-descargadas/+page.svelte b/frontend/src/routes/dashboard/reports/partes-descargadas/+page.svelte new file mode 100644 index 00000000..610f89c9 --- /dev/null +++ b/frontend/src/routes/dashboard/reports/partes-descargadas/+page.svelte @@ -0,0 +1,865 @@ + + +
+
+
+

+ + Reporte de clases exportadas / descargadas +

+ + {bootstrap ? 'CONECTADO' : 'BASE'} + +
+
Reportes de Control Fiscal
+
+ + + +
+ + + + Rango de Fechas y Clases + + + +
+

Rango de fechas

+
+
+ + { + const input = e.currentTarget; + if (input && typeof input.showPicker === 'function') input.showPicker(); + }} + /> +
+
+ + { + const input = e.currentTarget; + if (input && typeof input.showPicker === 'function') input.showPicker(); + }} + /> +
+
+
+ +
+

Rango de clases

+
+
+ + (classRange.from = normalizeSelectValue(v))}> + + + {#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} + + + + Sin límite + {#if classesCatalog.length} + {#each classesCatalog as item} + + {item.class_code} - {item.description_es || item.description_en || ''} + + {/each} + {:else} +
Sin opciones
+ {/if} +
+
+
+
+ + (classRange.to = normalizeSelectValue(v))}> + + + {#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} + + + + Sin límite + {#if classesCatalog.length} + {#each classesCatalog as item} + + {item.class_code} - {item.description_es || item.description_en || ''} + + {/each} + {:else} +
Sin opciones
+ {/if} +
+
+
+
+
+ + +
+
+ + + + + Filtrar por + + + +
+
+ + (filters.materialType = normalizeSelectValue(v))}> + + + {#if filters.materialType} + {@const selectedMaterial = materialTypes.find((item) => item.key === filters.materialType)} + {selectedMaterial ? `${selectedMaterial.key} - ${selectedMaterial.description}` : filters.materialType} + {:else} + {selectPlaceholder()} + {/if} + + + + Todos + {#if materialTypes.length} + {#each materialTypes as item} + {item.key} - {item.description} + {/each} + {:else} +
Sin opciones
+ {/if} +
+
+
+ +
+ + (filters.pedimentoKey = normalizeSelectValue(v))}> + + + {#if filters.pedimentoKey} + {@const selectedCode = pedimentoCodes.find((item) => item.code === filters.pedimentoKey)} + {selectedCode ? `${selectedCode.code} - ${selectedCode.description}` : filters.pedimentoKey} + {:else} + {selectPlaceholder()} + {/if} + + + + Todos + {#if pedimentoCodes.length} + {#each pedimentoCodes as item} + {item.code} - {item.description} + {/each} + {:else} +
Sin opciones
+ {/if} +
+
+
+ +
+ + (filters.soldTo = normalizeSelectValue(v))}> + + + {#if filters.soldTo} + {@const selectedClient = soldToOptions.find((item) => String(item.id) === filters.soldTo)} + {selectedClient?.name || selectPlaceholder()} + {:else} + {selectPlaceholder()} + {/if} + + + + Todos + {#if soldToOptions.length} + {#each soldToOptions as item} + {item.name} + {/each} + {:else} +
Sin opciones
+ {/if} +
+
+
+
+ +
+
+ + (filters.invoiceType = normalizeSelectValue(v))}> + + + {#if filters.invoiceType} + {@const selectedType = filteredInvoiceTypes.find((item) => item.key === filters.invoiceType)} + {selectedType ? `${selectedType.key} - ${selectedType.description}` : filters.invoiceType} + {:else} + {selectPlaceholder()} + {/if} + + + + Todos + {#if filteredInvoiceTypes.length} + {#each filteredInvoiceTypes as item} + {item.key} - {item.description} + {/each} + {:else} +
Sin opciones
+ {/if} +
+
+
+ +
+ + (filters.provider = normalizeSelectValue(v))}> + + + {#if filters.provider} + {@const selectedProvider = providerOptions.find((item) => String(item.id) === filters.provider)} + {selectedProvider?.name || selectPlaceholder()} + {:else} + {selectPlaceholder()} + {/if} + + + + Todos + {#if providerOptions.length} + {#each providerOptions as item} + {item.name} + {/each} + {:else} +
Sin opciones
+ {/if} +
+
+
+ +
+ + (filters.shippedTo = normalizeSelectValue(v))}> + + + {#if filters.shippedTo} + {@const selectedShippedTo = shippedToOptions.find((item) => String(item.id) === filters.shippedTo)} + {selectedShippedTo?.name || selectPlaceholder()} + {:else} + {selectPlaceholder()} + {/if} + + + + Todos + {#if shippedToOptions.length} + {#each shippedToOptions as item} + {item.name} + {/each} + {:else} +
Sin opciones
+ {/if} +
+
+
+
+ +
+
+ + (filters.parts = normalizeSelectValue(v))}> + + + {#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} + + + + Todos + {#if partsCatalog.length} + {#each partsCatalog as item} + {item.part_number} - {item.description_spanish || item.description_english || ''} + {/each} + {:else} +
Sin opciones
+ {/if} +
+
+
+ +
+ + (filters.destinationCustoms = normalizeSelectValue(v))}> + + + {#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} + + + + Todos + {#if customsSections.length} + {#each customsSections as item} + {item.customs_code} - {item.section_name} + {/each} + {:else} +
Sin opciones
+ {/if} +
+
+
+
+ + + +
+
+ + +
+ + +
+
+ + +
+
+
+ +
+ + + {#each [ + { value: 'temporales', label: 'Temporales' }, + { value: 'definitivos', label: 'Definitivos' }, + { value: 'ambos', label: 'Ambos' } + ] as item} +
+ + +
+ {/each} +
+
+
+
+
+ + + + + Configuración y Salida + + + +
+
+ + + {#each [ + { value: 'dollars', label: 'Dólares' }, + { value: 'pesos', label: 'Pesos' }, + { value: 'both', label: 'Ambos' } + ] as item} +
+ + +
+ {/each} +
+
+ +
+ + +
+ + +
+
+ + +
+
+
+ +
+ + + {#each [ + { value: 'kilos', label: 'Kilos' }, + { value: 'libras', label: 'Libras' }, + { value: 'ambos', label: 'Ambos' } + ] as item} +
+ + +
+ {/each} +
+
+ +
+ + +
+ + +
+
+ + +
+
+
+
+
+
+ + + + Opciones + + +
+ {#each allOptions as option} +
+ + +
+ {/each} +
+
+ + + + +
+
+
From 82c319afea158b4c41c35f0a7fb853d24491f64d Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 17 Apr 2026 08:42:52 -0600 Subject: [PATCH 2/7] feature/correccion-enteros-csv --- .github/copilot-instructions.md | 48 ------- .github/skills/caveman-ultra/SKILL.md | 133 ------------------ .../exportacion/partes_descargadas/service.py | 71 +++++----- 3 files changed, 38 insertions(+), 214 deletions(-) delete mode 100644 .github/copilot-instructions.md delete mode 100644 .github/skills/caveman-ultra/SKILL.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 58fcd1df..00000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,48 +0,0 @@ -# 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. \ No newline at end of file diff --git a/.github/skills/caveman-ultra/SKILL.md b/.github/skills/caveman-ultra/SKILL.md deleted file mode 100644 index 3f73e2c6..00000000 --- a/.github/skills/caveman-ultra/SKILL.md +++ /dev/null @@ -1,133 +0,0 @@ ---- -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 - - -
{label}...
- - -``` - -## 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. \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/service.py b/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/service.py index 7dc41b6c..cd0805c9 100644 --- a/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/service.py +++ b/backend/api/v1/modules/a76/reports/exportacion/partes_descargadas/service.py @@ -86,6 +86,11 @@ class DownloadedPartsReportService: return '' return value.replace(',', ' ').replace('\r', ' ').replace('\n', ' ').strip() + def _excel_text(self, value) -> str: + if value is None or value == '': + return '' + return f"'{value}" + def _as_date(self, value: Optional[date | datetime]) -> Optional[date]: if value is None: return None @@ -864,40 +869,40 @@ class DownloadedPartsReportService: 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._excel_text(export_ped_values[0]), + self._excel_text(export_ped_values[1]), + self._excel_text(self._format_date(row.expo_payment_date, req.julian_date)), + self._excel_text(row.expo_clave or ''), + self._excel_text(self._format_date(row.discharge_date, req.julian_date)), + self._excel_text(row.expo_invoice_number or ''), + self._excel_text(self._format_date(row.expo_invoice_date, req.julian_date)), + self._excel_text(class_code), + self._excel_text(description_value), + self._excel_text(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._excel_text(row.unit_of_measure or ''), + self._excel_text(import_ped_values[0]), + self._excel_text(import_ped_values[1]), + self._excel_text(self._format_date(row.impo_payment_date, req.julian_date)), + self._excel_text(row.import_invoice_number or row.import_invoice_str or ''), + self._excel_text(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 '', + self._excel_text(row.part_number_str or ''), + self._excel_text(part_description), + self._excel_text(row.material_key or ''), + self._excel_text(class_fraction), + self._excel_text(import_ped_18), + self._excel_text(export_ped_18), + self._excel_text(row.tariff_uom or ''), ] if req.include_american_fraction_and_country: csv_row.extend([ - row.american_fraction or '', - row.country_of_origin or '', + self._excel_text(row.american_fraction or ''), + self._excel_text(row.country_of_origin or ''), ]) writer.writerow(csv_row) @@ -915,14 +920,14 @@ class DownloadedPartsReportService: ]) 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 '', + self._excel_text(series.row or ''), + self._excel_text(series.serial_numbers or ''), + self._excel_text(series.model or ''), + self._excel_text(series.sub_model or ''), + self._excel_text(row.part_number_str or ''), + self._excel_text(series.number_id or ''), + self._excel_text(row.export_brand or ''), + self._excel_text(row.export_model or ''), ]) flush_class_total() From a29a2a7c8aab1fe92eb70b4428de55a4eb2415f2 Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 17 Apr 2026 08:57:40 -0600 Subject: [PATCH 3/7] fix/selector-fecha --- frontend/src/app.css | 15 +++++++ .../src/lib/components/ui/input/input.svelte | 39 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/frontend/src/app.css b/frontend/src/app.css index af7ea21c..990d5f97 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -38,6 +38,7 @@ --sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-border: oklch(0.92 0.004 286.32); --sidebar-ring: oklch(0.623 0.214 259.815); + color-scheme: light; } .dark { @@ -72,6 +73,7 @@ --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.488 0.243 264.376); + color-scheme: dark; } @@ -128,6 +130,19 @@ color: var(--color-foreground); -webkit-text-fill-color: var(--color-foreground); } + + input[type="date"]::-webkit-calendar-picker-indicator, + input[type="datetime-local"]::-webkit-calendar-picker-indicator { + display: none; + opacity: 0; + } + + .dark input[type="date"]::-webkit-calendar-picker-indicator, + .dark input[type="datetime-local"]::-webkit-calendar-picker-indicator { + cursor: pointer; + filter: invert(1) brightness(1.15); + opacity: 0.9; + } } @layer components { diff --git a/frontend/src/lib/components/ui/input/input.svelte b/frontend/src/lib/components/ui/input/input.svelte index ef1fbe7d..1f394e32 100644 --- a/frontend/src/lib/components/ui/input/input.svelte +++ b/frontend/src/lib/components/ui/input/input.svelte @@ -1,4 +1,5 @@ {#if type === "file"} @@ -35,6 +49,31 @@ bind:value {...restProps} /> +{:else if isDateInput} +
+ + +
{:else} Date: Fri, 17 Apr 2026 10:22:08 -0600 Subject: [PATCH 4/7] fix/partida-campos-obligatorios --- .../a76/items/exports/validators/common.py | 51 +++++++++++++++++-- .../us-fraction-selector-dialog.svelte | 41 +++++++++++---- .../edit/items/fa/item-sheet-fa.svelte | 6 ++- .../invoices/edit/items/fa/main-data.svelte | 5 +- .../edit/items/fa/packages-section.svelte | 40 +++++++++++++-- frontend/src/lib/utils/items-logic.ts | 47 ++++++++++++----- 6 files changed, 159 insertions(+), 31 deletions(-) diff --git a/backend/api/v1/modules/a76/items/exports/validators/common.py b/backend/api/v1/modules/a76/items/exports/validators/common.py index c8622b97..d8e204f5 100644 --- a/backend/api/v1/modules/a76/items/exports/validators/common.py +++ b/backend/api/v1/modules/a76/items/exports/validators/common.py @@ -14,6 +14,9 @@ from api.v1.modules.a76.invoices.models import InvoiceHeader from api.v1.modules.a76.classes.models import Class from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure from api.v1.modules.a76.general_catalogs.packages.models import Package +from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import ( + USTariffFraction, +) from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem from api.v1.modules.public.reference_data.countries.models import Country from api.v1.modules.a76.general_catalogs.sectors.models import Sector @@ -360,18 +363,56 @@ def validate_common( ) if line.customs.american_fraction: - american_fraction_exists = db.query( - exists().where( - LineCustom.american_fraction == line.customs.american_fraction + def _normalize_american_fraction_code(raw_code: str) -> list[str]: + normalized_raw = (raw_code or "").strip() + if not normalized_raw: + return [] + + digits_only = normalized_raw.replace(".", "").replace(" ", "").replace("-", "") + candidates = [normalized_raw] + + if len(digits_only) == 10: + candidates.append( + f"{digits_only[:4]}.{digits_only[4:6]}.{digits_only[6:8]}.{digits_only[8:10]}" + ) + elif len(digits_only) == 8: + candidates.append(f"{digits_only[:4]}.{digits_only[4:6]}.{digits_only[6:8]}") + + candidates.append(digits_only) + + seen: set[str] = set() + deduped: list[str] = [] + for candidate in candidates: + if not candidate or candidate in seen: + continue + seen.add(candidate) + deduped.append(candidate) + return deduped + + candidates = _normalize_american_fraction_code(line.customs.american_fraction) + us_fraction: USTariffFraction | None = None + for candidate in candidates: + us_fraction = ( + db.query(USTariffFraction) + .filter( + USTariffFraction.code == candidate, + USTariffFraction.tenant_id == tenant_id, + USTariffFraction.company_id == company_id, + ) + .first() ) - ).scalar() - if not american_fraction_exists: + if us_fraction: + break + + if not us_fraction: errors.add_error( field=f"line[{line_number}].customs.american_fraction", message="La fracción americana especificada no existe.", solution=["Proporciona una fracción americana valida."], code="AMERICAN_FRACTION_NOT_FOUND", ) + else: + line.customs.american_fraction = us_fraction.code if line.order: if len(line.order) > 20: diff --git a/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte index 4ba7a2a6..b581144c 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte @@ -21,32 +21,55 @@ let items = $state([]); let loading = $state(false); let searchTerm = $state(""); - let loaded = $state(false); + let loadedForCompanyId = $state(null); + + const activeCompanyId = $derived(companyStore.activeCompany?.id); + + function normalizeAmericanFractionCode(code: string) { + return (code || '').replace(/[.\s-]/g, ''); + } + + function isEligibleAmericanFraction(item: USTariffFraction) { + const normalizedCode = normalizeAmericanFractionCode(item.code || ''); + return /^\d{8}$/.test(normalizedCode) || /^\d{10}$/.test(normalizedCode); + } // Filtro local let filteredItems = $derived( items.filter(i => - (i.code || "").includes(searchTerm) || + isEligibleAmericanFraction(i) && + ((i.code || "").includes(searchTerm) || (i.description || "").toLowerCase().includes(searchTerm.toLowerCase()) + ) ) ); // Cargar datos al abrir $effect(() => { - if (open && !loaded && companyStore.activeCompany?.id) { - loadFractions(); + if (!open) return; + + if (!activeCompanyId) { + items = []; + loadedForCompanyId = null; + return; + } + + if (loadedForCompanyId !== activeCompanyId) { + searchTerm = ''; + items = []; + void loadFractions(activeCompanyId); } }); - async function loadFractions() { - if (!companyStore.activeCompany?.id) { + async function loadFractions(companyId: number) { + if (!companyId) { toast.error("No hay empresa seleccionada"); return; } loading = true; try { - const response = await getUSTariffFractions(1, 1000, companyStore.activeCompany.id); + const response = await getUSTariffFractions(1, 1000, companyId); if (response.error) { console.error("Error al cargar fracciones americanas:", response.error); @@ -55,8 +78,8 @@ } if (response.data?.items) { - items = response.data.items; - loaded = true; + items = response.data.items.filter((item) => isEligibleAmericanFraction(item)); + loadedForCompanyId = companyId; } else { console.warn("No se encontraron fracciones americanas:", response); toast.info("No se encontraron fracciones americanas registradas"); diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte index ef166084..37573833 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte @@ -75,6 +75,9 @@ if (editingItem.fa_data.discharge === undefined) { editingItem.fa_data.discharge = false; } + if ((editingItem.fa_data.movement_type_import === undefined || editingItem.fa_data.movement_type_import === '') && (showLinkToImportBlock || showRepairBlock)) { + editingItem.fa_data.movement_type_import = 'TEM'; + } } }); @@ -357,6 +360,7 @@
+

Los campos marcados con * son obligatorios.

{ @@ -483,7 +487,7 @@
- + Main Data +

+ Los campos marcados con * son obligatorios. +

@@ -448,7 +451,7 @@
- +
(0); let isLoadingPackage = $state(false); @@ -114,10 +116,21 @@ package_weight_unit = pkg.weight_unit || 0; quantities.package_description = pkg.description_es || pkg.description_en || pkg.key; } + + function handleAmericanFractionSelect(fraction: any) { + customs.american_fraction = fraction.code || ''; + (customs as any).american_fraction_description = fraction.description || ''; + if (fraction.ad_valorem !== null && fraction.ad_valorem !== undefined) { + customs.advalorem_american = fraction.ad_valorem; + } + }
PACKAGES +

+ Los campos marcados con * son obligatorios. +

@@ -169,7 +182,7 @@
WEIGHTS
- +
@@ -200,8 +213,28 @@
- - + +
+ !disabled && (americanFractionDialogOpen = true)} + /> + +
@@ -230,3 +263,4 @@
+ diff --git a/frontend/src/lib/utils/items-logic.ts b/frontend/src/lib/utils/items-logic.ts index c5a227bf..f9f0b4e5 100644 --- a/frontend/src/lib/utils/items-logic.ts +++ b/frontend/src/lib/utils/items-logic.ts @@ -259,12 +259,42 @@ const FIELD_MAP: Record = { 'financial.unit_cost_capture': 'Costo Unitario', 'customs.fraction': 'Fracción Arancelaria', 'customs.origin_country': 'País de Origen', + 'customs.american_fraction': 'Fracción Americana', 'fa_data.search_invoice': 'Factura de Referencia', 'fa_data.search_line': 'Línea de Referencia', 'fa_data.search_type': 'Tipo de Búsqueda', 'fa_data.movement_type_import': 'Tipo de Importación' }; +function humanizeFieldPath(field: string): string { + const rawField = (field || '').trim(); + if (!rawField) return 'campo'; + + const lineMatch = rawField.match(/^line\[(\d+)\]\.(.+)$/i); + const fieldPath = lineMatch?.[2] || rawField; + const mappedPath = fieldPath.replace(/^body\./i, ''); + const fieldLabel = FIELD_MAP[mappedPath] || mappedPath.replace(/\./g, ' → '); + + if (lineMatch) { + return `Partida ${lineMatch[1]} - ${fieldLabel}`; + } + + return fieldLabel; +} + +function humanizeValidationMessage(message: string): string { + const rawMessage = (message || '').trim(); + if (!rawMessage) return 'error de validación'; + + return rawMessage + .replace(/line\[(\d+)\]\.(\w+(?:\.\w+)*)/gi, (_match, lineNumber, fieldPath) => { + return `Partida ${lineNumber} - ${humanizeFieldPath(fieldPath)}`; + }) + .replace(/\b(field required|is required)\b/gi, 'es obligatorio') + .replace(/\b(value is not a valid decimal)\b/gi, 'debe ser un número válido') + .replace(/\b(value is not a valid integer)\b/gi, 'debe ser un número entero válido'); +} + /** * Formats a backend error into a human-readable Spanish message. * Handles 422 (Validation), 403 (Forbidden), 404 (Not Found), and 500 (Server Error). @@ -285,12 +315,8 @@ export function formatItemError(error: any): string { // New structure (ApiResponse.validationErrors) if (status === 422 && Array.isArray(validationErrors)) { const errors = validationErrors.map((err: any) => { - const field = err.field || ''; - const fieldName = FIELD_MAP[field] || field || 'campo'; - - let msg = err.message || 'error de validación'; - if (msg.includes('field required')) msg = 'es obligatorio'; - if (msg.includes('value is not a valid decimal')) msg = 'debe ser un número válido'; + const fieldName = humanizeFieldPath(err.field || ''); + const msg = humanizeValidationMessage(err.message || 'error de validación'); return `• ${fieldName}: ${msg}`; }); @@ -305,11 +331,8 @@ export function formatItemError(error: any): string { .filter((l: string) => l !== 'body') .join('.'); - const fieldName = FIELD_MAP[locPath] || locPath || 'campo'; - - let msg = err.msg || 'error de validación'; - if (msg.includes('field required')) msg = 'es obligatorio'; - if (msg.includes('value is not a valid decimal')) msg = 'debe ser un número válido'; + const fieldName = humanizeFieldPath(locPath); + const msg = humanizeValidationMessage(err.msg || 'error de validación'); return `• ${fieldName}: ${msg}`; }); @@ -322,7 +345,7 @@ export function formatItemError(error: any): string { if (d.includes('Access denied')) return 'No tienes permisos para realizar esta acción.'; if (d.includes('not found')) return 'El registro no existe o fue eliminado.'; if (d.includes('Class mismatch')) return 'Error de validación: ' + d; - return d; + return humanizeValidationMessage(d); } // 4. Fallbacks by status code From db6c5a3372259b0c1131f9920251980f37790020 Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 17 Apr 2026 10:49:14 -0600 Subject: [PATCH 5/7] fix/impo-invoice-save --- frontend/src/lib/api.ts | 74 +++++++++++++++++-- .../edit/items/fa/packages-section.svelte | 4 +- .../edit/items/inv/item-sheet-inv.svelte | 21 +++--- frontend/src/lib/utils/items-logic.ts | 67 ++++++++++++++++- 4 files changed, 145 insertions(+), 21 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6e265dc7..31e47ba2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -26,6 +26,65 @@ export function humanizeLineReferences(text: string): string { return text.replace(/\bline\[(\d+)\]/gi, 'partida $1'); } +function humanizeFieldPath(field: string): string { + const rawField = (field || '').trim(); + if (!rawField) return 'campo'; + + const lineMatch = rawField.match(/^line\[(\d+)\]\.(.+)$/i); + const fieldPath = lineMatch?.[2] || rawField; + const label = fieldPath + .replace(/^body\./i, '') + .replace(/\./g, ' → ') + .replace(/_/g, ' '); + + if (lineMatch) { + return `Partida ${lineMatch[1]} - ${label}`; + } + + return label; +} + +function humanizeValidationMessage(message: string): string { + const rawMessage = (message || '').trim(); + if (!rawMessage) return 'error de validación'; + + return rawMessage + .replace(/\b(field required|is required)\b/gi, 'es obligatorio') + .replace(/\b(value is not a valid decimal)\b/gi, 'debe ser un número válido') + .replace(/\b(value is not a valid integer)\b/gi, 'debe ser un número entero válido'); +} + +function formatValidationHint(field: string, message: string, code?: string): string { + const fieldLabel = humanizeFieldPath(field); + const normalizedMessage = humanizeValidationMessage(message); + + if (code === 'REQUIRED' || code === 'REQUIRED_FIELD' || /es obligatorio|es requerido/i.test(normalizedMessage)) { + return `Completa ${fieldLabel}.`; + } + + if (code === 'AMERICAN_FRACTION_NOT_FOUND') { + return 'La fracción americana seleccionada no existe. Elige una opción del catálogo.'; + } + + if (code === 'UNIT_OF_MEASURE_NOT_FOUND') { + return 'La unidad de medida seleccionada no existe. Elige una opción del catálogo.'; + } + + if (code === 'ORIGIN_COUNTRY_NOT_FOUND') { + return 'El país de origen seleccionado no existe. Elige una opción del catálogo.'; + } + + if (code === 'CLASS_NOT_FOUND') { + return 'La clase seleccionada no existe. Elige una opción del catálogo.'; + } + + if (code === 'FRACTION_TYPE_INVALID') { + return 'Selecciona un tipo de tarifa válido.'; + } + + return normalizedMessage; +} + /** * Título y descripción listos para toasts / alertas a partir de ApiResponse. * Prioriza los mensajes que ya envía el backend y evita duplicar rutas técnicas. @@ -34,7 +93,7 @@ export function friendlyApiErrorParts(res: ApiResponse): { title: string; descri const validationErrors = res.validationErrors; if (validationErrors?.length) { const blocks = validationErrors.map((e) => { - const base = humanizeLineReferences((e.message || '').trim() || e.field); + const base = formatValidationHint(e.field || '', e.message || '', e.code); const hints = e.solution?.filter(Boolean).length ? '\n' + e.solution!.map((s) => `• ${humanizeLineReferences(s)}`).join('\n') : ''; @@ -52,7 +111,7 @@ export function friendlyApiErrorParts(res: ApiResponse): { title: string; descri } if (res.error) { - const err = humanizeLineReferences(res.error.trim()); + const err = humanizeValidationMessage(humanizeLineReferences(res.error.trim())); if (err.startsWith('Error de validación:')) { return { title: 'Revisa los datos ingresados', @@ -241,6 +300,7 @@ async function fetchApi( if (response.status === 422) { // HTTPException(detail={ message, errors }) — catálogo / CSV parity const det = data.detail; + const validationErrors = (errors: unknown[]) => errors as NonNullable; if ( det && typeof det === 'object' && @@ -250,7 +310,7 @@ async function fetchApi( const d = det as { message?: string; errors: unknown[] }; return { error: d.message || 'Error de validación', - validationErrors: d.errors, + validationErrors: validationErrors(d.errors), status: response.status }; } @@ -258,7 +318,7 @@ async function fetchApi( if (data.errors && Array.isArray(data.errors)) { return { error: data.message || 'Error de validación', - validationErrors: data.errors, + validationErrors: validationErrors(data.errors), status: response.status }; } @@ -421,7 +481,7 @@ async function fetchApiFormDataPost( if (data.errors && Array.isArray(data.errors)) { resolve({ error: data.message || 'Error de validación', - validationErrors: data.errors, + validationErrors: data.errors as NonNullable, status: 422 }); return; @@ -431,8 +491,8 @@ async function fetchApiFormDataPost( if (Array.isArray(data.detail)) { const errors = data.detail .map((err: any) => { - const field = err.loc ? err.loc.join('.') : 'campo desconocido'; - return `${field}: ${err.msg}`; + const field = err.loc ? err.loc.filter((loc: string) => loc !== 'body').join('.') : 'campo desconocido'; + return `${humanizeFieldPath(field)}: ${humanizeValidationMessage(err.msg || 'error de validación')}`; }) .join(', '); errorMessage += errors; diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte index c2cdb76f..7a91f129 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte @@ -134,12 +134,12 @@
- +
- +
+

+ Los campos marcados con * son obligatorios. +

General @@ -272,7 +275,7 @@
- +
- + {#if editingItem?.quantity} {/if}
- +
- + {#if editingItem?.financial} {/if}
- +
- +
- + {#if editingItem?.customs}
- + {#if editingItem?.customs} {/if} diff --git a/frontend/src/lib/utils/items-logic.ts b/frontend/src/lib/utils/items-logic.ts index f9f0b4e5..f4caeb03 100644 --- a/frontend/src/lib/utils/items-logic.ts +++ b/frontend/src/lib/utils/items-logic.ts @@ -263,7 +263,28 @@ const FIELD_MAP: Record = { 'fa_data.search_invoice': 'Factura de Referencia', 'fa_data.search_line': 'Línea de Referencia', 'fa_data.search_type': 'Tipo de Búsqueda', - 'fa_data.movement_type_import': 'Tipo de Importación' + 'fa_data.movement_type_import': 'Tipo de Importación', + 'fa_data.is_subitem': 'Es Subpartida', + 'fa_data.subitem_number': 'Número de Partida Principal' +}; + +const FIELD_GUIDANCE: Record = { + class_id: 'Selecciona una clase.', + unit_of_measure: 'Selecciona una unidad de medida.', + 'quantity.quantity': 'Captura una cantidad válida mayor a cero.', + 'quantity.net_weight': 'Captura un peso neto válido mayor a cero.', + 'customs.fraction': 'Selecciona una fracción arancelaria válida.', + 'customs.origin_country': 'Selecciona un país de origen válido.', + 'customs.fraction_type': 'Selecciona un tipo de tarifa.', + 'customs.american_fraction': 'Selecciona una fracción americana válida.', + 'description.description_spanish': 'Captura la descripción en español.', + 'description.description_english': 'Captura la descripción en inglés.', + 'financial.unit_cost_capture': 'Captura un costo unitario válido.', + 'fa_data.search_invoice': 'Selecciona una factura de referencia.', + 'fa_data.search_line': 'Selecciona una línea de referencia.', + 'fa_data.search_type': 'Selecciona un tipo de búsqueda.', + 'fa_data.movement_type_import': 'Selecciona TEM o DEF.', + 'fa_data.subitem_number': 'Captura el número de la partida principal.' }; function humanizeFieldPath(field: string): string { @@ -295,6 +316,46 @@ function humanizeValidationMessage(message: string): string { .replace(/\b(value is not a valid integer)\b/gi, 'debe ser un número entero válido'); } +function formatFriendlyFieldMessage(fieldName: string, message: string, code?: string): string { + const cleanFieldName = fieldName.replace(/^Partida \d+ - /, ''); + const guidance = FIELD_GUIDANCE[cleanFieldName] || FIELD_GUIDANCE[fieldName]; + const normalizedMessage = humanizeValidationMessage(message); + + if (code === 'REQUIRED' || code === 'REQUIRED_FIELD' || /es requerido|es obligatorio/i.test(normalizedMessage)) { + return guidance || `Completa ${fieldName}.`; + } + + if (code === 'AMERICAN_FRACTION_NOT_FOUND') { + return `La fracción americana seleccionada no existe. Elige una opción del catálogo.`; + } + + if (code === 'FRACTION_TYPE_INVALID') { + return 'Selecciona un tipo de tarifa válido.'; + } + + if (code === 'UNIT_OF_MEASURE_NOT_FOUND') { + return 'La unidad de medida seleccionada no existe. Elige una opción del catálogo.'; + } + + if (code === 'ORIGIN_COUNTRY_NOT_FOUND') { + return 'El país de origen seleccionado no existe. Elige una opción del catálogo.'; + } + + if (code === 'CLASS_NOT_FOUND') { + return 'La clase seleccionada no existe. Elige una opción del catálogo.'; + } + + if (code === 'PACKAGE_NOT_FOUND' || code === 'PACKAGE_ID_REQUIRED') { + return 'El paquete seleccionado no es válido. Elige una opción del catálogo.'; + } + + if (code === 'MOVEMENT_TYPE_IMPORT_INVALID') { + return 'Selecciona TEM o DEF para el tipo de importación.'; + } + + return normalizedMessage; +} + /** * Formats a backend error into a human-readable Spanish message. * Handles 422 (Validation), 403 (Forbidden), 404 (Not Found), and 500 (Server Error). @@ -316,7 +377,7 @@ export function formatItemError(error: any): string { if (status === 422 && Array.isArray(validationErrors)) { const errors = validationErrors.map((err: any) => { const fieldName = humanizeFieldPath(err.field || ''); - const msg = humanizeValidationMessage(err.message || 'error de validación'); + const msg = formatFriendlyFieldMessage(fieldName, err.message || 'error de validación', err.code); return `• ${fieldName}: ${msg}`; }); @@ -332,7 +393,7 @@ export function formatItemError(error: any): string { .join('.'); const fieldName = humanizeFieldPath(locPath); - const msg = humanizeValidationMessage(err.msg || 'error de validación'); + const msg = formatFriendlyFieldMessage(fieldName, err.msg || 'error de validación', err.type); return `• ${fieldName}: ${msg}`; }); From 20bcb7797e9e30e2cb100c4ecc7ef95a18031fe2 Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 17 Apr 2026 11:03:28 -0600 Subject: [PATCH 6/7] fix/modo-lectura-activo-fijo --- .../sectors/data-table-actions.svelte | 9 --------- .../dashboard/reference_data/states/columns.ts | 6 +++--- .../states/data-table-actions.svelte | 18 ++++++++++++------ .../reference_data/states/+page.svelte | 2 +- 4 files changed, 16 insertions(+), 19 deletions(-) diff --git a/frontend/src/lib/components/dashboard/reference_data/sectors/data-table-actions.svelte b/frontend/src/lib/components/dashboard/reference_data/sectors/data-table-actions.svelte index 1f087b36..bf90e548 100644 --- a/frontend/src/lib/components/dashboard/reference_data/sectors/data-table-actions.svelte +++ b/frontend/src/lib/components/dashboard/reference_data/sectors/data-table-actions.svelte @@ -5,7 +5,6 @@ import type { Sector } from "./columns.js"; import CreateEditDialog from "./create-edit-dialog.svelte"; import DetailsDialog from "./details-dialog.svelte"; - import DeleteDialog from "./delete-dialog.svelte"; let { item, @@ -17,7 +16,6 @@ let showDetailsDialog = $state(false); let showEditDialog = $state(false); - let showDeleteDialog = $state(false); function handleCopyId() { navigator.clipboard.writeText(item.key.toString()); @@ -30,10 +28,6 @@ function handleEdit() { showEditDialog = true; } - - function handleDelete() { - showDeleteDialog = true; - } @@ -55,12 +49,9 @@ Ver detalles Editar - - Eliminar - diff --git a/frontend/src/lib/components/dashboard/reference_data/states/columns.ts b/frontend/src/lib/components/dashboard/reference_data/states/columns.ts index eb88329d..1a1b4c35 100644 --- a/frontend/src/lib/components/dashboard/reference_data/states/columns.ts +++ b/frontend/src/lib/components/dashboard/reference_data/states/columns.ts @@ -10,7 +10,7 @@ export type State = { ame_key?: string | null; }; -export function createColumns(onSuccess?: () => void): ColumnDef[] { +export function createColumns(onSuccess?: () => void, readOnly = false): ColumnDef[] { return [ { accessorKey: "m3_key", @@ -74,11 +74,11 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { { id: "actions", cell: ({ row }) => { - return renderComponent(DataTableActions, { item: row.original, onSuccess }); + return renderComponent(DataTableActions, { item: row.original, onSuccess, readOnly }); } } ]; } // Mantener compatibilidad hacia atrás -export const columns = createColumns(); +export const columns = createColumns(undefined, true); diff --git a/frontend/src/lib/components/dashboard/reference_data/states/data-table-actions.svelte b/frontend/src/lib/components/dashboard/reference_data/states/data-table-actions.svelte index 1c2746b6..ac13a9de 100644 --- a/frontend/src/lib/components/dashboard/reference_data/states/data-table-actions.svelte +++ b/frontend/src/lib/components/dashboard/reference_data/states/data-table-actions.svelte @@ -9,10 +9,12 @@ let { item, - onSuccess + onSuccess, + readOnly = false }: { item: State; onSuccess?: () => void; + readOnly?: boolean; } = $props(); let showDetailsDialog = $state(false); @@ -54,13 +56,17 @@ Ver detalles - Editar - - Eliminar + {#if !readOnly} + Editar + + Eliminar + {/if} - - +{#if !readOnly} + + +{/if} diff --git a/frontend/src/routes/dashboard/reference_data/states/+page.svelte b/frontend/src/routes/dashboard/reference_data/states/+page.svelte index 9153e117..28188744 100644 --- a/frontend/src/routes/dashboard/reference_data/states/+page.svelte +++ b/frontend/src/routes/dashboard/reference_data/states/+page.svelte @@ -140,7 +140,7 @@ } // Crear columnas con el callback onSuccess - const columns = createColumns(handleSuccess); + const columns = createColumns(handleSuccess, true);
From 1036a2edb5cde3c20f7e5ae436337034ea9ad9b5 Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 17 Apr 2026 12:36:05 -0600 Subject: [PATCH 7/7] fix/conductores-longitud-placa --- ...8b9c0d1e2_driver_transporter_key_length.py | 39 ++ .../modules/a76/transportation/drivers/dto.py | 7 +- .../a76/transportation/drivers/models.py | 2 +- .../drivers/create-edit-dialog.svelte | 654 ++++++++++++------ .../transporters/create-edit-dialog.svelte | 20 +- .../general_catalogs/drivers/+page.svelte | 62 +- 6 files changed, 500 insertions(+), 284 deletions(-) create mode 100644 backend/alembic/versions/f7a8b9c0d1e2_driver_transporter_key_length.py diff --git a/backend/alembic/versions/f7a8b9c0d1e2_driver_transporter_key_length.py b/backend/alembic/versions/f7a8b9c0d1e2_driver_transporter_key_length.py new file mode 100644 index 00000000..c5ba8e21 --- /dev/null +++ b/backend/alembic/versions/f7a8b9c0d1e2_driver_transporter_key_length.py @@ -0,0 +1,39 @@ +"""fix driver transporter_key length and validations + +Revision ID: f7a8b9c0d1e2 +Revises: e76_app_settings +Create Date: 2026-04-17 00:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "f7a8b9c0d1e2" +down_revision = "e76_app_settings" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.alter_column( + "driver", + "transporter_key", + schema="a76", + existing_type=sa.String(length=5), + type_=sa.String(length=30), + existing_nullable=False, + ) + + +def downgrade() -> None: + op.alter_column( + "driver", + "transporter_key", + schema="a76", + existing_type=sa.String(length=30), + type_=sa.String(length=5), + existing_nullable=False, + ) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/transportation/drivers/dto.py b/backend/api/v1/modules/a76/transportation/drivers/dto.py index e67daee0..5366d209 100644 --- a/backend/api/v1/modules/a76/transportation/drivers/dto.py +++ b/backend/api/v1/modules/a76/transportation/drivers/dto.py @@ -1,10 +1,13 @@ from typing import Optional -from pydantic import BaseModel +from pydantic import BaseModel, Field + + +TRANSPORTER_KEY_MAX_LENGTH = 30 class DriverBaseDTO(BaseModel): - transporter_key: str + transporter_key: str = Field(..., max_length=TRANSPORTER_KEY_MAX_LENGTH) driver_id: Optional[int] = None line: int driver_name: Optional[str] = None diff --git a/backend/api/v1/modules/a76/transportation/drivers/models.py b/backend/api/v1/modules/a76/transportation/drivers/models.py index dc6d7ee9..57aed494 100644 --- a/backend/api/v1/modules/a76/transportation/drivers/models.py +++ b/backend/api/v1/modules/a76/transportation/drivers/models.py @@ -10,7 +10,7 @@ class Driver(Base, TenantScopedMixin, TimestampMixin): ) transporter_key = Column( - String(5), + String(30), ForeignKey("a76.transporter.transporter_key", ondelete="CASCADE"), primary_key=True, nullable=False, diff --git a/frontend/src/lib/components/dashboard/transportation/drivers/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/drivers/create-edit-dialog.svelte index 875c7c47..ca480f5e 100644 --- a/frontend/src/lib/components/dashboard/transportation/drivers/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/transportation/drivers/create-edit-dialog.svelte @@ -1,6 +1,7 @@ - + {title} - {isEdit - ? 'Modifica los datos del conductor' - : 'Completa los datos para crear un nuevo conductor'} + {isEdit ? 'Modifica los datos del conductor' : 'Completa los datos para crear un nuevo conductor'} -
{ - e.preventDefault(); - handleSubmit(); - }} - class="space-y-6" - > + { e.preventDefault(); handleSubmit(); }} class="space-y-4"> {#if error} -
- {error} -
+
{error}
{/if} -
-
- - {#if isEdit} - - {:else} - - - {transportersLoading - ? 'Cargando transportistas...' - : transporters.length === 0 - ? 'No hay transportistas' - : transporters.find((t) => t.transporter_key === formData.transporter_key) - ? `${formData.transporter_key} - ${transporters.find((t) => t.transporter_key === formData.transporter_key)?.name || ''}` - : 'Seleccionar transportista'} - - - {#each transporters as t} - - {t.transporter_key} — {t.name || t.short_name || 'Sin nombre'} - - {/each} - {#if !transportersLoading && transporters.length === 0} -
- No hay transportistas. Crea uno en el catálogo Transportistas. -
- {/if} -
-
- {/if} -
+ + + 1) Generales + 2) Identificaciones + -
- - -
+ + +
-
- - -
+ +
+ + {#if isEdit} + + {:else} + + + {transportersLoading + ? 'Cargando...' + : transporters.find((t) => t.transporter_key === formData.transporter_key) + ? `${formData.transporter_key} — ${transporters.find((t) => t.transporter_key === formData.transporter_key)?.name || ''}` + : 'Seleccionar transportista'} + + + {#each transporters as t} + + {t.transporter_key} — {t.name || t.short_name || 'Sin nombre'} + + {/each} + {#if !transportersLoading && transporters.length === 0} +
No hay transportistas. Crea uno primero.
+ {/if} +
+
+ {/if} +
-
- - -
+ +
+ + +
-
- - -
+ +
+ + +
-
- - -
+ +
+ + +
+
+ + + + {formData.class_type || '— Opcional —'} + + + — Vacío — + {#each CLASE_OPCIONES as c} + {c} + {/each} + + +
-
- - -
+ +
+ + +
-
- - -
+ +
+ + +
-
- - -
+ +
+ + +
-
- - - - {countriesLoading - ? 'Cargando países...' - : formData.birth_country - ? `${formData.birth_country} — ${countries.find((c) => c.ame_key === formData.birth_country)?.description_es ?? ''}` - : '— Opcional —'} - - - — Vacío — - {#each countries as c} - - {c.ame_key} — {c.description_es} - - {/each} - - -
+ +
+ + +
-
- - -
+ +
+ + +
+
+ + + + {formData.gender || '— Opcional —'} + + + — Vacío — + M — Masculino + F — Femenino + + +
-
- - -
+ +
+ + + + {countriesLoading ? 'Cargando...' : formData.birth_country ? `${formData.birth_country} — ${countries.find((c) => c.ame_key === formData.birth_country)?.description_es ?? ''}` : '— Opcional —'} + + + — Vacío — + {#each countries as c} + {c.ame_key} — {c.description_es} + {/each} + + +
-
- - -
-
+ +
+ + + + {formData.hazardous_material_auth || '— Opcional —'} + + + — Vacío — + SI + NO + + +
+
+ + +
+ +
+ + + + +
+ + +
+ + +
+
+ + +
+ + +
+

Primera Identificación

+
+ +
+ + + + {FORMA_ID_OPCIONES.find((o) => o.value === formData.id_key1)?.label || '— Opcional —'} + + + — Vacío — + {#each FORMA_ID_OPCIONES as o} + {o.label} + {/each} + + +
+
+ + +
+
+ + +
+
+ + + + {formData.id_country1 ? `${formData.id_country1} — ${countries.find((c) => c.ame_key === formData.id_country1)?.description_es ?? ''}` : '— Opcional —'} + + + — Vacío — + {#each countries as c} + {c.ame_key} — {c.description_es} + {/each} + + +
+ + +
+

Segunda Identificación

+
+ +
+ + + + {FORMA_ID_OPCIONES.find((o) => o.value === formData.id_key2)?.label || '— Opcional —'} + + + — Vacío — + {#each FORMA_ID_OPCIONES as o} + {o.label} + {/each} + + +
+
+ + +
+
+ + +
+
+ + + + {formData.id_country2 ? `${formData.id_country2} — ${countries.find((c) => c.ame_key === formData.id_country2)?.description_es ?? ''}` : '— Opcional —'} + + + — Vacío — + {#each countries as c} + {c.ame_key} — {c.description_es} + {/each} + + +
+ +
+
+ - - + +
diff --git a/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte index 8d2b931f..a39db20b 100644 --- a/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte @@ -213,7 +213,7 @@ bind:value={formData.transporter_key} disabled={isEdit} required - maxlength={23} + maxlength={30} />
@@ -234,12 +234,12 @@
- +
- +
@@ -249,7 +249,7 @@
- +
@@ -293,7 +293,7 @@
- +
@@ -345,7 +345,7 @@
- +
@@ -356,23 +356,23 @@
- +
- +
- +
- +
diff --git a/frontend/src/routes/dashboard/general_catalogs/drivers/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/drivers/+page.svelte index 778c613c..8f19bbcd 100644 --- a/frontend/src/routes/dashboard/general_catalogs/drivers/+page.svelte +++ b/frontend/src/routes/dashboard/general_catalogs/drivers/+page.svelte @@ -1,6 +1,4 @@