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} +
+
+ + + + +
+
+