From 5db940b518e6fffbadfc021aa01b1e831a0051a4 Mon Sep 17 00:00:00 2001 From: acazares Date: Wed, 21 Jan 2026 13:09:29 -0600 Subject: [PATCH 01/55] refactor(models): clean up whitespace and update has_express_line type in Company model --- .../a76/general_catalogs/company/models.py | 59 +++++++++---------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/backend/api/v1/modules/a76/general_catalogs/company/models.py b/backend/api/v1/modules/a76/general_catalogs/company/models.py index 7b6860d8..ae94fc89 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/models.py @@ -1,6 +1,7 @@ """ Modelo principal de Company """ + from typing import Optional, TYPE_CHECKING from sqlalchemy import Integer, String, SmallInteger, ForeignKey, Boolean from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -19,39 +20,37 @@ if TYPE_CHECKING: class Company(Base, TimestampMixin): """Información principal de la empresa""" - + __tablename__ = "company" __table_args__ = {"schema": "a76", "extend_existing": True} - + # Primary key id: Mapped[int] = mapped_column(Integer, primary_key=True) tenant_id: Mapped[int] = mapped_column( - ForeignKey("core.tenants.id"), - nullable=False, - index=True + ForeignKey("core.tenants.id"), nullable=False, index=True ) - + # Información básica name: Mapped[Optional[str]] = mapped_column(String(256)) rfc: Mapped[Optional[str]] = mapped_column(String(30)) curp: Mapped[Optional[str]] = mapped_column(String(19)) main_activity: Mapped[Optional[str]] = mapped_column(String(80)) - + # Programa program: Mapped[Optional[str]] = mapped_column(String(7)) program_number: Mapped[Optional[str]] = mapped_column(String(40)) prosec: Mapped[Optional[int]] = mapped_column(SmallInteger) prosec_authorization: Mapped[Optional[str]] = mapped_column(String(20)) - + # Sectores sector1: Mapped[Optional[str]] = mapped_column(String(150)) sector2: Mapped[Optional[str]] = mapped_column(String(150)) sector3: Mapped[Optional[str]] = mapped_column(String(5)) - + # Identificadores manufacturer_id: Mapped[Optional[str]] = mapped_column(String(25)) broker_company: Mapped[Optional[str]] = mapped_column(String(6)) - + # Responsable responsible: Mapped[Optional[str]] = mapped_column(String(80)) responsible_name: Mapped[Optional[str]] = mapped_column(String(20)) @@ -59,15 +58,15 @@ class Company(Base, TimestampMixin): responsible_mother_last_name: Mapped[Optional[str]] = mapped_column(String(20)) responsible_rfc: Mapped[Optional[str]] = mapped_column(String(30)) position: Mapped[Optional[str]] = mapped_column(String(30)) - + # Configuración básica logo: Mapped[Optional[str]] = mapped_column(String(255)) - has_express_line: Mapped[Optional[str]] = mapped_column(String(2)) + has_express_line: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) order_format_type: Mapped[Optional[str]] = mapped_column(String(19)) is_service_company: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) client_name: Mapped[Optional[str]] = mapped_column(String(300)) subassembly_mode: Mapped[Optional[str]] = mapped_column(String(7)) - + # Configuraciones técnicas (flags) previous_code: Mapped[Optional[int]] = mapped_column(SmallInteger) active_labels: Mapped[Optional[int]] = mapped_column(SmallInteger) @@ -80,7 +79,7 @@ class Company(Base, TimestampMixin): activate_facmexame: Mapped[Optional[int]] = mapped_column(SmallInteger) part_reference: Mapped[Optional[int]] = mapped_column(SmallInteger) international_firm: Mapped[Optional[int]] = mapped_column(SmallInteger) - + # Configuraciones simples ftp_key: Mapped[Optional[str]] = mapped_column(String(10)) sifra_path: Mapped[Optional[str]] = mapped_column(String(255)) @@ -88,59 +87,59 @@ class Company(Base, TimestampMixin): sql_language: Mapped[Optional[str]] = mapped_column(String(19)) balance_operation_mode: Mapped[Optional[str]] = mapped_column(String(50)) inter_db_name: Mapped[Optional[str]] = mapped_column(String(100)) - + # ==================== RELACIONES CON SUBTABLAS ==================== - + addresses: Mapped[list["CompanyAddress"]] = relationship( "CompanyAddress", back_populates="company", cascade="all, delete-orphan", - lazy="selectin" + lazy="selectin", ) - + certification: Mapped[Optional["CompanyCertification"]] = relationship( "CompanyCertification", back_populates="company", uselist=False, cascade="all, delete-orphan", - lazy="selectin" + lazy="selectin", ) - + digital_certificates: Mapped[list["CompanyDigitalCertificate"]] = relationship( "CompanyDigitalCertificate", back_populates="company", cascade="all, delete-orphan", - lazy="selectin" + lazy="selectin", ) - + ventanilla_unica: Mapped[Optional["CompanyVU"]] = relationship( "CompanyVU", back_populates="company", uselist=False, cascade="all, delete-orphan", - lazy="selectin" + lazy="selectin", ) - + electronic_agent: Mapped[Optional["CompanyElectronicAgent"]] = relationship( "CompanyElectronicAgent", back_populates="company", uselist=False, cascade="all, delete-orphan", - lazy="selectin" + lazy="selectin", ) - + prevalidator: Mapped[Optional["CompanyPrevalidator"]] = relationship( "CompanyPrevalidator", back_populates="company", uselist=False, cascade="all, delete-orphan", - lazy="selectin" + lazy="selectin", ) - + cfdi: Mapped[Optional["CompanyCFDI"]] = relationship( "CompanyCFDI", back_populates="company", uselist=False, cascade="all, delete-orphan", - lazy="selectin" - ) \ No newline at end of file + lazy="selectin", + ) From b98f4c3bb28c5c1d497ae309964842b4b67026ed Mon Sep 17 00:00:00 2001 From: acazares Date: Wed, 21 Jan 2026 13:20:39 -0600 Subject: [PATCH 02/55] fix(api): update package endpoint URLs to include trailing slashes --- .../lib/api/dashboard/a76/general_catalogs/packages.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/packages.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/packages.ts index 13a9783a..e55a59e4 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/packages.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/packages.ts @@ -52,7 +52,7 @@ export async function getPackages( ...filters }); - return await api.get(`/v1/a76/packages?${params.toString()}`); + return await api.get(`/v1/a76/packages/?${params.toString()}`); } @@ -64,7 +64,7 @@ export async function createPackage( data: PackageCreate, companyId: number ): Promise> { - return await api.post(`/v1/a76/packages?company_id=${companyId}`, data); + return await api.post(`/v1/a76/packages/?company_id=${companyId}`, data); } @@ -73,9 +73,9 @@ export async function updatePackage( data: PackageUpdate, companyId: number ): Promise> { - return await api.put(`/v1/a76/packages/${id}?company_id=${companyId}`, data); + return await api.put(`/v1/a76/packages/${id}/?company_id=${companyId}`, data); } export async function deletePackage(id: number, companyId: number): Promise> { - return await api.delete(`/v1/a76/packages/${id}?company_id=${companyId}`); + return await api.delete(`/v1/a76/packages/${id}/?company_id=${companyId}`); } \ No newline at end of file From 4b4599362fd3edc50144c13024676bc12e03ab16 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Wed, 21 Jan 2026 15:43:19 -0600 Subject: [PATCH 03/55] Se cambio la tabla de unidades de medida generales por unidades de medida --- .../general_catalogs/units_of_measure/dto.py | 25 +--- .../units_of_measure/service.py | 2 +- .../a76/general_catalogs/units-of-measure.ts | 4 +- .../general/data-table-actions.svelte | 2 + .../units_of_measure/main/data-table.svelte | 107 ++++++++++++++++++ .../src/lib/components/sidebar/modules.ts | 34 +++--- 6 files changed, 135 insertions(+), 39 deletions(-) create mode 100644 frontend/src/lib/components/dashboard/general_catalogs/units_of_measure/main/data-table.svelte diff --git a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/dto.py b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/dto.py index 56c9d37f..ef5997fe 100644 --- a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/dto.py +++ b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/dto.py @@ -36,14 +36,8 @@ class UnitOfMeasureBase(BaseModel): oma_code: Optional[str] = Field(None, max_length=20) -class UnitOfMeasureGeneralBase(BaseModel): - code: str = Field(..., max_length=20, description="Unit Code") - description: Optional[str] = Field(None, max_length=100) - conversion_factor: Optional[Decimal] = None - mexico_unit: Optional[str] = Field(None, max_length=20) - american_unit_code: Optional[str] = Field(None, max_length=20) - customs_code: Optional[int] = Field(None) - ace_code: Optional[str] = Field(None, max_length=20) +class UnitOfMeasureGeneralBase(UnitOfMeasureBase): + pass # --- Create DTOs --- @@ -105,14 +99,8 @@ class UnitOfMeasureUpdate(BaseModel): oma_code: Optional[str] = Field(None, max_length=20) -class UnitOfMeasureGeneralUpdate(BaseModel): - code: Optional[str] = Field(None, max_length=20) - description: Optional[str] = Field(None, max_length=100) - conversion_factor: Optional[Decimal] = None - mexico_unit: Optional[str] = Field(None, max_length=20) - american_unit_code: Optional[str] = Field(None, max_length=20) - customs_code: Optional[int] = Field(None) - ace_code: Optional[str] = Field(None, max_length=20) +class UnitOfMeasureGeneralUpdate(UnitOfMeasureUpdate): + pass # --- Response DTOs --- @@ -142,6 +130,5 @@ class UnitOfMeasureResponse(UnitOfMeasureBase): model_config = ConfigDict(from_attributes=True) -class UnitOfMeasureGeneralResponse(UnitOfMeasureGeneralBase): - id: int - model_config = ConfigDict(from_attributes=True) +class UnitOfMeasureGeneralResponse(UnitOfMeasureResponse): + pass diff --git a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/service.py b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/service.py index f032f76e..c514c2ee 100644 --- a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/service.py @@ -154,7 +154,7 @@ class UnitOfMeasureService(BaseService): class UnitOfMeasureGeneralService(BaseService): - model = UnitOfMeasureGeneral + model = UnitOfMeasure def get_all_uom_general(session: Session, skip: int = 0, limit: int = 100) -> Sequence[UnitOfMeasureGeneral]: diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/units-of-measure.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/units-of-measure.ts index f680bae3..b32bedaf 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/units-of-measure.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/units-of-measure.ts @@ -183,7 +183,7 @@ export interface UnitOfMeasureGeneralUpdate { } export interface UnitOfMeasureGeneralListResponse { - items: UnitOfMeasureGeneral[]; + items: UnitOfMeasureGeneral[]; total: number; page: number; page_size: number; @@ -214,7 +214,7 @@ export async function updateUnitOfMeasureGeneral(id: number, data: UnitOfMeasure } export async function deleteUnitOfMeasureGeneral(id: number, companyId: number): Promise> { - return await api.delete(`/v1/a76/units-of-measure/general/${id}/?company_id=${companyId}`); + return await api.delete(`/v1/a76/units-of-measure/general/${id}?company_id=${companyId}`); } // --- Customs --- diff --git a/frontend/src/lib/components/dashboard/general_catalogs/units_of_measure/general/data-table-actions.svelte b/frontend/src/lib/components/dashboard/general_catalogs/units_of_measure/general/data-table-actions.svelte index a84164f4..96ce74e0 100644 --- a/frontend/src/lib/components/dashboard/general_catalogs/units_of_measure/general/data-table-actions.svelte +++ b/frontend/src/lib/components/dashboard/general_catalogs/units_of_measure/general/data-table-actions.svelte @@ -2,6 +2,7 @@ import { Button } from '$lib/components/ui/button'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import { MoreHorizontal, Pencil, Trash2 } from 'lucide-svelte'; + import { toast } from 'svelte-sonner'; import CreateEditDialog from './create-edit-dialog.svelte'; import type { UnitOfMeasureGeneral } from '$lib/api/dashboard/a76/general_catalogs/units-of-measure'; import { deleteUnitOfMeasureGeneral } from '$lib/api/dashboard/a76/general_catalogs/units-of-measure'; @@ -25,6 +26,7 @@ const response = await deleteUnitOfMeasureGeneral(unit.id, activeCompanyId); if (response.error) { + toast.error(response.error); } else if (response.status === 204 || response.status === 200) { onSuccess?.(); } diff --git a/frontend/src/lib/components/dashboard/general_catalogs/units_of_measure/main/data-table.svelte b/frontend/src/lib/components/dashboard/general_catalogs/units_of_measure/main/data-table.svelte new file mode 100644 index 00000000..77c412d8 --- /dev/null +++ b/frontend/src/lib/components/dashboard/general_catalogs/units_of_measure/main/data-table.svelte @@ -0,0 +1,107 @@ + + +
+ + + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} + + {#each headerGroup.headers as header (header.id)} + + {#if !header.isPlaceholder} + + {/if} + + {/each} + + {/each} + + + {#each table.getRowModel().rows as row (row.id)} + + {#each row.getVisibleCells() as cell (cell.id)} + + + + {/each} + + {:else} + + + No hay resultados. + + + {/each} + + +
+ + +
+
+ Total: {totalItems} registros +
+
+ + +
+
diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index e6dbde48..a0dd99a8 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -1,18 +1,18 @@ -import { - Archive, - ArrowDownToLine, - ArrowUpFromLine, - BadgeCheck, - ChartPie, - Database, - FileText, - Frame, - GalleryVerticalEnd, - LayoutDashboard, - Package, - Settings2, - Shield, - Users, +import { + Archive, + ArrowDownToLine, + ArrowUpFromLine, + BadgeCheck, + ChartPie, + Database, + FileText, + Frame, + GalleryVerticalEnd, + LayoutDashboard, + Package, + Settings2, + Shield, + Users, } from 'lucide-svelte'; import * as m from "$lib/paraglide/messages.js"; import { Title } from '../ui/alert'; @@ -296,7 +296,7 @@ export function getSidebarData(): SidebarData { { title: m["sidebar.goods.classes"](), url: "/dashboard/goods/fixed-asset-classes", - }, + }, { title: m["sidebar.goods.parts"](), url: "/dashboard/goods/parts", @@ -369,7 +369,7 @@ export function getSidebarData(): SidebarData { { title: m["sidebar.export_invoices.repair"](), url: "/dashboard/invoices?operation_type=exp&invoice_type=REPAR", - }, + }, ], }, { From 9120568f10dc14856373afaa335504977e27be1a Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Wed, 21 Jan 2026 15:44:03 -0600 Subject: [PATCH 04/55] feat(seed): add tariff fractions seed data to initial migration --- .../7937209f9718_seed_initial_data.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/backend/alembic/versions/7937209f9718_seed_initial_data.py b/backend/alembic/versions/7937209f9718_seed_initial_data.py index f4e2a5de..6e1e2359 100644 --- a/backend/alembic/versions/7937209f9718_seed_initial_data.py +++ b/backend/alembic/versions/7937209f9718_seed_initial_data.py @@ -69,6 +69,9 @@ from api.v1.modules.a76.general_catalogs.units_of_measure.seed_ame import ( from api.v1.modules.a76.general_catalogs.units_of_measure.seed_adua import ( seed as adua_seed, ) +from api.v1.modules.a76.general_catalogs.tariff_fractions.seed import ( + seed as tariff_fractions_seed, +) from api.v1.modules.core.permissions.seed import ( seed_invoices, seed_user, @@ -462,6 +465,22 @@ def upgrade() -> None: """ ) + # --- SEEDS A76 (Tariff Fractions - Fracciones Arancelarias Mexicanas) --- + values_tariff_fractions = ", ".join( + [ + f"({format_value(code)}, {format_value(fraction)}, {format_value(description)}, " + f"{format_value(nico)}, {format_value(umt)}, {format_value(adv_impo)}, {format_value(adv_expo)})" + for code, fraction, description, nico, umt, adv_impo, adv_expo in tariff_fractions_seed + ] + ) + op.execute( + f""" + INSERT INTO a76.tariff_fractions (code, fraction, description, nico, umt, adv_impo, adv_expo) + VALUES {values_tariff_fractions} + ON CONFLICT (code) DO NOTHING; + """ + ) + def downgrade() -> None: """Downgrade schema.""" From e28a81c07c0ed3089b530b590b3decc233f4396f Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Wed, 21 Jan 2026 15:44:54 -0600 Subject: [PATCH 05/55] fix(table): add overflow handling and className support for table columns --- frontend/src/app.css | 2 +- .../dashboard/pedimentos/columns.ts | 22 +++++++++++++++++++ .../dashboard/pedimentos/data-table.svelte | 10 ++++----- frontend/src/routes/dashboard/+layout.svelte | 4 ++-- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index 8d9a66ab..6c344082 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -118,6 +118,6 @@ @apply border-border outline-ring/50; } body { - @apply bg-background text-foreground; + @apply bg-background text-foreground overflow-x-hidden; } } diff --git a/frontend/src/lib/components/dashboard/pedimentos/columns.ts b/frontend/src/lib/components/dashboard/pedimentos/columns.ts index 2bdfbe51..d56a174e 100644 --- a/frontend/src/lib/components/dashboard/pedimentos/columns.ts +++ b/frontend/src/lib/components/dashboard/pedimentos/columns.ts @@ -4,6 +4,13 @@ import { createRawSnippet } from "svelte"; import DataTableActions from "./data-table-actions.svelte"; import type { Pedimento } from "$lib/api/dashboard/a76/pedimentos"; +// Extender el tipo ColumnMeta para incluir className +declare module "@tanstack/table-core" { + interface ColumnMeta { + className?: string; + } +} + /** * Formatea un número como moneda */ @@ -120,6 +127,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { { accessorKey: "pedimento_type", header: "Tipo", + meta: { className: "hidden md:table-cell" }, cell: ({ row }) => { const typeSnippet = createRawSnippet<[{ type?: string | null }]>((getType) => { const { type } = getType(); @@ -134,6 +142,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { { accessorKey: "pedimento_code", header: "Clave", + meta: { className: "hidden lg:table-cell" }, cell: ({ row }) => { const codeSnippet = createRawSnippet<[{ code?: string | null }]>((getCode) => { const { code } = getCode(); @@ -148,6 +157,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { { accessorKey: "regime", header: "Régimen", + meta: { className: "hidden lg:table-cell" }, cell: ({ row }) => { const regimeSnippet = createRawSnippet<[{ regime?: string | null }]>((getRegime) => { const { regime } = getRegime(); @@ -162,6 +172,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { { accessorKey: "pedimento_dates.start_date", header: "Fecha Inicio", + meta: { className: "hidden xl:table-cell" }, cell: ({ row }) => { const dateSnippet = createRawSnippet<[{ date: string }]>((getDate) => { const { date } = getDate(); @@ -176,6 +187,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { { accessorKey: "pedimento_dates.end_date", header: "Fecha Final", + meta: { className: "hidden xl:table-cell" }, cell: ({ row }) => { const dateSnippet = createRawSnippet<[{ date: string }]>((getDate) => { const { date } = getDate(); @@ -190,6 +202,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { { accessorKey: "pedimento_dates.payment_date", header: "Fecha de Pago", + meta: { className: "hidden xl:table-cell" }, cell: ({ row }) => { const dateSnippet = createRawSnippet<[{ date: string }]>((getDate) => { const { date } = getDate(); @@ -204,6 +217,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { { accessorKey: "pedimento_config_update_rectification.pediment_rectifed_18", header: "Pedimento 18", + meta: { className: "hidden lg:table-cell" }, cell: ({ row }) => { const ped18Snippet = createRawSnippet<[{ value?: string | null }]>((getValue) => { const { value } = getValue(); @@ -218,6 +232,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { { accessorKey: "pedimento_config_update_rectification.r1", header: "Pedimento R1", + meta: { className: "hidden lg:table-cell" }, cell: ({ row }) => { const r1Snippet = createRawSnippet<[{ value?: string | null }]>((getValue) => { const { value } = getValue(); @@ -232,6 +247,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { { accessorKey: "pedimento_validation.electronic_signature", header: "Acuse Electrónico", + meta: { className: "hidden xl:table-cell" }, cell: ({ row }) => { const ackSnippet = createRawSnippet<[{ value?: string | null }]>((getValue) => { const { value } = getValue(); @@ -249,6 +265,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { { accessorKey: "pedimento_payments.total_contributions", header: "¿Se pagó el impuesto?", + meta: { className: "hidden xl:table-cell" }, cell: ({ row }) => { const paidSnippet = createRawSnippet<[{ value?: string | null }]>((getValue) => { const { value } = getValue(); @@ -266,6 +283,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { { accessorKey: "client_id", header: "Cliente", + meta: { className: "hidden md:table-cell" }, cell: ({ row }) => { const clientSnippet = createRawSnippet<[{ clientId?: number | null }]>((getClient) => { const { clientId } = getClient(); @@ -309,6 +327,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { }); return renderSnippet(headerSnippet, {}); }, + meta: { className: "hidden lg:table-cell" }, cell: ({ row }) => { const valueSnippet = createRawSnippet<[{ value: string }]>((getValue) => { const { value } = getValue(); @@ -330,6 +349,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { }); return renderSnippet(headerSnippet, {}); }, + meta: { className: "hidden xl:table-cell" }, cell: ({ row }) => { const priceSnippet = createRawSnippet<[{ price: string }]>((getPrice) => { const { price } = getPrice(); @@ -351,6 +371,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { }); return renderSnippet(headerSnippet, {}); }, + meta: { className: "hidden lg:table-cell" }, cell: ({ row }) => { const weightSnippet = createRawSnippet<[{ weight: string }]>((getWeight) => { const { weight } = getWeight(); @@ -365,6 +386,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { { accessorKey: "created_at", header: "Fecha de Creación", + meta: { className: "hidden 2xl:table-cell" }, cell: ({ row }) => { const dateSnippet = createRawSnippet<[{ date: string }]>((getDate) => { const { date } = getDate(); diff --git a/frontend/src/lib/components/dashboard/pedimentos/data-table.svelte b/frontend/src/lib/components/dashboard/pedimentos/data-table.svelte index 4c448e3d..1a5b4fd6 100644 --- a/frontend/src/lib/components/dashboard/pedimentos/data-table.svelte +++ b/frontend/src/lib/components/dashboard/pedimentos/data-table.svelte @@ -60,13 +60,13 @@
-
- - +
+ + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} {#each headerGroup.headers as header (header.id)} - + {#if !header.isPlaceholder} {#each row.getVisibleCells() as cell (cell.id)} - + - +
@@ -55,7 +55,7 @@ -->
-
+
{@render children()}
From 67f285fbbccbe424a419d6c6ccdc4173b92bbc33 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Wed, 21 Jan 2026 16:01:32 -0600 Subject: [PATCH 06/55] Se cambio el nombre en el side bar y en la pagina --- frontend/messages/en.json | 12 ++-- frontend/messages/es.json | 72 +++++++++---------- .../units_of_measure/general/+page.svelte | 2 +- 3 files changed, 43 insertions(+), 43 deletions(-) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 541dac72..4225b510 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -41,7 +41,7 @@ "valuation_methods": "Valuation Methods", "countries": "Countries", "ports": "Ports", - "unit_measures": "Units of Measure - General", + "unit_measures": "Units of Measure", "um_customs_mex": "Units of Measure - Mexican Customs", "um_customs_ame": "Units of Measure - American Customs", "um_ace": "Units of Measure - ACE", @@ -65,7 +65,7 @@ }, "goods": { "title": "Goods", - "classes": "Classes", + "classes": "Classes", "parts": "Parts" }, "pedimentos": { @@ -77,16 +77,16 @@ "customs_sections": "Customs Sections", "anexo_22_app_31": "Anexo 22 App 3" }, - "import_invoices":{ + "import_invoices": { "title": "Import Invoices", "temporary": "Temporary", "definitive": "Definitive", "mexican_purchases": "Mexican Purchases", - "regime_change": "Regime Change" + "regime_change": "Regime Change" }, "export_invoices": { "title": "Export Invoices", - "exportation": "Exportation", + "exportation": "Exportation", "repair": "Repair" }, "clients_and_providers": "Clients and Providers", @@ -97,4 +97,4 @@ "logout": "Logout" } } -} +} \ No newline at end of file diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 9e2a85c5..2acc042b 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1,33 +1,33 @@ { - "$schema": "https://inlang.com/schema/inlang-message-format", - "hello_world": "Hello, {name} from es!", - "sidebar": { - "reference_data": { - "title": "Catálogos Fijos", - "codes_pedimento_regimen": "Códigos de Pedimento y Régimen", - "containers": "Contenedores", - "countries": "Países", - "currency_types": "Tipos de moneda", - "customs_sections": "Secciones de aduanas", - "customs_warehouses": "Recintos", - "incoterms": "Incoterms", - "invoice_types": "Tipos de factura", - "material_types": "Tipos de material", - "payment_methods": "Métodos de pago", - "pedimento_codes": "Códigos de pedimento", - "pedimento_regimes": "Regímenes de pedimentos", - "sectors": "Sectores", - "states": "Estados", - "transportation_modes": "Métodos de transporte", - "transportation_types": "Tipos de transporte", - "valuation_methods": "Métodos de valoración", - "configuracion": "Configuración", - "general": "General", - "licencia": "Licencia", - "usuarios": "Usuarios", - "ayuda": "Ayuda" - }, - "general_catalogs": { + "$schema": "https://inlang.com/schema/inlang-message-format", + "hello_world": "Hello, {name} from es!", + "sidebar": { + "reference_data": { + "title": "Catálogos Fijos", + "codes_pedimento_regimen": "Códigos de Pedimento y Régimen", + "containers": "Contenedores", + "countries": "Países", + "currency_types": "Tipos de moneda", + "customs_sections": "Secciones de aduanas", + "customs_warehouses": "Recintos", + "incoterms": "Incoterms", + "invoice_types": "Tipos de factura", + "material_types": "Tipos de material", + "payment_methods": "Métodos de pago", + "pedimento_codes": "Códigos de pedimento", + "pedimento_regimes": "Regímenes de pedimentos", + "sectors": "Sectores", + "states": "Estados", + "transportation_modes": "Métodos de transporte", + "transportation_types": "Tipos de transporte", + "valuation_methods": "Métodos de valoración", + "configuracion": "Configuración", + "general": "General", + "licencia": "Licencia", + "usuarios": "Usuarios", + "ayuda": "Ayuda" + }, + "general_catalogs": { "title": "Catalogos Generales", "company_information": "Información de la empresa", "packages": "Bultos", @@ -41,7 +41,7 @@ "valuation_methods": "Metódos de valoración", "countries": "Países", "ports": "Puertos", - "unit_measures": "UM general", + "unit_measures": "Unidades de medida", "um_customs_mex": "UM Aduanas MX", "um_customs_ame": "UM Aduanas USA", "um_ace": "UM ACE", @@ -65,10 +65,10 @@ }, "goods": { "title": "Mercancías", - "classes": "Clases", + "classes": "Clases", "parts": "Partes" }, - "pedimentos": { + "pedimentos": { "title": "Pedimentos", "pedimento_management": "Gestión de Pedimentos", "pedimento_codes": "Claves de Pedimento", @@ -77,16 +77,16 @@ "customs_sections": "Secciones Aduaneras", "anexo_22_app_31": "Anexo 22 App 3" }, - "import_invoices":{ + "import_invoices": { "title": "Facturas de importación", "temporary": "Temporal", "definitive": "Definitiva", "mexican_purchases": "Compras mexicanas", - "regime_change": "Cambio de régimen" + "regime_change": "Cambio de régimen" }, "export_invoices": { "title": "Facturas de exportación", - "exportation": "Exportación", + "exportation": "Exportación", "repair": "Reparación" }, "clients_and_providers": "Clientes y Proveedores", @@ -95,5 +95,5 @@ "profile": "Perfil", "settings": "Configuración" } - } + } } \ No newline at end of file diff --git a/frontend/src/routes/dashboard/general_catalogs/units_of_measure/general/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/units_of_measure/general/+page.svelte index f8b53600..bae27c42 100644 --- a/frontend/src/routes/dashboard/general_catalogs/units_of_measure/general/+page.svelte +++ b/frontend/src/routes/dashboard/general_catalogs/units_of_measure/general/+page.svelte @@ -44,7 +44,7 @@
-

Unidades de Medida Generales

+

Unidades de Medida

Catálogo general de unidades de medida

From f7ac2d50c3aec2eca570739de906d16fe92c14ef Mon Sep 17 00:00:00 2001 From: acazares Date: Wed, 21 Jan 2026 16:16:55 -0600 Subject: [PATCH 07/55] fix: update part_number and component_part_number fields in various schemas and components --- .../imports/temporary/validators/create.py | 61 ++++--- .../imports/temporary/validators/update.py | 130 ++++++++------- .../modules/a76/items/line_items/schemas.py | 12 +- backend/api/v1/modules/a76/items/service.py | 153 ++++++++++-------- frontend/src/lib/api/dashboard/a76/items.ts | 12 +- .../dashboard/goods/parts/partForm.svelte | 7 +- .../edit/items/fa/item-configuration.svelte | 2 +- .../invoices/edit/items/items-tab-form.svelte | 8 +- 8 files changed, 203 insertions(+), 182 deletions(-) diff --git a/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py b/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py index d6080ac3..d14a4113 100644 --- a/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py +++ b/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py @@ -1,6 +1,7 @@ """ Validaciones para creación de items vía API. """ + from sqlalchemy.orm import Session from core.exceptions import ErrorCollector from api.v1.modules.a76.items.line_items.schemas import LineItemCreate @@ -12,11 +13,11 @@ def validate_create( line: LineItemCreate, tenant_id: int, company_id: int, - errors: ErrorCollector + errors: ErrorCollector, ) -> None: """ Validaciones para crear LineItems vía API (actualmente en uso). - + Args: db: Sesión de base de datos line: Datos del line item @@ -30,43 +31,34 @@ def validate_create( field="line_number", message="El número de línea es obligatorio", solution="Proporciona un número de línea válido", - code="REQUIRED" + code="REQUIRED", ) - - # 2. Validar part_number_id - if not line.part_number_id: - errors.add_error( - field="part_number_id", - message="Part Number ID es obligatorio", - solution="Selecciona un número de parte válido del catálogo", - code="REQUIRED" - ) - - # 3. Validar class_id + + # 2. Validar class_id if not line.class_id: errors.add_error( field="class_id", message="Clase (ID) es obligatorio", solution="Selecciona una clasificación válida del catálogo", - code="REQUIRED" + code="REQUIRED", ) - + # 4. Validar unit_of_measure if not line.unit_of_measure: errors.add_error( field="unit_of_measure", message="U.M. es obligatorio", solution="Proporciona una unidad de medida válida", - code="REQUIRED" + code="REQUIRED", ) - + # 5. Validar quantity.quantity if not line.quantity: errors.add_error( field="quantity", message="Quantity es obligatorio", solution="Proporciona una cantidad válida", - code="REQUIRED" + code="REQUIRED", ) else: # Validar con nombre amigable @@ -75,44 +67,51 @@ def validate_create( field="quantity.quantity", message="Quantity debe ser mayor a cero", solution="Proporciona una cantidad válida", - code="INVALID_VALUE" if line.quantity.quantity is not None else "REQUIRED" + code=( + "INVALID_VALUE" + if line.quantity.quantity is not None + else "REQUIRED" + ), ) - + # 6. Validar financial.unit_cost if not line.financial: errors.add_error( field="financial", message="Unit Cost es obligatorio", solution="Proporciona el costo unitario del item", - code="REQUIRED" + code="REQUIRED", ) else: has_cost = ( - line.financial.unit_cost_usd or - line.financial.unit_cost_mxn or - line.financial.unit_cost_capture + line.financial.unit_cost_usd + or line.financial.unit_cost_mxn + or line.financial.unit_cost_capture ) if not has_cost: errors.add_error( field="financial.unit_cost", message="Unit Cost es obligatorio", solution="Proporciona al menos un costo unitario (USD, MXN o captura)", - code="REQUIRED" + code="REQUIRED", ) - + # 7. Validar description.description_spanish if line.description: - if not line.description.description_spanish or not line.description.description_spanish.strip(): + if ( + not line.description.description_spanish + or not line.description.description_spanish.strip() + ): errors.add_error( field="description.description_spanish", message="Description in Spanish es obligatorio", solution="Proporciona una descripción del item en español", - code="REQUIRED" + code="REQUIRED", ) else: errors.add_error( field="description.description_spanish", message="Description in Spanish es obligatorio", solution="Proporciona una descripción del item en español", - code="REQUIRED" - ) \ No newline at end of file + code="REQUIRED", + ) diff --git a/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py b/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py index 8feaa515..f5d183b9 100644 --- a/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py +++ b/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py @@ -1,6 +1,7 @@ """ Validaciones para actualización de items vía API. """ + from sqlalchemy.orm import Session from core.exceptions import ErrorCollector from api.v1.modules.a76.items.line_items.schemas import LineItemUpdate @@ -13,12 +14,12 @@ def validate_update( tenant_id: int, company_id: int, errors: ErrorCollector, - invoice_id: int = None + invoice_id: int = None, ) -> None: """ Validaciones para actualizar LineItems vía API. Incluye todas las validaciones de negocio de Clarion. - + Args: db: Sesión de base de datos line: Datos del line item a actualizar @@ -33,36 +34,27 @@ def validate_update( field="line_number", message="El número de línea no puede estar vacío", solution="Proporciona un número de línea válido", - code="REQUIRED" + code="REQUIRED", ) - - # 2. Validar part_number_id si se proporciona - if line.part_number_id is not None and not line.part_number_id: - errors.add_error( - field="part_number_id", - message="Part Number ID no puede estar vacío", - solution="Selecciona un número de parte válido del catálogo", - code="REQUIRED" - ) - - # 3. Validar class_id si se proporciona + + # 2. Validar class_id si se proporciona if line.class_id is not None and not line.class_id: errors.add_error( field="class_id", message="Clase (ID) no puede estar vacío", solution="Selecciona una clasificación válida del catálogo", - code="REQUIRED" + code="REQUIRED", ) - + # 4. Validar unit_of_measure si se proporciona if line.unit_of_measure is not None and not line.unit_of_measure: errors.add_error( field="unit_of_measure", message="U.M. no puede estar vacío", solution="Proporciona una unidad de medida válida", - code="REQUIRED" + code="REQUIRED", ) - + # 5. Validar cantidad si se proporciona if line.quantity: # Si se proporciona el objeto quantity, validar que quantity.quantity sea válido @@ -72,7 +64,7 @@ def validate_update( field="quantity.quantity", message="Quantity debe ser mayor a cero", solution="Proporciona una cantidad válida", - code="INVALID_VALUE" + code="INVALID_VALUE", ) else: # Si se proporciona quantity pero quantity.quantity es None, es requerido @@ -80,9 +72,9 @@ def validate_update( field="quantity.quantity", message="Quantity es obligatorio", solution="Proporciona una cantidad mayor a 0", - code="REQUIRED" + code="REQUIRED", ) - + # 6. Validar peso neto si se proporciona if line.quantity and line.quantity.net_weight is not None: if line.quantity.net_weight <= 0: @@ -90,25 +82,25 @@ def validate_update( field="quantity.net_weight", message="Net Weight debe ser mayor a cero", solution="Proporciona un peso neto válido", - code="INVALID_VALUE" + code="INVALID_VALUE", ) - + # 7. Validar costo unitario si se proporciona financial (excepto subpartidas) if line.financial: is_subitem = line.fa_data and line.fa_data.is_subitem if line.fa_data else False - + if not is_subitem: has_cost = ( - line.financial.unit_cost_usd or - line.financial.unit_cost_mxn or - line.financial.unit_cost_capture + line.financial.unit_cost_usd + or line.financial.unit_cost_mxn + or line.financial.unit_cost_capture ) if not has_cost: errors.add_error( field="financial.unit_cost", message="Unit Cost es obligatorio", solution="Proporciona al menos un costo unitario (USD, MXN o captura)", - code="REQUIRED" + code="REQUIRED", ) # Validar que sean positivos if line.financial.unit_cost_usd is not None: @@ -117,7 +109,7 @@ def validate_update( field="financial.unit_cost_usd", message="Unit Cost (USD) debe ser mayor a cero", solution="Proporciona un costo unitario válido", - code="INVALID_VALUE" + code="INVALID_VALUE", ) if line.financial.unit_cost_mxn is not None: if line.financial.unit_cost_mxn <= 0: @@ -125,7 +117,7 @@ def validate_update( field="financial.unit_cost_mxn", message="Unit Cost (MXN) debe ser mayor a cero", solution="Proporciona un costo unitario válido", - code="INVALID_VALUE" + code="INVALID_VALUE", ) if line.financial.unit_cost_capture is not None: if line.financial.unit_cost_capture <= 0: @@ -133,9 +125,9 @@ def validate_update( field="financial.unit_cost_capture", message="Unit Cost (Captura) debe ser mayor a cero", solution="Proporciona un costo unitario válido", - code="INVALID_VALUE" + code="INVALID_VALUE", ) - + # 8. Validar datos aduanales si se proporcionan if line.customs: # Validar país de origen @@ -144,56 +136,65 @@ def validate_update( field="customs.origin_country", message="Origin Country no puede estar vacío", solution="Selecciona el país de origen del item", - code="REQUIRED" + code="REQUIRED", ) - + # Validar preferencia arancelaria if line.customs.preference is not None and not line.customs.preference: errors.add_error( field="customs.preference", message="La preferencia arancelaria no puede estar vacía", solution="Selecciona la preferencia arancelaria", - code="REQUIRED" + code="REQUIRED", ) - + # Validar formato de pago de impuestos if line.customs.tax_paid: val_tax = line.customs.tax_paid.upper() - if val_tax not in ['SI', 'NO', 'S', 'N']: + if val_tax not in ["SI", "NO", "S", "N"]: errors.add_error( field="customs.tax_paid", message="El valor de pago de impuesto debe ser SI/NO o S/N", solution="Proporciona un valor válido: SI, NO, S o N", - code="INVALID_VALUE" + code="INVALID_VALUE", ) - + # Validar forma de pago si existe if line.customs.payment_form: - from api.v1.modules.a76.general_catalogs.forms_of_payment.models import PaymentForm - - payment = db.query(PaymentForm).filter( - PaymentForm.code == line.customs.payment_form, - PaymentForm.tenant_id == tenant_id - ).first() - + from api.v1.modules.a76.general_catalogs.forms_of_payment.models import ( + PaymentForm, + ) + + payment = ( + db.query(PaymentForm) + .filter( + PaymentForm.code == line.customs.payment_form, + PaymentForm.tenant_id == tenant_id, + ) + .first() + ) + if not payment: errors.add_error( field="customs.payment_form", message=f"La forma de pago '{line.customs.payment_form}' no es válida", solution="Selecciona una forma de pago válida del catálogo", - code="INVALID_VALUE" + code="INVALID_VALUE", ) - + # 9. Validar descripción en español si se proporciona - if line.description and hasattr(line.description, 'description_spanish'): - if line.description.description_spanish is not None and not line.description.description_spanish: + if line.description and hasattr(line.description, "description_spanish"): + if ( + line.description.description_spanish is not None + and not line.description.description_spanish + ): errors.add_error( field="description.description_spanish", message="La descripción en español no puede estar vacía", solution="Proporciona una descripción del item en español", - code="REQUIRED" + code="REQUIRED", ) - + # 10. Validar subpartidas si se actualizan if line.fa_data and line.fa_data.is_subitem: # Es subpartida, debe tener partida principal @@ -202,30 +203,35 @@ def validate_update( field="fa_data.main_line_id", message="La subpartida debe tener asignada una partida principal", solution="Selecciona la partida principal de esta subpartida", - code="REQUIRED" + code="REQUIRED", ) elif invoice_id: # Validar que la partida principal exista en la misma factura from api.v1.modules.a76.items.line_items.models import LineItem from api.v1.modules.a76.items.models import Item - - parent = db.query(LineItem).join(LineItem.item).filter( - LineItem.line_number == line.fa_data.main_line_id, - Item.invoice_id == invoice_id, - LineItem.company_id == company_id - ).first() - + + parent = ( + db.query(LineItem) + .join(LineItem.item) + .filter( + LineItem.line_number == line.fa_data.main_line_id, + Item.invoice_id == invoice_id, + LineItem.company_id == company_id, + ) + .first() + ) + if not parent: errors.add_error( field="fa_data.main_line_id", message=f"La partida principal {line.fa_data.main_line_id} no existe en esta factura", solution="Verifica el número de la partida principal", - code="NOT_FOUND" + code="NOT_FOUND", ) elif parent.fa_data and parent.fa_data.is_subitem: errors.add_error( field="fa_data.main_line_id", message="La partida principal no puede ser otra subpartida", solution="Selecciona una partida normal como principal", - code="INVALID_VALUE" + code="INVALID_VALUE", ) diff --git a/backend/api/v1/modules/a76/items/line_items/schemas.py b/backend/api/v1/modules/a76/items/line_items/schemas.py index 0bea3647..bd3520b0 100644 --- a/backend/api/v1/modules/a76/items/line_items/schemas.py +++ b/backend/api/v1/modules/a76/items/line_items/schemas.py @@ -47,19 +47,15 @@ class LineItemBase(BaseModel): line_number: int = Field(..., description="Line number") # Part identification - part_number_id: Optional[int] = Field(None, description="Part number") - component_part_number_id: Optional[int] = Field( + part_number: Optional[int] = Field(None, description="Part number") + component_part_number: Optional[int] = Field( None, description="Component part number" ) class_id: Optional[int] = Field(None, description="Class code") # Unit of measure - unit_of_measure: Optional[int] = Field( - None, description="Unit of measure" - ) - alternate_unit: Optional[int] = Field( - None, description="Alternate unit" - ) + unit_of_measure: Optional[int] = Field(None, description="Unit of measure") + alternate_unit: Optional[int] = Field(None, description="Alternate unit") uma_key: Optional[str] = Field(None, max_length=2, description="UMA key") auxiliary_unit: Optional[str] = Field( None, max_length=5, description="Auxiliary unit" diff --git a/backend/api/v1/modules/a76/items/service.py b/backend/api/v1/modules/a76/items/service.py index 2866fb3d..7d8959f7 100644 --- a/backend/api/v1/modules/a76/items/service.py +++ b/backend/api/v1/modules/a76/items/service.py @@ -33,7 +33,7 @@ from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem from .models import Item from api.v1.modules.a76.invoices.models import InvoiceHeader from api.v1.modules.a76.general_catalogs.company.models import Company - + logger = logging.getLogger(__name__) @@ -158,79 +158,86 @@ class ItemService: company_id: int, ) -> Item: """Create a new item with all related nested data (multiple lines)""" - + # Validaciones con ErrorCollector errors = ErrorCollector() - + # Validar que la factura exista y no esté actualizada (si viene invoice_id) invoice = None if item_data.invoice_id: - invoice = db.query(InvoiceHeader).filter( - InvoiceHeader.id == item_data.invoice_id, - InvoiceHeader.tenant_id == tenant_id, - InvoiceHeader.company_id == company_id - ).first() - + invoice = ( + db.query(InvoiceHeader) + .filter( + InvoiceHeader.id == item_data.invoice_id, + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + ) + .first() + ) + if not invoice: errors.add_error( field="invoice_id", message="La factura especificada no existe", code="NOT_FOUND", - value=str(item_data.invoice_id) + value=str(item_data.invoice_id), ) - + # Validar cada line item que se va a crear if item_data.lines: for idx, line_data in enumerate(item_data.lines): # Convertir a LineItemCreate para validar line_create = LineItemCreate(**line_data.model_dump()) - + validate_create(db, line_create, tenant_id, company_id, errors) - + # Validaciones adicionales específicas del negocio - + # Validar apóstrofes en número de parte - if line_data.part_number_id and "'" in str(line_data.part_number_id): + if line_data.part_number and "'" in str(line_data.part_number): errors.add_error( - field=f"lines[{idx}].part_number_id", + field=f"lines[{idx}].part_number", message=f"Advertencia: El Número de Parte contiene apóstrofes y serán omitidos", - code="WARNING_APOSTROPHE" + code="WARNING_APOSTROPHE", ) - + # Validar tipo de partida - if hasattr(line_data, 'item_type'): + if hasattr(line_data, "item_type"): tipo_partida = line_data.item_type - if tipo_partida and tipo_partida not in ['N', 'S']: + if tipo_partida and tipo_partida not in ["N", "S"]: errors.add_error( field=f"lines[{idx}].item_type", message=f"Tipo de partida debe ser 'N' (Normal) o 'S' (Subpartida), recibido: '{tipo_partida}'", code="INVALID_ITEM_TYPE", - value=str(tipo_partida) + value=str(tipo_partida), ) - + # Si es subpartida (S), debe tener partida principal - if tipo_partida == 'S': - if not hasattr(line_data, 'main_line_id') or not line_data.main_line_id: + if tipo_partida == "S": + if ( + not hasattr(line_data, "main_line_id") + or not line_data.main_line_id + ): errors.add_error( field=f"lines[{idx}].main_line_id", message="Las subpartidas (tipo 'S') deben tener una partida principal", - code="MISSING_MAIN_LINE" + code="MISSING_MAIN_LINE", ) - + # Validar que el line_number sea consecutivo (si se especifica) - if hasattr(line_data, 'line_number') and line_data.line_number: + if hasattr(line_data, "line_number") and line_data.line_number: expected_line = idx + 1 if line_data.line_number != expected_line: errors.add_error( field=f"lines[{idx}].line_number", message=f"Número de línea esperado: {expected_line}, recibido: {line_data.line_number}", code="INVALID_LINE_SEQUENCE", - value=str(line_data.line_number) + value=str(line_data.line_number), ) - + # Si hay errores, lanzar excepción ANTES de intentar crear errors.raise_if_errors("Error al crear el item") - + try: # Extract lines data lines_data = item_data.lines or [] @@ -311,7 +318,9 @@ class ItemService: # Create FA data if provided if fa_data: - fa_dict = fa_data.model_dump(exclude={"line_item_id"}) # Exclude line_item_id from DTO + fa_dict = fa_data.model_dump( + exclude={"line_item_id"} + ) # Exclude line_item_id from DTO fa_dict["id"] = db_line.id # FA table uses same ID as line item fa_dict["tenant_id"] = tenant_id fa_dict["company_id"] = company_id @@ -343,47 +352,54 @@ class ItemService: company_id: int, ) -> Item: """Update an item and optionally its nested data (multiple lines)""" - + # Get existing item db_item = ItemService.get_by_id(db, item_id, tenant_id, company_id) if not db_item: raise HTTPException(status_code=404, detail="Item not found") - + # Validaciones con ErrorCollector errors = ErrorCollector() - + # Si se está actualizando el invoice_id, validar la factura invoice = None if item_data.invoice_id: - invoice = db.query(InvoiceHeader).filter( - InvoiceHeader.id == item_data.invoice_id, - InvoiceHeader.tenant_id == tenant_id, - InvoiceHeader.company_id == company_id - ).first() - + invoice = ( + db.query(InvoiceHeader) + .filter( + InvoiceHeader.id == item_data.invoice_id, + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + ) + .first() + ) + if not invoice: errors.add_error( field="invoice_id", message="La factura especificada no existe", code="NOT_FOUND", - value=str(item_data.invoice_id) + value=str(item_data.invoice_id), ) else: # Si no se está actualizando invoice_id, obtener la factura actual por invoice_id from api.v1.modules.a76.invoices.models import InvoiceHeader - invoice = db.query(InvoiceHeader).filter( - InvoiceHeader.id == db_item.invoice_id - ).first() - + + invoice = ( + db.query(InvoiceHeader) + .filter(InvoiceHeader.id == db_item.invoice_id) + .first() + ) + # Validar cada line item que se va a actualizar if item_data.lines: for idx, line_data in enumerate(item_data.lines): # Si el line tiene ID, es actualización; si no, es creación - if hasattr(line_data, 'id') and line_data.id: + if hasattr(line_data, "id") and line_data.id: # Buscar el line item existente existing_line = next( (line for line in db_item.lines if line.id == line_data.id), - None + None, ) if existing_line: # Convertir a LineItemUpdate para validar @@ -393,43 +409,46 @@ class ItemService: # Es un nuevo line item, validar como creación line_create = LineItemCreate(**line_data.model_dump()) validate_create(db, line_create, tenant_id, company_id, errors) - + # Validaciones adicionales específicas del negocio # (Aplican tanto para crear como actualizar) - + # Validar apóstrofes en número de parte - if line_data.part_number_id and "'" in str(line_data.part_number_id): + if line_data.part_number and "'" in str(line_data.part_number): errors.add_error( - field=f"lines[{idx}].part_number_id", + field=f"lines[{idx}].part_number", message=f"Advertencia: El Número de Parte contiene apóstrofes y serán omitidos", solution=None, - code="WARNING_APOSTROPHE" + code="WARNING_APOSTROPHE", ) - + # Validar tipo de partida - if hasattr(line_data, 'item_type'): + if hasattr(line_data, "item_type"): tipo_partida = line_data.item_type - if tipo_partida and tipo_partida not in ['N', 'S']: + if tipo_partida and tipo_partida not in ["N", "S"]: errors.add_error( field=f"lines[{idx}].item_type", message=f"Tipo de partida debe ser 'N' (Normal) o 'S' (Subpartida), recibido: '{tipo_partida}'", solution=None, code="INVALID_ITEM_TYPE", - value=str(tipo_partida) + value=str(tipo_partida), ) - + # Si es subpartida (S), debe tener partida principal - if tipo_partida == 'S': - if not hasattr(line_data, 'main_line_id') or not line_data.main_line_id: + if tipo_partida == "S": + if ( + not hasattr(line_data, "main_line_id") + or not line_data.main_line_id + ): errors.add_error( field=f"lines[{idx}].main_line_id", message="Las subpartidas (tipo 'S') deben tener una partida principal", solution=None, - code="MISSING_MAIN_LINE" + code="MISSING_MAIN_LINE", ) - + # Validar que el line_number sea consecutivo (si se especifica) - if hasattr(line_data, 'line_number') and line_data.line_number: + if hasattr(line_data, "line_number") and line_data.line_number: expected_line = idx + 1 if line_data.line_number != expected_line: errors.add_error( @@ -437,12 +456,12 @@ class ItemService: message=f"Número de línea esperado: {expected_line}, recibido: {line_data.line_number}", solution=None, code="INVALID_LINE_SEQUENCE", - value=str(line_data.line_number) + value=str(line_data.line_number), ) - + # Si hay errores, lanzar excepción ANTES de actualizar errors.raise_if_errors("Error al actualizar el item") - + try: # Extract lines data @@ -519,7 +538,9 @@ class ItemService: # Create FA data if provided if fa_data is not None: - fa_dict = fa_data.model_dump(exclude_unset=True, exclude={"line_item_id"}) + fa_dict = fa_data.model_dump( + exclude_unset=True, exclude={"line_item_id"} + ) fa_dict["id"] = db_line.id # FA table uses same ID as line item fa_dict["tenant_id"] = tenant_id fa_dict["company_id"] = company_id diff --git a/frontend/src/lib/api/dashboard/a76/items.ts b/frontend/src/lib/api/dashboard/a76/items.ts index 60cec7d3..5c04f975 100644 --- a/frontend/src/lib/api/dashboard/a76/items.ts +++ b/frontend/src/lib/api/dashboard/a76/items.ts @@ -125,8 +125,8 @@ export interface LineItem { line_number: number; // Identification - part_number_id?: string; - component_part_number_id?: string; + part_number?: string; + component_part_number?: string; class_id?: number; identifier?: string; @@ -244,7 +244,7 @@ export const itemsApi = { const params = new URLSearchParams({ company_id: companyId.toString() }); - return api.get(`/v1/a76/items/invoice/${invoiceId}/items/?${params.toString()}`); + return api.get(`/v1/a76/items/invoice/${invoiceId}/items?${params.toString()}`); }, /** @@ -264,7 +264,7 @@ export const itemsApi = { const params = new URLSearchParams({ company_id: companyId.toString() }); - return api.post(`/v1/a76/items/?${params.toString()}`, data); + return api.post(`/v1/a76/items?${params.toString()}`, data); }, /** @@ -274,7 +274,7 @@ export const itemsApi = { const params = new URLSearchParams({ company_id: companyId.toString() }); - return api.put(`/v1/a76/items/${itemId}/?${params.toString()}`, data); + return api.put(`/v1/a76/items/${itemId}?${params.toString()}`, data); }, /** @@ -284,6 +284,6 @@ export const itemsApi = { const params = new URLSearchParams({ company_id: companyId.toString() }); - return api.delete(`/v1/a76/items/${itemId}/?${params.toString()}`); + return api.delete(`/v1/a76/items/${itemId}?${params.toString()}`); } }; diff --git a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte index 5991f15e..6ae6bd7e 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte @@ -232,7 +232,6 @@ const activeCompanyId = companyStore.activeCompany?.id; if (!activeCompanyId) { error = 'No hay una compañía activa seleccionada'; return; } if (!formData.client_id) { error = 'Debe seleccionar un Cliente'; return; } - if (!formData.part_number.trim()) { error = 'Número de Parte requerido'; return; } loading = true; try { @@ -272,7 +271,7 @@ } catch (e: any) { console.error("Submit Error:", e); error = e.message || 'Error al guardar'; - toast.error(error); + toast.error(error!); } finally { loading = false; } } @@ -299,7 +298,7 @@ {#if error}
- ⚠️ {error} + ⚠️ {error!}
{/if} @@ -540,7 +539,7 @@
- +
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte index 34333daa..26da88e3 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte @@ -74,7 +74,7 @@
- +

ID de número de parte existente en catálogo

diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte index 3c4210d3..84c62aa3 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte @@ -139,8 +139,8 @@ lines: [{ line_number: 1, // LineItem fields - part_number_id: undefined, - component_part_number_id: undefined, + part_number: undefined, + component_part_number: undefined, class_id: undefined, identifier: undefined, unit_of_measure: undefined, @@ -294,8 +294,8 @@ }; // Convert integer fields - cleaned.part_number_id = toNumberOrUndefined(cleaned.part_number_id); - cleaned.component_part_number_id = toNumberOrUndefined(cleaned.component_part_number_id); + cleaned.part_number = toNumberOrUndefined(cleaned.part_number); + cleaned.component_part_number = toNumberOrUndefined(cleaned.component_part_number); cleaned.class_id = toNumberOrUndefined(cleaned.class_id); cleaned.unit_of_measure = toNumberOrUndefined(cleaned.unit_of_measure); cleaned.alternate_unit = toNumberOrUndefined(cleaned.alternate_unit); From faa3fcea46b486a6a581b85f815fecfa9d656b64 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Wed, 21 Jan 2026 16:20:11 -0600 Subject: [PATCH 08/55] Se borro el sh de reinicio --- reinicio.sh | 5 ----- 1 file changed, 5 deletions(-) delete mode 100755 reinicio.sh diff --git a/reinicio.sh b/reinicio.sh deleted file mode 100755 index 4e886a29..00000000 --- a/reinicio.sh +++ /dev/null @@ -1,5 +0,0 @@ -#bin/bash - -docker compose down -docker compose up -d --build - From 18559025b1b6c4d11c87b6a39fa90ee4eaa604c8 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Wed, 21 Jan 2026 17:32:37 -0600 Subject: [PATCH 09/55] fix(pedimentos): standardize ID naming convention and enhance row selection functionality in data table --- .../a76/pedmientos/routes/pedimentos.py | 2 +- .../src/lib/api/dashboard/a76/pedimentos.ts | 2 +- .../dashboard/pedimentos/columns.ts | 43 +++++--- .../dashboard/pedimentos/data-table.svelte | 40 +++++++- .../routes/dashboard/pedimentos/+page.svelte | 98 +++++++++++++++++++ 5 files changed, 168 insertions(+), 17 deletions(-) diff --git a/backend/api/v1/modules/a76/pedmientos/routes/pedimentos.py b/backend/api/v1/modules/a76/pedmientos/routes/pedimentos.py index d4edfaee..fbc8f887 100644 --- a/backend/api/v1/modules/a76/pedmientos/routes/pedimentos.py +++ b/backend/api/v1/modules/a76/pedmientos/routes/pedimentos.py @@ -16,7 +16,7 @@ router = TenantCRUDRoutes( prefix="", # No prefix here, will be added in main router tags=["a76 / pedimentos"], # Tag for Swagger documentation resource_name="Pedimento", - id_name="pedimento_id", + id_name="id", # Use standard REST convention enable_list=True, # Enable GET / with pagination enable_filters=True, # Enable status, client_id, year filters default_page_size=50, diff --git a/frontend/src/lib/api/dashboard/a76/pedimentos.ts b/frontend/src/lib/api/dashboard/a76/pedimentos.ts index 574c447f..a5069c9f 100644 --- a/frontend/src/lib/api/dashboard/a76/pedimentos.ts +++ b/frontend/src/lib/api/dashboard/a76/pedimentos.ts @@ -324,5 +324,5 @@ export const pedimentosApi = { * @param id - ID del pedimento a eliminar * @param companyId - ID de la compañía (por defecto 1) */ - delete: (id: number, companyId = 1) => api.delete(`/v1/a76/pedimentos/${id}/?company_id=${companyId}`) + delete: (id: number, companyId = 1) => api.delete(`/v1/a76/pedimentos/${id}?company_id=${companyId}`) }; diff --git a/frontend/src/lib/components/dashboard/pedimentos/columns.ts b/frontend/src/lib/components/dashboard/pedimentos/columns.ts index d56a174e..3c085396 100644 --- a/frontend/src/lib/components/dashboard/pedimentos/columns.ts +++ b/frontend/src/lib/components/dashboard/pedimentos/columns.ts @@ -1,7 +1,6 @@ import type { ColumnDef } from "@tanstack/table-core"; import { renderComponent, renderSnippet } from "$lib/components/ui/data-table/index.js"; import { createRawSnippet } from "svelte"; -import DataTableActions from "./data-table-actions.svelte"; import type { Pedimento } from "$lib/api/dashboard/a76/pedimentos"; // Extender el tipo ColumnMeta para incluir className @@ -93,6 +92,33 @@ function getStatusColor(status?: string | null): string { export function createColumns(onSuccess?: () => void): ColumnDef[] { return [ + { + id: "select", + header: ({ table }) => { + return renderSnippet( + createRawSnippet(() => ({ + render: () => `
` + })) + ); + }, + cell: ({ row }) => { + const isSelected = row.getIsSelected(); + + const checkboxSnippet = createRawSnippet<[{ selected: boolean }]>((getProps) => { + const { selected } = getProps(); + return { + render: () => `
+ +
` + }; + }); + + return renderSnippet(checkboxSnippet, { selected: isSelected }); + }, + size: 40, + enableSorting: false, + enableHiding: false + }, { accessorKey: "id", header: "ID", @@ -214,7 +240,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { return renderSnippet(dateSnippet, { date: formatDate(row.original.pedimento_dates?.payment_date) }); } }, - { + /*{ accessorKey: "pedimento_config_update_rectification.pediment_rectifed_18", header: "Pedimento 18", meta: { className: "hidden lg:table-cell" }, @@ -228,8 +254,8 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { }); return renderSnippet(ped18Snippet, { value: row.original.pedimento_config_update_rectification?.pediment_rectifed_18 }); } - }, - { + },*/ + /*{ accessorKey: "pedimento_config_update_rectification.r1", header: "Pedimento R1", meta: { className: "hidden lg:table-cell" }, @@ -243,7 +269,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { }); return renderSnippet(r1Snippet, { value: row.original.pedimento_config_update_rectification?.r1 }); } - }, + },*/ { accessorKey: "pedimento_validation.electronic_signature", header: "Acuse Electrónico", @@ -397,13 +423,8 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { }); return renderSnippet(dateSnippet, { date: formatDate(row.original.created_at) }); } - }, - { - id: "actions", - cell: ({ row }) => { - return renderComponent(DataTableActions, { item: row.original, onSuccess }); - } } + // Columna de acciones eliminada - ahora usamos botones en el footer ]; } diff --git a/frontend/src/lib/components/dashboard/pedimentos/data-table.svelte b/frontend/src/lib/components/dashboard/pedimentos/data-table.svelte index 1a5b4fd6..8f4bfe0d 100644 --- a/frontend/src/lib/components/dashboard/pedimentos/data-table.svelte +++ b/frontend/src/lib/components/dashboard/pedimentos/data-table.svelte @@ -2,10 +2,13 @@ import { onMount } from 'svelte'; import { type ColumnDef, - getCoreRowModel + getCoreRowModel, + type RowSelectionState } from "@tanstack/table-core"; import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js"; import * as Table from "$lib/components/ui/table/index.js"; + import { Button } from "$lib/components/ui/button"; + import { Edit } from "lucide-svelte"; type DataTableProps = { columns: ColumnDef[]; @@ -13,6 +16,8 @@ loading: boolean; hasMore: boolean; loadMore: () => void; + selectedId?: number | null; + onRowClick?: (row: TData) => void; }; let { @@ -20,7 +25,9 @@ columns, loading, hasMore, - loadMore + loadMore, + selectedId = null, + onRowClick }: DataTableProps = $props(); const table = createSvelteTable({ @@ -28,12 +35,28 @@ return data; }, columns, - getCoreRowModel: getCoreRowModel() + getCoreRowModel: getCoreRowModel(), + getRowId: (row: any) => row.id?.toString(), + state: { + get rowSelection() { + return selectedId ? { [selectedId]: true } : {}; + } + }, + enableRowSelection: true, + enableMultiRowSelection: false }); let scrollContainer = $state(); let loadingTrigger = $state(); + // Función para manejar doble clic en una fila + function handleRowDoubleClick(row: any) { + const pedimento = row.original; + if (pedimento?.id) { + window.location.href = `/dashboard/pedimentos/edit/${pedimento.id}`; + } + } + // Intersection Observer para detectar cuando el usuario llega al final onMount(() => { const observer = new IntersectionObserver( @@ -80,7 +103,16 @@ {#each table.getRowModel().rows as row (row.id)} - + { + if (onRowClick) { + onRowClick(row.original); + } + }} + ondblclick={() => handleRowDoubleClick(row)} + class="cursor-pointer hover:bg-muted/50 transition-colors {row.getIsSelected() ? 'bg-primary/10' : ''}" + > {#each row.getVisibleCells() as cell (cell.id)} (data.error || null); + + // Estado para selección de filas + let selectedId = $state(null); + let hasSelection = $derived(selectedId !== null); + let showDeleteDialog = $state(false); + + function handleRowClick(pedimento: Pedimento) { + // Toggle: si ya está seleccionado, deseleccionar; si no, seleccionar + selectedId = selectedId === pedimento.id ? null : pedimento.id; + console.log('🔘 Row clicked, pedimento.id:', pedimento.id, 'selectedId:', selectedId, 'hasSelection:', hasSelection); + } + + function handleEditSelected() { + if (selectedId) { + window.location.href = `/dashboard/pedimentos/edit/${selectedId}`; + } + } + + function handleDelete() { + if (!selectedId) { + return; + } + showDeleteDialog = true; + } + + async function confirmDelete() { + if (!selectedId) return; + + const companyId = companyStore.activeCompany?.id; + if (!companyId) { + error = 'No hay compañía seleccionada'; + return; + } + + try { + const response = await pedimentosApi.delete(selectedId, companyId); + + if (response.error) { + console.error('🗑️ [Pedimentos] Error al eliminar:', response.error); + error = response.error; + return; + } + + // Recargar datos + await reloadData(); + + selectedId = null; + showDeleteDialog = false; + } catch (e) { + console.error('🗑️ [Pedimentos] Error deleting:', e); + error = 'Error al eliminar el pedimento'; + } + } async function loadMore() { if (loading || !hasMore) return; @@ -343,7 +398,50 @@ {loading} {hasMore} {loadMore} + {selectedId} + onRowClick={handleRowClick} /> + + +
+
+ +
+ + + +
+
+
+ + + + + + ¿Eliminar pedimento? + + Esta acción no se puede deshacer. El pedimento será eliminado permanentemente. + + +
+ + +
+
+
From d4e4e1f7f849afae2c9d739e93e4c24a09dcb1ed Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Thu, 22 Jan 2026 08:10:57 -0600 Subject: [PATCH 10/55] Se corrigio el problema de los logos --- .../a76/general_catalogs/company/models.py | 5 +- .../a76/general_catalogs/company/routes.py | 56 +------ .../a76/general_catalogs/company/service.py | 12 ++ .../a76/general_catalogs/units-of-measure.ts | 2 +- .../components/sidebar/team-switcher.svelte | 6 +- frontend/src/lib/stores/company.svelte.ts | 24 +++ .../edit/[[id]]/+page.svelte | 94 ++++------- schema_dump.txt | 151 ++++++++++++++++++ 8 files changed, 227 insertions(+), 123 deletions(-) create mode 100644 schema_dump.txt diff --git a/backend/api/v1/modules/a76/general_catalogs/company/models.py b/backend/api/v1/modules/a76/general_catalogs/company/models.py index ae94fc89..fdcc0262 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/models.py @@ -61,9 +61,9 @@ class Company(Base, TimestampMixin): # Configuración básica logo: Mapped[Optional[str]] = mapped_column(String(255)) - has_express_line: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) + has_express_line: Mapped[Optional[str]] = mapped_column(String(2), default="N") order_format_type: Mapped[Optional[str]] = mapped_column(String(19)) - is_service_company: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) + is_service_company: Mapped[Optional[str]] = mapped_column(String(2), default="N") client_name: Mapped[Optional[str]] = mapped_column(String(300)) subassembly_mode: Mapped[Optional[str]] = mapped_column(String(7)) @@ -78,6 +78,7 @@ class Company(Base, TimestampMixin): parts_replacement: Mapped[Optional[int]] = mapped_column(SmallInteger) activate_facmexame: Mapped[Optional[int]] = mapped_column(SmallInteger) part_reference: Mapped[Optional[int]] = mapped_column(SmallInteger) + part_reference: Mapped[Optional[int]] = mapped_column(SmallInteger) international_firm: Mapped[Optional[int]] = mapped_column(SmallInteger) # Configuraciones simples diff --git a/backend/api/v1/modules/a76/general_catalogs/company/routes.py b/backend/api/v1/modules/a76/general_catalogs/company/routes.py index cc59a1bc..6aadda85 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/routes.py @@ -312,68 +312,14 @@ async def update_company( return CompanyResponseDTO.model_validate(updated_company) -@router.post( - "/{company_id}/upload-logo", - response_model=dict, - summary="Upload company logo", -) -async def upload_company_logo( - company_id: int, - file: UploadFile = File(...), - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -): - """Upload logo for a company""" - tenant_id = current_user.get("tenant_id") - if not tenant_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Tenant ID not found in user data", - ) - # 1. Verify company exists - company = CompanyService.get_by_id(db, company_id, tenant_id, 0) - if not company: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Company not found", - ) - - # 2. Define upload path - # Use a persistent path: 'app_data/logos/{company_id}' - upload_dir = Path(f"app_data/logos/{company_id}") - upload_dir.mkdir(parents=True, exist_ok=True) - - # 3. Save file - # Preserve original filename - filename = file.filename or "logo.png" - file_path = upload_dir / filename - - try: - # Check if file exists and remove it to avoid accumulation if needed, - # or just overwrite (shutil.copyfileobj overwrites) - with open(file_path, "wb") as buffer: - shutil.copyfileobj(file.file, buffer) - except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Could not save file: {e}", - ) - - # 4. Returns the absolute path keys - abs_path = str(file_path.absolute()) - - return {"path": abs_path} @router.get( "/{company_id}/logo/image", summary="Get company logo image", ) -@router.get( - "/{company_id}/logo/image", - summary="Get company logo image", -) + async def get_company_logo_image( company_id: int, db: Session = Depends(get_core_db), diff --git a/backend/api/v1/modules/a76/general_catalogs/company/service.py b/backend/api/v1/modules/a76/general_catalogs/company/service.py index 63835cd1..5ed24feb 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/service.py @@ -112,7 +112,14 @@ class CompanyService: # Update only provided fields update_data = company_data.model_dump(exclude_unset=True) + boolean_fields_str = ["has_express_line", "is_service_company"] + for field, value in update_data.items(): + if field in boolean_fields_str: + # Convert boolean to "S"/"N" + if isinstance(value, bool): + value = "S" if value else "N" + setattr(company, field, value) try: @@ -177,6 +184,11 @@ class CompanyService: # 1. Preparar datos obj_data = data.model_dump(exclude_unset=True) + boolean_fields_str = ["has_express_line", "is_service_company"] + for field in boolean_fields_str: + if field in obj_data and isinstance(obj_data[field], bool): + obj_data[field] = "S" if obj_data[field] else "N" + # 2. Crear objeto SQLAlchemy db_obj = Company(**obj_data, tenant_id=tenant_id) diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/units-of-measure.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/units-of-measure.ts index f680bae3..7b731651 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/units-of-measure.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/units-of-measure.ts @@ -183,7 +183,7 @@ export interface UnitOfMeasureGeneralUpdate { } export interface UnitOfMeasureGeneralListResponse { - items: UnitOfMeasureGeneral[]; + items: UnitOfMeasureGeneral[]; total: number; page: number; page_size: number; diff --git a/frontend/src/lib/components/sidebar/team-switcher.svelte b/frontend/src/lib/components/sidebar/team-switcher.svelte index c61c8a3d..088b02df 100644 --- a/frontend/src/lib/components/sidebar/team-switcher.svelte +++ b/frontend/src/lib/components/sidebar/team-switcher.svelte @@ -10,10 +10,10 @@ const sidebar = useSidebar(); - // Derivar la URL del logo + // Derivar la URL del logo usando el endpoint específico let activeCompanyLogoUrl = $derived( companyStore.activeCompany?.logo - ? getBackendAssetUrl(companyStore.activeCompany.logo) + ? getBackendAssetUrl(`v1/a76/company/${companyStore.activeCompany.id}/logo/image?t=${new Date().getTime()}`) : null ); @@ -89,7 +89,7 @@
{#if company.logo} {company.name} diff --git a/frontend/src/lib/stores/company.svelte.ts b/frontend/src/lib/stores/company.svelte.ts index b3da0f1f..9f04bcdb 100644 --- a/frontend/src/lib/stores/company.svelte.ts +++ b/frontend/src/lib/stores/company.svelte.ts @@ -144,6 +144,30 @@ class CompanyStore { } } + /** + * Actualiza los datos de une empresa en el store localmente + * Útil para reflejar cambios inmediatos (ej: cambio de logo) sin recargar + */ + updateCompany(id: number, data: Partial) { + // 1. Actualizar en la lista + const index = this._companies.findIndex(c => c.id === id); + if (index !== -1) { + this._companies[index] = { ...this._companies[index], ...data }; + + // 2. Si es la activa, actualizar también + if (this._activeCompany?.id === id) { + this._activeCompany = { ...this._activeCompany, ...data }; + // Actualizar persistencia si es necesario + if (typeof window !== 'undefined') { + // Disparar evento para notificar cambios a componentes que no usan el store reactivo directo (si los hay) + window.dispatchEvent(new CustomEvent('companyChanged', { + detail: { companyId: id } + })); + } + } + } + } + /** * Restaura la compañía activa desde localStorage */ diff --git a/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte index 136f81ff..290ce649 100644 --- a/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte @@ -14,6 +14,7 @@ uploadCompanyLogo, type Company } from '$lib/api/dashboard/a76/general_catalogs/company'; + import { companyStore } from '$lib/stores/company.svelte'; import { getBackendAssetUrl } from '$lib/utils'; import { ArrowLeft, LoaderCircle, Save, Upload, X, Building2, FileText, User, Settings } from 'lucide-svelte'; @@ -25,15 +26,18 @@ let loading = $state(false); let uploading = $state(false); let error = $state(null); + let activeTab = $state('general'); + let logoFile = $state(null); let logoPreview = $state(null); let currentLogo = $state(null); let uploadingLogo = $state(false); - let activeTab = $state('general'); - + // URL completa del logo derivada let currentLogoUrl = $derived( - logoPreview || getBackendAssetUrl(currentLogo) || '' + logoPreview + ? logoPreview + : (currentLogo ? getBackendAssetUrl(`v1/a76/company/${id}/logo/image?t=${new Date().getTime()}`) : '') ); // 2. Estado Inicial (Reset) @@ -104,7 +108,15 @@ is_service_company: item.is_service_company || false, order_format_type: item.order_format_type || '', ctpat_svi: item.ctpat_svi || '', - trusted_exporter_number: item.trusted_exporter_number || '' + trusted_exporter_number: item.trusted_exporter_number || '', + logo: item.logo || '', + previous_code: item.previous_code || 0, + client_name: item.client_name || '', + subassembly_mode: item.subassembly_mode || '', + broker_company: item.broker_company || '', + inter_db_name: item.inter_db_name || '', + prevalidator_key: item.prevalidator_key || '', + seventh_amendment: item.seventh_amendment || false }; // Guardar la URL del logo actual si existe if (item.logo) { @@ -118,6 +130,9 @@ } } + + const clean = (value: any) => (typeof value === 'string' ? value.trim() || null : value); + function handleLogoChange(event: Event) { const target = event.target as HTMLInputElement; const file = target.files?.[0]; @@ -170,6 +185,9 @@ currentLogo = response.data.logo_path; logoFile = null; logoPreview = null; + + // Actualizar el store reactivamente + companyStore.updateCompany(companyId, { logo: response.data.logo_path }); } } catch (e: any) { error = `Error al subir el logo: ${e.message}`; @@ -178,33 +196,6 @@ } } - const clean = (value: any) => (typeof value === 'string' ? value.trim() || null : value); - - async function handleFileSelect(e: Event) { - const input = e.target as HTMLInputElement; - if (!input.files || input.files.length === 0) return; - - const file = input.files[0]; - if (!isEdit) { - alert("Primero debes guardar la empresa antes de subir un logo."); - return; - } - - uploading = true; - try { - const res = await uploadCompanyLogo(Number(id), file); - if (res.data) { - formData.logo = res.data.path; - } else if (res.error) { - alert("Error al subir imagen: " + res.error); - } - } catch (err) { - alert("Error al intentar subir la imagen"); - } finally { - uploading = false; - } - } - async function handleSubmit() { error = null; loading = true; @@ -222,6 +213,7 @@ main_activity: clean(formData.main_activity), program: clean(formData.program), program_number: clean(formData.program_number), + prosec: Number(formData.prosec) || 0, prosec_authorization: clean(formData.prosec_authorization), responsible_name: clean(formData.responsible_name), responsible_last_name: clean(formData.responsible_last_name), @@ -239,7 +231,10 @@ broker_company: clean(formData.broker_company), inter_db_name: clean(formData.inter_db_name), prevalidator_key: clean(formData.prevalidator_key), - seventh_amendment: formData.seventh_amendment + seventh_amendment: formData.seventh_amendment, + // Ensure optional booleans are passed correctly or default to false/null if needed + has_express_line: formData.has_express_line, + is_service_company: formData.is_service_company }; const response = isEdit @@ -285,6 +280,8 @@
+ +
@@ -344,36 +341,9 @@
-
-
- -
- - {#if isEdit} -
- - -
- {/if} -
-

Sube una imagen para obtener su ruta local.

-
-
- - -
+
+ +
diff --git a/schema_dump.txt b/schema_dump.txt new file mode 100644 index 00000000..fbdc9a3b --- /dev/null +++ b/schema_dump.txt @@ -0,0 +1,151 @@ + Table "a76.company" + Column | Type | Collation | Nullable | Default +------------------------------+-----------------------------+-----------+----------+----------------------------------------- + id | integer | | not null | nextval('a76.company_id_seq'::regclass) + tenant_id | integer | | not null | + name | character varying(256) | | | + rfc | character varying(30) | | | + curp | character varying(19) | | | + main_activity | character varying(80) | | | + program | character varying(7) | | | + program_number | character varying(40) | | | + prosec | smallint | | | + prosec_authorization | character varying(20) | | | + sector1 | character varying(150) | | | + sector2 | character varying(150) | | | + sector3 | character varying(5) | | | + manufacturer_id | character varying(25) | | | + broker_company | character varying(6) | | | + responsible | character varying(80) | | | + responsible_name | character varying(20) | | | + responsible_last_name | character varying(20) | | | + responsible_mother_last_name | character varying(20) | | | + responsible_rfc | character varying(30) | | | + position | character varying(30) | | | + logo | character varying(255) | | | + has_express_line | character varying(2) | | | + order_format_type | character varying(19) | | | + is_service_company | boolean | | | + client_name | character varying(300) | | | + subassembly_mode | character varying(7) | | | + previous_code | smallint | | | + active_labels | smallint | | | + active_fractions | smallint | | | + activate_caat | smallint | | | + trans_interface | smallint | | | + american_costs | smallint | | | + scaf_readonly | smallint | | | + parts_replacement | smallint | | | + activate_facmexame | smallint | | | + part_reference | smallint | | | + international_firm | smallint | | | + ftp_key | character varying(10) | | | + sifra_path | character varying(255) | | | + version_type | character varying(20) | | | + sql_language | character varying(19) | | | + balance_operation_mode | character varying(50) | | | + inter_db_name | character varying(100) | | | + created_at | timestamp without time zone | | not null | now() + updated_at | timestamp without time zone | | not null | now() + deleted_at | timestamp without time zone | | | +Indexes: + "company_pkey" PRIMARY KEY, btree (id) + "ix_a76_company_tenant_id" btree (tenant_id) +Foreign-key constraints: + "company_tenant_id_fkey" FOREIGN KEY (tenant_id) REFERENCES core.tenants(id) +Referenced by: + TABLE "a76."CompanyVU"" CONSTRAINT "CompanyVU_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) ON DELETE CASCADE + TABLE "a76.classes" CONSTRAINT "classes_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.classification_concepts" CONSTRAINT "classification_concepts_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.clients_and_providers_address" CONSTRAINT "clients_and_providers_address_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.clients_and_providers" CONSTRAINT "clients_and_providers_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.clients_and_providers_programs" CONSTRAINT "clients_and_providers_programs_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.company_address" CONSTRAINT "company_address_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) ON DELETE CASCADE + TABLE "a76.company_certification" CONSTRAINT "company_certification_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) ON DELETE CASCADE + TABLE "a76.company_cfdi" CONSTRAINT "company_cfdi_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) ON DELETE CASCADE + TABLE "a76.company_digital_certificate" CONSTRAINT "company_digital_certificate_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) ON DELETE CASCADE + TABLE "a76.company_electronic_agent" CONSTRAINT "company_electronic_agent_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) ON DELETE CASCADE + TABLE "a76.company_prevalidator" CONSTRAINT "company_prevalidator_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) ON DELETE CASCADE + TABLE "core.company_roles" CONSTRAINT "company_roles_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.country_rule_oct" CONSTRAINT "country_rule_oct_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.ctm_receipts" CONSTRAINT "ctm_receipts_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.customs_brokers" CONSTRAINT "customs_brokers_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.customs_brokers_personnel" CONSTRAINT "customs_brokers_personnel_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.customs_brokers_vu" CONSTRAINT "customs_brokers_vu_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.depreciation_catalog" CONSTRAINT "depreciation_catalog_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.document_types_digitization" CONSTRAINT "document_types_digitization_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.doda_american_pedimentos" CONSTRAINT "doda_american_pedimentos_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.doda" CONSTRAINT "doda_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.doda_container_seals" CONSTRAINT "doda_container_seals_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.doda_containers" CONSTRAINT "doda_containers_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.doda_pedimentos" CONSTRAINT "doda_pedimentos_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.driver" CONSTRAINT "driver_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.electronic_notices" CONSTRAINT "electronic_notices_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.equivalencies" CONSTRAINT "equivalencies_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.equivalency_items" CONSTRAINT "equivalency_items_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.error_catalogs" CONSTRAINT "error_catalogs_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.error_classifications" CONSTRAINT "error_classifications_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.exchange_rate" CONSTRAINT "exchange_rate_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a24.fa_classes" CONSTRAINT "fa_classes_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a24.fa_item_lines" CONSTRAINT "fa_item_lines_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a24.fa_partes" CONSTRAINT "fa_partes_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.fda_catalog" CONSTRAINT "fda_catalog_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.fraction_rule_octave" CONSTRAINT "fraction_rule_octave_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.identifier_details" CONSTRAINT "identifier_details_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.identifiers" CONSTRAINT "identifiers_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.inpc" CONSTRAINT "inpc_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a24.inv_partes" CONSTRAINT "inv_partes_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.invoice_collections" CONSTRAINT "invoice_collections_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.invoice_compliance_mx" CONSTRAINT "invoice_compliance_mx_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.invoice_financials" CONSTRAINT "invoice_financials_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.invoice_header" CONSTRAINT "invoice_header_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.invoice_logistics" CONSTRAINT "invoice_logistics_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.invoice_sales_details" CONSTRAINT "invoice_sales_details_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.item_line_series" CONSTRAINT "item_line_series_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.item_lines" CONSTRAINT "item_lines_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.items" CONSTRAINT "items_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.legends" CONSTRAINT "legends_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.multi_currency_types" CONSTRAINT "multi_currency_types_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.packages" CONSTRAINT "packages_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.packing_lists" CONSTRAINT "packing_lists_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.parts" CONSTRAINT "parts_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_config_additional" CONSTRAINT "pedimento_config_additional_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_config_calculations" CONSTRAINT "pedimento_config_calculations_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_config_parameters" CONSTRAINT "pedimento_config_parameters_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_config_surcharges" CONSTRAINT "pedimento_config_surcharges_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_config_update_rectification" CONSTRAINT "pedimento_config_update_rectification_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_config_updates" CONSTRAINT "pedimento_config_updates_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_containers" CONSTRAINT "pedimento_containers_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_contributions" CONSTRAINT "pedimento_contributions_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_customs_offices" CONSTRAINT "pedimento_customs_offices_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_dates" CONSTRAINT "pedimento_dates_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_decrementables" CONSTRAINT "pedimento_decrementables_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_guides" CONSTRAINT "pedimento_guides_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_incrementables" CONSTRAINT "pedimento_incrementables_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_indexes" CONSTRAINT "pedimento_indexes_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_packages" CONSTRAINT "pedimento_packages_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_payments" CONSTRAINT "pedimento_payments_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_rectification_destination" CONSTRAINT "pedimento_rectification_destination_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_rectification_origin" CONSTRAINT "pedimento_rectification_origin_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_seals" CONSTRAINT "pedimento_seals_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_transport_carriers" CONSTRAINT "pedimento_transport_carriers_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_transport_means" CONSTRAINT "pedimento_transport_means_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimento_validation" CONSTRAINT "pedimento_validation_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.pedimentos" CONSTRAINT "pedimentos_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.permission_rule_oct" CONSTRAINT "permission_rule_oct_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.ports" CONSTRAINT "ports_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.prevalidators" CONSTRAINT "prevalidators_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "core.role_permissions" CONSTRAINT "role_permissions_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.seal" CONSTRAINT "seal_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.signatures" CONSTRAINT "signatures_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.subassembly_entries" CONSTRAINT "subassembly_entries_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.trailer" CONSTRAINT "trailer_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.transporter" CONSTRAINT "transporter_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.unit_conversions" CONSTRAINT "unit_conversions_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.units_of_measure" CONSTRAINT "units_of_measure_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.units_of_measure_general" CONSTRAINT "units_of_measure_general_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "core.user_company_permissions" CONSTRAINT "user_company_permissions_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "core.user_company_roles" CONSTRAINT "user_company_roles_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "core.user_tenants" CONSTRAINT "user_tenants_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + TABLE "a76.vehicle" CONSTRAINT "vehicle_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) + From 69fe605901a3b980804707a952f52740a8487a58 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Thu, 22 Jan 2026 08:22:29 -0600 Subject: [PATCH 11/55] Se borro archivos basura --- schema_dump.txt | 151 ------------------------------------------------ 1 file changed, 151 deletions(-) delete mode 100644 schema_dump.txt diff --git a/schema_dump.txt b/schema_dump.txt deleted file mode 100644 index fbdc9a3b..00000000 --- a/schema_dump.txt +++ /dev/null @@ -1,151 +0,0 @@ - Table "a76.company" - Column | Type | Collation | Nullable | Default -------------------------------+-----------------------------+-----------+----------+----------------------------------------- - id | integer | | not null | nextval('a76.company_id_seq'::regclass) - tenant_id | integer | | not null | - name | character varying(256) | | | - rfc | character varying(30) | | | - curp | character varying(19) | | | - main_activity | character varying(80) | | | - program | character varying(7) | | | - program_number | character varying(40) | | | - prosec | smallint | | | - prosec_authorization | character varying(20) | | | - sector1 | character varying(150) | | | - sector2 | character varying(150) | | | - sector3 | character varying(5) | | | - manufacturer_id | character varying(25) | | | - broker_company | character varying(6) | | | - responsible | character varying(80) | | | - responsible_name | character varying(20) | | | - responsible_last_name | character varying(20) | | | - responsible_mother_last_name | character varying(20) | | | - responsible_rfc | character varying(30) | | | - position | character varying(30) | | | - logo | character varying(255) | | | - has_express_line | character varying(2) | | | - order_format_type | character varying(19) | | | - is_service_company | boolean | | | - client_name | character varying(300) | | | - subassembly_mode | character varying(7) | | | - previous_code | smallint | | | - active_labels | smallint | | | - active_fractions | smallint | | | - activate_caat | smallint | | | - trans_interface | smallint | | | - american_costs | smallint | | | - scaf_readonly | smallint | | | - parts_replacement | smallint | | | - activate_facmexame | smallint | | | - part_reference | smallint | | | - international_firm | smallint | | | - ftp_key | character varying(10) | | | - sifra_path | character varying(255) | | | - version_type | character varying(20) | | | - sql_language | character varying(19) | | | - balance_operation_mode | character varying(50) | | | - inter_db_name | character varying(100) | | | - created_at | timestamp without time zone | | not null | now() - updated_at | timestamp without time zone | | not null | now() - deleted_at | timestamp without time zone | | | -Indexes: - "company_pkey" PRIMARY KEY, btree (id) - "ix_a76_company_tenant_id" btree (tenant_id) -Foreign-key constraints: - "company_tenant_id_fkey" FOREIGN KEY (tenant_id) REFERENCES core.tenants(id) -Referenced by: - TABLE "a76."CompanyVU"" CONSTRAINT "CompanyVU_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) ON DELETE CASCADE - TABLE "a76.classes" CONSTRAINT "classes_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.classification_concepts" CONSTRAINT "classification_concepts_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.clients_and_providers_address" CONSTRAINT "clients_and_providers_address_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.clients_and_providers" CONSTRAINT "clients_and_providers_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.clients_and_providers_programs" CONSTRAINT "clients_and_providers_programs_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.company_address" CONSTRAINT "company_address_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) ON DELETE CASCADE - TABLE "a76.company_certification" CONSTRAINT "company_certification_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) ON DELETE CASCADE - TABLE "a76.company_cfdi" CONSTRAINT "company_cfdi_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) ON DELETE CASCADE - TABLE "a76.company_digital_certificate" CONSTRAINT "company_digital_certificate_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) ON DELETE CASCADE - TABLE "a76.company_electronic_agent" CONSTRAINT "company_electronic_agent_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) ON DELETE CASCADE - TABLE "a76.company_prevalidator" CONSTRAINT "company_prevalidator_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) ON DELETE CASCADE - TABLE "core.company_roles" CONSTRAINT "company_roles_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.country_rule_oct" CONSTRAINT "country_rule_oct_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.ctm_receipts" CONSTRAINT "ctm_receipts_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.customs_brokers" CONSTRAINT "customs_brokers_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.customs_brokers_personnel" CONSTRAINT "customs_brokers_personnel_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.customs_brokers_vu" CONSTRAINT "customs_brokers_vu_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.depreciation_catalog" CONSTRAINT "depreciation_catalog_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.document_types_digitization" CONSTRAINT "document_types_digitization_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.doda_american_pedimentos" CONSTRAINT "doda_american_pedimentos_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.doda" CONSTRAINT "doda_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.doda_container_seals" CONSTRAINT "doda_container_seals_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.doda_containers" CONSTRAINT "doda_containers_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.doda_pedimentos" CONSTRAINT "doda_pedimentos_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.driver" CONSTRAINT "driver_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.electronic_notices" CONSTRAINT "electronic_notices_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.equivalencies" CONSTRAINT "equivalencies_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.equivalency_items" CONSTRAINT "equivalency_items_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.error_catalogs" CONSTRAINT "error_catalogs_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.error_classifications" CONSTRAINT "error_classifications_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.exchange_rate" CONSTRAINT "exchange_rate_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a24.fa_classes" CONSTRAINT "fa_classes_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a24.fa_item_lines" CONSTRAINT "fa_item_lines_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a24.fa_partes" CONSTRAINT "fa_partes_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.fda_catalog" CONSTRAINT "fda_catalog_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.fraction_rule_octave" CONSTRAINT "fraction_rule_octave_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.identifier_details" CONSTRAINT "identifier_details_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.identifiers" CONSTRAINT "identifiers_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.inpc" CONSTRAINT "inpc_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a24.inv_partes" CONSTRAINT "inv_partes_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.invoice_collections" CONSTRAINT "invoice_collections_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.invoice_compliance_mx" CONSTRAINT "invoice_compliance_mx_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.invoice_financials" CONSTRAINT "invoice_financials_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.invoice_header" CONSTRAINT "invoice_header_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.invoice_logistics" CONSTRAINT "invoice_logistics_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.invoice_sales_details" CONSTRAINT "invoice_sales_details_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.item_line_series" CONSTRAINT "item_line_series_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.item_lines" CONSTRAINT "item_lines_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.items" CONSTRAINT "items_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.legends" CONSTRAINT "legends_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.multi_currency_types" CONSTRAINT "multi_currency_types_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.packages" CONSTRAINT "packages_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.packing_lists" CONSTRAINT "packing_lists_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.parts" CONSTRAINT "parts_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_config_additional" CONSTRAINT "pedimento_config_additional_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_config_calculations" CONSTRAINT "pedimento_config_calculations_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_config_parameters" CONSTRAINT "pedimento_config_parameters_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_config_surcharges" CONSTRAINT "pedimento_config_surcharges_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_config_update_rectification" CONSTRAINT "pedimento_config_update_rectification_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_config_updates" CONSTRAINT "pedimento_config_updates_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_containers" CONSTRAINT "pedimento_containers_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_contributions" CONSTRAINT "pedimento_contributions_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_customs_offices" CONSTRAINT "pedimento_customs_offices_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_dates" CONSTRAINT "pedimento_dates_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_decrementables" CONSTRAINT "pedimento_decrementables_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_guides" CONSTRAINT "pedimento_guides_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_incrementables" CONSTRAINT "pedimento_incrementables_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_indexes" CONSTRAINT "pedimento_indexes_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_packages" CONSTRAINT "pedimento_packages_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_payments" CONSTRAINT "pedimento_payments_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_rectification_destination" CONSTRAINT "pedimento_rectification_destination_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_rectification_origin" CONSTRAINT "pedimento_rectification_origin_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_seals" CONSTRAINT "pedimento_seals_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_transport_carriers" CONSTRAINT "pedimento_transport_carriers_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_transport_means" CONSTRAINT "pedimento_transport_means_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimento_validation" CONSTRAINT "pedimento_validation_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.pedimentos" CONSTRAINT "pedimentos_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.permission_rule_oct" CONSTRAINT "permission_rule_oct_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.ports" CONSTRAINT "ports_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.prevalidators" CONSTRAINT "prevalidators_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "core.role_permissions" CONSTRAINT "role_permissions_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.seal" CONSTRAINT "seal_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.signatures" CONSTRAINT "signatures_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.subassembly_entries" CONSTRAINT "subassembly_entries_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.trailer" CONSTRAINT "trailer_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.transporter" CONSTRAINT "transporter_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.unit_conversions" CONSTRAINT "unit_conversions_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.units_of_measure" CONSTRAINT "units_of_measure_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.units_of_measure_general" CONSTRAINT "units_of_measure_general_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "core.user_company_permissions" CONSTRAINT "user_company_permissions_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "core.user_company_roles" CONSTRAINT "user_company_roles_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "core.user_tenants" CONSTRAINT "user_tenants_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - TABLE "a76.vehicle" CONSTRAINT "vehicle_company_id_fkey" FOREIGN KEY (company_id) REFERENCES a76.company(id) - From e338236a1c4d4b08b0497da7e5275e47cf21ff6b Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Thu, 22 Jan 2026 09:49:59 -0600 Subject: [PATCH 12/55] Se arreglo el problema de company --- .../a76/general_catalogs/company/models.py | 2 +- .../a76/general_catalogs/company/routes.py | 161 ++--------- .../a76/general_catalogs/company/service.py | 270 ++++++++++++++---- .../company/submodels/certification.py | 2 +- backend/debug_mapper.py | 15 + backend/inspect_schema.py | 20 ++ backend/verify_logo_presence.py | 32 +++ 7 files changed, 311 insertions(+), 191 deletions(-) create mode 100644 backend/debug_mapper.py create mode 100644 backend/inspect_schema.py create mode 100644 backend/verify_logo_presence.py diff --git a/backend/api/v1/modules/a76/general_catalogs/company/models.py b/backend/api/v1/modules/a76/general_catalogs/company/models.py index fdcc0262..61ccc8a6 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/models.py @@ -63,7 +63,7 @@ class Company(Base, TimestampMixin): logo: Mapped[Optional[str]] = mapped_column(String(255)) has_express_line: Mapped[Optional[str]] = mapped_column(String(2), default="N") order_format_type: Mapped[Optional[str]] = mapped_column(String(19)) - is_service_company: Mapped[Optional[str]] = mapped_column(String(2), default="N") + is_service_company: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) client_name: Mapped[Optional[str]] = mapped_column(String(300)) subassembly_mode: Mapped[Optional[str]] = mapped_column(String(7)) diff --git a/backend/api/v1/modules/a76/general_catalogs/company/routes.py b/backend/api/v1/modules/a76/general_catalogs/company/routes.py index 6aadda85..e6d4bc52 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/routes.py @@ -50,7 +50,8 @@ async def create_company( ) service = CompanyService(db) - return service.create_company_manually(data, tenant_id=tenant_id) + new_company = service.create_company_manually(data, tenant_id=tenant_id) + return CompanyResponseDTO.model_validate(service.flatten_company_dto(new_company)) @router.get( @@ -94,7 +95,10 @@ async def list_companies( total_pages = (total + page_size - 1) // page_size return { - "items": [CompanyResponseDTO.model_validate(item) for item in items], + "items": [ + CompanyResponseDTO.model_validate(service.flatten_company_dto(item)) + for item in items + ], "total": total, "page": page, "page_size": page_size, @@ -122,135 +126,12 @@ async def get_my_companies( service = CompanyService(db) companies = service.get_companies_by_tenant(tenant_id) - return [CompanyResponseDTO.model_validate(company) for company in companies] - - -@router.get( - "/status/exists", - response_model=dict, - summary="Check if company exists for tenant", -) -async def check_company_exists( - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -): - """Check if a company exists for the current tenant""" - tenant_id = current_user.get("tenant_id") - if not tenant_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Tenant ID not found in user data", - ) - - service = CompanyService(db) - exists = service.exists_company(tenant_id) - - return {"exists": exists} - - -@router.get( - "/info/basic/{company_id}", - response_model=dict, - summary="Get basic company info", -) -async def get_basic_info( - company_id: int, - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -): - """Get basic information about a company""" - tenant_id = current_user.get("tenant_id") - company_id_from_user = current_user.get("company_id") - - # Validate access - validate_access_to_resource( - db, tenant_id, company_id_from_user, Company, company_id, "id" - ) - - company = CompanyService.get_by_id(db, company_id, tenant_id, company_id_from_user) - if not company: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Company not found", - ) - - return { - "id": company.id, - "name": company.name, - "rfc": company.rfc, - "program": company.program, - } - - -@router.get( - "/info/responsible/{company_id}", - response_model=dict, - summary="Get responsible person info", -) -async def get_responsible_info( - company_id: int, - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -): - """Get responsible person information for a company""" - tenant_id = current_user.get("tenant_id") - company_id_from_user = current_user.get("company_id") - - # Validate access - validate_access_to_resource( - db, tenant_id, company_id_from_user, Company, company_id, "id" - ) - - company = CompanyService.get_by_id(db, company_id, tenant_id, company_id_from_user) - if not company: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Company not found", - ) - - return { - "responsible": company.responsible, - "responsible_name": company.responsible_name, - "responsible_last_name": company.responsible_last_name, - "responsible_mother_last_name": company.responsible_mother_last_name, - "responsible_rfc": company.responsible_rfc, - "position": company.position, - } - - -@router.get( - "/info/program/{company_id}", - response_model=dict, - summary="Get program information", -) -async def get_program_info( - company_id: int, - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -): - """Get program information for a company""" - tenant_id = current_user.get("tenant_id") - company_id_from_user = current_user.get("company_id") - - # Validate access - validate_access_to_resource( - db, tenant_id, company_id_from_user, Company, company_id, "id" - ) - - company = CompanyService.get_by_id(db, company_id, tenant_id, company_id_from_user) - if not company: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Company not found", - ) - - return { - "program": company.program, - "program_number": company.program_number, - "prosec": company.prosec, - "prosec_authorization": company.prosec_authorization, - } + return [ + CompanyResponseDTO.model_validate(service.flatten_company_dto(company)) + for company in companies + ] +# ... existing code ... @router.get( "/{company_id}", @@ -270,6 +151,7 @@ async def get_company( detail="Tenant ID not found in user data", ) + service = CompanyService(db) company = CompanyService.get_by_id(db, company_id, tenant_id, 0) if not company: raise HTTPException( @@ -277,7 +159,7 @@ async def get_company( detail="Company not found", ) - return CompanyResponseDTO.model_validate(company) + return CompanyResponseDTO.model_validate(service.flatten_company_dto(company)) @router.put( @@ -299,17 +181,18 @@ async def update_company( detail="Tenant ID not found in user data", ) - updated_company = CompanyService.update(db, company_id, tenant_id, 0, data) + service = CompanyService(db) + updated_company = service.update(db, company_id, tenant_id, 0, data) if not updated_company: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Company not found", ) - return CompanyResponseDTO.model_validate(updated_company) + return CompanyResponseDTO.model_validate(service.flatten_company_dto(updated_company)) + - return CompanyResponseDTO.model_validate(updated_company) @@ -347,6 +230,13 @@ async def get_company_logo_image( raise HTTPException(status_code=404, detail="Logo file not found on server") return FileResponse(file_path) + + +@router.delete( + "/{company_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a company", +) async def delete_company( company_id: int, db: Session = Depends(get_core_db), @@ -443,7 +333,8 @@ async def upload_company_logo( # Actualizar la empresa con la ruta del logo update_data = CompanyUpdateDTO(logo=file_path) - updated_company = CompanyService.update(db, company_id, tenant_id, 0, update_data) + service = CompanyService(db) + updated_company = service.update(db, company_id, tenant_id, 0, update_data) return { "message": "Logo uploaded successfully", diff --git a/backend/api/v1/modules/a76/general_catalogs/company/service.py b/backend/api/v1/modules/a76/general_catalogs/company/service.py index 5ed24feb..90460e79 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/service.py @@ -97,8 +97,139 @@ class CompanyService: logger.error(f"Error creating company: {str(e)}") raise HTTPException(status_code=500, detail="Error creating company") - @staticmethod + + # ==================== HELPERS FOR FIELD MAPPING ==================== + def _extract_company_fields(self, data: Dict[str, Any]) -> Dict[str, Any]: + """Extrae campos que pertenecen a la tabla Company principal""" + company_fields = [ + "name", "rfc", "curp", "main_activity", "program", "program_number", + "prosec", "prosec_authorization", "sector1", "sector2", "sector3", + "manufacturer_id", "broker_company", "responsible", "responsible_name", + "responsible_last_name", "responsible_mother_last_name", "responsible_rfc", + "position", "logo", "has_express_line", "order_format_type", + "is_service_company", "client_name", "subassembly_mode", "previous_code", + "active_labels", "active_fractions", "activate_caat", "trans_interface", + "american_costs", "scaf_readonly", "parts_replacement", "activate_facmexame", + "part_reference", "international_firm", "ftp_key", "sifra_path", + "version_type", "sql_language", "balance_operation_mode", "inter_db_name" + ] + return {k: v for k, v in data.items() if k in company_fields} + + def _extract_certification_fields(self, data: Dict[str, Any]) -> Dict[str, Any]: + """Extrae campos que pertenecen a CompanyCertification""" + cert_fields = [ + "is_certified_company", "certified_company_registration", + "certified_company_start_date", "certified_company_end_date", + "annex31_certification_date", "annex31_certification_number", + "annex31_modality", "annex31_company_type", "annex31_renewal_date", + "annex31_final_certification_date", "is_oea_company", "ctpat_svi", + "trusted_exporter_number", "neec_company" + ] + return {k: v for k, v in data.items() if k in cert_fields} + + def _extract_prevalidator_fields(self, data: Dict[str, Any]) -> Dict[str, Any]: + """Extrae campos que pertenecen a CompanyPrevalidator""" + # Note: 'prevalidator_key' in DTO maps to 'key' in model + fields = {} + if "prevalidator_key" in data: + fields["key"] = data["prevalidator_key"] + + # Add other fields if present in DTO in the future + return fields + + def flatten_company_dto(self, company: Company) -> Dict[str, Any]: + """Flattens Company and its submodels into a single dict for DTO validation""" + # 1. Base Company fields + result = { + k: getattr(company, k) + for k in company.__mapper__.c.keys() + } + # Explicitly ensure logo is present (defensive programming) + if hasattr(company, 'logo'): + result['logo'] = company.logo + + # Convert has_express_line from String "S"/"N" to Boolean + if hasattr(company, 'has_express_line'): + val = getattr(company, 'has_express_line', "N") + result['has_express_line'] = (val == "S") + + # 2. Certification fields + if company.certification: + cert_fields = [ + "is_certified_company", "certified_company_registration", + "certified_company_start_date", "certified_company_end_date", + "annex31_certification_date", "annex31_certification_number", + "annex31_modality", "annex31_company_type", "annex31_renewal_date", + "annex31_final_certification_date", "is_oea_company", "ctpat_svi", + "trusted_exporter_number", "neec_company" + ] + for field in cert_fields: + val = getattr(company.certification, field, None) + if val is not None: + result[field] = val + + # 3. Prevalidator fields + if company.prevalidator: + if company.prevalidator.key: + result["prevalidator_key"] = company.prevalidator.key + + return result + + # ==================== CRUD METHODS ==================== + + def create_company_manually(self, data: CompanyCreateDTO, tenant_id: int) -> Company: + from .submodels.certification import CompanyCertification + from .submodels.prevalidator import CompanyPrevalidator + + try: + # 1. Preparar datos + obj_data = data.model_dump(exclude_unset=True) + + # Handle boolean flags for Company (Hybrid Approach) + # has_express_line is String(2), is_service_company is Boolean + if "has_express_line" in obj_data and isinstance(obj_data["has_express_line"], bool): + obj_data["has_express_line"] = "S" if obj_data["has_express_line"] else "N" + + # 2. Extract fields for each model + company_data = self._extract_company_fields(obj_data) + cert_data = self._extract_certification_fields(obj_data) + preval_data = self._extract_prevalidator_fields(obj_data) + + # 3. Create Company + db_company = Company(**company_data, tenant_id=tenant_id) + self.db.add(db_company) + self.db.flush() # Generate ID + + # 4. Create Certification if data exists + if cert_data: + cert = CompanyCertification(**cert_data, company_id=db_company.id) + self.db.add(cert) + + # 5. Create Prevalidator if data exists + if preval_data: + preval = CompanyPrevalidator(**preval_data, company_id=db_company.id) + self.db.add(preval) + + # 6. Commit + self.db.commit() + self.db.refresh(db_company) + + return db_company + + except IntegrityError as e: + self.db.rollback() + logger.error(f"IntegrityError creating company manually: {str(e)}") + raise HTTPException( + status_code=400, + detail="Error de integridad: Es posible que esta empresa ya exista.", + ) + except Exception as e: + self.db.rollback() + logger.error(f"Error creating company manually: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error creando empresa: {str(e)}") + def update( + self, # Changed to instance method to use self helper methods db: Session, company_id: int, tenant_id: int, @@ -106,30 +237,57 @@ class CompanyService: company_data: CompanyUpdateDTO, ) -> Optional[Company]: """Update a company""" - company = CompanyService.get_by_id(db, company_id, tenant_id, company_id_unused) + from .submodels.certification import CompanyCertification + from .submodels.prevalidator import CompanyPrevalidator + + # Use self.db if db is passed as None, or use passed db (legacy support) + session = db if db else self.db + + company = self.get_by_id(session, company_id, tenant_id, company_id_unused) if not company: return None # Update only provided fields update_data = company_data.model_dump(exclude_unset=True) - boolean_fields_str = ["has_express_line", "is_service_company"] - for field, value in update_data.items(): - if field in boolean_fields_str: - # Convert boolean to "S"/"N" - if isinstance(value, bool): - value = "S" if value else "N" - + # 1. Update Company fields + company_fields = self._extract_company_fields(update_data) + + # Hybrid Approach: has_express_line is String, is_service_company is Boolean + if "has_express_line" in company_fields and isinstance(company_fields["has_express_line"], bool): + company_fields["has_express_line"] = "S" if company_fields["has_express_line"] else "N" + + for field, value in company_fields.items(): setattr(company, field, value) + # 2. Update Certification + cert_fields = self._extract_certification_fields(update_data) + if cert_fields: + if company.certification: + for field, value in cert_fields.items(): + setattr(company.certification, field, value) + else: + new_cert = CompanyCertification(**cert_fields, company_id=company.id) + session.add(new_cert) + + # 3. Update Prevalidator + preval_fields = self._extract_prevalidator_fields(update_data) + if preval_fields: + if company.prevalidator: + for field, value in preval_fields.items(): + setattr(company.prevalidator, field, value) + else: + new_preval = CompanyPrevalidator(**preval_fields, company_id=company.id) + session.add(new_preval) + try: - db.commit() - db.refresh(company) + session.commit() + session.refresh(company) return company except Exception as e: - db.rollback() + session.rollback() logger.error(f"Error updating company {company_id}: {str(e)}") - raise HTTPException(status_code=500, detail="Error updating company") + raise HTTPException(status_code=500, detail=f"Error al actualizar la empresa: {str(e)}") @staticmethod def delete( @@ -141,19 +299,56 @@ class CompanyService: return False try: + # Manual cascade delete for submodels to ensure order and avoid FK issues + # (Even though cascade="all, delete-orphan" is set, manual deletion is safer for strict DBs) + + # 1. Delete Certification + if company.certification: + db.delete(company.certification) + + # 2. Delete Prevalidator + if company.prevalidator: + db.delete(company.prevalidator) + + # 3. Delete Electronic Agent + if company.electronic_agent: + db.delete(company.electronic_agent) + + # 4. Delete VU + if company.ventanilla_unica: + db.delete(company.ventanilla_unica) + + # 5. Delete CFDI + if company.cfdi: + db.delete(company.cfdi) + + # 6. Delete Digital Certificates + for cert in company.digital_certificates: + db.delete(cert) + + # 7. Delete Addresses + for addr in company.addresses: + db.delete(addr) + + # Flush to execute submodel deletions first + db.flush() + db.delete(company) db.commit() return True except IntegrityError as e: db.rollback() logger.error(f"IntegrityError deleting company {company_id}: {str(e)}") - # Check if it's a foreign key constraint - if "foreign key constraint" in str(e).lower(): - raise HTTPException( - status_code=400, - detail="No se puede eliminar la empresa porque tiene registros relacionados (facturas, conceptos, etc.)" - ) - raise HTTPException(status_code=400, detail="Error al eliminar la empresa") + # Try to get detailed error from psycopg2 + detail = "No se puede eliminar la empresa porque tiene registros relacionados." + if hasattr(e, 'orig') and hasattr(e.orig, 'diag'): + if e.orig.diag.message_detail: + detail += f" Detalles: {e.orig.diag.message_detail}" + + raise HTTPException( + status_code=400, + detail=detail + ) except Exception as e: db.rollback() logger.error(f"Error deleting company {company_id}: {str(e)}") @@ -176,37 +371,4 @@ class CompanyService: .filter(Company.tenant_id == tenant_id) .first() is not None - ) - - def create_company_manually(self, data: CompanyCreateDTO, tenant_id: int) -> Company: - - try: - # 1. Preparar datos - obj_data = data.model_dump(exclude_unset=True) - - boolean_fields_str = ["has_express_line", "is_service_company"] - for field in boolean_fields_str: - if field in obj_data and isinstance(obj_data[field], bool): - obj_data[field] = "S" if obj_data[field] else "N" - - # 2. Crear objeto SQLAlchemy - db_obj = Company(**obj_data, tenant_id=tenant_id) - - # 3. Guardar - self.db.add(db_obj) - self.db.commit() - self.db.refresh(db_obj) - - return db_obj - - except IntegrityError as e: - self.db.rollback() - logger.error(f"IntegrityError creating company manually: {str(e)}") - raise HTTPException( - status_code=400, - detail="Error de integridad: Es posible que esta empresa ya exista.", - ) - except Exception as e: - self.db.rollback() - logger.error(f"Error creating company manually: {str(e)}") - raise HTTPException(status_code=500, detail=f"Error creando empresa: {str(e)}") \ No newline at end of file + ) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/general_catalogs/company/submodels/certification.py b/backend/api/v1/modules/a76/general_catalogs/company/submodels/certification.py index 5624704b..eea80e08 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/submodels/certification.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/submodels/certification.py @@ -2,7 +2,7 @@ Modelo de certificaciones de empresa """ from typing import Optional, TYPE_CHECKING -from sqlalchemy import Integer, String, SmallInteger, ForeignKey +from sqlalchemy import Integer, String, SmallInteger, ForeignKey, Boolean from sqlalchemy.orm import Mapped, mapped_column, relationship from core.database import Base from api.v1.common.base_models import TimestampMixin diff --git a/backend/debug_mapper.py b/backend/debug_mapper.py new file mode 100644 index 00000000..cc19459b --- /dev/null +++ b/backend/debug_mapper.py @@ -0,0 +1,15 @@ +from api.v1.modules.a76.general_catalogs.company.models import Company +from api.v1.modules.a76.general_catalogs.company.service import CompanyService +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +print("Checking Company Mapper keys...") +try: + keys = Company.__mapper__.c.keys() + print(f"Keys found: {keys}") + if 'logo' in keys: + print("SUCCESS: 'logo' is in keys") + else: + print("FAILURE: 'logo' is NOT in keys") +except Exception as e: + print(f"Error: {e}") diff --git a/backend/inspect_schema.py b/backend/inspect_schema.py new file mode 100644 index 00000000..63a44ca6 --- /dev/null +++ b/backend/inspect_schema.py @@ -0,0 +1,20 @@ +from sqlalchemy import create_engine, text + +# Connect to DB (adjust for localhost) +DB_URL = "postgresql://postgres:postgres@localhost:5432/anexo76_core" +engine = create_engine(DB_URL) + +sql = """ +SELECT column_name, data_type, character_maximum_length +FROM information_schema.columns +WHERE table_name = 'company' AND table_schema = 'a76' +AND column_name IN ('has_express_line', 'is_service_company'); +""" + +try: + with engine.connect() as conn: + result = conn.execute(text(sql)) + for row in result: + print(f"Column: {row[0]}, Type: {row[1]}, Length: {row[2]}") +except Exception as e: + print(f"Error: {e}") diff --git a/backend/verify_logo_presence.py b/backend/verify_logo_presence.py new file mode 100644 index 00000000..9ccabeb1 --- /dev/null +++ b/backend/verify_logo_presence.py @@ -0,0 +1,32 @@ +from api.v1.modules.a76.general_catalogs.company.models import Company +from api.v1.modules.a76.general_catalogs.company.service import CompanyService +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session +import os + +# Connect to DB (adjust for localhost) +DB_URL = "postgresql://postgres:postgres@localhost:5432/anexo76_core" +engine = create_engine(DB_URL) +SessionLocal = sessionmaker(bind=engine) +session = SessionLocal() + +try: + print("Querying first company...") + company = session.query(Company).first() + if company: + print(f"Company ID: {company.id}") + print(f"Direct Logo Access: '{company.logo}'") + + service = CompanyService(session) + flattened = service.flatten_company_dto(company) + + print(f"Flattened Logo: '{flattened.get('logo')}'") + + has_logo = 'logo' in flattened + print(f"Is 'logo' key in dict?: {has_logo}") + else: + print("No companies found in DB.") +except Exception as e: + print(f"Error: {e}") +finally: + session.close() From 9b32c019ef976dd5c32d169d57b1a77edb0634a0 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Thu, 22 Jan 2026 09:52:21 -0600 Subject: [PATCH 13/55] feat(line_items): update part number and component part number fields to use integers; enhance schemas and services for better data handling --- .../v1/modules/a76/items/line_items/models.py | 8 +- .../modules/a76/items/line_items/schemas.py | 14 +- backend/api/v1/modules/a76/items/service.py | 12 ++ .../edit/items/fa/item-configuration.svelte | 42 ++++- .../invoices/edit/items/fa/main-data.svelte | 71 +++++-- .../edit/items/fa/part-number-dialog.svelte | 175 ++++++++++++++++++ .../invoices/edit/items/items-tab-form.svelte | 123 +++++++++++- .../dashboard/pedimentos/columns.ts | 16 +- .../api-sveltekit/classes/[id]/+server.ts | 87 +++++++++ .../src/routes/api-sveltekit/parts/+server.ts | 91 +++++++++ .../api-sveltekit/parts/[id]/+server.ts | 87 +++++++++ .../units-of-measure/[id]/+server.ts | 87 +++++++++ 12 files changed, 777 insertions(+), 36 deletions(-) create mode 100644 frontend/src/lib/components/dashboard/invoices/edit/items/fa/part-number-dialog.svelte create mode 100644 frontend/src/routes/api-sveltekit/classes/[id]/+server.ts create mode 100644 frontend/src/routes/api-sveltekit/parts/+server.ts create mode 100644 frontend/src/routes/api-sveltekit/parts/[id]/+server.ts create mode 100644 frontend/src/routes/api-sveltekit/units-of-measure/[id]/+server.ts diff --git a/backend/api/v1/modules/a76/items/line_items/models.py b/backend/api/v1/modules/a76/items/line_items/models.py index 6ac7bee0..a7603d11 100644 --- a/backend/api/v1/modules/a76/items/line_items/models.py +++ b/backend/api/v1/modules/a76/items/line_items/models.py @@ -40,11 +40,11 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin): line_number: Mapped[int] = mapped_column(Integer) # LINEAIMPO/LINEAEXPO/LINEA # Part identification - part_number: Mapped[Optional[str]] = mapped_column( - ForeignKey("a76.parts.id") + part_number: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("a76.parts.id") ) # NUMPARTE - component_part_number: Mapped[Optional[str]] = mapped_column( - ForeignKey("a76.parts.id") + component_part_number: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("a76.parts.id") ) # NUMPARTECOM class_id: Mapped[Optional[int]] = mapped_column( ForeignKey("a76.classes.id") diff --git a/backend/api/v1/modules/a76/items/line_items/schemas.py b/backend/api/v1/modules/a76/items/line_items/schemas.py index 0bea3647..725ff80f 100644 --- a/backend/api/v1/modules/a76/items/line_items/schemas.py +++ b/backend/api/v1/modules/a76/items/line_items/schemas.py @@ -44,12 +44,16 @@ from api.v1.modules.a24.fa.fa_item_lines.dto import ( class LineItemBase(BaseModel): """Base schema for line items""" + model_config = ConfigDict(populate_by_name=True) + line_number: int = Field(..., description="Line number") # Part identification - part_number_id: Optional[int] = Field(None, description="Part number") + part_number_id: Optional[int] = Field( + None, description="Part number", alias="part_number", serialization_alias="part_number_id" + ) component_part_number_id: Optional[int] = Field( - None, description="Component part number" + None, description="Component part number", alias="component_part_number", serialization_alias="component_part_number_id" ) class_id: Optional[int] = Field(None, description="Class code") @@ -257,6 +261,12 @@ class LineItemResponse(LineItemBase): if hasattr(data, key): result[key] = getattr(data, key) + # Map model field names to schema field names for aliased fields + if hasattr(data, "part_number"): + result["part_number_id"] = data.part_number + if hasattr(data, "component_part_number"): + result["component_part_number_id"] = data.component_part_number + # Extract class info if hasattr(data, "class_info") and data.class_info is not None: result["class_code"] = data.class_info.class_code diff --git a/backend/api/v1/modules/a76/items/service.py b/backend/api/v1/modules/a76/items/service.py index 2866fb3d..db983fd5 100644 --- a/backend/api/v1/modules/a76/items/service.py +++ b/backend/api/v1/modules/a76/items/service.py @@ -269,6 +269,12 @@ class ItemService: line_dict["tenant_id"] = tenant_id line_dict["company_id"] = company_id + # Map schema field names to model field names + if "part_number_id" in line_dict: + line_dict["part_number"] = line_dict.pop("part_number_id") + if "component_part_number_id" in line_dict: + line_dict["component_part_number"] = line_dict.pop("component_part_number_id") + # Create line item db_line = LineItem(**line_dict) db.add(db_line) @@ -485,6 +491,12 @@ class ItemService: line_dict["tenant_id"] = tenant_id line_dict["company_id"] = company_id + # Map schema field names to model field names + if "part_number_id" in line_dict: + line_dict["part_number"] = line_dict.pop("part_number_id") + if "component_part_number_id" in line_dict: + line_dict["component_part_number"] = line_dict.pop("component_part_number_id") + db_line = LineItem(**line_dict) db.add(db_line) db.flush() diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte index 34333daa..831d2739 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte @@ -2,6 +2,9 @@ import * as RadioGroup from '$lib/components/ui/radio-group'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; + import { Button } from '$lib/components/ui/button'; + import { Folder } from 'lucide-svelte'; + import PartNumberDialog from './part-number-dialog.svelte'; import type { LineItem, LineDescriptions } from '$lib/api/dashboard/a76/items'; let { @@ -12,6 +15,8 @@ descriptions: LineDescriptions; } = $props(); + let showPartDialog = $state(false); + // Initialize fa_data for fixed asset system if (!lineItem.fa_data) { lineItem.fa_data = {}; @@ -29,8 +34,18 @@ if (!lineItem.fa_data) lineItem.fa_data = {}; lineItem.fa_data.contains_subitems = val === 'si'; } + + function handlePartSelect(part: any) { + lineItem.part_number_id = part.id; + // Store part number for display + (lineItem as any).part_number = part.part_number; + (lineItem as any).part_description_es = part.description_spanish; + (lineItem as any).part_description_en = part.description_english; + } + +
@@ -72,11 +87,32 @@
- +
- + (showPartDialog = true)} + /> +
-

ID de número de parte existente en catálogo

+ {#if (lineItem as any).part_description_es} +

{(lineItem as any).part_description_es}

+ {/if} + {#if lineItem.part_number_id} +

ID: {lineItem.part_number_id}

+ {/if}
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/main-data.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/main-data.svelte index e11174dc..ffa3152c 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/main-data.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/main-data.svelte @@ -35,20 +35,27 @@ function handleClassSelect(classItem: any) { lineItem.class_id = classItem.id; - // Store the code in the lineItem for display + // Store the unit of measure and description for display + (lineItem as any).class_unit_of_measure = classItem.unit_of_measure; (lineItem as any).class_code = classItem.class_code; + (lineItem as any).class_description = classItem.description_es || classItem.description_en; } function handleUnitSelect(unit: any) { lineItem.unit_of_measure = unit.id; + // Store unit code for display + (lineItem as any).unit_code = unit.code; + (lineItem as any).unit_description = unit.description || unit.description_en; } function handleCountrySelect(country: any) { - customs.origin_country = country.mex_key || country.m3_key; + customs.origin_country = country.m3_key || country.mex_key; + (customs as any).origin_country_name = country.description || country.description_en; } function handleFractionSelect(fraction: any) { customs.fraction = fraction.fraction; + (customs as any).fraction_description = fraction.description; } @@ -63,14 +70,16 @@
- +
(showClassDialog = true)} />
- {#if (lineItem as any).class_code} -

Código: {(lineItem as any).class_code}

+ {#if (lineItem as any).class_description} +

{(lineItem as any).class_description}

+ {/if} + {#if lineItem.class_id} +

ID: {lineItem.class_id}

{/if}
@@ -95,7 +107,15 @@
- + (showUnitDialog = true)} + />
+ {#if (lineItem as any).unit_description} +

{(lineItem as any).unit_description}

+ {/if}
@@ -117,9 +140,16 @@
- +
- + (showFractionDialog = true)} + />
+ {#if (customs as any).fraction_description} +

{(customs as any).fraction_description}

+ {/if}
- +
- + (showCountryDialog = true)} + />
+ {#if (customs as any).origin_country_name} +

{(customs as any).origin_country_name}

+ {/if}
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/part-number-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/part-number-dialog.svelte new file mode 100644 index 00000000..bcb53791 --- /dev/null +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/part-number-dialog.svelte @@ -0,0 +1,175 @@ + + + + + + Seleccionar Número de Parte + + Busca y selecciona un número de parte para la partida + + + +
+
+ + +
+
+ +
+ {#if isSearching} +
+ +
+ {:else} + + + + Número de Parte + Descripción (ES) + Descripción (EN) + Clase + + + + + {#if displayedParts.length === 0} + + + No se encontraron números de parte + + + {:else} + {#each displayedParts as part} + handleSelect(part)}> + {part.part_number} + {part.description_spanish || '-'} + {part.description_english || '-'} + {part.part_class || '-'} + + + + + {/each} + {/if} + + + {/if} +
+ + +

+ Mostrando {displayedParts.length} de {filteredParts.length} resultados +

+
+
+
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte index 3c4210d3..afda3f78 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte @@ -212,9 +212,109 @@ selectedItem = lineData.full_item; // Deep clone and normalize numeric values editingItem = normalizeItemData({ ...lineData.full_item }); + // Enrich with descriptive data + enrichItemData(editingItem); showItemSheet = true; } + // Enrich item with descriptive data for display + async function enrichItemData(item: Partial) { + if (!item.lines || item.lines.length === 0 || !activeCompanyId) return; + + const line = item.lines[0]; + + // Load class data + if (line.class_id) { + try { + const response = await fetch( + `/api-sveltekit/classes/${line.class_id}?company_id=${activeCompanyId}`, + { method: 'GET', headers: { 'Content-Type': 'application/json' } } + ); + if (response.ok) { + const classData = await response.json(); + (line as any).class_code = classData.class_code; + (line as any).class_unit_of_measure = classData.unit_of_measure; + (line as any).class_description = classData.description_es || classData.description_en; + } + } catch (error) { + console.error('Error loading class data:', error); + } + } + + // Load part number data + if (line.part_number_id) { + try { + const response = await fetch( + `/api-sveltekit/parts/${line.part_number_id}?company_id=${activeCompanyId}`, + { method: 'GET', headers: { 'Content-Type': 'application/json' } } + ); + if (response.ok) { + const partData = await response.json(); + (line as any).part_number = partData.part_number; + (line as any).part_description_es = partData.description_spanish; + (line as any).part_description_en = partData.description_english; + } + } catch (error) { + console.error('Error loading part data:', error); + } + } + + // Load unit of measure data + if (line.unit_of_measure) { + try { + const response = await fetch( + `/api-sveltekit/units-of-measure/${line.unit_of_measure}`, + { method: 'GET', headers: { 'Content-Type': 'application/json' } } + ); + if (response.ok) { + const unitData = await response.json(); + (line as any).unit_code = unitData.code; + (line as any).unit_description = unitData.description || unitData.description_en; + } + } catch (error) { + console.error('Error loading unit data:', error); + } + } + + // Load country data (if needed) + if (line.customs?.origin_country) { + try { + const response = await fetch( + `/api-sveltekit/countries?search=${line.customs.origin_country}`, + { method: 'GET', headers: { 'Content-Type': 'application/json' } } + ); + if (response.ok) { + const data = await response.json(); + if (data.items && data.items.length > 0) { + const country = data.items[0]; + (line.customs as any).origin_country_name = country.description || country.description_en; + } + } + } catch (error) { + console.error('Error loading country data:', error); + } + } + + // Load fraction data (if needed) + if (line.customs?.fraction) { + try { + const response = await fetch( + `/api-sveltekit/tariff-fractions?search=${line.customs.fraction}`, + { method: 'GET', headers: { 'Content-Type': 'application/json' } } + ); + if (response.ok) { + const data = await response.json(); + if (data.items && data.items.length > 0) { + const fraction = data.items[0]; + (line.customs as any).fraction_description = fraction.description; + } + } + } catch (error) { + console.error('Error loading fraction data:', error); + } + } + } + // Normalize numeric values from strings to numbers function normalizeItemData(item: Partial): Partial { if (item.lines && item.lines.length > 0) { @@ -300,6 +400,22 @@ cleaned.unit_of_measure = toNumberOrUndefined(cleaned.unit_of_measure); cleaned.alternate_unit = toNumberOrUndefined(cleaned.alternate_unit); + // Remove display-only fields + delete cleaned.class_code; + delete cleaned.class_unit_of_measure; + delete cleaned.class_description; + delete cleaned.part_number; + delete cleaned.part_description_es; + delete cleaned.part_description_en; + delete cleaned.unit_code; + delete cleaned.unit_description; + + // Remove display-only fields from nested objects + if (cleaned.customs) { + delete cleaned.customs.origin_country_name; + delete cleaned.customs.fraction_description; + } + // Remove empty nested objects if (!hasValues(cleaned.financial)) delete cleaned.financial; if (!hasValues(cleaned.quantity)) delete cleaned.quantity; @@ -325,7 +441,7 @@ order: editingItem.order, warehouse: editingItem.warehouse, location: editingItem.location, - lines: editingItem.lines || [] + lines: cleanedLines }); // Verificar si hay errores de validación @@ -384,12 +500,15 @@ isSaving = true; try { + // Clean lines data before sending + const cleanedLines = (editingItem.lines || []).map(cleanLineData); + const response = await itemsApi.update(selectedItem.id, activeCompanyId, { reference_number: editingItem.reference_number, order: editingItem.order, warehouse: editingItem.warehouse, location: editingItem.location, - lines: editingItem.lines || [] + lines: cleanedLines }); // Verificar si hay errores de validación diff --git a/frontend/src/lib/components/dashboard/pedimentos/columns.ts b/frontend/src/lib/components/dashboard/pedimentos/columns.ts index 2bdfbe51..784a23d7 100644 --- a/frontend/src/lib/components/dashboard/pedimentos/columns.ts +++ b/frontend/src/lib/components/dashboard/pedimentos/columns.ts @@ -1,7 +1,6 @@ import type { ColumnDef } from "@tanstack/table-core"; import { renderComponent, renderSnippet } from "$lib/components/ui/data-table/index.js"; import { createRawSnippet } from "svelte"; -import DataTableActions from "./data-table-actions.svelte"; import type { Pedimento } from "$lib/api/dashboard/a76/pedimentos"; /** @@ -201,7 +200,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { return renderSnippet(dateSnippet, { date: formatDate(row.original.pedimento_dates?.payment_date) }); } }, - { + /*{ accessorKey: "pedimento_config_update_rectification.pediment_rectifed_18", header: "Pedimento 18", cell: ({ row }) => { @@ -214,8 +213,8 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { }); return renderSnippet(ped18Snippet, { value: row.original.pedimento_config_update_rectification?.pediment_rectifed_18 }); } - }, - { + },*/ + /*{ accessorKey: "pedimento_config_update_rectification.r1", header: "Pedimento R1", cell: ({ row }) => { @@ -228,7 +227,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { }); return renderSnippet(r1Snippet, { value: row.original.pedimento_config_update_rectification?.r1 }); } - }, + },*/ { accessorKey: "pedimento_validation.electronic_signature", header: "Acuse Electrónico", @@ -375,13 +374,8 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { }); return renderSnippet(dateSnippet, { date: formatDate(row.original.created_at) }); } - }, - { - id: "actions", - cell: ({ row }) => { - return renderComponent(DataTableActions, { item: row.original, onSuccess }); - } } + // Columna de acciones eliminada - ahora usamos botones en el footer ]; } diff --git a/frontend/src/routes/api-sveltekit/classes/[id]/+server.ts b/frontend/src/routes/api-sveltekit/classes/[id]/+server.ts new file mode 100644 index 00000000..d8de375c --- /dev/null +++ b/frontend/src/routes/api-sveltekit/classes/[id]/+server.ts @@ -0,0 +1,87 @@ +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ cookies, url, params }) => { + const token = cookies.get('access_token'); + const { id } = params; + + // Obtener company_id de la cookie o query params + const companyId = cookies.get('active_company_id') || url.searchParams.get('company_id'); + + if (!companyId) { + return new Response( + JSON.stringify({ error: 'No company selected' }), + { + status: 400, + headers: { + 'Content-Type': 'application/json' + } + } + ); + } + + // Configurar la URL de la API usando las variables de entorno + let apiUrl = process.env.INTERNAL_API_URL; + if (!apiUrl) { + apiUrl = process.env.VITE_API_URL; + // Reemplazar 'localhost' con 'backend' para llamadas desde el servidor (SSR) + apiUrl = apiUrl?.replace('localhost', 'backend').replace('127.0.0.1', 'backend'); + } + + // Normalizar la URL + const baseUrl = apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`; + + try { + const fetchUrl = `${baseUrl}v1/a76/classes/${id}?company_id=${companyId}`; + console.log('Fetching class from:', fetchUrl); + + const response = await fetch( + fetchUrl, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Error response from backend:', errorText); + return new Response( + JSON.stringify({ + error: 'Failed to fetch class', + details: errorText + }), + { + status: response.status, + headers: { + 'Content-Type': 'application/json' + } + } + ); + } + + const data = await response.json(); + return new Response(JSON.stringify(data), { + status: 200, + headers: { + 'Content-Type': 'application/json' + } + }); + } catch (error) { + console.error('Error in class API route:', error); + return new Response( + JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error' + }), + { + status: 500, + headers: { + 'Content-Type': 'application/json' + } + } + ); + } +}; diff --git a/frontend/src/routes/api-sveltekit/parts/+server.ts b/frontend/src/routes/api-sveltekit/parts/+server.ts new file mode 100644 index 00000000..1c1cae0e --- /dev/null +++ b/frontend/src/routes/api-sveltekit/parts/+server.ts @@ -0,0 +1,91 @@ +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ cookies, url }) => { + const token = cookies.get('access_token'); + + // Obtener company_id de la cookie + const companyId = cookies.get('active_company_id'); + + if (!companyId) { + return new Response( + JSON.stringify({ error: 'No company selected' }), + { + status: 400, + headers: { + 'Content-Type': 'application/json' + } + } + ); + } + + // Configurar la URL de la API usando las variables de entorno + let apiUrl = process.env.INTERNAL_API_URL; + if (!apiUrl) { + apiUrl = process.env.VITE_API_URL; + // Reemplazar 'localhost' con 'backend' para llamadas desde el servidor (SSR) + apiUrl = apiUrl?.replace('localhost', 'backend').replace('127.0.0.1', 'backend'); + } + + // Normalizar la URL + const baseUrl = apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`; + + // Get query parameters and add company_id + const searchParams = new URLSearchParams(url.search); + searchParams.set('company_id', companyId); + const queryString = searchParams.toString(); + + try { + const fetchUrl = `${baseUrl}v1/a76/parts?${queryString}`; + console.log('Fetching parts from:', fetchUrl); + + const response = await fetch( + fetchUrl, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Error response from backend:', errorText); + return new Response( + JSON.stringify({ + error: 'Failed to fetch parts', + details: errorText + }), + { + status: response.status, + headers: { + 'Content-Type': 'application/json' + } + } + ); + } + + const data = await response.json(); + return new Response(JSON.stringify(data), { + status: 200, + headers: { + 'Content-Type': 'application/json' + } + }); + } catch (error) { + console.error('Error in parts API route:', error); + return new Response( + JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error' + }), + { + status: 500, + headers: { + 'Content-Type': 'application/json' + } + } + ); + } +}; diff --git a/frontend/src/routes/api-sveltekit/parts/[id]/+server.ts b/frontend/src/routes/api-sveltekit/parts/[id]/+server.ts new file mode 100644 index 00000000..ba82f40d --- /dev/null +++ b/frontend/src/routes/api-sveltekit/parts/[id]/+server.ts @@ -0,0 +1,87 @@ +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ cookies, url, params }) => { + const token = cookies.get('access_token'); + const { id } = params; + + // Obtener company_id de la cookie o query params + const companyId = cookies.get('active_company_id') || url.searchParams.get('company_id'); + + if (!companyId) { + return new Response( + JSON.stringify({ error: 'No company selected' }), + { + status: 400, + headers: { + 'Content-Type': 'application/json' + } + } + ); + } + + // Configurar la URL de la API usando las variables de entorno + let apiUrl = process.env.INTERNAL_API_URL; + if (!apiUrl) { + apiUrl = process.env.VITE_API_URL; + // Reemplazar 'localhost' con 'backend' para llamadas desde el servidor (SSR) + apiUrl = apiUrl?.replace('localhost', 'backend').replace('127.0.0.1', 'backend'); + } + + // Normalizar la URL + const baseUrl = apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`; + + try { + const fetchUrl = `${baseUrl}v1/a76/parts/${id}?company_id=${companyId}`; + console.log('Fetching part from:', fetchUrl); + + const response = await fetch( + fetchUrl, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Error response from backend:', errorText); + return new Response( + JSON.stringify({ + error: 'Failed to fetch part', + details: errorText + }), + { + status: response.status, + headers: { + 'Content-Type': 'application/json' + } + } + ); + } + + const data = await response.json(); + return new Response(JSON.stringify(data), { + status: 200, + headers: { + 'Content-Type': 'application/json' + } + }); + } catch (error) { + console.error('Error in part API route:', error); + return new Response( + JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error' + }), + { + status: 500, + headers: { + 'Content-Type': 'application/json' + } + } + ); + } +}; diff --git a/frontend/src/routes/api-sveltekit/units-of-measure/[id]/+server.ts b/frontend/src/routes/api-sveltekit/units-of-measure/[id]/+server.ts new file mode 100644 index 00000000..110e12c4 --- /dev/null +++ b/frontend/src/routes/api-sveltekit/units-of-measure/[id]/+server.ts @@ -0,0 +1,87 @@ +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ cookies, params, url }) => { + const token = cookies.get('access_token'); + const { id } = params; + + // Obtener company_id de la cookie o query params + const companyId = cookies.get('active_company_id') || url.searchParams.get('company_id'); + + if (!companyId) { + return new Response( + JSON.stringify({ error: 'No company selected' }), + { + status: 400, + headers: { + 'Content-Type': 'application/json' + } + } + ); + } + + // Configurar la URL de la API usando las variables de entorno + let apiUrl = process.env.INTERNAL_API_URL; + if (!apiUrl) { + apiUrl = process.env.VITE_API_URL; + // Reemplazar 'localhost' con 'backend' para llamadas desde el servidor (SSR) + apiUrl = apiUrl?.replace('localhost', 'backend').replace('127.0.0.1', 'backend'); + } + + // Normalizar la URL + const baseUrl = apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`; + + try { + const fetchUrl = `${baseUrl}v1/a76/units-of-measure/${id}?company_id=${companyId}`; + console.log('Fetching unit of measure from:', fetchUrl); + + const response = await fetch( + fetchUrl, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Error response from backend:', errorText); + return new Response( + JSON.stringify({ + error: 'Failed to fetch unit of measure', + details: errorText + }), + { + status: response.status, + headers: { + 'Content-Type': 'application/json' + } + } + ); + } + + const data = await response.json(); + return new Response(JSON.stringify(data), { + status: 200, + headers: { + 'Content-Type': 'application/json' + } + }); + } catch (error) { + console.error('Error in unit of measure API route:', error); + return new Response( + JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error' + }), + { + status: 500, + headers: { + 'Content-Type': 'application/json' + } + } + ); + } +}; From 587dfed2f2b43055bc7e721e8ebac5fa4e69cd11 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Thu, 22 Jan 2026 11:44:54 -0600 Subject: [PATCH 14/55] Se arreglo el catalogo de UM --- .../units_of_measure/routes.py | 6 ++ .../a76/general_catalogs/units-of-measure.ts | 2 +- .../goods/modales/unit-measure-dialog.svelte | 80 ++++++------------- 3 files changed, 32 insertions(+), 56 deletions(-) diff --git a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/routes.py b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/routes.py index 9a9b3db7..93fb39de 100644 --- a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/routes.py @@ -17,6 +17,7 @@ ace_router = TenantCRUDRoutes( id_name="id", enable_list=True, enable_filters=True, + max_page_size=10000, ).router router.include_router(ace_router) @@ -32,6 +33,7 @@ oma_router = TenantCRUDRoutes( id_name="id", enable_list=True, enable_filters=True, + max_page_size=10000, ).router router.include_router(oma_router) @@ -47,6 +49,7 @@ american_router = TenantCRUDRoutes( id_name="id", enable_list=True, enable_filters=True, + max_page_size=10000, ).router router.include_router(american_router) @@ -62,6 +65,7 @@ customs_router = TenantCRUDRoutes( id_name="id", enable_list=True, enable_filters=True, + max_page_size=10000, ).router router.include_router(customs_router) @@ -77,6 +81,7 @@ general_router = TenantCRUDRoutes( id_name="id", enable_list=True, enable_filters=True, + max_page_size=10000, ).router router.include_router(general_router) @@ -93,5 +98,6 @@ main_router = TenantCRUDRoutes( id_name="id", enable_list=True, enable_filters=True, + max_page_size=10000, ).router router.include_router(main_router) diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/units-of-measure.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/units-of-measure.ts index 7b731651..566c900e 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/units-of-measure.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/units-of-measure.ts @@ -295,7 +295,7 @@ export interface UnitOfMeasureListResponse { export async function getUnitsOfMeasure( page: number = 1, - pageSize: number = 50, + pageSize: number = 1000, companyId: number, filters: Record = {} ): Promise> { diff --git a/frontend/src/lib/components/dashboard/goods/modales/unit-measure-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/unit-measure-dialog.svelte index 2520f8b6..43f7e76b 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/unit-measure-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/unit-measure-dialog.svelte @@ -16,15 +16,27 @@ } = $props(); let items = $state([]); + let allItems = $state([]); // Store full dataset let loading = $state(false); let searchTerm = $state(""); - let page = $state(1); - let totalPages = $state(1); - let searchTimeout: NodeJS.Timeout; + + // Derived state for filtering + $effect(() => { + if (!searchTerm) { + items = allItems; + } else { + const lowerTerm = searchTerm.toLowerCase(); + items = allItems.filter(item => + item.code.toLowerCase().includes(lowerTerm) || + (item.description && item.description.toLowerCase().includes(lowerTerm)) || + (item.description_en && item.description_en.toLowerCase().includes(lowerTerm)) + ); + } + }); // Cargar datos al abrir $effect(() => { - if (open && companyStore.activeCompany) { + if (open && companyStore.activeCompany && allItems.length === 0) { loadData(); } }); @@ -34,17 +46,12 @@ loading = true; try { - const filters = searchTerm ? { code: searchTerm } : {}; - // Nota: Si tu backend soporta búsqueda por descripción, úsalo aquí. - // Por ahora asumo búsqueda por 'code' o 'description' según tu filtro backend. - - const response = await getUnitsOfMeasure(page, 10, companyStore.activeCompany.id, { - q: searchTerm // Asumiendo que tu backend tiene un filtro genérico 'q' o usa 'code'/'description' - }); + // Fetch everything once using the high limit + const response = await getUnitsOfMeasure(1, 1000, companyStore.activeCompany.id, {}); if (response.data) { - items = response.data.items; - totalPages = response.data.pages; + allItems = response.data.items; + items = allItems; // Initialize view } } catch (error) { console.error("Error cargando unidades:", error); @@ -56,32 +63,13 @@ function handleSearch(e: Event) { const value = (e.target as HTMLInputElement).value; searchTerm = value; - page = 1; - - clearTimeout(searchTimeout); - searchTimeout = setTimeout(() => { - loadData(); - }, 500); + // No network call needed, $effect handles filtering } function handleSelect(item: UnitOfMeasure) { onSelect(item); open = false; } - - function nextPage() { - if (page < totalPages) { - page++; - loadData(); - } - } - - function prevPage() { - if (page > 1) { - page--; - loadData(); - } - } @@ -104,7 +92,7 @@ />
-
+
{#if loading}
@@ -146,27 +134,9 @@
- -
- Página {page} de {totalPages} -
- - -
+ +
+ Mostrando {items.length} registros
From add2b739e0232e730c35aa4c77ae0cb0fa1db714 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Thu, 22 Jan 2026 12:19:04 -0600 Subject: [PATCH 15/55] Se quito PZ del catalogo --- .../lib/components/dashboard/goods/parts/partForm.svelte | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte index 6ae6bd7e..2555cf86 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte @@ -76,7 +76,7 @@ description_english: '', part_class: '', material_type: '', - unit_of_measure: 'PZ', + unit_of_measure: '', unit_weight: 0, weight_type: 'KG', unit_cost: 0, @@ -124,7 +124,7 @@ part_class: d.part_class || '', material_type: d.inv_data?.material_type || '', origin_country: d.fa_data?.origin_country || 'MEX', - unit_of_measure: d.unit_of_measure || 'PZ', + unit_of_measure: d.unit_of_measure || '', fraction: d.fraction || '', us_fraction: d.us_fraction || '', unit_weight: Number(d.unit_weight) || 0, @@ -232,6 +232,7 @@ const activeCompanyId = companyStore.activeCompany?.id; if (!activeCompanyId) { error = 'No hay una compañía activa seleccionada'; return; } if (!formData.client_id) { error = 'Debe seleccionar un Cliente'; return; } + if (!formData.unit_of_measure) { error = 'Debe seleccionar una Unidad de Medida'; return; } loading = true; try { @@ -359,7 +360,7 @@
- showUOMModal = true} class="pl-9 cursor-pointer font-mono" placeholder="PZ"/> + showUOMModal = true} class="pl-9 cursor-pointer font-mono" placeholder="Seleccione..."/>
From af863e624fd163163f21f348c1fe4a412c587090 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Thu, 22 Jan 2026 12:19:58 -0600 Subject: [PATCH 16/55] fix(main-data): implement automatic class description updates based on class_id changes --- .../invoices/edit/items/fa/main-data.svelte | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/main-data.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/main-data.svelte index ffa3152c..21e16369 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/main-data.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/main-data.svelte @@ -8,6 +8,7 @@ import TariffFractionDialog from './tariff-fraction-dialog.svelte'; import ClassDialog from './class-dialog.svelte'; import type { LineItem, LineQuantities, LineFinancials, LineCustoms } from '$lib/api/dashboard/a76/items'; + import { companyStore } from '$lib/stores/company.svelte'; let { lineItem = $bindable(), @@ -26,6 +27,9 @@ let showCountryDialog = $state(false); let showFractionDialog = $state(false); + // Track previous class_id to detect changes + let previousClassId = $state(undefined); + // Initialize from existing data $effect(() => { if (lineItem.class_code) { @@ -33,12 +37,66 @@ } }); + // Watch for class_id changes and update descriptions automatically + $effect(() => { + const currentClassId = lineItem.class_id; + const activeCompanyId = companyStore?.activeCompany?.id; + + // Only fetch if class_id changed, is valid, and we have a company + if (currentClassId && currentClassId !== previousClassId && activeCompanyId) { + previousClassId = currentClassId; + + // Fetch all classes and find the one with matching ID + fetch(`/api-sveltekit/classes?company_id=${activeCompanyId}&limit=100`) + .then(response => { + if (response.ok) { + return response.json(); + } + throw new Error('Failed to fetch classes'); + }) + .then(data => { + const classes = data.items || []; + const classItem = classes.find((c: any) => c.id === currentClassId); + + if (classItem) { + // Store the code and description in the lineItem for display + (lineItem as any).class_code = classItem.class_code; + (lineItem as any).class_unit_of_measure = classItem.unit_of_measure; + (lineItem as any).class_description = classItem.description_es || classItem.description_en; + + // Update description fields if description object exists + if ((lineItem as any).description) { + if (classItem.description_es) { + (lineItem as any).description.description_spanish = classItem.description_es; + } + if (classItem.description_en) { + (lineItem as any).description.description_english = classItem.description_en; + } + } + } + }) + .catch(error => { + console.error('Error fetching class data:', error); + }); + } + }); + function handleClassSelect(classItem: any) { lineItem.class_id = classItem.id; // Store the unit of measure and description for display (lineItem as any).class_unit_of_measure = classItem.unit_of_measure; (lineItem as any).class_code = classItem.class_code; (lineItem as any).class_description = classItem.description_es || classItem.description_en; + + // Update description fields if description object exists + if ((lineItem as any).description) { + if (classItem.description_es) { + (lineItem as any).description.description_spanish = classItem.description_es; + } + if (classItem.description_en) { + (lineItem as any).description.description_english = classItem.description_en; + } + } } function handleUnitSelect(unit: any) { From 0782e784ac70db3a419394ac645efe870c3dc817 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Thu, 22 Jan 2026 12:26:57 -0600 Subject: [PATCH 17/55] fix(validators): update part_number_id validation to be optional in item creation --- .../a76/items/imports/temporary/validators/create.py | 10 ++-------- .../invoices/edit/items/fa/item-configuration.svelte | 2 +- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py b/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py index d6080ac3..7c3ae0d8 100644 --- a/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py +++ b/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py @@ -33,14 +33,8 @@ def validate_create( code="REQUIRED" ) - # 2. Validar part_number_id - if not line.part_number_id: - errors.add_error( - field="part_number_id", - message="Part Number ID es obligatorio", - solution="Selecciona un número de parte válido del catálogo", - code="REQUIRED" - ) + # 2. Validar part_number_id (OPCIONAL - ya no es obligatorio) + # El campo part_number_id ahora es opcional # 3. Validar class_id if not line.class_id: diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte index 831d2739..e71c6096 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte @@ -87,7 +87,7 @@
- +
Date: Thu, 22 Jan 2026 12:54:26 -0600 Subject: [PATCH 18/55] fix(validators): add required validations for customs fields in item creation and update feat(item-sheet): implement cancel functionality to restore original item data --- .../imports/temporary/validators/create.py | 18 +++++ .../imports/temporary/validators/update.py | 44 +++++++---- .../edit/items/fa/item-sheet-fa.svelte | 6 +- .../invoices/edit/items/fa/main-data.svelte | 1 + .../edit/items/inv/item-sheet-inv.svelte | 4 +- .../invoices/edit/items/items-tab-form.svelte | 77 ++++++++++++++++++- 6 files changed, 129 insertions(+), 21 deletions(-) diff --git a/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py b/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py index 7c3ae0d8..2507f953 100644 --- a/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py +++ b/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py @@ -109,4 +109,22 @@ def validate_create( message="Description in Spanish es obligatorio", solution="Proporciona una descripción del item en español", code="REQUIRED" + ) + + # 8. Validar customs.origin_country + if not line.customs or not line.customs.origin_country: + errors.add_error( + field="customs.origin_country", + message="País de Origen es obligatorio", + solution="Selecciona el país de origen del item", + code="REQUIRED" + ) + + # 9. Validar customs.fraction_type + if not line.customs or not line.customs.fraction_type: + errors.add_error( + field="customs.fraction_type", + message="Tipo de Tarifa es obligatorio", + solution="Selecciona el tipo de tarifa (GENERAL, PROSEC, ALADI, TLCS)", + code="REQUIRED" ) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py b/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py index 8feaa515..08aed895 100644 --- a/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py +++ b/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py @@ -138,14 +138,25 @@ def validate_update( # 8. Validar datos aduanales si se proporcionan if line.customs: - # Validar país de origen - if line.customs.origin_country is not None and not line.customs.origin_country: - errors.add_error( - field="customs.origin_country", - message="Origin Country no puede estar vacío", - solution="Selecciona el país de origen del item", - code="REQUIRED" - ) + # Validar país de origen (OBLIGATORIO) + if line.customs.origin_country is not None: + if not line.customs.origin_country: + errors.add_error( + field="customs.origin_country", + message="País de Origen es obligatorio", + solution="Selecciona el país de origen del item", + code="REQUIRED" + ) + + # Validar tipo de tarifa (OBLIGATORIO) + if line.customs.fraction_type is not None: + if not line.customs.fraction_type: + errors.add_error( + field="customs.fraction_type", + message="Tipo de Tarifa es obligatorio", + solution="Selecciona el tipo de tarifa (GENERAL, PROSEC, ALADI, TLCS)", + code="REQUIRED" + ) # Validar preferencia arancelaria if line.customs.preference is not None and not line.customs.preference: @@ -184,15 +195,16 @@ def validate_update( code="INVALID_VALUE" ) - # 9. Validar descripción en español si se proporciona + # 9. Validar descripción en español (OBLIGATORIA) if line.description and hasattr(line.description, 'description_spanish'): - if line.description.description_spanish is not None and not line.description.description_spanish: - errors.add_error( - field="description.description_spanish", - message="La descripción en español no puede estar vacía", - solution="Proporciona una descripción del item en español", - code="REQUIRED" - ) + if line.description.description_spanish is not None: + if not line.description.description_spanish.strip(): + errors.add_error( + field="description.description_spanish", + message="Descripción en Español es obligatoria", + solution="Proporciona una descripción del item en español", + code="REQUIRED" + ) # 10. Validar subpartidas si se actualizan if line.fa_data and line.fa_data.is_subitem: 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 a091ac75..5bf87a1c 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 @@ -23,6 +23,7 @@ editingItem = $bindable(), invoice, onSave, + onCancel, isSaving = false }: { open: boolean; @@ -30,6 +31,7 @@ editingItem: Partial; invoice: Invoice | null; onSave: () => void; + onCancel?: () => void; isSaving?: boolean; } = $props(); @@ -55,7 +57,7 @@

-
@@ -164,7 +166,7 @@
-
- - + + - From f38d9a3fbc65c9fd42ee2a138f9cdd51a9fbbd6c Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Fri, 23 Jan 2026 11:34:20 -0600 Subject: [PATCH 32/55] Eliminacion de script de pruebas --- scripts/cleanup_exchange_rate.py | 30 ------------------------------ 1 file changed, 30 deletions(-) delete mode 100644 scripts/cleanup_exchange_rate.py diff --git a/scripts/cleanup_exchange_rate.py b/scripts/cleanup_exchange_rate.py deleted file mode 100644 index 276d364e..00000000 --- a/scripts/cleanup_exchange_rate.py +++ /dev/null @@ -1,30 +0,0 @@ -import sys -import os -from datetime import datetime - -# Add backend directory to path -sys.path.append('/home/josmar/dev/anexo76/backend') - -from api.v1.modules.a76.general_catalogs.exchange_rate.services import ExchangeRateService -from core.database import get_core_db -# from api.v1.common.tenant_crud_routes import get_core_db # This might be needing another import path - -db = next(get_core_db()) - -today = datetime.now().strftime("%Y-%m-%d") -tenant_id = 1 -company_id = 1 - -print(f"Checking for exchange rate on {today}...") -filters = {"date": today} -rates, total = ExchangeRateService.get_all(db, tenant_id, company_id, filters=filters) - -if total > 0: - print(f"Found {total} exchange rate(s) for today. Deleting...") - for rate in rates: - ExchangeRateService.delete(db, rate.id, tenant_id, company_id) - print("Deleted.") -else: - print("No exchange rate found for today.") - -db.close() From 123310badd83b5be95f5340dadbb908901847f06 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Fri, 23 Jan 2026 11:44:13 -0600 Subject: [PATCH 33/55] fix(invoices): simplify operation type handling in invoice payload and server load --- .../dashboard/invoices/edit/save-invoice.ts | 4 +--- frontend/src/routes/dashboard/invoices/+page.svelte | 5 ----- .../dashboard/invoices/edit/[id]/+page.server.ts | 11 ++++------- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts b/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts index 03982769..e6f6b550 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts +++ b/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts @@ -110,9 +110,7 @@ function buildInvoicePayload(formData: FormDataSet): CreateInvoiceData | UpdateI const payload: any = { // Datos generales desde InvoiceTopFieldsFormData system: 'fixed_asset', - operation_type: InvoiceTopFieldsFormData?.operation_type !== null && InvoiceTopFieldsFormData?.operation_type !== undefined - ? (InvoiceTopFieldsFormData.operation_type === 1 ? 'exp' : 'imp') as OperationType - : undefined, + operation_type: InvoiceTopFieldsFormData?.operation_type || undefined, invoice_type: InvoiceTopFieldsFormData?.invoice_type || undefined, document_type: generalFormData?.document_type || undefined, invoice_number: InvoiceTopFieldsFormData?.invoice_number || undefined, diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index 06086755..61138a73 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -427,11 +427,6 @@ function handleCreateClick() { const params = new URLSearchParams(window.location.search); - const operationType = params.get('operation_type'); - if (operationType) { - const operationTypeNumber = operationType === 'exp' ? 1 : 2; - params.set('operation_type', operationTypeNumber.toString()); - } const queryString = params.toString(); const url = queryString diff --git a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.server.ts b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.server.ts index 2ec29c64..78a4d101 100644 --- a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.server.ts +++ b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.server.ts @@ -20,13 +20,10 @@ export const load: PageServerLoad = async ({ params, cookies, fetch, url }) => { const operationTypeParam = url.searchParams.get('operation_type'); const invoiceTypeParam = url.searchParams.get('invoice_type'); - // Parsear operation_type de forma segura - let parsedOperationType: number | null = null; - if (operationTypeParam) { - const parsed = parseInt(operationTypeParam, 10); - if (!isNaN(parsed)) { - parsedOperationType = parsed; - } + // Validar que operation_type sea 'exp' o 'imp' + let parsedOperationType: string | null = null; + if (operationTypeParam && (operationTypeParam === 'exp' || operationTypeParam === 'imp')) { + parsedOperationType = operationTypeParam; } // Cargar datos de referencia necesarios From beb32088b8f97ba750b4e80aefd01d8cf65d04ef Mon Sep 17 00:00:00 2001 From: acazares Date: Fri, 23 Jan 2026 12:19:57 -0600 Subject: [PATCH 34/55] fix(docker): update celery worker container name and add valkey service --- docker-compose.prod.yml | 28 ++++++++++++++++++++++++++-- docker-compose.yml | 4 ++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index eb05fe4c..f06b8238 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -180,11 +180,13 @@ services: "-k", "uvicorn.workers.UvicornWorker", "-w", - "${WEB_CONCURRENCY:-4}", + "${WEB_CONCURRENCY:-1}", "-b", "0.0.0.0:8000", "--log-level", - "info" + "info", + "--forwarded-allow-ips", + "*" ] healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:8000/api/health || exit 1"] @@ -204,6 +206,28 @@ services: reservations: memory: 256M + # celery + celery_worker: + image: dev.aduanasoft.com/anexo76/backend:latest + container_name: worker + command: celery -A core.celery_app worker --loglevel=info + environment: + - VALKEY_URL=redis://valkey:6379/0 + depends_on: + - backend + - valkey + networks: + - backend-net + + valkey: + image: valkey/valkey:7.2 + container_name: valkey + restart: always + ports: + - "6579:6379" + networks: + - backend-net + # Frontend - SvelteKit frontend: image: dev.aduanasoft.com/anexo76/frontend:latest diff --git a/docker-compose.yml b/docker-compose.yml index c26575a5..e0a1d684 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -265,7 +265,7 @@ services: # celery celery_worker: build: ./backend - container_name: a76_worker + container_name: worker command: celery -A core.celery_app worker --loglevel=info environment: - VALKEY_URL=redis://valkey:6379/0 @@ -277,7 +277,7 @@ services: valkey: image: valkey/valkey:7.2 - container_name: a76_valkey + container_name: valkey restart: always ports: - "6379:6379" From 3cbe4e843f440f13c803e6be2bd087f770ed4730 Mon Sep 17 00:00:00 2001 From: acazares Date: Fri, 23 Jan 2026 12:20:43 -0600 Subject: [PATCH 35/55] fix(models, schemas, service): remove weight_unit field and update part_number references --- backend/api/v1/modules/a76/items/line_quantities/models.py | 1 - backend/api/v1/modules/a76/items/line_quantities/schemas.py | 1 - backend/api/v1/modules/a76/items/service.py | 4 ++-- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/backend/api/v1/modules/a76/items/line_quantities/models.py b/backend/api/v1/modules/a76/items/line_quantities/models.py index c7a60558..21baa88d 100644 --- a/backend/api/v1/modules/a76/items/line_quantities/models.py +++ b/backend/api/v1/modules/a76/items/line_quantities/models.py @@ -34,7 +34,6 @@ class LineQuantity(Base): serial_count: Mapped[Optional[int]] = mapped_column(Integer) # CANT_SERIES/CANT_SERIESDEF # Weight - weight_unit: Mapped[Optional[str]] = mapped_column(String(3)) # 'KG' o 'LB' net_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # PESONETO gross_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # PESOBRUTO diff --git a/backend/api/v1/modules/a76/items/line_quantities/schemas.py b/backend/api/v1/modules/a76/items/line_quantities/schemas.py index 71552342..fa1a5ecc 100644 --- a/backend/api/v1/modules/a76/items/line_quantities/schemas.py +++ b/backend/api/v1/modules/a76/items/line_quantities/schemas.py @@ -22,7 +22,6 @@ class LineQuantityBase(BaseModel): serial_count: Optional[int] = Field(None, description="Serial count (CANT_SERIES/CANT_SERIESDEF)") # Weight - weight_unit: Optional[str] = Field(None, max_length=3, description="Weight unit ('KG' or 'LB')") net_weight: Optional[Decimal] = Field(None, description="Net weight (PESONETO)") gross_weight: Optional[Decimal] = Field(None, description="Gross weight (PESOBRUTO)") diff --git a/backend/api/v1/modules/a76/items/service.py b/backend/api/v1/modules/a76/items/service.py index 912a7b89..2efe582a 100644 --- a/backend/api/v1/modules/a76/items/service.py +++ b/backend/api/v1/modules/a76/items/service.py @@ -194,7 +194,7 @@ class ItemService: # Validaciones adicionales específicas del negocio # Validar apóstrofes en número de parte - if line_data.part_number and "'" in str(line_data.part_number): + if line_data.part_number_id and "'" in str(line_data.part_number_id): errors.add_error( field=f"lines[{idx}].part_number", message=f"Advertencia: El Número de Parte contiene apóstrofes y serán omitidos", @@ -420,7 +420,7 @@ class ItemService: # (Aplican tanto para crear como actualizar) # Validar apóstrofes en número de parte - if line_data.part_number and "'" in str(line_data.part_number): + if line_data.part_number_id and "'" in str(line_data.part_number_id): errors.add_error( field=f"lines[{idx}].part_number", message=f"Advertencia: El Número de Parte contiene apóstrofes y serán omitidos", From 4ee7b0e7332ab78d5693361810e12642c8f76a73 Mon Sep 17 00:00:00 2001 From: acazares Date: Fri, 23 Jan 2026 12:23:09 -0600 Subject: [PATCH 36/55] Refactor FacturaImportacionMexService for improved readability and maintainability - Organized import statements for better clarity. - Enhanced formatting and consistency in code style. - Improved error handling and default values in _obtener_datos_cliente method. - Streamlined data fetching and processing logic in obtener_datos method. - Added unit description fetching from UnitOfMeasure model in obtener_datos method. - Updated comments for better understanding of the code flow. --- .../importacion/consolidados/mex/service.py | 714 ++++++++++++------ .../importacion/facturas/mex/service.py | 528 +++++++++---- 2 files changed, 881 insertions(+), 361 deletions(-) diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py index 5cff09b4..34cbc9ef 100644 --- a/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py @@ -10,18 +10,24 @@ from fastapi import HTTPException from sqlalchemy.orm import Session # --- MODELOS --- -from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics, InvoiceComplianceMx +from api.v1.modules.a76.invoices.models import ( + InvoiceHeader, + InvoiceLogistics, + InvoiceComplianceMx, +) 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.line_items.models import LineItem from api.v1.modules.a76.clients_and_providers.models import ( - ClientProvider, ClientProviderAddress, ClientProviderPrograms + ClientProvider, + ClientProviderAddress, + ClientProviderPrograms, ) from api.v1.modules.a76.parts.models import Part from api.v1.modules.a76.pedmientos.models import Pedimentos from api.v1.modules.a76.general_catalogs.company.models import Company from api.v1.modules.a76.customs_brokers.models import CustomsBroker -from api.v1.modules.a76.items.models import Item +from api.v1.modules.a76.items.models import Item # --- TRANSPORTATION MODELS --- from api.v1.modules.a76.transportation.transporters.models import Transporter @@ -32,20 +38,27 @@ from api.v1.modules.a76.transportation.drivers.models import Driver # --- MODELO DE FRACCIONES --- from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction +# --- MODELO DE UNIDADES DE MEDIDA --- +from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure + # --- SCHEMAS --- from .schemas import ( - ClienteSchema, PartidaSchema, TotalesSchema, - FacturaSchema, FacturaImportacionCompleta + ClienteSchema, + PartidaSchema, + TotalesSchema, + FacturaSchema, + FacturaImportacionCompleta, ) + class ConsolidadoImportacionMexService: def __init__(self): self.template_dir = Path(__file__).parent.parent / "templates" self.jinja_env = Environment( loader=FileSystemLoader(self.template_dir), - autoescape=select_autoescape(['html', 'xml']) + autoescape=select_autoescape(["html", "xml"]), ) - self.template = self.jinja_env.get_template('cons_mex_ver.html') + self.template = self.jinja_env.get_template("cons_mex_ver.html") def _get_wkhtmltopdf_config(self): path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf" @@ -54,28 +67,49 @@ class ConsolidadoImportacionMexService: return pdfkit.configuration(wkhtmltopdf=path) def formatear_numero(self, valor, decimales: int = 2): - if valor is None: return 0.0 + if valor is None: + return 0.0 try: return round(float(valor), decimales) - except: return 0.0 + except: + return 0.0 def _format_fraccion_fallback(self, fraccion_raw: str) -> str: if not fraccion_raw or len(fraccion_raw) < 8: return fraccion_raw return f"{fraccion_raw[:4]}.{fraccion_raw[4:6]}.{fraccion_raw[6:]}" - def _obtener_datos_cliente(self, db: Session, client_id: int, rol: str) -> ClienteSchema: + def _obtener_datos_cliente( + self, db: Session, client_id: int, rol: str + ) -> ClienteSchema: main = db.query(ClientProvider).filter(ClientProvider.id == client_id).first() if not main: - return ClienteSchema(header=rol, nombre="Desconocido", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="MEX") - - addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first() - prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first() + return ClienteSchema( + header=rol, + nombre="Desconocido", + direccion="", + tax_id="", + codigo_postal="", + ciudad="", + estado="", + pais="MEX", + ) + + addr = ( + db.query(ClientProviderAddress) + .filter(ClientProviderAddress.client_id == client_id) + .first() + ) + prog = ( + db.query(ClientProviderPrograms) + .filter(ClientProviderPrograms.client_id == client_id) + .first() + ) return ClienteSchema( header=rol, nombre=(main.name or main.short_name) or "S/N", - direccion=(addr.streets or "") if addr else "", + direccion=(addr.streets or "") if addr else "", num_exterior=(addr.exterior_number or "") if addr else "", num_interior=(addr.interior_number or "") if addr else "", colonia=(addr.neighborhood or "") if addr else "", @@ -83,48 +117,115 @@ class ConsolidadoImportacionMexService: ciudad=(addr.city or "") if addr else "", estado=(addr.state or "") if addr else "", pais=(addr.country or "MEX") if addr else "MEX", - tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""), - programa="IMMEX" if (prog and prog.program) else "", - autorizacion=prog.program_number if prog else "", - prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "", - reg_emp=prog.val_certified_company_registry if (prog and hasattr(prog, 'val_certified_company_registry')) else ( - prog.certified_company_registry if (prog and prog.certified_company_registry) else "" + tax_id=( + prog.tax_id + if (prog and prog.tax_id) + else (getattr(main, "rfc", "") or "") + ), + programa="IMMEX" if (prog and prog.program) else "", + autorizacion=prog.program_number if prog else "", + prosec=( + prog.prosec_authorization + if (prog and prog.prosec and prog.prosec_authorization) + else "" + ), + reg_emp=( + prog.val_certified_company_registry + if (prog and hasattr(prog, "val_certified_company_registry")) + else ( + prog.certified_company_registry + if (prog and prog.certified_company_registry) + else "" + ) + ), + cert=( + prog.is_certified_company + if (prog and prog.is_certified_company) + else "" ), - cert=prog.is_certified_company if (prog and prog.is_certified_company) else "" ) - def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> FacturaImportacionCompleta: + def obtener_datos( + self, + db: Session, + invoice_id: int, + company_id: int, + progress_callback: Optional[Callable] = None, + ) -> FacturaImportacionCompleta: try: - if progress_callback: progress_callback(10, "Buscando factura...") - header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first() - if not header: raise HTTPException(status_code=404, detail="Factura no encontrada") + if progress_callback: + progress_callback(10, "Buscando factura...") + header = ( + db.query(InvoiceHeader) + .filter( + InvoiceHeader.id == invoice_id, + InvoiceHeader.company_id == company_id, + ) + .first() + ) + if not header: + raise HTTPException(status_code=404, detail="Factura no encontrada") - compliance = header.compliance_mx + compliance = header.compliance_mx logistics = header.logistics if header.logistics else None financials = header.financials if header.financials else None - if progress_callback: progress_callback(20, "Obteniendo datos de pedimento...") - pedimento_id = compliance.pedimento_id if (compliance and compliance.pedimento_id) else header.related_doc_id - pedimento = db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() if pedimento_id else None - - if progress_callback: progress_callback(30, "Obteniendo cliente y proveedor...") + if progress_callback: + progress_callback(20, "Obteniendo datos de pedimento...") + pedimento_id = ( + compliance.pedimento_id + if (compliance and compliance.pedimento_id) + else header.related_doc_id + ) + pedimento = ( + db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() + if pedimento_id + else None + ) + + if progress_callback: + progress_callback(30, "Obteniendo cliente y proveedor...") proveedor_id = compliance.provider_id if compliance else None - cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:") if proveedor_id else ClienteSchema(header="Proveedor", nombre="No Asignado", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="") + cliente_proveedor = ( + self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:") + if proveedor_id + else ClienteSchema( + header="Proveedor", + nombre="No Asignado", + direccion="", + tax_id="", + codigo_postal="", + ciudad="", + estado="", + pais="", + ) + ) nombre_agente = "" if compliance and compliance.customs_broker_id: - broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first() - if broker: nombre_agente = broker.name + broker = ( + db.query(CustomsBroker) + .filter(CustomsBroker.id == compliance.customs_broker_id) + .first() + ) + if broker: + nombre_agente = broker.name company = db.query(Company).filter(Company.id == header.company_id).first() # Datos Default (Company/Importer) - Used for fallback or Right Side (Enviado A) # Default Header (Company) cliente_default = ClienteSchema( header="Importer / Consignee:", - nombre=getattr(company, 'name', "Empresa Local"), + nombre=getattr(company, "name", "Empresa Local"), direccion="DOMICILIO FISCAL", - num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX", - tax_id=getattr(company, 'rfc', ""), - programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "") + num_exterior="", + colonia="", + codigo_postal="", + ciudad="", + estado="", + pais="MEX", + tax_id=getattr(company, "rfc", ""), + programa=getattr(company, "program", "IMMEX"), + autorizacion=getattr(company, "program_number", ""), ) # Left Side Logic (Consignatario / Sold To) @@ -136,34 +237,49 @@ class ConsolidadoImportacionMexService: clean_header = "Consignee / Consignatario:" else: clean_header = "Sold To / Vendido a:" - - cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header) - + + cliente_vendido = self._obtener_datos_cliente( + db, compliance.sold_to_id, clean_header + ) + # Right Side Logic (Enviado A / Shipped To) cliente_enviado = cliente_default if compliance and compliance.shipped_to_id: # Map to Shipped To / Enviado a clean_header_shipped = "Shipped To / Enviado a:" - - # Fetch client data - cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped) - remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else "" - acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A" + # Fetch client data + cliente_enviado = self._obtener_datos_cliente( + db, compliance.shipped_to_id, clean_header_shipped + ) + + remesa_valor = ( + str(compliance.remesa) if (compliance and compliance.remesa) else "" + ) + acuse_valor = ( + str(compliance.edocument) + if (compliance and compliance.edocument) + else "N/A" + ) patente_val = "" if pedimento and pedimento.license: patente_val = pedimento.license - elif 'broker' in locals() and broker and broker.license: + elif "broker" in locals() and broker and broker.license: patente_val = broker.license - # --- Transport Data Fetching --- - transporte_txt = str(logistics.transport_type) if (logistics and logistics.transport_type) else "" + transporte_txt = ( + str(logistics.transport_type) + if (logistics and logistics.transport_type) + else "" + ) num_transporte_val = (logistics.trailer_num or "") if logistics else "" - + # Init values - placas_val = (logistics.license_plate or "") if logistics else "" # Placas Tracto + placas_val = ( + (logistics.license_plate or "") if logistics else "" + ) # Placas Tracto placas_remolque_val = "" transportista_val = (logistics.carrier_id or "") if logistics else "" caat_val = "" @@ -177,54 +293,74 @@ class ConsolidadoImportacionMexService: if logistics: # 1. Transporter (CAAT / SCAC) if logistics.carrier_id: - transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first() + transporter_obj = ( + db.query(Transporter) + .filter(Transporter.transporter_key == logistics.carrier_id) + .first() + ) if transporter_obj: caat_val = transporter_obj.caat_code or "" - scac_val = transporter_obj.transport_code or "" # Mapping transport_code to SCAC + scac_val = ( + transporter_obj.transport_code or "" + ) # Mapping transport_code to SCAC transportista_val = transporter_obj.name or logistics.carrier_id # Clarion Logic: Name first # Line 1: Name transport_lines.append(transporter_obj.name or "") - + # Line 2: Streets if transporter_obj.streets: transport_lines.append(transporter_obj.streets) - + # Line 3: City, State, Country loc_line = "" if transporter_obj.city: - loc_line = transporter_obj.city - if transporter_obj.state: - loc_line += f", {transporter_obj.state}, " - else: - loc_line += ", " + loc_line = transporter_obj.city + if transporter_obj.state: + loc_line += f", {transporter_obj.state}, " + else: + loc_line += ", " else: - if transporter_obj.state: - loc_line = f"{transporter_obj.state}," - - country_desc = transporter_obj.country or "" + if transporter_obj.state: + loc_line = f"{transporter_obj.state}," + + country_desc = transporter_obj.country or "" if loc_line: loc_line += f" {country_desc}" elif country_desc: loc_line = country_desc - + if loc_line.strip(", "): transport_lines.append(loc_line) # 2. Vehicle (Placas Tracto) - Try transport_id first if logistics.transport_id: - veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first() + veh_obj = ( + db.query(Vehicle) + .filter(Vehicle.vehicle_key == logistics.transport_id) + .first() + ) if veh_obj: - placas_val = veh_obj.plate_number or placas_val - elif logistics.vehicle_num: # Fallback to vehicle_num if populated and transport_id failed/empty - veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.vehicle_num).first() - if veh_obj: - placas_val = veh_obj.plate_number or placas_val + placas_val = veh_obj.plate_number or placas_val + elif ( + logistics.vehicle_num + ): # Fallback to vehicle_num if populated and transport_id failed/empty + veh_obj = ( + db.query(Vehicle) + .filter(Vehicle.vehicle_key == logistics.vehicle_num) + .first() + ) + if veh_obj: + placas_val = veh_obj.plate_number or placas_val # 3. Trailer (Placas Remolque) if logistics.trailer_num: - trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first() + trl_obj = ( + db.query(Trailer) + .filter(Trailer.trailer_number == logistics.trailer_num) + .first() + ) if trl_obj: placas_remolque_val = trl_obj.plate_number or "" @@ -232,36 +368,40 @@ class ConsolidadoImportacionMexService: if logistics.carrier_id and logistics.driver_name: conductor_nombre = logistics.driver_name # Attempt to find driver by name + carrier - drv_obj = db.query(Driver).filter( - Driver.transporter_key == logistics.carrier_id, - Driver.driver_name == logistics.driver_name - ).first() + drv_obj = ( + db.query(Driver) + .filter( + Driver.transporter_key == logistics.carrier_id, + Driver.driver_name == logistics.driver_name, + ) + .first() + ) if drv_obj: - licencia_cond_val = drv_obj.license_number or "" - + licencia_cond_val = drv_obj.license_number or "" + # --- Building the rest of the block --- - + # Line 4: Driver if conductor_nombre: - transport_lines.append(f"Driver/Conductor: {conductor_nombre}") - + transport_lines.append(f"Driver/Conductor: {conductor_nombre}") + # Line 5: Conveyance / Transporte t_label = "Conveyance / Transporte" - t_val = placas_val # Default to Truck Plate - + t_val = placas_val # Default to Truck Plate + if logistics.transport_type: ttype = str(logistics.transport_type).lower() if "caja" in ttype or "trailer" in ttype: t_label = "Trailer / Caja" t_val = placas_remolque_val or num_transporte_val elif "placa" in ttype: - t_label = "Plates / Placas" + t_label = "Plates / Placas" elif "camion" in ttype or "truck" in ttype: - t_label = "Truck / Camión" - + t_label = "Truck / Camión" + if t_val: - transport_lines.append(f"{t_label}: {t_val}") - + transport_lines.append(f"{t_label}: {t_val}") + # Line 6: SCAC / CAAT codes_line = "" if scac_val: @@ -271,9 +411,9 @@ class ConsolidadoImportacionMexService: codes_line += f", CAAT Code/Clave: {caat_val}" else: codes_line = f"CAAT Code/Clave: {caat_val}" - + if codes_line: - transport_lines.append(codes_line) + transport_lines.append(codes_line) # Join with newlines transport_block_str = "\n".join([l for l in transport_lines if l]) @@ -281,11 +421,23 @@ class ConsolidadoImportacionMexService: factura_schema = FacturaSchema( numero=header.invoice_number or "S/N", fecha=str(header.invoice_date) if header.invoice_date else "", - tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0), - moneda=getattr(header, 'currency', "USD") or "USD", + tipo_cambio=( + float(financials.exchange_rate) + if (financials and financials.exchange_rate) + else ( + float(pedimento.exchange_rate) + if pedimento and pedimento.exchange_rate + else 1.0 + ) + ), + moneda=getattr(header, "currency", "USD") or "USD", incoterm=(logistics.incoterm or "") if logistics else "", observaciones=header.observation_es or header.observation_en or "", - pedimento=f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" if pedimento else "", + pedimento=( + f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" + if pedimento + else "" + ), clave_pedimento=pedimento.pedimento_code if pedimento else "", regimen=header.document_type or "", patente=patente_val, @@ -298,68 +450,104 @@ class ConsolidadoImportacionMexService: caat=caat_val, scac=scac_val, licencia_conductor=licencia_cond_val, - aduana=compliance.aduana if (compliance and compliance.aduana) else (pedimento.customs_office[:2] if (pedimento and pedimento.customs_office) else ""), + aduana=( + compliance.aduana + if (compliance and compliance.aduana) + else ( + pedimento.customs_office[:2] + if (pedimento and pedimento.customs_office) + else "" + ) + ), precinto=(logistics.seal_number or "") if logistics else "", destino=(logistics.destination_goods or "") if logistics else "", - remesa=remesa_valor, acuse_electronico=acuse_valor, - representante_legal=getattr(company, 'responsible', "") or "", - nombre_empresa=getattr(company, 'name', "") or "", - transportista_info=transport_block_str + remesa=remesa_valor, + acuse_electronico=acuse_valor, + representante_legal=getattr(company, "responsible", "") or "", + nombre_empresa=getattr(company, "name", "") or "", + transportista_info=transport_block_str, ) - - if progress_callback: progress_callback(50, "Procesando partidas...") - + + if progress_callback: + progress_callback(50, "Procesando partidas...") + # --- Fetch Lines from SINGLE Invoice (Requested Scope Change) --- # User requested to ONLY report items from the specific selected invoice, # NOT consolidating all invoices from the same Pedimento. target_invoice_ids = [header.id] - - lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter( - Item.invoice_id.in_(target_invoice_ids) - ).all() + + lines = ( + db.query(LineItem) + .join(Item, LineItem.item_id == Item.id) + .filter(Item.invoice_id.in_(target_invoice_ids)) + .all() + ) partidas_list = [] - + # --- AGGREGATION LOGIC (Refactoring based on Clarion) --- from collections import defaultdict + # Key: (us_fraction_code, origin_country) # Value: Object with accumulated fields - aggregated_data = defaultdict(lambda: { - "qty": 0.0, - "net_weight_kgs": 0.0, - "gross_weight_kgs": 0.0, - "total_value": 0.0, - "est_total_value": 0.0, - "description": "", - "advalorem_txt": "0%", - "unit_measure": "PZA", # Placeholder, takes first one found - "hts_code_print": "", - "part_number_display": "CONSOLIDADO" - }) + aggregated_data = defaultdict( + lambda: { + "qty": 0.0, + "net_weight_kgs": 0.0, + "gross_weight_kgs": 0.0, + "total_value": 0.0, + "est_total_value": 0.0, + "description": "", + "advalorem_txt": "0%", + "unit_measure": "PZA", # Placeholder, takes first one found + "hts_code_print": "", + "part_number_display": "CONSOLIDADO", + } + ) # Pre-fetch US Tariff Fractions for efficiency if possible, or query inside loop (caching recommended) - # For simplicity in this step, we query inside or rely on Part data. + # For simplicity in this step, we query inside or rely on Part data. # Ideally fetch USTariffFraction from DB based on Part.us_fraction # --- Optimización: Cargar Facturas en Memoria --- - invoices_list = db.query(InvoiceHeader).filter(InvoiceHeader.id.in_(target_invoice_ids)).all() + invoices_list = ( + db.query(InvoiceHeader) + .filter(InvoiceHeader.id.in_(target_invoice_ids)) + .all() + ) invoice_map = {inv.id: inv for inv in invoices_list} - from api.v1.modules.a76.general_catalogs.us_tariff_fractions.models import USTariffFraction + from api.v1.modules.a76.general_catalogs.us_tariff_fractions.models import ( + USTariffFraction, + ) for line in lines: - qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first() - fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first() + qty = ( + db.query(LineQuantity) + .filter(LineQuantity.item_line_id == line.id) + .first() + ) + fin = ( + db.query(LineFinancial) + .filter(LineFinancial.item_line_id == line.id) + .first() + ) part_master = db.query(Part).filter(Part.id == line.part_number).first() - + # --- Resolver Identificadores (MOVED INSIDE MAIN LOOP) --- us_fraction_raw = "" origin_final = "MEX" - + if part_master: - origin_final = part_master.fa_data.origin_country if (part_master.fa_data and part_master.fa_data.origin_country) else "MEX" - us_fraction_raw = part_master.us_fraction if part_master.us_fraction else "" - + origin_final = ( + part_master.fa_data.origin_country + if (part_master.fa_data and part_master.fa_data.origin_country) + else "MEX" + ) + us_fraction_raw = ( + part_master.us_fraction if part_master.us_fraction else "" + ) + # Key for aggregation us_frac_clean = us_fraction_raw.strip() agg_key = (us_frac_clean, origin_final) @@ -367,13 +555,13 @@ class ConsolidadoImportacionMexService: q_line = float(qty.quantity) if (qty and qty.quantity) else 0.0 nw_line = float(qty.net_weight) if qty else 0.0 gw_line = float(qty.gross_weight) if qty else 0.0 - + # --- Multi-Currency Normalization Logic --- # Determine Line Currency context # Use manual lookup instead of specific attribute invoice_id = line.item.invoice_id if line.item else None line_invoice = invoice_map.get(invoice_id) if invoice_id else None - + line_currency_is_mxn = False line_exchange_rate = 1.0 @@ -381,30 +569,36 @@ class ConsolidadoImportacionMexService: # Check explicit currency string AND code curr_desc = str(line_invoice.financials.currency or "").upper() curr_code = str(line_invoice.financials.currency_type or "").upper() - + # Logic: It is MXN if description says PESO/MX or code is MXN/MN - is_mx_desc = ("MX" in curr_desc or "PESO" in curr_desc) - is_mx_code = ("MXN" in curr_code or "MN" == curr_code) - + is_mx_desc = "MX" in curr_desc or "PESO" in curr_desc + is_mx_code = "MXN" in curr_code or "MN" == curr_code + # But if code allows clarifying USD, prioritize that - is_usd_code = ("USD" in curr_code) - + is_usd_code = "USD" in curr_code + if is_usd_code: line_currency_is_mxn = False elif is_mx_code or is_mx_desc: line_currency_is_mxn = True else: - line_currency_is_mxn = False # Default to Foreign/USD if unsure + line_currency_is_mxn = False # Default to Foreign/USD if unsure + + line_exchange_rate = float( + line_invoice.financials.exchange_rate or 1.0 + ) - line_exchange_rate = float(line_invoice.financials.exchange_rate or 1.0) - # Target Report Currency - report_is_mxn = (factura_schema.moneda == 'MXN') + report_is_mxn = factura_schema.moneda == "MXN" # DEBUG LOGGING if line_invoice: - print(f"DEBUG: Line {line.id} - Inv {line_invoice.id} - CurrDesc: '{curr_desc}' Code: '{curr_code}' - Rate: {line_exchange_rate}") - print(f"DEBUG: Is MXN Context? {line_currency_is_mxn}. Report is MXN? {report_is_mxn}") + print( + f"DEBUG: Line {line.id} - Inv {line_invoice.id} - CurrDesc: '{curr_desc}' Code: '{curr_code}' - Rate: {line_exchange_rate}" + ) + print( + f"DEBUG: Is MXN Context? {line_currency_is_mxn}. Report is MXN? {report_is_mxn}" + ) # --- Get Financials for Line (Raw) --- v_total_raw = 0.0 @@ -421,11 +615,11 @@ class ConsolidadoImportacionMexService: total_comm = float(fin.total_commercial_value or 0.0) unit_comm_usd = float(fin.unit_cost_commercial_usd or 0.0) unit_usd = float(fin.unit_cost_usd or 0.0) - + # 1. Direct Total: Custom Value (Best case) if val_usd > 0: v_total_raw = val_usd - + # 2. Direct Total: Commercial Total elif total_comm > 0: # Convert if invoice currency is MXN @@ -433,18 +627,18 @@ class ConsolidadoImportacionMexService: v_total_raw = total_comm / line_exchange_rate else: v_total_raw = total_comm - + # 3. Calc from Commercial Unit Cost (Safe Fallback) elif unit_comm_usd > 0 and q_line > 0: v_total_raw = unit_comm_usd * q_line - + # 4. Calc from Customs Unit Cost (Unknown Risk - Last Resort) elif unit_usd > 0 and q_line > 0: v_total_raw = unit_usd * q_line - + else: v_total_raw = 0.0 - + # NOTE: v_unitario_raw is left as 0.0 here. # It will be calculated in the 'Calculation Gap Fill' block below: # v_unitario_raw = v_total_raw / q_line @@ -474,40 +668,70 @@ class ConsolidadoImportacionMexService: # else: # v_total_line = 0.0 # v_unitario_line = 0.0 - + print(f"DEBUG: ValRaw: {v_total_raw} -> ValFinal: {v_total_line}") - + # --- Resolve Fraction Details (Description & Rate) --- # Only if this is the first time we see this key (or overwrite, doesn't matter much as they should be same for same HTS) - # We check if we already have description set to avoid re-querying if we want optimization, + # We check if we already have description set to avoid re-querying if we want optimization, # but relying on DB query per distinct fraction is safer. - + current_agg = aggregated_data[agg_key] - + if not current_agg["description"]: - us_frac_db = db.query(USTariffFraction).filter(USTariffFraction.code == us_frac_clean).first() + us_frac_db = ( + db.query(USTariffFraction) + .filter(USTariffFraction.code == us_frac_clean) + .first() + ) if us_frac_db: - current_agg["description"] = us_frac_db.description or "Sin Descripción" - # Parse AdValorem from DB if available, else 0 ?? - # Creating logical placeholder. The provided Clarion code used `FraAme.Adv` - adv_val = us_frac_db.ad_valorem # Assuming field exists based on viewing file later? - # Wait, in us-tariff-fractions.ts I saw `ad_valorem: number | null`. - current_agg["advalorem_txt"] = f"{adv_val}%" if adv_val is not None else "0%" + current_agg["description"] = ( + us_frac_db.description or "Sin Descripción" + ) + # Parse AdValorem from DB if available, else 0 ?? + # Creating logical placeholder. The provided Clarion code used `FraAme.Adv` + adv_val = ( + us_frac_db.ad_valorem + ) # Assuming field exists based on viewing file later? + # Wait, in us-tariff-fractions.ts I saw `ad_valorem: number | null`. + current_agg["advalorem_txt"] = ( + f"{adv_val}%" if adv_val is not None else "0%" + ) else: - current_agg["description"] = part_master.description_spanish if part_master else "S/D" + current_agg["description"] = ( + part_master.description_spanish if part_master else "S/D" + ) current_agg["hts_code_print"] = us_frac_clean - current_agg["unit_measure"] = qty.weight_unit if qty else "KGS" # Default to first found + + # Obtener descripción de la unidad de medida desde la tabla a76.item_lines + if line.unit_of_measure: + uom = ( + db.query(UnitOfMeasure) + .filter( + UnitOfMeasure.id == line.unit_of_measure, + UnitOfMeasure.company_id == company_id, + ) + .first() + ) + current_agg["unit_measure"] = ( + uom.description + if (uom and uom.description) + else (uom.code if uom else "KGS") + ) + else: + current_agg["unit_measure"] = "KGS" # Default fallback # --- Calculate Estimated Tax for this Line --- rate = 0.0 try: clean_adv = current_agg["advalorem_txt"].replace("%", "").strip() rate = float(clean_adv) / 100.0 - except: rate = 0.0 - + except: + rate = 0.0 + v_est_line = v_total_line * rate - + # --- Accumulate --- current_agg["qty"] += q_line current_agg["net_weight_kgs"] += nw_line @@ -515,51 +739,59 @@ class ConsolidadoImportacionMexService: current_agg["total_value"] += v_total_line current_agg["est_total_value"] += v_est_line - # --- Convert Aggregated Data to Schema List --- partidas_list = [] - + for (hts, origin), data in aggregated_data.items(): - + # Calculate Unit Price based on Total Value / Total Qty unit_price = 0.0 if data["qty"] > 0: unit_price = data["total_value"] / data["qty"] - - partidas_list.append(PartidaSchema( - numero_parte="VARIOS", # Or empty - descripcion=data["description"], - fraccion=data["hts_code_print"], - origen=origin, - advalorem=data["advalorem_txt"], - preferencia="General", - cantidad_importacion=self.formatear_numero(data["qty"]), - unidad_medida=data["unit_measure"], - cantidad_bultos=0, # Summing bultos might be tricky if not homogeneous, check logic later - clave_bultos="", - peso_neto=self.formatear_numero(data["net_weight_kgs"]), - peso_bruto=self.formatear_numero(data["gross_weight_kgs"]), - valor_costo_unitario=self.formatear_numero(unit_price), - valor_total=self.formatear_numero(data["total_value"]), - valor_estimado=self.formatear_numero(data["est_total_value"]) - )) - + + partidas_list.append( + PartidaSchema( + numero_parte="VARIOS", # Or empty + descripcion=data["description"], + fraccion=data["hts_code_print"], + origen=origin, + advalorem=data["advalorem_txt"], + preferencia="General", + cantidad_importacion=self.formatear_numero(data["qty"]), + unidad_medida=data["unit_measure"], + cantidad_bultos=0, # Summing bultos might be tricky if not homogeneous, check logic later + clave_bultos="", + peso_neto=self.formatear_numero(data["net_weight_kgs"]), + peso_bruto=self.formatear_numero(data["gross_weight_kgs"]), + valor_costo_unitario=self.formatear_numero(unit_price), + valor_total=self.formatear_numero(data["total_value"]), + valor_estimado=self.formatear_numero(data["est_total_value"]), + ) + ) + # Sort by Fraction (HTS Code) partidas_list.sort(key=lambda x: x.fraccion) - totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio)) + totales = self.calcular_totales( + partidas_list, Decimal(factura_schema.tipo_cambio) + ) return FacturaImportacionCompleta( - cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido, - cliente_enviado=cliente_enviado, factura=factura_schema, - partidas=partidas_list, totales=totales + cliente_proveedor=cliente_proveedor, + cliente_vendido=cliente_vendido, + cliente_enviado=cliente_enviado, + factura=factura_schema, + partidas=partidas_list, + totales=totales, ) except Exception as e: print(f"Error Service A76: {e}") raise HTTPException(status_code=500, detail=f"Error: {str(e)}") - def calcular_totales(self, partidas: List[PartidaSchema], tipo_cambio: Decimal) -> TotalesSchema: + def calcular_totales( + self, partidas: List[PartidaSchema], tipo_cambio: Decimal + ) -> TotalesSchema: cant = sum(p.cantidad_importacion for p in partidas) valor = sum(p.valor_total for p in partidas) peso_n = sum(p.peso_neto for p in partidas) @@ -567,23 +799,41 @@ class ConsolidadoImportacionMexService: bultos = sum(p.cantidad_bultos for p in partidas) claves = [p.clave_bultos for p in partidas if p.clave_bultos] clave_comun = max(set(claves), key=claves.count) if claves else "" - if bultos > 1 and clave_comun and not clave_comun.endswith("S"): clave_comun += "S" - v_est = sum(p.valor_estimado for p in partidas if isinstance(p.valor_estimado, (int, float, Decimal))) - - tc = float(tipo_cambio) if tipo_cambio else 1.0 - return TotalesSchema( - cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun, - peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b), - valor_total_total=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0), - valor_estimado_total=self.formatear_numero(v_est) + if bultos > 1 and clave_comun and not clave_comun.endswith("S"): + clave_comun += "S" + v_est = sum( + p.valor_estimado + for p in partidas + if isinstance(p.valor_estimado, (int, float, Decimal)) ) - def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None) -> Tuple[bytes, str, str]: - if progress_callback: progress_callback(5, "Iniciando servicio de reporte...") + tc = float(tipo_cambio) if tipo_cambio else 1.0 + return TotalesSchema( + cantidad_total=self.formatear_numero(cant), + bultos_total=bultos, + clave_bultos=clave_comun, + peso_neto_total=self.formatear_numero(peso_n), + peso_bruto_total=self.formatear_numero(peso_b), + valor_total_total=self.formatear_numero(valor), + valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0), + valor_estimado_total=self.formatear_numero(v_est), + ) + + def generar_factura_completa( + self, + db: Session, + invoice_id: int, + company_id: int, + formato: str = "pdf", + progress_callback: Optional[Callable] = None, + ) -> Tuple[bytes, str, str]: + if progress_callback: + progress_callback(5, "Iniciando servicio de reporte...") datos = self.obtener_datos(db, invoice_id, company_id, progress_callback) - - if progress_callback: progress_callback(80, "Renderizando plantilla...") - + + if progress_callback: + progress_callback(80, "Renderizando plantilla...") + # LOGO LOGIC logo_b64 = None try: @@ -592,7 +842,7 @@ class ConsolidadoImportacionMexService: comp_logo = db.query(Company).filter(Company.id == company_id).first() if comp_logo and comp_logo.logo: p = Path(comp_logo.logo) - + # Logic robusta de búsqueda (igual que en routes.py) target_path = p if not target_path.exists(): @@ -604,27 +854,49 @@ class ConsolidadoImportacionMexService: if target_path.exists(): with open(target_path, "rb") as image_file: - encoded_string = base64.b64encode(image_file.read()).decode('utf-8') + encoded_string = base64.b64encode(image_file.read()).decode( + "utf-8" + ) # Detect MIME type loosely mime = "image/png" - if target_path.suffix.lower() in ['.jpg', '.jpeg']: mime = "image/jpeg" + if target_path.suffix.lower() in [".jpg", ".jpeg"]: + mime = "image/jpeg" logo_b64 = f"data:{mime};base64,{encoded_string}" except Exception as e: print(f"Error loading logo: {e}") context = { - 'cliente_proveedor': datos.cliente_proveedor.model_dump(), 'cliente_vendido': datos.cliente_vendido.model_dump(), - 'cliente_enviado': datos.cliente_enviado.model_dump(), 'factura': datos.factura.model_dump(), - 'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump(), - 'logo_b64': logo_b64 + "cliente_proveedor": datos.cliente_proveedor.model_dump(), + "cliente_vendido": datos.cliente_vendido.model_dump(), + "cliente_enviado": datos.cliente_enviado.model_dump(), + "factura": datos.factura.model_dump(), + "partidas": [p.model_dump() for p in datos.partidas], + "totales": datos.totales.model_dump(), + "logo_b64": logo_b64, } html_content = self.template.render(**context) nombre = f"Consolidado_{datos.factura.numero}.{formato}" - if formato == "html": return html_content.encode('utf-8'), nombre, "text/html" - - if progress_callback: progress_callback(90, "Generando PDF final...") - options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None} - pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config()) - - if progress_callback: progress_callback(100, "Completado") + if formato == "html": + return html_content.encode("utf-8"), nombre, "text/html" + + if progress_callback: + progress_callback(90, "Generando PDF final...") + options = { + "page-size": "Letter", + "margin-top": "0.5in", + "margin-right": "0.5in", + "margin-bottom": "0.5in", + "margin-left": "0.5in", + "encoding": "UTF-8", + "enable-local-file-access": None, + } + pdf = pdfkit.from_string( + html_content, + False, + options=options, + configuration=self._get_wkhtmltopdf_config(), + ) + + if progress_callback: + progress_callback(100, "Completado") return pdf, nombre, "application/pdf" diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py index 200a3942..b6746120 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py @@ -10,18 +10,20 @@ from fastapi import HTTPException from sqlalchemy.orm import Session # --- MODELOS --- -from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics 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.line_items.models import LineItem from api.v1.modules.a76.clients_and_providers.models import ( - ClientProvider, ClientProviderAddress, ClientProviderPrograms + ClientProvider, + ClientProviderAddress, + ClientProviderPrograms, ) from api.v1.modules.a76.parts.models import Part from api.v1.modules.a76.pedmientos.models import Pedimentos from api.v1.modules.a76.general_catalogs.company.models import Company from api.v1.modules.a76.customs_brokers.models import CustomsBroker -from api.v1.modules.a76.items.models import Item +from api.v1.modules.a76.items.models import Item # --- TRANSPORTATION MODELS --- from api.v1.modules.a76.transportation.transporters.models import Transporter @@ -32,20 +34,27 @@ from api.v1.modules.a76.transportation.drivers.models import Driver # --- MODELO DE FRACCIONES --- from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction +# --- MODELO DE UNIDADES DE MEDIDA --- +from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure + # --- SCHEMAS --- from .schemas import ( - ClienteSchema, PartidaSchema, TotalesSchema, - FacturaSchema, FacturaImportacionCompleta + ClienteSchema, + PartidaSchema, + TotalesSchema, + FacturaSchema, + FacturaImportacionCompleta, ) + class FacturaImportacionMexService: def __init__(self): self.template_dir = Path(__file__).parent.parent / "templates" self.jinja_env = Environment( loader=FileSystemLoader(self.template_dir), - autoescape=select_autoescape(['html', 'xml']) + autoescape=select_autoescape(["html", "xml"]), ) - self.template = self.jinja_env.get_template('factura_mex_ver.html') + self.template = self.jinja_env.get_template("factura_mex_ver.html") def _get_wkhtmltopdf_config(self): path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf" @@ -54,28 +63,49 @@ class FacturaImportacionMexService: return pdfkit.configuration(wkhtmltopdf=path) def formatear_numero(self, valor, decimales: int = 2): - if valor is None: return 0.0 + if valor is None: + return 0.0 try: return round(float(valor), decimales) - except: return 0.0 + except: + return 0.0 def _format_fraccion_fallback(self, fraccion_raw: str) -> str: if not fraccion_raw or len(fraccion_raw) < 8: return fraccion_raw return f"{fraccion_raw[:4]}.{fraccion_raw[4:6]}.{fraccion_raw[6:]}" - def _obtener_datos_cliente(self, db: Session, client_id: int, rol: str) -> ClienteSchema: + def _obtener_datos_cliente( + self, db: Session, client_id: int, rol: str + ) -> ClienteSchema: main = db.query(ClientProvider).filter(ClientProvider.id == client_id).first() if not main: - return ClienteSchema(header=rol, nombre="Desconocido", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="MEX") - - addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first() - prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first() + return ClienteSchema( + header=rol, + nombre="Desconocido", + direccion="", + tax_id="", + codigo_postal="", + ciudad="", + estado="", + pais="MEX", + ) + + addr = ( + db.query(ClientProviderAddress) + .filter(ClientProviderAddress.client_id == client_id) + .first() + ) + prog = ( + db.query(ClientProviderPrograms) + .filter(ClientProviderPrograms.client_id == client_id) + .first() + ) return ClienteSchema( header=rol, nombre=(main.name or main.short_name) or "S/N", - direccion=(addr.streets or "") if addr else "", + direccion=(addr.streets or "") if addr else "", num_exterior=(addr.exterior_number or "") if addr else "", num_interior=(addr.interior_number or "") if addr else "", colonia=(addr.neighborhood or "") if addr else "", @@ -83,47 +113,114 @@ class FacturaImportacionMexService: ciudad=(addr.city or "") if addr else "", estado=(addr.state or "") if addr else "", pais=(addr.country or "MEX") if addr else "MEX", - tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""), - programa="IMMEX" if (prog and prog.program) else "", - autorizacion=prog.program_number if prog else "", - prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "", - reg_emp=prog.val_certified_company_registry if (prog and hasattr(prog, 'val_certified_company_registry')) else ( - prog.certified_company_registry if (prog and prog.certified_company_registry) else "" + tax_id=( + prog.tax_id + if (prog and prog.tax_id) + else (getattr(main, "rfc", "") or "") + ), + programa="IMMEX" if (prog and prog.program) else "", + autorizacion=prog.program_number if prog else "", + prosec=( + prog.prosec_authorization + if (prog and prog.prosec and prog.prosec_authorization) + else "" + ), + reg_emp=( + prog.val_certified_company_registry + if (prog and hasattr(prog, "val_certified_company_registry")) + else ( + prog.certified_company_registry + if (prog and prog.certified_company_registry) + else "" + ) + ), + cert=( + prog.is_certified_company + if (prog and prog.is_certified_company) + else "" ), - cert=prog.is_certified_company if (prog and prog.is_certified_company) else "" ) - def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> FacturaImportacionCompleta: + def obtener_datos( + self, + db: Session, + invoice_id: int, + company_id: int, + progress_callback: Optional[Callable] = None, + ) -> FacturaImportacionCompleta: try: - if progress_callback: progress_callback(10, "Buscando factura...") - header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first() - if not header: raise HTTPException(status_code=404, detail="Factura no encontrada") + if progress_callback: + progress_callback(10, "Buscando factura...") + header = ( + db.query(InvoiceHeader) + .filter( + InvoiceHeader.id == invoice_id, + InvoiceHeader.company_id == company_id, + ) + .first() + ) + if not header: + raise HTTPException(status_code=404, detail="Factura no encontrada") - compliance = header.compliance_mx + compliance = header.compliance_mx logistics = header.logistics if header.logistics else None financials = header.financials if header.financials else None - if progress_callback: progress_callback(20, "Obteniendo datos de pedimento...") - pedimento_id = compliance.pedimento_id if (compliance and compliance.pedimento_id) else header.related_doc_id - pedimento = db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() if pedimento_id else None - - if progress_callback: progress_callback(30, "Obteniendo cliente y proveedor...") + if progress_callback: + progress_callback(20, "Obteniendo datos de pedimento...") + pedimento_id = ( + compliance.pedimento_id + if (compliance and compliance.pedimento_id) + else header.related_doc_id + ) + pedimento = ( + db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() + if pedimento_id + else None + ) + + if progress_callback: + progress_callback(30, "Obteniendo cliente y proveedor...") proveedor_id = compliance.provider_id if compliance else None - cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:") if proveedor_id else ClienteSchema(header="Proveedor", nombre="No Asignado", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="") + cliente_proveedor = ( + self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:") + if proveedor_id + else ClienteSchema( + header="Proveedor", + nombre="No Asignado", + direccion="", + tax_id="", + codigo_postal="", + ciudad="", + estado="", + pais="", + ) + ) nombre_agente = "" if compliance and compliance.customs_broker_id: - broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first() - if broker: nombre_agente = broker.name + broker = ( + db.query(CustomsBroker) + .filter(CustomsBroker.id == compliance.customs_broker_id) + .first() + ) + if broker: + nombre_agente = broker.name company = db.query(Company).filter(Company.id == header.company_id).first() # Datos Default (Company/Importer) - Used for fallback or Right Side (Enviado A) cliente_default = ClienteSchema( header="Importador / consignatario:", - nombre=getattr(company, 'name', "Empresa Local"), + nombre=getattr(company, "name", "Empresa Local"), direccion="DOMICILIO FISCAL", - num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX", - tax_id=getattr(company, 'rfc', ""), - programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "") + num_exterior="", + colonia="", + codigo_postal="", + ciudad="", + estado="", + pais="MEX", + tax_id=getattr(company, "rfc", ""), + programa=getattr(company, "program", "IMMEX"), + autorizacion=getattr(company, "program_number", ""), ) # Left Side Logic (Consignatario / Sold To) @@ -131,34 +228,51 @@ class FacturaImportacionMexService: if compliance and compliance.sold_to_id: raw_header = compliance.sold_to_header or "CONSIGNATARIO" clean_header = raw_header.replace("_", " ").capitalize() + ":" - cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header) - + cliente_vendido = self._obtener_datos_cliente( + db, compliance.sold_to_id, clean_header + ) + # Right Side Logic (Enviado A / Shipped To) cliente_enviado = cliente_default if compliance and compliance.shipped_to_id: # Clean header: "enviado_a" -> "Enviado a:" raw_header_shipped = compliance.shipped_to_header or "DESTINATARIO" - clean_header_shipped = raw_header_shipped.replace("_", " ").capitalize() + ":" - - # Fetch client data - cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped) + clean_header_shipped = ( + raw_header_shipped.replace("_", " ").capitalize() + ":" + ) - remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else "" - acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A" + # Fetch client data + cliente_enviado = self._obtener_datos_cliente( + db, compliance.shipped_to_id, clean_header_shipped + ) + + remesa_valor = ( + str(compliance.remesa) if (compliance and compliance.remesa) else "" + ) + acuse_valor = ( + str(compliance.edocument) + if (compliance and compliance.edocument) + else "N/A" + ) patente_val = "" if pedimento and pedimento.license: patente_val = pedimento.license - elif 'broker' in locals() and broker and broker.license: + elif "broker" in locals() and broker and broker.license: patente_val = broker.license - # --- Transport Data Fetching --- - transporte_txt = str(logistics.transport_type) if (logistics and logistics.transport_type) else "" + transporte_txt = ( + str(logistics.transport_type) + if (logistics and logistics.transport_type) + else "" + ) num_transporte_val = (logistics.trailer_num or "") if logistics else "" - + # Init values - placas_val = (logistics.license_plate or "") if logistics else "" # Placas Tracto + placas_val = ( + (logistics.license_plate or "") if logistics else "" + ) # Placas Tracto placas_remolque_val = "" transportista_val = (logistics.carrier_id or "") if logistics else "" caat_val = "" @@ -168,46 +282,82 @@ class FacturaImportacionMexService: if logistics: # 1. Transporter (CAAT / SCAC) if logistics.carrier_id: - transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first() + transporter_obj = ( + db.query(Transporter) + .filter(Transporter.transporter_key == logistics.carrier_id) + .first() + ) if transporter_obj: caat_val = transporter_obj.caat_code or "" - scac_val = transporter_obj.transport_code or "" # Mapping transport_code to SCAC + scac_val = ( + transporter_obj.transport_code or "" + ) # Mapping transport_code to SCAC transportista_val = transporter_obj.name or logistics.carrier_id # 2. Vehicle (Placas Tracto) - Try transport_id first if logistics.transport_id: - veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first() + veh_obj = ( + db.query(Vehicle) + .filter(Vehicle.vehicle_key == logistics.transport_id) + .first() + ) if veh_obj: - placas_val = veh_obj.plate_number or placas_val - elif logistics.vehicle_num: # Fallback to vehicle_num if populated and transport_id failed/empty - veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.vehicle_num).first() - if veh_obj: - placas_val = veh_obj.plate_number or placas_val + placas_val = veh_obj.plate_number or placas_val + elif ( + logistics.vehicle_num + ): # Fallback to vehicle_num if populated and transport_id failed/empty + veh_obj = ( + db.query(Vehicle) + .filter(Vehicle.vehicle_key == logistics.vehicle_num) + .first() + ) + if veh_obj: + placas_val = veh_obj.plate_number or placas_val # 3. Trailer (Placas Remolque) if logistics.trailer_num: - trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first() + trl_obj = ( + db.query(Trailer) + .filter(Trailer.trailer_number == logistics.trailer_num) + .first() + ) if trl_obj: placas_remolque_val = trl_obj.plate_number or "" # 4. Driver (License) if logistics.carrier_id and logistics.driver_name: # Attempt to find driver by name + carrier - drv_obj = db.query(Driver).filter( - Driver.transporter_key == logistics.carrier_id, - Driver.driver_name == logistics.driver_name - ).first() + drv_obj = ( + db.query(Driver) + .filter( + Driver.transporter_key == logistics.carrier_id, + Driver.driver_name == logistics.driver_name, + ) + .first() + ) if drv_obj: - licencia_cond_val = drv_obj.license_number or "" + licencia_cond_val = drv_obj.license_number or "" factura_schema = FacturaSchema( numero=header.invoice_number or "S/N", fecha=str(header.invoice_date) if header.invoice_date else "", - tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0), - moneda=getattr(header, 'currency', "USD") or "USD", + tipo_cambio=( + float(financials.exchange_rate) + if (financials and financials.exchange_rate) + else ( + float(pedimento.exchange_rate) + if pedimento and pedimento.exchange_rate + else 1.0 + ) + ), + moneda=getattr(header, "currency", "USD") or "USD", incoterm=(logistics.incoterm or "") if logistics else "", observaciones=header.observation_es or header.observation_en or "", - pedimento=f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" if pedimento else "", + pedimento=( + f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" + if pedimento + else "" + ), clave_pedimento=pedimento.pedimento_code if pedimento else "", regimen=header.document_type or "", patente=patente_val, @@ -220,45 +370,74 @@ class FacturaImportacionMexService: caat=caat_val, scac=scac_val, licencia_conductor=licencia_cond_val, - aduana=compliance.aduana if (compliance and compliance.aduana) else (pedimento.customs_office[:2] if (pedimento and pedimento.customs_office) else ""), + aduana=( + compliance.aduana + if (compliance and compliance.aduana) + else ( + pedimento.customs_office[:2] + if (pedimento and pedimento.customs_office) + else "" + ) + ), precinto=(logistics.seal_number or "") if logistics else "", destino=(logistics.destination_goods or "") if logistics else "", - remesa=remesa_valor, acuse_electronico=acuse_valor + remesa=remesa_valor, + acuse_electronico=acuse_valor, + ) + + if progress_callback: + progress_callback(50, "Procesando partidas...") + lines = ( + db.query(LineItem) + .join(Item, LineItem.item_id == Item.id) + .filter(Item.invoice_id == header.id) + .all() ) - - if progress_callback: progress_callback(50, "Procesando partidas...") - lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(Item.invoice_id == header.id).all() partidas_list = [] - + for line in lines: - qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first() - fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first() + qty = ( + db.query(LineQuantity) + .filter(LineQuantity.item_line_id == line.id) + .first() + ) + fin = ( + db.query(LineFinancial) + .filter(LineFinancial.item_line_id == line.id) + .first() + ) part_master = db.query(Part).filter(Part.id == line.part_number).first() desc_final = "S/D" num_parte_final = str(line.part_number or "S/N") - fraccion_raw = "" + fraccion_raw = "" origen_final = "MEX" if part_master: - desc_final = part_master.description_spanish or part_master.description_english or "Sin Desc." + desc_final = ( + part_master.description_spanish + or part_master.description_english + or "Sin Desc." + ) num_parte_final = part_master.part_number fraccion_raw = part_master.fraction if part_master.fraction else "" - + # Fetch Origin from Master Catalog (FaPart) if part_master.fa_data and part_master.fa_data.origin_country: origen_final = part_master.fa_data.origin_country - fraccion_limpia = fraccion_raw.replace(".", "").strip() if fraccion_limpia: fraccion_limpia = fraccion_limpia[:8].zfill(8) # Consultar tabla tariff_fractions - fraccion_db = db.query(TariffFraction).filter(TariffFraction.code == fraccion_limpia).first() + fraccion_db = ( + db.query(TariffFraction) + .filter(TariffFraction.code == fraccion_limpia) + .first() + ) - - preferencia_txt = "General" + preferencia_txt = "General" advalorem_txt = "0%" fraccion_imprimir = fraccion_raw @@ -268,20 +447,20 @@ class FacturaImportacionMexService: if adv_db and adv_db.strip() not in ["0", "0.0", "0.00", ""]: advalorem_txt = adv_db if "%" in adv_db else f"{adv_db}%" else: - advalorem_txt = "0%" - + advalorem_txt = "0%" + fraccion_imprimir = fraccion_db.fraction or fraccion_raw else: - + fraccion_imprimir = self._format_fraccion_fallback(fraccion_limpia) # Logic to determine values - Prioritize Specific Currency Columns v_unitario = 0.0 v_total = 0.0 - + if fin: - is_mxn = (factura_schema.moneda == 'MXN') - + is_mxn = factura_schema.moneda == "MXN" + # 1. Try Specific Currency Columns First if is_mxn: v_unitario = float(fin.unit_cost_commercial_mxn or 0.0) @@ -292,50 +471,83 @@ class FacturaImportacionMexService: # 2. Fallback to Generic independently if Specific is 0 if not v_unitario: - v_unitario = float(fin.commercial_unit_cost or 0.0) - + v_unitario = float(fin.commercial_unit_cost or 0.0) + if not v_total: - v_total = float(fin.total_commercial_value or 0.0) + v_total = float(fin.total_commercial_value or 0.0) # 3. Calculate from Quantity if still missing cantidad = float(qty.quantity) if (qty and qty.quantity) else 0.0 - + if cantidad > 0: if v_unitario > 0 and v_total == 0: v_total = v_unitario * cantidad elif v_total > 0 and v_unitario == 0: v_unitario = v_total / cantidad - partidas_list.append(PartidaSchema( - numero_parte=num_parte_final, - descripcion=desc_final, - fraccion=fraccion_imprimir, - origen=origen_final, - advalorem=advalorem_txt, - preferencia=preferencia_txt, - cantidad_importacion=self.formatear_numero(qty.quantity if qty else 0), - unidad_medida=qty.weight_unit if qty else "PZA", - cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0, - clave_bultos=(qty.package_key or "") if qty else "", - peso_neto=self.formatear_numero(qty.net_weight if qty else 0), - peso_bruto=self.formatear_numero(qty.gross_weight if qty else 0), - valor_costo_unitario=self.formatear_numero(v_unitario), - valor_total=self.formatear_numero(v_total) - )) + # Obtener descripción de la unidad de medida desde la tabla a76.item_lines + unidad_desc = "" + if line.unit_of_measure: + uom = ( + db.query(UnitOfMeasure) + .filter( + UnitOfMeasure.id == line.unit_of_measure, + UnitOfMeasure.company_id == company_id, + ) + .first() + ) + if uom: + unidad_desc = uom.description or uom.code + else: + unidad_desc = "" - totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio)) + partidas_list.append( + PartidaSchema( + numero_parte=num_parte_final, + descripcion=desc_final, + fraccion=fraccion_imprimir, + origen=origen_final, + advalorem=advalorem_txt, + preferencia=preferencia_txt, + cantidad_importacion=self.formatear_numero( + qty.quantity if qty else 0 + ), + unidad_medida=unidad_desc, + cantidad_bultos=( + int(qty.package_quantity) + if qty and qty.package_quantity + else 0 + ), + clave_bultos=(qty.package_key or "") if qty else "", + peso_neto=self.formatear_numero(qty.net_weight if qty else 0), + peso_bruto=self.formatear_numero( + qty.gross_weight if qty else 0 + ), + valor_costo_unitario=self.formatear_numero(v_unitario), + valor_total=self.formatear_numero(v_total), + ) + ) + + totales = self.calcular_totales( + partidas_list, Decimal(factura_schema.tipo_cambio) + ) return FacturaImportacionCompleta( - cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido, - cliente_enviado=cliente_enviado, factura=factura_schema, - partidas=partidas_list, totales=totales + cliente_proveedor=cliente_proveedor, + cliente_vendido=cliente_vendido, + cliente_enviado=cliente_enviado, + factura=factura_schema, + partidas=partidas_list, + totales=totales, ) except Exception as e: print(f"Error Service A76: {e}") raise HTTPException(status_code=500, detail=f"Error: {str(e)}") - def calcular_totales(self, partidas: List[PartidaSchema], tipo_cambio: Decimal) -> TotalesSchema: + def calcular_totales( + self, partidas: List[PartidaSchema], tipo_cambio: Decimal + ) -> TotalesSchema: cant = sum(p.cantidad_importacion for p in partidas) valor = sum(p.valor_total for p in partidas) peso_n = sum(p.peso_neto for p in partidas) @@ -343,20 +555,34 @@ class FacturaImportacionMexService: bultos = sum(p.cantidad_bultos for p in partidas) claves = [p.clave_bultos for p in partidas if p.clave_bultos] clave_comun = max(set(claves), key=claves.count) if claves else "" - if bultos > 1 and clave_comun and not clave_comun.endswith("S"): clave_comun += "S" + if bultos > 1 and clave_comun and not clave_comun.endswith("S"): + clave_comun += "S" tc = float(tipo_cambio) if tipo_cambio else 1.0 - return TotalesSchema( - cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun, - peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b), - valor_total_total=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0) + return TotalesSchema( + cantidad_total=self.formatear_numero(cant), + bultos_total=bultos, + clave_bultos=clave_comun, + peso_neto_total=self.formatear_numero(peso_n), + peso_bruto_total=self.formatear_numero(peso_b), + valor_total_total=self.formatear_numero(valor), + valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0), ) - def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None) -> Tuple[bytes, str, str]: - if progress_callback: progress_callback(5, "Iniciando servicio de reporte...") + def generar_factura_completa( + self, + db: Session, + invoice_id: int, + company_id: int, + formato: str = "pdf", + progress_callback: Optional[Callable] = None, + ) -> Tuple[bytes, str, str]: + if progress_callback: + progress_callback(5, "Iniciando servicio de reporte...") datos = self.obtener_datos(db, invoice_id, company_id, progress_callback) - - if progress_callback: progress_callback(80, "Renderizando plantilla...") - + + if progress_callback: + progress_callback(80, "Renderizando plantilla...") + # LOGO LOGIC logo_b64 = None try: @@ -365,7 +591,7 @@ class FacturaImportacionMexService: comp_logo = db.query(Company).filter(Company.id == company_id).first() if comp_logo and comp_logo.logo: p = Path(comp_logo.logo) - + # Logic robusta de búsqueda (igual que en routes.py) target_path = p if not target_path.exists(): @@ -377,27 +603,49 @@ class FacturaImportacionMexService: if target_path.exists(): with open(target_path, "rb") as image_file: - encoded_string = base64.b64encode(image_file.read()).decode('utf-8') + encoded_string = base64.b64encode(image_file.read()).decode( + "utf-8" + ) # Detect MIME type loosely mime = "image/png" - if target_path.suffix.lower() in ['.jpg', '.jpeg']: mime = "image/jpeg" + if target_path.suffix.lower() in [".jpg", ".jpeg"]: + mime = "image/jpeg" logo_b64 = f"data:{mime};base64,{encoded_string}" except Exception as e: print(f"Error loading logo: {e}") context = { - 'cliente_proveedor': datos.cliente_proveedor.model_dump(), 'cliente_vendido': datos.cliente_vendido.model_dump(), - 'cliente_enviado': datos.cliente_enviado.model_dump(), 'factura': datos.factura.model_dump(), - 'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump(), - 'logo_b64': logo_b64 + "cliente_proveedor": datos.cliente_proveedor.model_dump(), + "cliente_vendido": datos.cliente_vendido.model_dump(), + "cliente_enviado": datos.cliente_enviado.model_dump(), + "factura": datos.factura.model_dump(), + "partidas": [p.model_dump() for p in datos.partidas], + "totales": datos.totales.model_dump(), + "logo_b64": logo_b64, } html_content = self.template.render(**context) nombre = f"Factura_{datos.factura.numero}.{formato}" - if formato == "html": return html_content.encode('utf-8'), nombre, "text/html" - - if progress_callback: progress_callback(90, "Generando PDF final...") - options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None} - pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config()) - - if progress_callback: progress_callback(100, "Completado") - return pdf, nombre, "application/pdf" \ No newline at end of file + if formato == "html": + return html_content.encode("utf-8"), nombre, "text/html" + + if progress_callback: + progress_callback(90, "Generando PDF final...") + options = { + "page-size": "Letter", + "margin-top": "0.5in", + "margin-right": "0.5in", + "margin-bottom": "0.5in", + "margin-left": "0.5in", + "encoding": "UTF-8", + "enable-local-file-access": None, + } + pdf = pdfkit.from_string( + html_content, + False, + options=options, + configuration=self._get_wkhtmltopdf_config(), + ) + + if progress_callback: + progress_callback(100, "Completado") + return pdf, nombre, "application/pdf" From 69aa2ba50040fbbebcacb2b17fee062c8ea4d85b Mon Sep 17 00:00:00 2001 From: acazares Date: Fri, 23 Jan 2026 12:23:34 -0600 Subject: [PATCH 37/55] fix(api): remove trailing slashes from company API endpoints for consistency --- .../src/lib/api/dashboard/a76/general_catalogs/company.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts index 369cde53..ee507945 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts @@ -19,8 +19,7 @@ export interface Company { responsible_last_name: string | null; responsible_mother_last_name: string | null; responsible_rfc?: string | null; - position?: string | null; - logo?: string | null; + position?: string | null; has_express_line?: boolean; is_service_company?: boolean; order_format_type?: string | null; @@ -107,7 +106,7 @@ export async function getCompanies( } export async function getCompany(id: number): Promise> { - return await api.get(`/v1/a76/company/${id}/`); + return await api.get(`/v1/a76/company/${id}`); } export async function createCompany(data: CompanyCreate): Promise> { @@ -115,7 +114,7 @@ export async function createCompany(data: CompanyCreate): Promise> { - return await api.put(`/v1/a76/company/${id}/`, data); + return await api.put(`/v1/a76/company/${id}`, data); } export async function deleteCompany(id: number): Promise> { From 709cb7ef0a8ba6c124c550e35c8600243966304e Mon Sep 17 00:00:00 2001 From: acazares Date: Fri, 23 Jan 2026 12:59:51 -0600 Subject: [PATCH 38/55] fix(company): remove redundant line for logo in Company interface --- frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts index 369cde53..1d17c5e3 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts @@ -19,8 +19,7 @@ export interface Company { responsible_last_name: string | null; responsible_mother_last_name: string | null; responsible_rfc?: string | null; - position?: string | null; - logo?: string | null; + position?: string | null; has_express_line?: boolean; is_service_company?: boolean; order_format_type?: string | null; From 52708c5ba0c11d1427e7cb0adf93dfe483143365 Mon Sep 17 00:00:00 2001 From: acazares Date: Fri, 23 Jan 2026 13:42:58 -0600 Subject: [PATCH 39/55] feat(api): integrate Sitar API configuration and authentication --- .env.example | 7 +++++-- .../general_catalogs/exchange_rate/services.py | 15 ++++++++------- backend/core/config.py | 11 +++++++---- docker-compose.yml | 6 +++--- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index c66ef877..8e04d045 100644 --- a/.env.example +++ b/.env.example @@ -21,14 +21,12 @@ KEYCLOAK_FRONTEND_CLIENT_ID=anexo76-frontend # ----- Backend ----- DEBUG=True ENVIRONMENT=development - CORE_DB_HOST=postgres-a76 CORE_DB_PORT=5432 CORE_DB_NAME=anexo76_core CORE_DB_USER=postgres CORE_DB_PASSWORD=postgres - # ----- Frontend ----- NODE_ENV=development VITE_API_URL=http://localhost:8000/api @@ -36,3 +34,8 @@ INTERNAL_API_URL=http://backend:8000/api VITE_KEYCLOAK_REALM=master VITE_KEYCLOAK_URL=http://localhost:8080 VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend + +# ----- Sitar API ----- +SITAR_API_URL=http://api.sitar.aduanasoft.com +SITAR_API_USER=your_sitar_user +SITAR_API_PASSWORD=your_sitar_password diff --git a/backend/api/v1/modules/a76/general_catalogs/exchange_rate/services.py b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/services.py index 94f6e1f4..ee33f82a 100644 --- a/backend/api/v1/modules/a76/general_catalogs/exchange_rate/services.py +++ b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/services.py @@ -145,10 +145,11 @@ class ExchangeRateService: return True @staticmethod - def _get_external_api_token(base_url, username, password) -> Optional[str]: + def _get_sitar_api_token(base_url, username, password) -> Optional[str]: """Helper to get authentication token from external API""" try: - login_url = f"{base_url}/auth/login" + print(base_url) + login_url = f"{base_url}/exchange-rate/auth/login" payload = {"username": username, "password": password} headers = {"Content-Type": "application/json"} @@ -176,9 +177,9 @@ class ExchangeRateService: Optional[float]: The exchange rate value if found, None otherwise. """ # API Credentials - API_BASE_URL = settings.EXTERNAL_API_URL - API_USER = settings.EXTERNAL_API_USER - API_PASS = settings.EXTERNAL_API_PASSWORD + API_BASE_URL = settings.SITAR_API_URL + API_USER = settings.SITAR_API_USER + API_PASS = settings.SITAR_API_PASSWORD if not API_USER or not API_PASS: print("ERROR: External API credentials not properly configured in settings") @@ -188,14 +189,14 @@ class ExchangeRateService: print(f"DEBUG: Fetching External API for date: {date_str}") # 1. Get Token - token = ExchangeRateService._get_external_api_token(API_BASE_URL, API_USER, API_PASS) + token = ExchangeRateService._get_sitar_api_token(API_BASE_URL, API_USER, API_PASS) if not token: print("Failed to obtain external API token") return None # 2. Fetch Exchange Rate # The API endpoint is /tipoCambio/{YYYY-MM-DD} - tc_endpoint = f"{API_BASE_URL}/tipoCambio/{date_str}" + tc_endpoint = f"{API_BASE_URL}/exchange-rate/tipoCambio/{date_str}" # Auth header: The API expects just the token string in common usage, but we try standard first # based on user feedback/code: 'Authorization:' . $token diff --git a/backend/core/config.py b/backend/core/config.py index 54301641..37c5d52f 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -43,12 +43,15 @@ class Settings(BaseSettings): LICENSE_CHECK_ENABLED: bool = True # External APIs - EXTERNAL_API_URL: str = "http://74.208.80.245:3000" - EXTERNAL_API_USER: str = "" - EXTERNAL_API_PASSWORD: str = "" + SITAR_API_URL: str = "api.sitar.aduanasoft.com:880" + SITAR_API_USER: str = "" + SITAR_API_PASSWORD: str = "" model_config = SettingsConfigDict( - env_file=[".env", "../.env"], case_sensitive=True, extra="ignore", env_file_encoding="utf-8" + env_file=[".env", "../.env"], + case_sensitive=True, + extra="ignore", + env_file_encoding="utf-8", ) @property diff --git a/docker-compose.yml b/docker-compose.yml index a9bcb766..498a6782 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -175,9 +175,9 @@ services: - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret} - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173,http://localhost:3000} - - EXTERNAL_API_URL=${EXTERNAL_API_URL} - - EXTERNAL_API_USER=${EXTERNAL_API_USER} - - EXTERNAL_API_PASSWORD=${EXTERNAL_API_PASSWORD} + - SITAR_API_URL=${SITAR_API_URL} + - SITAR_API_USER=${SITAR_API_USER} + - SITAR_API_PASSWORD=${SITAR_API_PASSWORD} ports: - "8000:8000" depends_on: From 2a82215a6a9147549ccf83b6bb7e3c155d23693d Mon Sep 17 00:00:00 2001 From: acazares Date: Fri, 23 Jan 2026 14:01:03 -0600 Subject: [PATCH 40/55] feat(docker): add SITAR API environment variables and configure backend uploads volume --- docker-compose.prod.yml | 8 +++++++- docker-compose.yml | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f06b8238..4eabf108 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -159,6 +159,9 @@ services: - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret} - CORS_ORIGINS=${CORS_ORIGINS:-https://anexo76-dev.aduanasoft.com,http://localhost:3000} + - SITAR_API_URL=${SITAR_API_URL} + - SITAR_API_USER=${SITAR_API_USER} + - SITAR_API_PASSWORD=${SITAR_API_PASSWORD} ports: - "3467:8000" depends_on: @@ -166,7 +169,8 @@ services: condition: service_healthy keycloak: condition: service_healthy - volumes: + volumes: + - backend_uploads:/app/uploads - ./scripts/backend-entrypoint.sh:/entrypoint.sh:ro networks: - backend-net @@ -286,6 +290,8 @@ volumes: driver: local backend_cache: driver: local + backend_uploads: + driver: local networks: backend-net: diff --git a/docker-compose.yml b/docker-compose.yml index 498a6782..041c1bdf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -188,6 +188,7 @@ services: volumes: - ./backend:/app - backend_cache:/app/__pycache__ + - backend_uploads:/app/uploads - ./scripts/backend-entrypoint.sh:/entrypoint.sh:ro networks: - backend-net @@ -298,6 +299,8 @@ volumes: driver: local backend_cache: driver: local + backend_uploads: + driver: local networks: backend-net: From 9e602a2337ff27613a682411425f329fc63895b6 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Fri, 23 Jan 2026 17:59:08 -0600 Subject: [PATCH 41/55] Se ingreso factura americana y modal con botoncitos --- .../consolidados/temporary/mex/service.py | 630 ++++++++++++++++++ .../importacion/facturas/mex/schemas.py | 1 + .../importacion/facturas/mex/service.py | 56 +- .../reports/importacion/facturas/routes.py | 3 +- .../a76/reports/importacion/facturas/task.py | 21 +- .../facturas/templates/factura_mex_ver.html | 2 +- .../facturas/templates/factura_usa_ver.html | 597 +++++++++++++++++ .../facturas/temporary/__init__.py | 0 .../facturas/temporary/mex/service.py | 403 +++++++++++ .../importacion/facturas/usa/service.py | 451 +++++++++++++ .../dashboard/a76/reports/reports-invoices.ts | 22 +- .../invoices/download-invoice-button.svelte | 89 +-- .../invoices/invoice-download-modal.svelte | 184 +++++ .../routes/dashboard/invoices/+page.svelte | 41 +- 14 files changed, 2396 insertions(+), 104 deletions(-) create mode 100644 backend/api/v1/modules/a76/reports/importacion/consolidados/temporary/mex/service.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_usa_ver.html create mode 100644 backend/api/v1/modules/a76/reports/importacion/facturas/temporary/__init__.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/facturas/temporary/mex/service.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py create mode 100644 frontend/src/lib/components/dashboard/invoices/invoice-download-modal.svelte diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/temporary/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/consolidados/temporary/mex/service.py new file mode 100644 index 00000000..5cff09b4 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/temporary/mex/service.py @@ -0,0 +1,630 @@ +import shutil +import base64 +import pdfkit +from pathlib import Path +from decimal import Decimal +from typing import Tuple, List, Callable, Optional + +from jinja2 import Environment, FileSystemLoader, select_autoescape +from fastapi import HTTPException +from sqlalchemy.orm import Session + +# --- MODELOS --- +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics, InvoiceComplianceMx +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.line_items.models import LineItem +from api.v1.modules.a76.clients_and_providers.models import ( + ClientProvider, ClientProviderAddress, ClientProviderPrograms +) +from api.v1.modules.a76.parts.models import Part +from api.v1.modules.a76.pedmientos.models import Pedimentos +from api.v1.modules.a76.general_catalogs.company.models import Company +from api.v1.modules.a76.customs_brokers.models import CustomsBroker +from api.v1.modules.a76.items.models import Item + +# --- TRANSPORTATION MODELS --- +from api.v1.modules.a76.transportation.transporters.models import Transporter +from api.v1.modules.a76.transportation.vehicles.models import Vehicle +from api.v1.modules.a76.transportation.trailers.models import Trailer +from api.v1.modules.a76.transportation.drivers.models import Driver + +# --- MODELO DE FRACCIONES --- +from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction + +# --- SCHEMAS --- +from .schemas import ( + ClienteSchema, PartidaSchema, TotalesSchema, + FacturaSchema, FacturaImportacionCompleta +) + +class ConsolidadoImportacionMexService: + def __init__(self): + self.template_dir = Path(__file__).parent.parent / "templates" + self.jinja_env = Environment( + loader=FileSystemLoader(self.template_dir), + autoescape=select_autoescape(['html', 'xml']) + ) + self.template = self.jinja_env.get_template('cons_mex_ver.html') + + def _get_wkhtmltopdf_config(self): + path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf" + if not Path(path).exists(): + raise RuntimeError("wkhtmltopdf no encontrado.") + return pdfkit.configuration(wkhtmltopdf=path) + + def formatear_numero(self, valor, decimales: int = 2): + if valor is None: return 0.0 + try: + return round(float(valor), decimales) + except: return 0.0 + + def _format_fraccion_fallback(self, fraccion_raw: str) -> str: + if not fraccion_raw or len(fraccion_raw) < 8: + return fraccion_raw + return f"{fraccion_raw[:4]}.{fraccion_raw[4:6]}.{fraccion_raw[6:]}" + + def _obtener_datos_cliente(self, db: Session, client_id: int, rol: str) -> ClienteSchema: + main = db.query(ClientProvider).filter(ClientProvider.id == client_id).first() + if not main: + return ClienteSchema(header=rol, nombre="Desconocido", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="MEX") + + addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first() + prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first() + + return ClienteSchema( + header=rol, + nombre=(main.name or main.short_name) or "S/N", + direccion=(addr.streets or "") if addr else "", + num_exterior=(addr.exterior_number or "") if addr else "", + num_interior=(addr.interior_number or "") if addr else "", + colonia=(addr.neighborhood or "") if addr else "", + codigo_postal=(addr.postal_code or "") if addr else "", + ciudad=(addr.city or "") if addr else "", + estado=(addr.state or "") if addr else "", + pais=(addr.country or "MEX") if addr else "MEX", + tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""), + programa="IMMEX" if (prog and prog.program) else "", + autorizacion=prog.program_number if prog else "", + prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "", + reg_emp=prog.val_certified_company_registry if (prog and hasattr(prog, 'val_certified_company_registry')) else ( + prog.certified_company_registry if (prog and prog.certified_company_registry) else "" + ), + cert=prog.is_certified_company if (prog and prog.is_certified_company) else "" + ) + + def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> FacturaImportacionCompleta: + try: + if progress_callback: progress_callback(10, "Buscando factura...") + header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first() + if not header: raise HTTPException(status_code=404, detail="Factura no encontrada") + + compliance = header.compliance_mx + logistics = header.logistics if header.logistics else None + financials = header.financials if header.financials else None + if progress_callback: progress_callback(20, "Obteniendo datos de pedimento...") + pedimento_id = compliance.pedimento_id if (compliance and compliance.pedimento_id) else header.related_doc_id + pedimento = db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() if pedimento_id else None + + if progress_callback: progress_callback(30, "Obteniendo cliente y proveedor...") + proveedor_id = compliance.provider_id if compliance else None + cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:") if proveedor_id else ClienteSchema(header="Proveedor", nombre="No Asignado", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="") + + nombre_agente = "" + if compliance and compliance.customs_broker_id: + broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first() + if broker: nombre_agente = broker.name + + company = db.query(Company).filter(Company.id == header.company_id).first() + # Datos Default (Company/Importer) - Used for fallback or Right Side (Enviado A) + # Default Header (Company) + cliente_default = ClienteSchema( + header="Importer / Consignee:", + nombre=getattr(company, 'name', "Empresa Local"), + direccion="DOMICILIO FISCAL", + num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX", + tax_id=getattr(company, 'rfc', ""), + programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "") + ) + + # Left Side Logic (Consignatario / Sold To) + cliente_vendido = cliente_default + if compliance and compliance.sold_to_id: + # Map known headers or default to Sold To / Vendido a + raw = (compliance.sold_to_header or "").upper() + if "CONSIGN" in raw: + clean_header = "Consignee / Consignatario:" + else: + clean_header = "Sold To / Vendido a:" + + cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header) + + # Right Side Logic (Enviado A / Shipped To) + cliente_enviado = cliente_default + if compliance and compliance.shipped_to_id: + # Map to Shipped To / Enviado a + clean_header_shipped = "Shipped To / Enviado a:" + + # Fetch client data + cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped) + + remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else "" + acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A" + + patente_val = "" + if pedimento and pedimento.license: + patente_val = pedimento.license + elif 'broker' in locals() and broker and broker.license: + patente_val = broker.license + + + # --- Transport Data Fetching --- + transporte_txt = str(logistics.transport_type) if (logistics and logistics.transport_type) else "" + num_transporte_val = (logistics.trailer_num or "") if logistics else "" + + # Init values + placas_val = (logistics.license_plate or "") if logistics else "" # Placas Tracto + placas_remolque_val = "" + transportista_val = (logistics.carrier_id or "") if logistics else "" + caat_val = "" + scac_val = "" + licencia_cond_val = "" + conductor_nombre = "" + + # Block Logic (Clarion Style) for transportista_info + transport_lines = [] + + if logistics: + # 1. Transporter (CAAT / SCAC) + if logistics.carrier_id: + transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first() + if transporter_obj: + caat_val = transporter_obj.caat_code or "" + scac_val = transporter_obj.transport_code or "" # Mapping transport_code to SCAC + transportista_val = transporter_obj.name or logistics.carrier_id + + # Clarion Logic: Name first + # Line 1: Name + transport_lines.append(transporter_obj.name or "") + + # Line 2: Streets + if transporter_obj.streets: + transport_lines.append(transporter_obj.streets) + + # Line 3: City, State, Country + loc_line = "" + if transporter_obj.city: + loc_line = transporter_obj.city + if transporter_obj.state: + loc_line += f", {transporter_obj.state}, " + else: + loc_line += ", " + else: + if transporter_obj.state: + loc_line = f"{transporter_obj.state}," + + country_desc = transporter_obj.country or "" + if loc_line: + loc_line += f" {country_desc}" + elif country_desc: + loc_line = country_desc + + if loc_line.strip(", "): + transport_lines.append(loc_line) + + # 2. Vehicle (Placas Tracto) - Try transport_id first + if logistics.transport_id: + veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first() + if veh_obj: + placas_val = veh_obj.plate_number or placas_val + elif logistics.vehicle_num: # Fallback to vehicle_num if populated and transport_id failed/empty + veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.vehicle_num).first() + if veh_obj: + placas_val = veh_obj.plate_number or placas_val + + # 3. Trailer (Placas Remolque) + if logistics.trailer_num: + trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first() + if trl_obj: + placas_remolque_val = trl_obj.plate_number or "" + + # 4. Driver (License) + if logistics.carrier_id and logistics.driver_name: + conductor_nombre = logistics.driver_name + # Attempt to find driver by name + carrier + drv_obj = db.query(Driver).filter( + Driver.transporter_key == logistics.carrier_id, + Driver.driver_name == logistics.driver_name + ).first() + if drv_obj: + licencia_cond_val = drv_obj.license_number or "" + + # --- Building the rest of the block --- + + # Line 4: Driver + if conductor_nombre: + transport_lines.append(f"Driver/Conductor: {conductor_nombre}") + + # Line 5: Conveyance / Transporte + t_label = "Conveyance / Transporte" + t_val = placas_val # Default to Truck Plate + + if logistics.transport_type: + ttype = str(logistics.transport_type).lower() + if "caja" in ttype or "trailer" in ttype: + t_label = "Trailer / Caja" + t_val = placas_remolque_val or num_transporte_val + elif "placa" in ttype: + t_label = "Plates / Placas" + elif "camion" in ttype or "truck" in ttype: + t_label = "Truck / Camión" + + if t_val: + transport_lines.append(f"{t_label}: {t_val}") + + # Line 6: SCAC / CAAT + codes_line = "" + if scac_val: + codes_line = f"SCAC Code/Clave: {scac_val}" + if caat_val: + if codes_line: + codes_line += f", CAAT Code/Clave: {caat_val}" + else: + codes_line = f"CAAT Code/Clave: {caat_val}" + + if codes_line: + transport_lines.append(codes_line) + + # Join with newlines + transport_block_str = "\n".join([l for l in transport_lines if l]) + + factura_schema = FacturaSchema( + numero=header.invoice_number or "S/N", + fecha=str(header.invoice_date) if header.invoice_date else "", + tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0), + moneda=getattr(header, 'currency', "USD") or "USD", + incoterm=(logistics.incoterm or "") if logistics else "", + observaciones=header.observation_es or header.observation_en or "", + pedimento=f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" if pedimento else "", + clave_pedimento=pedimento.pedimento_code if pedimento else "", + regimen=header.document_type or "", + patente=patente_val, + agente_aduanal=nombre_agente, + transporte=transporte_txt, + num_transporte=num_transporte_val, + placas=placas_val, + placas_remolque=placas_remolque_val, + transportista=transportista_val, + caat=caat_val, + scac=scac_val, + licencia_conductor=licencia_cond_val, + aduana=compliance.aduana if (compliance and compliance.aduana) else (pedimento.customs_office[:2] if (pedimento and pedimento.customs_office) else ""), + precinto=(logistics.seal_number or "") if logistics else "", + destino=(logistics.destination_goods or "") if logistics else "", + remesa=remesa_valor, acuse_electronico=acuse_valor, + representante_legal=getattr(company, 'responsible', "") or "", + nombre_empresa=getattr(company, 'name', "") or "", + transportista_info=transport_block_str + ) + + if progress_callback: progress_callback(50, "Procesando partidas...") + + # --- Fetch Lines from SINGLE Invoice (Requested Scope Change) --- + # User requested to ONLY report items from the specific selected invoice, + # NOT consolidating all invoices from the same Pedimento. + target_invoice_ids = [header.id] + + lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter( + Item.invoice_id.in_(target_invoice_ids) + ).all() + + partidas_list = [] + + # --- AGGREGATION LOGIC (Refactoring based on Clarion) --- + from collections import defaultdict + # Key: (us_fraction_code, origin_country) + # Value: Object with accumulated fields + aggregated_data = defaultdict(lambda: { + "qty": 0.0, + "net_weight_kgs": 0.0, + "gross_weight_kgs": 0.0, + "total_value": 0.0, + "est_total_value": 0.0, + "description": "", + "advalorem_txt": "0%", + "unit_measure": "PZA", # Placeholder, takes first one found + "hts_code_print": "", + "part_number_display": "CONSOLIDADO" + }) + + # Pre-fetch US Tariff Fractions for efficiency if possible, or query inside loop (caching recommended) + # For simplicity in this step, we query inside or rely on Part data. + # Ideally fetch USTariffFraction from DB based on Part.us_fraction + + # --- Optimización: Cargar Facturas en Memoria --- + invoices_list = db.query(InvoiceHeader).filter(InvoiceHeader.id.in_(target_invoice_ids)).all() + invoice_map = {inv.id: inv for inv in invoices_list} + + from api.v1.modules.a76.general_catalogs.us_tariff_fractions.models import USTariffFraction + + for line in lines: + qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first() + fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first() + part_master = db.query(Part).filter(Part.id == line.part_number).first() + + # --- Resolver Identificadores (MOVED INSIDE MAIN LOOP) --- + us_fraction_raw = "" + origin_final = "MEX" + + if part_master: + origin_final = part_master.fa_data.origin_country if (part_master.fa_data and part_master.fa_data.origin_country) else "MEX" + us_fraction_raw = part_master.us_fraction if part_master.us_fraction else "" + + # Key for aggregation + us_frac_clean = us_fraction_raw.strip() + agg_key = (us_frac_clean, origin_final) + # --- Weights & Qty --- + q_line = float(qty.quantity) if (qty and qty.quantity) else 0.0 + nw_line = float(qty.net_weight) if qty else 0.0 + gw_line = float(qty.gross_weight) if qty else 0.0 + + # --- Multi-Currency Normalization Logic --- + # Determine Line Currency context + # Use manual lookup instead of specific attribute + invoice_id = line.item.invoice_id if line.item else None + line_invoice = invoice_map.get(invoice_id) if invoice_id else None + + line_currency_is_mxn = False + line_exchange_rate = 1.0 + + if line_invoice and line_invoice.financials: + # Check explicit currency string AND code + curr_desc = str(line_invoice.financials.currency or "").upper() + curr_code = str(line_invoice.financials.currency_type or "").upper() + + # Logic: It is MXN if description says PESO/MX or code is MXN/MN + is_mx_desc = ("MX" in curr_desc or "PESO" in curr_desc) + is_mx_code = ("MXN" in curr_code or "MN" == curr_code) + + # But if code allows clarifying USD, prioritize that + is_usd_code = ("USD" in curr_code) + + if is_usd_code: + line_currency_is_mxn = False + elif is_mx_code or is_mx_desc: + line_currency_is_mxn = True + else: + line_currency_is_mxn = False # Default to Foreign/USD if unsure + + line_exchange_rate = float(line_invoice.financials.exchange_rate or 1.0) + + # Target Report Currency + report_is_mxn = (factura_schema.moneda == 'MXN') + + # DEBUG LOGGING + if line_invoice: + print(f"DEBUG: Line {line.id} - Inv {line_invoice.id} - CurrDesc: '{curr_desc}' Code: '{curr_code}' - Rate: {line_exchange_rate}") + print(f"DEBUG: Is MXN Context? {line_currency_is_mxn}. Report is MXN? {report_is_mxn}") + + # --- Get Financials for Line (Raw) --- + v_total_raw = 0.0 + v_unitario_raw = 0.0 + + if fin: + # NEW PRIORITY LOGIC (To avoid Inflation from dirty Customs Unit Cost) + # Priority 1: Use 'fin.value_usd' if it exists and > 0. + # Priority 2: Use 'fin.total_commercial_value' if it exists and > 0. + # Priority 3: Calculate using 'fin.unit_cost_commercial_usd' * 'q_line'. + # Priority 4: Only use 'fin.unit_cost_usd' * 'q_line' if commercial data is also missing. + + val_usd = float(fin.value_usd or 0.0) + total_comm = float(fin.total_commercial_value or 0.0) + unit_comm_usd = float(fin.unit_cost_commercial_usd or 0.0) + unit_usd = float(fin.unit_cost_usd or 0.0) + + # 1. Direct Total: Custom Value (Best case) + if val_usd > 0: + v_total_raw = val_usd + + # 2. Direct Total: Commercial Total + elif total_comm > 0: + # Convert if invoice currency is MXN + if line_currency_is_mxn and line_exchange_rate > 0: + v_total_raw = total_comm / line_exchange_rate + else: + v_total_raw = total_comm + + # 3. Calc from Commercial Unit Cost (Safe Fallback) + elif unit_comm_usd > 0 and q_line > 0: + v_total_raw = unit_comm_usd * q_line + + # 4. Calc from Customs Unit Cost (Unknown Risk - Last Resort) + elif unit_usd > 0 and q_line > 0: + v_total_raw = unit_usd * q_line + + else: + v_total_raw = 0.0 + + # NOTE: v_unitario_raw is left as 0.0 here. + # It will be calculated in the 'Calculation Gap Fill' block below: + # v_unitario_raw = v_total_raw / q_line + # This guarantees consistency and avoids the inflated unit cost record (198.00). + + # --- Calculation Gap Fill (Raw) --- + if q_line > 0: + if v_total_raw == 0 and v_unitario_raw > 0: + v_total_raw = v_unitario_raw * q_line + if v_unitario_raw == 0 and v_total_raw > 0: + v_unitario_raw = v_total_raw / q_line + + # --- Conversion to Report Currency (DISABLED TEMPORARILY) --- + # User confirms all are USD. Forcing direct sum to avoid logic errors in detection. + v_total_line = v_total_raw + v_unitario_line = v_unitario_raw + + # if report_is_mxn and not line_currency_is_mxn: + # # USD -> MXN + # v_total_line = v_total_raw * line_exchange_rate + # v_unitario_line = v_unitario_raw * line_exchange_rate + # elif not report_is_mxn and line_currency_is_mxn: + # # MXN -> USD + # if line_exchange_rate > 0: + # v_total_line = v_total_raw / line_exchange_rate + # v_unitario_line = v_unitario_raw / line_exchange_rate + # else: + # v_total_line = 0.0 + # v_unitario_line = 0.0 + + print(f"DEBUG: ValRaw: {v_total_raw} -> ValFinal: {v_total_line}") + + # --- Resolve Fraction Details (Description & Rate) --- + # Only if this is the first time we see this key (or overwrite, doesn't matter much as they should be same for same HTS) + # We check if we already have description set to avoid re-querying if we want optimization, + # but relying on DB query per distinct fraction is safer. + + current_agg = aggregated_data[agg_key] + + if not current_agg["description"]: + us_frac_db = db.query(USTariffFraction).filter(USTariffFraction.code == us_frac_clean).first() + if us_frac_db: + current_agg["description"] = us_frac_db.description or "Sin Descripción" + # Parse AdValorem from DB if available, else 0 ?? + # Creating logical placeholder. The provided Clarion code used `FraAme.Adv` + adv_val = us_frac_db.ad_valorem # Assuming field exists based on viewing file later? + # Wait, in us-tariff-fractions.ts I saw `ad_valorem: number | null`. + current_agg["advalorem_txt"] = f"{adv_val}%" if adv_val is not None else "0%" + else: + current_agg["description"] = part_master.description_spanish if part_master else "S/D" + + current_agg["hts_code_print"] = us_frac_clean + current_agg["unit_measure"] = qty.weight_unit if qty else "KGS" # Default to first found + + # --- Calculate Estimated Tax for this Line --- + rate = 0.0 + try: + clean_adv = current_agg["advalorem_txt"].replace("%", "").strip() + rate = float(clean_adv) / 100.0 + except: rate = 0.0 + + v_est_line = v_total_line * rate + + # --- Accumulate --- + current_agg["qty"] += q_line + current_agg["net_weight_kgs"] += nw_line + current_agg["gross_weight_kgs"] += gw_line + current_agg["total_value"] += v_total_line + current_agg["est_total_value"] += v_est_line + + + # --- Convert Aggregated Data to Schema List --- + partidas_list = [] + + for (hts, origin), data in aggregated_data.items(): + + # Calculate Unit Price based on Total Value / Total Qty + unit_price = 0.0 + if data["qty"] > 0: + unit_price = data["total_value"] / data["qty"] + + partidas_list.append(PartidaSchema( + numero_parte="VARIOS", # Or empty + descripcion=data["description"], + fraccion=data["hts_code_print"], + origen=origin, + advalorem=data["advalorem_txt"], + preferencia="General", + cantidad_importacion=self.formatear_numero(data["qty"]), + unidad_medida=data["unit_measure"], + cantidad_bultos=0, # Summing bultos might be tricky if not homogeneous, check logic later + clave_bultos="", + peso_neto=self.formatear_numero(data["net_weight_kgs"]), + peso_bruto=self.formatear_numero(data["gross_weight_kgs"]), + valor_costo_unitario=self.formatear_numero(unit_price), + valor_total=self.formatear_numero(data["total_value"]), + valor_estimado=self.formatear_numero(data["est_total_value"]) + )) + + # Sort by Fraction (HTS Code) + partidas_list.sort(key=lambda x: x.fraccion) + + totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio)) + + return FacturaImportacionCompleta( + cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido, + cliente_enviado=cliente_enviado, factura=factura_schema, + partidas=partidas_list, totales=totales + ) + + except Exception as e: + print(f"Error Service A76: {e}") + raise HTTPException(status_code=500, detail=f"Error: {str(e)}") + + def calcular_totales(self, partidas: List[PartidaSchema], tipo_cambio: Decimal) -> TotalesSchema: + cant = sum(p.cantidad_importacion for p in partidas) + valor = sum(p.valor_total for p in partidas) + peso_n = sum(p.peso_neto for p in partidas) + peso_b = sum(p.peso_bruto for p in partidas) + bultos = sum(p.cantidad_bultos for p in partidas) + claves = [p.clave_bultos for p in partidas if p.clave_bultos] + clave_comun = max(set(claves), key=claves.count) if claves else "" + if bultos > 1 and clave_comun and not clave_comun.endswith("S"): clave_comun += "S" + v_est = sum(p.valor_estimado for p in partidas if isinstance(p.valor_estimado, (int, float, Decimal))) + + tc = float(tipo_cambio) if tipo_cambio else 1.0 + return TotalesSchema( + cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun, + peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b), + valor_total_total=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0), + valor_estimado_total=self.formatear_numero(v_est) + ) + + def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None) -> Tuple[bytes, str, str]: + if progress_callback: progress_callback(5, "Iniciando servicio de reporte...") + datos = self.obtener_datos(db, invoice_id, company_id, progress_callback) + + if progress_callback: progress_callback(80, "Renderizando plantilla...") + + # LOGO LOGIC + logo_b64 = None + try: + # Fetch company to get logo path + + comp_logo = db.query(Company).filter(Company.id == company_id).first() + if comp_logo and comp_logo.logo: + p = Path(comp_logo.logo) + + # Logic robusta de búsqueda (igual que en routes.py) + target_path = p + if not target_path.exists(): + # Intentar en la ruta estándar: app_data/logos/{id}/{nombre} + # Esto cubre el caso donde solo se guardó el nombre del archivo o la ruta absoluta cambió + fallback = Path(f"app_data/logos/{company_id}") / p.name + if fallback.exists(): + target_path = fallback + + if target_path.exists(): + with open(target_path, "rb") as image_file: + encoded_string = base64.b64encode(image_file.read()).decode('utf-8') + # Detect MIME type loosely + mime = "image/png" + if target_path.suffix.lower() in ['.jpg', '.jpeg']: mime = "image/jpeg" + logo_b64 = f"data:{mime};base64,{encoded_string}" + except Exception as e: + print(f"Error loading logo: {e}") + + context = { + 'cliente_proveedor': datos.cliente_proveedor.model_dump(), 'cliente_vendido': datos.cliente_vendido.model_dump(), + 'cliente_enviado': datos.cliente_enviado.model_dump(), 'factura': datos.factura.model_dump(), + 'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump(), + 'logo_b64': logo_b64 + } + html_content = self.template.render(**context) + nombre = f"Consolidado_{datos.factura.numero}.{formato}" + if formato == "html": return html_content.encode('utf-8'), nombre, "text/html" + + if progress_callback: progress_callback(90, "Generando PDF final...") + options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None} + pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config()) + + if progress_callback: progress_callback(100, "Completado") + return pdf, nombre, "application/pdf" diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/schemas.py b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/schemas.py index 9d4455b8..e9a52c8a 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/schemas.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/schemas.py @@ -30,6 +30,7 @@ class ClienteSchema(BaseModel): class FacturaSchema(BaseModel): numero: str + titulo_documento: str = "Factura de Importacion" # Titulo dinámico basado en document_type fecha: str tipo_cambio: float moneda: str diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py index 200a3942..069d332d 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py @@ -47,6 +47,46 @@ class FacturaImportacionMexService: ) self.template = self.jinja_env.get_template('factura_mex_ver.html') + def _get_document_title(self, invoice_type: str, is_american: bool = False) -> str: + """ + Determina el título del documento basado en el tipo de factura. + + Args: + invoice_type: Tipo de factura (TEM, DEF, MEX, CR) + is_american: Si es factura americana (True) o mexicana (False) + + Returns: + Título formateado para la factura + """ + # Mapeo para facturas mexicanas + mexican_titles = { + "MEX": "Factura Importación Compras Mexicanas", + "DEF": "Importación Definitiva", + "TEM": "Importación Temporal", + "CR": "Importación de Cambio de Régimen", + } + + # Mapeo para facturas americanas + american_titles = { + "MEX": "Mexican Purchases Import Invoice", + "DEF": "Definitive Importation", + "TEM": "Temporary Importation", + "CR": "Regime Change Importation", + } + + # Seleccionar el mapa correcto + titles = american_titles if is_american else mexican_titles + + # Obtener el título (normalizar a mayúsculas) + invoice_type_upper = invoice_type.upper() if invoice_type else "" + title = titles.get(invoice_type_upper, "") + + # Fallback a genéricos si no se encuentra + if not title: + return "Commercial Invoice" if is_american else "Factura de Importación" + + return title + def _get_wkhtmltopdf_config(self): path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf" if not Path(path).exists(): @@ -93,7 +133,7 @@ class FacturaImportacionMexService: cert=prog.is_certified_company if (prog and prog.is_certified_company) else "" ) - def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> FacturaImportacionCompleta: + def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None, currency_code: str = 'ORIGINAL') -> FacturaImportacionCompleta: try: if progress_callback: progress_callback(10, "Buscando factura...") header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first() @@ -200,11 +240,19 @@ class FacturaImportacionMexService: if drv_obj: licencia_cond_val = drv_obj.license_number or "" + # Determine Currency + moneda_final = getattr(header, 'currency', "USD") or "USD" + if currency_code == 'MXN': + moneda_final = 'MXN' + elif currency_code == 'USD': + moneda_final = 'USD' + factura_schema = FacturaSchema( numero=header.invoice_number or "S/N", + titulo_documento=self._get_document_title(header.invoice_type or "", is_american=False), fecha=str(header.invoice_date) if header.invoice_date else "", tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0), - moneda=getattr(header, 'currency', "USD") or "USD", + moneda=moneda_final, incoterm=(logistics.incoterm or "") if logistics else "", observaciones=header.observation_es or header.observation_en or "", pedimento=f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" if pedimento else "", @@ -351,9 +399,9 @@ class FacturaImportacionMexService: valor_total_total=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0) ) - def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None) -> Tuple[bytes, str, str]: + def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None, currency_code: str = 'ORIGINAL') -> Tuple[bytes, str, str]: if progress_callback: progress_callback(5, "Iniciando servicio de reporte...") - datos = self.obtener_datos(db, invoice_id, company_id, progress_callback) + datos = self.obtener_datos(db, invoice_id, company_id, progress_callback, currency_code) if progress_callback: progress_callback(80, "Renderizando plantilla...") diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py b/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py index f7c0d72d..324bb91a 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py @@ -40,9 +40,10 @@ async def get_task_status( async def trigger_descarga_factura( invoice_id: int, company_id: int = Query(..., description="ID de la empresa"), + invoice_type: str = Query('mexican', description="Tipo de factura: 'mexican' o 'american'"), current_user: Dict[str, Any] = Depends(get_current_user), db: Session = Depends(get_core_db) ): validate_access_to_resource(db, company_id, current_user) - task = generar_pdf_factura_async.delay(invoice_id, company_id) + task = generar_pdf_factura_async.delay(invoice_id, company_id, invoice_type) return {"task_id": task.id, "message": "Generación iniciada"} \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/task.py b/backend/api/v1/modules/a76/reports/importacion/facturas/task.py index 6cdf1342..fabd082f 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/task.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/task.py @@ -5,35 +5,36 @@ from celery import current_task, states from core.database import CoreSessionLocal from .mex.service import FacturaImportacionMexService +from .usa.service import FacturaImportacionUsaService logger = logging.getLogger(__name__) @celery_app.task(name="generar_pdf_factura_async", bind=True) -def generar_pdf_factura_async(self, invoice_id: int, company_id: int): +def generar_pdf_factura_async(self, invoice_id: int, company_id: int, invoice_type: str = 'mexican', currency_code: str = 'ORIGINAL'): - # 1. Abrimos conexión a la DB db = CoreSessionLocal() try: - logger.info(f"Worker procesando factura {invoice_id}...") + logger.info(f"Worker procesando factura {invoice_id} ({invoice_type}, {currency_code})...") - # 2. Instanciamos el servicio de reportes - service = FacturaImportacionMexService() + if invoice_type == 'american': + service = FacturaImportacionUsaService() + else: + service = FacturaImportacionMexService() - # Update state to PROCESSING self.update_state(state='PROCESSING', meta={'current': 5, 'total': 100, 'status': 'Iniciando generación...'}) def progress_callback(progress: int, status: str): self.update_state(state='PROCESSING', meta={'current': progress, 'total': 100, 'status': status}) - # 3. Generamos los bytes del PDF pdf_bytes, nombre, media_type = service.generar_factura_completa( db=db, invoice_id=invoice_id, company_id=company_id, - progress_callback=progress_callback + progress_callback=progress_callback, + currency_code=currency_code ) - # 4. Codificamos a base64 para que viaje seguro por Valkey + pdf_base64 = base64.b64encode(pdf_bytes).decode('utf-8') return { @@ -48,5 +49,5 @@ def generar_pdf_factura_async(self, invoice_id: int, company_id: int): return {"status": "error", "message": str(e)} finally: - # 5. MUY IMPORTANTE: Cerramos la conexión para no saturar Postgres + db.close() \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_mex_ver.html b/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_mex_ver.html index ade499e4..215d8fa8 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_mex_ver.html +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_mex_ver.html @@ -273,7 +273,7 @@
-

Factura de Importacion

+

{{ factura.titulo_documento }}

diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_usa_ver.html b/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_usa_ver.html new file mode 100644 index 00000000..22025ad7 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_usa_ver.html @@ -0,0 +1,597 @@ + + + + + + Commercial Invoice - {{ factura.numero }} + + + + +
+
+
+

{{ factura.titulo_documento }}

+
+

+
+
+

+


+
+
+
+
+ {% if logo_b64 %} +
+ +
+ {% endif %} +
+

{{ cliente_proveedor.header }}

+

{{ cliente_proveedor.nombre }}

+

{{ cliente_proveedor.direccion }} + {% if cliente_proveedor.num_exterior %} Ext: {{ cliente_proveedor.num_exterior }}{% endif %} + {% if cliente_proveedor.num_interior %} Int: {{ cliente_proveedor.num_interior }}{% endif %} +

+

{{ cliente_proveedor.colonia }} {% if cliente_proveedor.codigo_postal %} Zip Code: {{ + cliente_proveedor.codigo_postal }}{% endif %}

+

{{ cliente_proveedor.ciudad }}, {{ cliente_proveedor.estado }}, {{ cliente_proveedor.pais }}

+

TAX ID: {{ cliente_proveedor.tax_id }} + {% if cliente_proveedor.programa and cliente_proveedor.programa != 'Ninguno' %} + {{ cliente_proveedor.programa }}: {{ cliente_proveedor.autorizacion }} + {% endif %} +

+


+
+
+ +
+ + + + + + + + + + + + + + + + + + + +
+

INVOICE:

+
+

{{ factura.numero }}

+
+

Date:

+
+

{{ factura.fecha }}

+
+

Ex. Rate:

+
+

{{ factura.tipo_cambio }}

+
+

INCOTERM:

+
+

{{ factura.incoterm or '' }}

+
+

Customs:

+
+

{{ factura.aduana }}

+
+
+
+ +
+
+

{{ cliente_vendido.header }}

+

{{ cliente_vendido.nombre }}

+

{{ cliente_vendido.direccion }} + {% if cliente_vendido.num_exterior %} Ext: {{ cliente_vendido.num_exterior }}{% endif %} + {% if cliente_vendido.num_interior %} Int: {{ cliente_vendido.num_interior }}{% endif %} +

+

{{ cliente_vendido.colonia }} {% if cliente_vendido.codigo_postal %} Zip Code: {{ + cliente_vendido.codigo_postal }}{% endif %}

+

{{ cliente_vendido.ciudad }}, {{ cliente_vendido.estado }}, {{ cliente_vendido.pais }} +

+

Tax ID: {{ cliente_vendido.tax_id }} + {% if cliente_vendido.programa and cliente_vendido.programa != 'Ninguno' %} + {{ cliente_vendido.programa }}: {{ cliente_vendido.autorizacion }} + {% endif %} +

+

+ {% if cliente_vendido.prosec %}PROSEC: {{ cliente_vendido.prosec }} {% endif %} + {% if cliente_vendido.reg_emp %}REG EMP: {{ cliente_vendido.reg_emp }} {% endif %} + {% if cliente_vendido.cert %}CERT: {{ cliente_vendido.cert }}{% endif %} +

+
+ +
+

{{ cliente_enviado.header }}

+

{{ cliente_enviado.nombre }}

+

{{ cliente_enviado.direccion }} + {% if cliente_enviado.num_exterior %} Ext: {{ cliente_enviado.num_exterior }}{% endif %} + {% if cliente_enviado.num_interior %} Int: {{ cliente_enviado.num_interior }}{% endif %} +

+

{{ cliente_enviado.colonia }} {% if cliente_enviado.codigo_postal %} Zip Code: {{ + cliente_enviado.codigo_postal }}{% endif %}

+

{{ cliente_enviado.ciudad }}, {{ cliente_enviado.estado }}, {{ cliente_enviado.pais }} +

+

Tax ID: {{ cliente_enviado.tax_id }} + {% if cliente_enviado.programa and cliente_enviado.programa != 'Ninguno' %} + {{ cliente_enviado.programa }}: {{ cliente_enviado.autorizacion }} + {% endif %} +

+

+ {% if cliente_enviado.prosec %}PROSEC: {{ cliente_enviado.prosec }} {% endif %} + {% if cliente_enviado.reg_emp %}REG EMP: {{ cliente_enviado.reg_emp }} {% endif %} + {% if cliente_enviado.cert %}CERT: {{ cliente_enviado.cert }}{% endif %} +

+
+
+


+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for partida in partidas %} + + + + + + + + + + + + {% endfor %} + + + + + + + + + + + + + + + + + + + + + +
+

Carrier:

+
+

{{ factura.transportista }}

+
+

SCAC: {{ factura.scac }}

+
+

INCOTERM:

+
+

{{ factura.incoterm }}

+
+

Customs: {{ factura.aduana }} / Ped: {{ factura.pedimento }}

+
+


+
+

Transport:

+
+

{{ factura.transporte }}: {{ factura.num_transporte }}

+
+

CAAT: {{ factura.caat }}

+
+

Plates: {{ factura.placas or '' }} / Trl: {{ factura.placas_remolque or + '' }}

+
+

Driver/Lic:

+
+

{{ factura.licencia_conductor or 'N/A' }}

+
+

Line

+
+

Part Number

+

Description

+
+

Commercial

+
+

Packaging

+
+

Weight (KGS)

+
+

Values

+
+

Quantity

+
+

U.M.

+
+

Type

+
+

Net

+
+

Gross

+
+

Unit

+
+

Total

+
+

{{ loop.index }}

+
+

{{ partida.numero_parte }}

+

{{ partida.descripcion }}

+

HTS Code: {{ partida.fraccion }} / Orig: {{ partida.origen or 'MEX' }}

+

+ {% if partida.advalorem %}ADV: {{ partida.advalorem }}{% endif %} + {% if partida.preferencia %} / PREF: {{ partida.preferencia }}{% endif %} +

+
+

{{ partida.cantidad_importacion }}

+
+

{{ partida.unidad_medida }}

+
+

+ {% if partida.cantidad_bultos != 0 %}{{ partida.cantidad_bultos }}{% endif %} + {{ partida.clave_bultos }} +

+
+

{{ partida.peso_neto }}

+
+

{{ partida.peso_bruto }}

+
+

${{ partida.valor_costo_unitario }}

+
+

${{ partida.valor_total }}

+
+

+ Remarks: + TOTALS +

+
+

{{ totales.cantidad_total }}

+
+

+ {% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %} + {{ totales.clave_bultos or '' }} +

+
+

{{ totales.peso_neto_total }}

+
+

{{ totales.peso_bruto_total }}

+
+

${{ totales.valor_total_total }}

+
+

{{ factura.observaciones }}

+
+

+

{{ cliente_proveedor.nombre }}

+


+

Values expressed in: {{ factura.moneda + }}

+
+


+

+

I declare under penalty of perjury that the information contained in + this document is true and correct.

+
+ + + \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/__init__.py b/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/mex/service.py new file mode 100644 index 00000000..200a3942 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/mex/service.py @@ -0,0 +1,403 @@ +import shutil +import base64 +import pdfkit +from pathlib import Path +from decimal import Decimal +from typing import Tuple, List, Callable, Optional + +from jinja2 import Environment, FileSystemLoader, select_autoescape +from fastapi import HTTPException +from sqlalchemy.orm import Session + +# --- MODELOS --- +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics +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.line_items.models import LineItem +from api.v1.modules.a76.clients_and_providers.models import ( + ClientProvider, ClientProviderAddress, ClientProviderPrograms +) +from api.v1.modules.a76.parts.models import Part +from api.v1.modules.a76.pedmientos.models import Pedimentos +from api.v1.modules.a76.general_catalogs.company.models import Company +from api.v1.modules.a76.customs_brokers.models import CustomsBroker +from api.v1.modules.a76.items.models import Item + +# --- TRANSPORTATION MODELS --- +from api.v1.modules.a76.transportation.transporters.models import Transporter +from api.v1.modules.a76.transportation.vehicles.models import Vehicle +from api.v1.modules.a76.transportation.trailers.models import Trailer +from api.v1.modules.a76.transportation.drivers.models import Driver + +# --- MODELO DE FRACCIONES --- +from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction + +# --- SCHEMAS --- +from .schemas import ( + ClienteSchema, PartidaSchema, TotalesSchema, + FacturaSchema, FacturaImportacionCompleta +) + +class FacturaImportacionMexService: + def __init__(self): + self.template_dir = Path(__file__).parent.parent / "templates" + self.jinja_env = Environment( + loader=FileSystemLoader(self.template_dir), + autoescape=select_autoescape(['html', 'xml']) + ) + self.template = self.jinja_env.get_template('factura_mex_ver.html') + + def _get_wkhtmltopdf_config(self): + path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf" + if not Path(path).exists(): + raise RuntimeError("wkhtmltopdf no encontrado.") + return pdfkit.configuration(wkhtmltopdf=path) + + def formatear_numero(self, valor, decimales: int = 2): + if valor is None: return 0.0 + try: + return round(float(valor), decimales) + except: return 0.0 + + def _format_fraccion_fallback(self, fraccion_raw: str) -> str: + if not fraccion_raw or len(fraccion_raw) < 8: + return fraccion_raw + return f"{fraccion_raw[:4]}.{fraccion_raw[4:6]}.{fraccion_raw[6:]}" + + def _obtener_datos_cliente(self, db: Session, client_id: int, rol: str) -> ClienteSchema: + main = db.query(ClientProvider).filter(ClientProvider.id == client_id).first() + if not main: + return ClienteSchema(header=rol, nombre="Desconocido", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="MEX") + + addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first() + prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first() + + return ClienteSchema( + header=rol, + nombre=(main.name or main.short_name) or "S/N", + direccion=(addr.streets or "") if addr else "", + num_exterior=(addr.exterior_number or "") if addr else "", + num_interior=(addr.interior_number or "") if addr else "", + colonia=(addr.neighborhood or "") if addr else "", + codigo_postal=(addr.postal_code or "") if addr else "", + ciudad=(addr.city or "") if addr else "", + estado=(addr.state or "") if addr else "", + pais=(addr.country or "MEX") if addr else "MEX", + tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""), + programa="IMMEX" if (prog and prog.program) else "", + autorizacion=prog.program_number if prog else "", + prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "", + reg_emp=prog.val_certified_company_registry if (prog and hasattr(prog, 'val_certified_company_registry')) else ( + prog.certified_company_registry if (prog and prog.certified_company_registry) else "" + ), + cert=prog.is_certified_company if (prog and prog.is_certified_company) else "" + ) + + def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> FacturaImportacionCompleta: + try: + if progress_callback: progress_callback(10, "Buscando factura...") + header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first() + if not header: raise HTTPException(status_code=404, detail="Factura no encontrada") + + compliance = header.compliance_mx + logistics = header.logistics if header.logistics else None + financials = header.financials if header.financials else None + if progress_callback: progress_callback(20, "Obteniendo datos de pedimento...") + pedimento_id = compliance.pedimento_id if (compliance and compliance.pedimento_id) else header.related_doc_id + pedimento = db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() if pedimento_id else None + + if progress_callback: progress_callback(30, "Obteniendo cliente y proveedor...") + proveedor_id = compliance.provider_id if compliance else None + cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:") if proveedor_id else ClienteSchema(header="Proveedor", nombre="No Asignado", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="") + + nombre_agente = "" + if compliance and compliance.customs_broker_id: + broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first() + if broker: nombre_agente = broker.name + + company = db.query(Company).filter(Company.id == header.company_id).first() + # Datos Default (Company/Importer) - Used for fallback or Right Side (Enviado A) + cliente_default = ClienteSchema( + header="Importador / consignatario:", + nombre=getattr(company, 'name', "Empresa Local"), + direccion="DOMICILIO FISCAL", + num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX", + tax_id=getattr(company, 'rfc', ""), + programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "") + ) + + # Left Side Logic (Consignatario / Sold To) + cliente_vendido = cliente_default + if compliance and compliance.sold_to_id: + raw_header = compliance.sold_to_header or "CONSIGNATARIO" + clean_header = raw_header.replace("_", " ").capitalize() + ":" + cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header) + + # Right Side Logic (Enviado A / Shipped To) + cliente_enviado = cliente_default + if compliance and compliance.shipped_to_id: + # Clean header: "enviado_a" -> "Enviado a:" + raw_header_shipped = compliance.shipped_to_header or "DESTINATARIO" + clean_header_shipped = raw_header_shipped.replace("_", " ").capitalize() + ":" + + # Fetch client data + cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped) + + remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else "" + acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A" + + patente_val = "" + if pedimento and pedimento.license: + patente_val = pedimento.license + elif 'broker' in locals() and broker and broker.license: + patente_val = broker.license + + + # --- Transport Data Fetching --- + transporte_txt = str(logistics.transport_type) if (logistics and logistics.transport_type) else "" + num_transporte_val = (logistics.trailer_num or "") if logistics else "" + + # Init values + placas_val = (logistics.license_plate or "") if logistics else "" # Placas Tracto + placas_remolque_val = "" + transportista_val = (logistics.carrier_id or "") if logistics else "" + caat_val = "" + scac_val = "" + licencia_cond_val = "" + + if logistics: + # 1. Transporter (CAAT / SCAC) + if logistics.carrier_id: + transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first() + if transporter_obj: + caat_val = transporter_obj.caat_code or "" + scac_val = transporter_obj.transport_code or "" # Mapping transport_code to SCAC + transportista_val = transporter_obj.name or logistics.carrier_id + + # 2. Vehicle (Placas Tracto) - Try transport_id first + if logistics.transport_id: + veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first() + if veh_obj: + placas_val = veh_obj.plate_number or placas_val + elif logistics.vehicle_num: # Fallback to vehicle_num if populated and transport_id failed/empty + veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.vehicle_num).first() + if veh_obj: + placas_val = veh_obj.plate_number or placas_val + + # 3. Trailer (Placas Remolque) + if logistics.trailer_num: + trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first() + if trl_obj: + placas_remolque_val = trl_obj.plate_number or "" + + # 4. Driver (License) + if logistics.carrier_id and logistics.driver_name: + # Attempt to find driver by name + carrier + drv_obj = db.query(Driver).filter( + Driver.transporter_key == logistics.carrier_id, + Driver.driver_name == logistics.driver_name + ).first() + if drv_obj: + licencia_cond_val = drv_obj.license_number or "" + + factura_schema = FacturaSchema( + numero=header.invoice_number or "S/N", + fecha=str(header.invoice_date) if header.invoice_date else "", + tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0), + moneda=getattr(header, 'currency', "USD") or "USD", + incoterm=(logistics.incoterm or "") if logistics else "", + observaciones=header.observation_es or header.observation_en or "", + pedimento=f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" if pedimento else "", + clave_pedimento=pedimento.pedimento_code if pedimento else "", + regimen=header.document_type or "", + patente=patente_val, + agente_aduanal=nombre_agente, + transporte=transporte_txt, + num_transporte=num_transporte_val, + placas=placas_val, + placas_remolque=placas_remolque_val, + transportista=transportista_val, + caat=caat_val, + scac=scac_val, + licencia_conductor=licencia_cond_val, + aduana=compliance.aduana if (compliance and compliance.aduana) else (pedimento.customs_office[:2] if (pedimento and pedimento.customs_office) else ""), + precinto=(logistics.seal_number or "") if logistics else "", + destino=(logistics.destination_goods or "") if logistics else "", + remesa=remesa_valor, acuse_electronico=acuse_valor + ) + + if progress_callback: progress_callback(50, "Procesando partidas...") + lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(Item.invoice_id == header.id).all() + partidas_list = [] + + for line in lines: + qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first() + fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first() + part_master = db.query(Part).filter(Part.id == line.part_number).first() + + desc_final = "S/D" + num_parte_final = str(line.part_number or "S/N") + fraccion_raw = "" + origen_final = "MEX" + + if part_master: + desc_final = part_master.description_spanish or part_master.description_english or "Sin Desc." + num_parte_final = part_master.part_number + fraccion_raw = part_master.fraction if part_master.fraction else "" + + # Fetch Origin from Master Catalog (FaPart) + if part_master.fa_data and part_master.fa_data.origin_country: + origen_final = part_master.fa_data.origin_country + + + fraccion_limpia = fraccion_raw.replace(".", "").strip() + if fraccion_limpia: + fraccion_limpia = fraccion_limpia[:8].zfill(8) + + # Consultar tabla tariff_fractions + fraccion_db = db.query(TariffFraction).filter(TariffFraction.code == fraccion_limpia).first() + + + preferencia_txt = "General" + advalorem_txt = "0%" + fraccion_imprimir = fraccion_raw + + if fraccion_db: + # Si el valor en BD es None, "0", o vacío, dejarlo como "0%" o "EXENTO" + adv_db = fraccion_db.adv_impo + if adv_db and adv_db.strip() not in ["0", "0.0", "0.00", ""]: + advalorem_txt = adv_db if "%" in adv_db else f"{adv_db}%" + else: + advalorem_txt = "0%" + + fraccion_imprimir = fraccion_db.fraction or fraccion_raw + else: + + fraccion_imprimir = self._format_fraccion_fallback(fraccion_limpia) + + # Logic to determine values - Prioritize Specific Currency Columns + v_unitario = 0.0 + v_total = 0.0 + + if fin: + is_mxn = (factura_schema.moneda == 'MXN') + + # 1. Try Specific Currency Columns First + if is_mxn: + v_unitario = float(fin.unit_cost_commercial_mxn or 0.0) + v_total = float(fin.value_commercial_mxn or 0.0) + else: + v_unitario = float(fin.unit_cost_commercial_usd or 0.0) + v_total = float(fin.value_commercial_usd or 0.0) + + # 2. Fallback to Generic independently if Specific is 0 + if not v_unitario: + v_unitario = float(fin.commercial_unit_cost or 0.0) + + if not v_total: + v_total = float(fin.total_commercial_value or 0.0) + + # 3. Calculate from Quantity if still missing + cantidad = float(qty.quantity) if (qty and qty.quantity) else 0.0 + + if cantidad > 0: + if v_unitario > 0 and v_total == 0: + v_total = v_unitario * cantidad + elif v_total > 0 and v_unitario == 0: + v_unitario = v_total / cantidad + + partidas_list.append(PartidaSchema( + numero_parte=num_parte_final, + descripcion=desc_final, + fraccion=fraccion_imprimir, + origen=origen_final, + advalorem=advalorem_txt, + preferencia=preferencia_txt, + cantidad_importacion=self.formatear_numero(qty.quantity if qty else 0), + unidad_medida=qty.weight_unit if qty else "PZA", + cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0, + clave_bultos=(qty.package_key or "") if qty else "", + peso_neto=self.formatear_numero(qty.net_weight if qty else 0), + peso_bruto=self.formatear_numero(qty.gross_weight if qty else 0), + valor_costo_unitario=self.formatear_numero(v_unitario), + valor_total=self.formatear_numero(v_total) + )) + + totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio)) + + return FacturaImportacionCompleta( + cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido, + cliente_enviado=cliente_enviado, factura=factura_schema, + partidas=partidas_list, totales=totales + ) + + except Exception as e: + print(f"Error Service A76: {e}") + raise HTTPException(status_code=500, detail=f"Error: {str(e)}") + + def calcular_totales(self, partidas: List[PartidaSchema], tipo_cambio: Decimal) -> TotalesSchema: + cant = sum(p.cantidad_importacion for p in partidas) + valor = sum(p.valor_total for p in partidas) + peso_n = sum(p.peso_neto for p in partidas) + peso_b = sum(p.peso_bruto for p in partidas) + bultos = sum(p.cantidad_bultos for p in partidas) + claves = [p.clave_bultos for p in partidas if p.clave_bultos] + clave_comun = max(set(claves), key=claves.count) if claves else "" + if bultos > 1 and clave_comun and not clave_comun.endswith("S"): clave_comun += "S" + tc = float(tipo_cambio) if tipo_cambio else 1.0 + return TotalesSchema( + cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun, + peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b), + valor_total_total=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0) + ) + + def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None) -> Tuple[bytes, str, str]: + if progress_callback: progress_callback(5, "Iniciando servicio de reporte...") + datos = self.obtener_datos(db, invoice_id, company_id, progress_callback) + + if progress_callback: progress_callback(80, "Renderizando plantilla...") + + # LOGO LOGIC + logo_b64 = None + try: + # Fetch company to get logo path + + comp_logo = db.query(Company).filter(Company.id == company_id).first() + if comp_logo and comp_logo.logo: + p = Path(comp_logo.logo) + + # Logic robusta de búsqueda (igual que en routes.py) + target_path = p + if not target_path.exists(): + # Intentar en la ruta estándar: app_data/logos/{id}/{nombre} + # Esto cubre el caso donde solo se guardó el nombre del archivo o la ruta absoluta cambió + fallback = Path(f"app_data/logos/{company_id}") / p.name + if fallback.exists(): + target_path = fallback + + if target_path.exists(): + with open(target_path, "rb") as image_file: + encoded_string = base64.b64encode(image_file.read()).decode('utf-8') + # Detect MIME type loosely + mime = "image/png" + if target_path.suffix.lower() in ['.jpg', '.jpeg']: mime = "image/jpeg" + logo_b64 = f"data:{mime};base64,{encoded_string}" + except Exception as e: + print(f"Error loading logo: {e}") + + context = { + 'cliente_proveedor': datos.cliente_proveedor.model_dump(), 'cliente_vendido': datos.cliente_vendido.model_dump(), + 'cliente_enviado': datos.cliente_enviado.model_dump(), 'factura': datos.factura.model_dump(), + 'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump(), + 'logo_b64': logo_b64 + } + html_content = self.template.render(**context) + nombre = f"Factura_{datos.factura.numero}.{formato}" + if formato == "html": return html_content.encode('utf-8'), nombre, "text/html" + + if progress_callback: progress_callback(90, "Generando PDF final...") + options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None} + pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config()) + + if progress_callback: progress_callback(100, "Completado") + return pdf, nombre, "application/pdf" \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py new file mode 100644 index 00000000..8d69dfb3 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py @@ -0,0 +1,451 @@ +import shutil +import base64 +import pdfkit +from pathlib import Path +from decimal import Decimal +from typing import Tuple, List, Callable, Optional + +from jinja2 import Environment, FileSystemLoader, select_autoescape +from fastapi import HTTPException +from sqlalchemy.orm import Session + +# --- MODELOS --- +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics +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.line_items.models import LineItem +from api.v1.modules.a76.clients_and_providers.models import ( + ClientProvider, ClientProviderAddress, ClientProviderPrograms +) +from api.v1.modules.a76.parts.models import Part +from api.v1.modules.a76.pedmientos.models import Pedimentos +from api.v1.modules.a76.general_catalogs.company.models import Company +from api.v1.modules.a76.customs_brokers.models import CustomsBroker +from api.v1.modules.a76.items.models import Item + +# --- TRANSPORTATION MODELS --- +from api.v1.modules.a76.transportation.transporters.models import Transporter +from api.v1.modules.a76.transportation.vehicles.models import Vehicle +from api.v1.modules.a76.transportation.trailers.models import Trailer +from api.v1.modules.a76.transportation.drivers.models import Driver + +# --- MODELO DE FRACCIONES --- +from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction + +# --- SCHEMAS --- +# Reuse schemas from neighbor package as they fit the same data structure +from ..mex.schemas import ( + ClienteSchema, PartidaSchema, TotalesSchema, + FacturaSchema, FacturaImportacionCompleta +) + +class FacturaImportacionUsaService: + def __init__(self): + self.template_dir = Path(__file__).parent.parent / "templates" + self.jinja_env = Environment( + loader=FileSystemLoader(self.template_dir), + autoescape=select_autoescape(['html', 'xml']) + ) + self.template = self.jinja_env.get_template('factura_usa_ver.html') + + def _get_document_title(self, invoice_type: str, is_american: bool = True) -> str: + """ + Determina el título del documento basado en el tipo de factura. + + Args: + invoice_type: Tipo de factura (TEM, DEF, MEX, CR) + is_american: Si es factura americana (True) o mexicana (False) + + Returns: + Título formateado para la factura + """ + # Mapeo para facturas mexicanas + mexican_titles = { + "MEX": "Factura Importación Compras Mexicanas", + "DEF": "Importación Definitiva", + "TEM": "Importación Temporal", + "CR": "Importación de Cambio de Régimen", + } + + # Mapeo para facturas americanas + american_titles = { + "MEX": "Mexican Purchases Import Invoice", + "DEF": "Definitive Importation", + "TEM": "Temporary Importation", + "CR": "Regime Change Importation", + } + + # Seleccionar el mapa correcto + titles = american_titles if is_american else mexican_titles + + # Obtener el título (normalizar a mayúsculas) + invoice_type_upper = invoice_type.upper() if invoice_type else "" + title = titles.get(invoice_type_upper, "") + + # Fallback a genéricos si no se encuentra + if not title: + return "Commercial Invoice" if is_american else "Factura de Importación" + + return title + + def _get_wkhtmltopdf_config(self): + path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf" + if not Path(path).exists(): + raise RuntimeError("wkhtmltopdf no encontrado.") + return pdfkit.configuration(wkhtmltopdf=path) + + def formatear_numero(self, valor, decimales: int = 2): + if valor is None: return 0.0 + try: + return round(float(valor), decimales) + except: return 0.0 + + def _format_fraccion_fallback(self, fraccion_raw: str) -> str: + if not fraccion_raw or len(fraccion_raw) < 8: + return fraccion_raw + return f"{fraccion_raw[:4]}.{fraccion_raw[4:6]}.{fraccion_raw[6:]}" + + def _obtener_datos_cliente(self, db: Session, client_id: int, rol: str) -> ClienteSchema: + main = db.query(ClientProvider).filter(ClientProvider.id == client_id).first() + if not main: + return ClienteSchema(header=rol, nombre="Unknown", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="USA") + + addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first() + prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first() + + return ClienteSchema( + header=rol, + nombre=(main.name or main.short_name) or "N/A", + direccion=(addr.streets or "") if addr else "", + num_exterior=(addr.exterior_number or "") if addr else "", + num_interior=(addr.interior_number or "") if addr else "", + colonia=(addr.neighborhood or "") if addr else "", + codigo_postal=(addr.postal_code or "") if addr else "", + ciudad=(addr.city or "") if addr else "", + estado=(addr.state or "") if addr else "", + pais=(addr.country or "USA") if addr else "USA", + tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""), + programa="IMMEX" if (prog and prog.program) else "", + autorizacion=prog.program_number if prog else "", + prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "", + reg_emp=prog.val_certified_company_registry if (prog and hasattr(prog, 'val_certified_company_registry')) else ( + prog.certified_company_registry if (prog and prog.certified_company_registry) else "" + ), + cert=prog.is_certified_company if (prog and prog.is_certified_company) else "" + ) + + def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None, currency_code: str = 'ORIGINAL') -> FacturaImportacionCompleta: + try: + if progress_callback: progress_callback(10, "Searching invoice...") + header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first() + if not header: raise HTTPException(status_code=404, detail="Invoice not found") + + compliance = header.compliance_mx + logistics = header.logistics if header.logistics else None + financials = header.financials if header.financials else None + if progress_callback: progress_callback(20, "Fetching entry data...") + pedimento_id = compliance.pedimento_id if (compliance and compliance.pedimento_id) else header.related_doc_id + pedimento = db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() if pedimento_id else None + + if progress_callback: progress_callback(30, "Fetching client and supplier...") + proveedor_id = compliance.provider_id if compliance else None + cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Supplier:") if proveedor_id else ClienteSchema(header="Supplier", nombre="Unassigned", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="") + + nombre_agente = "" + if compliance and compliance.customs_broker_id: + broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first() + if broker: nombre_agente = broker.name + + company = db.query(Company).filter(Company.id == header.company_id).first() + # Datos Default (Company/Importer) + cliente_default = ClienteSchema( + header="Importer / Consignee:", + nombre=getattr(company, 'name', "Local Company"), + direccion="FISCAL ADDRESS", + num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX", + tax_id=getattr(company, 'rfc', ""), + programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "") + ) + + # Left Side Logic (Sold To) + cliente_vendido = cliente_default + if compliance and compliance.sold_to_id: + # Force English header for American Invoice + clean_header = "Sold To:" + # raw_header = compliance.sold_to_header or "SOLD_TO" + # clean_header = raw_header.replace("_", " ").title() + ":" + cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header) + + # Right Side Logic (Shipped To) + cliente_enviado = cliente_default + if compliance and compliance.shipped_to_id: + # Force English header for American Invoice + clean_header_shipped = "Shipped To:" + # raw_header_shipped = compliance.shipped_to_header or "SHIPPED_TO" + # clean_header_shipped = raw_header_shipped.replace("_", " ").title() + ":" + + # Fetch client data + cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped) + + remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else "" + acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A" + + patente_val = "" + if pedimento and pedimento.license: + patente_val = pedimento.license + elif 'broker' in locals() and broker and broker.license: + patente_val = broker.license + + + # --- Transport Data Fetching --- + transporte_txt = str(logistics.transport_type) if (logistics and logistics.transport_type) else "" + num_transporte_val = (logistics.trailer_num or "") if logistics else "" + + # Init values + placas_val = (logistics.license_plate or "") if logistics else "" # Plates + placas_remolque_val = "" + transportista_val = (logistics.carrier_id or "") if logistics else "" + caat_val = "" + scac_val = "" + licencia_cond_val = "" + + if logistics: + # 1. Transporter (CAAT / SCAC) + if logistics.carrier_id: + transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first() + if transporter_obj: + caat_val = transporter_obj.caat_code or "" + scac_val = transporter_obj.transport_code or "" # Mapping transport_code to SCAC + transportista_val = transporter_obj.name or logistics.carrier_id + + # 2. Vehicle (Plates) + if logistics.transport_id: + veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first() + if veh_obj: + placas_val = veh_obj.plate_number or placas_val + elif logistics.vehicle_num: + veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.vehicle_num).first() + if veh_obj: + placas_val = veh_obj.plate_number or placas_val + + # 3. Trailer + if logistics.trailer_num: + trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first() + if trl_obj: + placas_remolque_val = trl_obj.plate_number or "" + + # 4. Driver (License) + if logistics.carrier_id and logistics.driver_name: + drv_obj = db.query(Driver).filter( + Driver.transporter_key == logistics.carrier_id, + Driver.driver_name == logistics.driver_name + ).first() + if drv_obj: + licencia_cond_val = drv_obj.license_number or "" + + # Determine Currency + moneda_final = getattr(header, 'currency', "USD") or "USD" + if currency_code == 'MXN': + moneda_final = 'MXN' + elif currency_code == 'USD': + moneda_final = 'USD' + + factura_schema = FacturaSchema( + numero=header.invoice_number or "N/A", + titulo_documento=self._get_document_title(header.invoice_type or "", is_american=True), + fecha=str(header.invoice_date) if header.invoice_date else "", + tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0), + moneda=moneda_final, + incoterm=(logistics.incoterm or "") if logistics else "", + observaciones=header.observation_es or header.observation_en or "", + pedimento=f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" if pedimento else "", + clave_pedimento=pedimento.pedimento_code if pedimento else "", + regimen=header.document_type or "", + patente=patente_val, + agente_aduanal=nombre_agente, + transporte=transporte_txt, + num_transporte=num_transporte_val, + placas=placas_val, + placas_remolque=placas_remolque_val, + transportista=transportista_val, + caat=caat_val, + scac=scac_val, + licencia_conductor=licencia_cond_val, + aduana=compliance.aduana if (compliance and compliance.aduana) else (pedimento.customs_office[:2] if (pedimento and pedimento.customs_office) else ""), + precinto=(logistics.seal_number or "") if logistics else "", + destino=(logistics.destination_goods or "") if logistics else "", + remesa=remesa_valor, acuse_electronico=acuse_valor + ) + + if progress_callback: progress_callback(50, "Processing items...") + lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(Item.invoice_id == header.id).all() + partidas_list = [] + + for line in lines: + qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first() + fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first() + part_master = db.query(Part).filter(Part.id == line.part_number).first() + + desc_final = "N/D" + num_parte_final = str(line.part_number or "N/A") + fraccion_raw = "" + origen_final = "MEX" + + if part_master: + # Prefer English description if available, else Spanish + desc_final = part_master.description_english or part_master.description_spanish or "No Desc." + num_parte_final = part_master.part_number + # Prefer US Fraction (HTS) if available + fraccion_raw = part_master.us_fraction if part_master.us_fraction else (part_master.fraction if part_master.fraction else "") + + if part_master.fa_data and part_master.fa_data.origin_country: + origen_final = part_master.fa_data.origin_country + + + fraccion_limpia = fraccion_raw.replace(".", "").strip() + if fraccion_limpia: + fraccion_limpia = fraccion_limpia[:8].zfill(8) + + # Fetch tariff fraction + fraccion_db = db.query(TariffFraction).filter(TariffFraction.code == fraccion_limpia).first() + + preferencia_txt = "General" + advalorem_txt = "0%" + fraccion_imprimir = fraccion_raw + + if fraccion_db: + adv_db = fraccion_db.adv_impo + if adv_db and adv_db.strip() not in ["0", "0.0", "0.00", ""]: + advalorem_txt = adv_db if "%" in adv_db else f"{adv_db}%" + else: + advalorem_txt = "0%" + + fraccion_imprimir = fraccion_db.fraction or fraccion_raw + else: + fraccion_imprimir = self._format_fraccion_fallback(fraccion_limpia) + + # Prioritize USD for American Invoice logic if available? + # Sticking to same logic as Mex for now but could prioritize USD columns. + # Actually, duplicate logic from mex service for now to ensure consistency. + + v_unitario = 0.0 + v_total = 0.0 + + if fin: + is_mxn = (factura_schema.moneda == 'MXN') + + if is_mxn: + v_unitario = float(fin.unit_cost_commercial_mxn or 0.0) + v_total = float(fin.value_commercial_mxn or 0.0) + else: + v_unitario = float(fin.unit_cost_commercial_usd or 0.0) + v_total = float(fin.value_commercial_usd or 0.0) + + if not v_unitario: + v_unitario = float(fin.commercial_unit_cost or 0.0) + + if not v_total: + v_total = float(fin.total_commercial_value or 0.0) + + cantidad = float(qty.quantity) if (qty and qty.quantity) else 0.0 + + if cantidad > 0: + if v_unitario > 0 and v_total == 0: + v_total = v_unitario * cantidad + elif v_total > 0 and v_unitario == 0: + v_unitario = v_total / cantidad + + # UOM Mapping for English context + uom_raw = qty.weight_unit if qty else "PCS" + if uom_raw == "PZA": uom_raw = "PCS" + + partidas_list.append(PartidaSchema( + numero_parte=num_parte_final, + descripcion=desc_final, + fraccion=fraccion_imprimir, + origen=origen_final, + advalorem=advalorem_txt, + preferencia=preferencia_txt, + cantidad_importacion=self.formatear_numero(qty.quantity if qty else 0), + unidad_medida=uom_raw, + cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0, + clave_bultos=(qty.package_key or "") if qty else "", + peso_neto=self.formatear_numero(qty.net_weight if qty else 0), + peso_bruto=self.formatear_numero(qty.gross_weight if qty else 0), + valor_costo_unitario=self.formatear_numero(v_unitario), + valor_total=self.formatear_numero(v_total) + )) + + totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio)) + + return FacturaImportacionCompleta( + cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido, + cliente_enviado=cliente_enviado, factura=factura_schema, + partidas=partidas_list, totales=totales + ) + + except Exception as e: + print(f"Error Service A76 USA: {e}") + raise HTTPException(status_code=500, detail=f"Error: {str(e)}") + + def calcular_totales(self, partidas: List[PartidaSchema], tipo_cambio: Decimal) -> TotalesSchema: + cant = sum(p.cantidad_importacion for p in partidas) + valor = sum(p.valor_total for p in partidas) + peso_n = sum(p.peso_neto for p in partidas) + peso_b = sum(p.peso_bruto for p in partidas) + bultos = sum(p.cantidad_bultos for p in partidas) + claves = [p.clave_bultos for p in partidas if p.clave_bultos] + clave_comun = max(set(claves), key=claves.count) if claves else "" + # if bultos > 1 and clave_comun and not clave_comun.endswith("S"): clave_comun += "S" + # Don't pluralize strictly in English without logic, kept simple. + + tc = float(tipo_cambio) if tipo_cambio else 1.0 + return TotalesSchema( + cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun, + peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b), + valor_total_total=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0) + ) + + def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None, currency_code: str = 'ORIGINAL') -> Tuple[bytes, str, str]: + if progress_callback: progress_callback(5, "Starting report service...") + datos = self.obtener_datos(db, invoice_id, company_id, progress_callback, currency_code) + + if progress_callback: progress_callback(80, "Rendering template...") + + # LOGO LOGIC + logo_b64 = None + try: + comp_logo = db.query(Company).filter(Company.id == company_id).first() + if comp_logo and comp_logo.logo: + p = Path(comp_logo.logo) + target_path = p + if not target_path.exists(): + fallback = Path(f"app_data/logos/{company_id}") / p.name + if fallback.exists(): + target_path = fallback + + if target_path.exists(): + with open(target_path, "rb") as image_file: + encoded_string = base64.b64encode(image_file.read()).decode('utf-8') + mime = "image/png" + if target_path.suffix.lower() in ['.jpg', '.jpeg']: mime = "image/jpeg" + logo_b64 = f"data:{mime};base64,{encoded_string}" + except Exception as e: + print(f"Error loading logo: {e}") + + context = { + 'cliente_proveedor': datos.cliente_proveedor.model_dump(), 'cliente_vendido': datos.cliente_vendido.model_dump(), + 'cliente_enviado': datos.cliente_enviado.model_dump(), 'factura': datos.factura.model_dump(), + 'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump(), + 'logo_b64': logo_b64 + } + html_content = self.template.render(**context) + nombre = f"Commercial_Invoice_{datos.factura.numero}.{formato}" + if formato == "html": return html_content.encode('utf-8'), nombre, "text/html" + + if progress_callback: progress_callback(90, "Generating PDF...") + options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None} + pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config()) + + if progress_callback: progress_callback(100, "Completed") + return pdf, nombre, "application/pdf" diff --git a/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts b/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts index 33c619f7..10daa84d 100644 --- a/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts @@ -1,15 +1,19 @@ -const BASE_URL = import.meta.env.VITE_API_URL || ''; +const BASE_URL = import.meta.env.VITE_API_URL || ''; export const invoicesReportsApi = { - - triggerPdfGeneration: async (invoiceId: number, companyId: number) => { - const params = new URLSearchParams({ company_id: companyId.toString() }); + + triggerPdfGeneration: async (invoiceId: number, companyId: number, invoiceType: string = 'mexican', currency: string = 'ORIGINAL') => { + const params = new URLSearchParams({ + company_id: companyId.toString(), + invoice_type: invoiceType, + currency_code: currency + }); const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/${invoiceId}/download-async?${params.toString()}`; - + const token = localStorage.getItem('access_token'); const response = await fetch(endpoint, { - method: 'POST', + method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' @@ -17,12 +21,12 @@ export const invoicesReportsApi = { }); if (!response.ok) throw new Error('Error al iniciar la generación'); - return await response.json(); + return await response.json(); }, getTaskStatus: async (taskId: string) => { - const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/tasks/${taskId}`; - + const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/tasks/${taskId}`; + const token = localStorage.getItem('access_token'); const response = await fetch(endpoint, { method: 'GET', diff --git a/frontend/src/lib/components/dashboard/invoices/download-invoice-button.svelte b/frontend/src/lib/components/dashboard/invoices/download-invoice-button.svelte index a04b550b..e70c4149 100644 --- a/frontend/src/lib/components/dashboard/invoices/download-invoice-button.svelte +++ b/frontend/src/lib/components/dashboard/invoices/download-invoice-button.svelte @@ -1,90 +1,23 @@ + + console.log('Download not implemented in this context')} +/> diff --git a/frontend/src/lib/components/dashboard/invoices/invoice-download-modal.svelte b/frontend/src/lib/components/dashboard/invoices/invoice-download-modal.svelte new file mode 100644 index 00000000..fcff77cc --- /dev/null +++ b/frontend/src/lib/components/dashboard/invoices/invoice-download-modal.svelte @@ -0,0 +1,184 @@ + + + + + + Descargar Factura + + Configure las opciones para generar el documento PDF. + + + +
+ +
+ +
+ + invoiceType = v}> + + {invoiceType === 'mexican' ? 'Factura Mexicana' : 'Factura Americana'} + + + Factura Mexicana + Factura Americana + + +
+ + +
+ + format = v} + disabled + > + +
+ {#if format === 'vertical'} + + Vertical + {:else} + + Horizontal + {/if} +
+
+ + +
+ + Vertical +
+
+ +
+ + Horizontal +
+
+
+
+
+
+ + +
+ +
+ {#each currencyOptions as option} + + {/each} +
+
+ +
+ +
+ +
+ {#each uomOptions as option} + + {/each} +
+
+ + +
+ +
+ {#each weightOptions as option} + + {/each} +
+
+
+ +
+ + + + + +
+
diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index 06086755..9460c94e 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -1,5 +1,7 @@
@@ -625,7 +653,7 @@ Desactualizar - @@ -636,4 +664,15 @@
+ + + + + + {#if selectedInvoice && companyStore.activeCompany} + + {/if}
\ No newline at end of file From ae7b84fce6bfa5cf9cf9e6f6116e8e6f07b6e7a7 Mon Sep 17 00:00:00 2001 From: acazares Date: Sat, 24 Jan 2026 00:39:47 +0000 Subject: [PATCH 42/55] Actualizar .gitea/workflows/build.yml --- .gitea/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index f206e756..1514055e 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -7,7 +7,7 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: self-hosted steps: - name: Checkout código From 953150bf8f748bc286ee4e69667b717628692926 Mon Sep 17 00:00:00 2001 From: acazares Date: Sat, 24 Jan 2026 00:44:46 +0000 Subject: [PATCH 43/55] Actualizar .gitea/workflows/build.yml --- .gitea/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 1514055e..06023812 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -17,7 +17,7 @@ jobs: run: | echo "${{ secrets.HARBOR_PASSWORD }}" | docker login \ dev.aduanasoft.com \ - -u ${{ secrets.HARBOR_USERNAME }} \ + -u "${{ secrets.HARBOR_USERNAME }}" \ --password-stdin # ------------------------ From 5d024487c122738d3d568819f2ca7a94d7c1190a Mon Sep 17 00:00:00 2001 From: acazares Date: Sat, 24 Jan 2026 00:46:28 +0000 Subject: [PATCH 44/55] Actualizar .gitea/workflows/build.yml --- .gitea/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 06023812..760f8edd 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -15,9 +15,9 @@ jobs: - name: Login a Harbor run: | - echo "${{ secrets.HARBOR_PASSWORD }}" | docker login \ + echo '${{ secrets.HARBOR_PASSWORD }}' | docker login \ dev.aduanasoft.com \ - -u "${{ secrets.HARBOR_USERNAME }}" \ + -u '${{ secrets.HARBOR_USERNAME }}' \ --password-stdin # ------------------------ From c4aeb1415b0da4383fe3af0a13d8cb4a79d51268 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Mon, 26 Jan 2026 11:24:10 -0600 Subject: [PATCH 45/55] Se corrigio la fraccion americana --- .../importacion/facturas/usa/service.py | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py index 8d69dfb3..d5ec1d10 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py @@ -296,33 +296,26 @@ class FacturaImportacionUsaService: desc_final = part_master.description_english or part_master.description_spanish or "No Desc." num_parte_final = part_master.part_number # Prefer US Fraction (HTS) if available - fraccion_raw = part_master.us_fraction if part_master.us_fraction else (part_master.fraction if part_master.fraction else "") + fraccion_raw = part_master.us_fraction if part_master.us_fraction else "" if part_master.fa_data and part_master.fa_data.origin_country: origen_final = part_master.fa_data.origin_country - fraccion_limpia = fraccion_raw.replace(".", "").strip() - if fraccion_limpia: - fraccion_limpia = fraccion_limpia[:8].zfill(8) - - # Fetch tariff fraction - fraccion_db = db.query(TariffFraction).filter(TariffFraction.code == fraccion_limpia).first() - + # FRACTION LOGIC: Use US Fraction (us_fraction) if available, otherwise blank + fraccion_imprimir = "" + + # Check part master US fraction + if part_master and part_master.us_fraction: + fraccion_imprimir = part_master.us_fraction.strip() + + # Optional: Format if needed, but raw is usually fine for US HTS + # If valid US fraction logic requires looking up in DB, we could add that here. + # For now, per requirement: "Si no tiene, pues de queda en blanco" + + # Default "General" and "0%" if no specific logic for US duties yet preferencia_txt = "General" advalorem_txt = "0%" - fraccion_imprimir = fraccion_raw - - if fraccion_db: - adv_db = fraccion_db.adv_impo - if adv_db and adv_db.strip() not in ["0", "0.0", "0.00", ""]: - advalorem_txt = adv_db if "%" in adv_db else f"{adv_db}%" - else: - advalorem_txt = "0%" - - fraccion_imprimir = fraccion_db.fraction or fraccion_raw - else: - fraccion_imprimir = self._format_fraccion_fallback(fraccion_limpia) # Prioritize USD for American Invoice logic if available? # Sticking to same logic as Mex for now but could prioritize USD columns. From fe60de37c7adf35e7ed2fce459b9284babf5640d Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Mon, 26 Jan 2026 11:52:19 -0600 Subject: [PATCH 46/55] Se corrigio la decripcion, se quito leyes mexicanas --- .../importacion/facturas/templates/factura_usa_ver.html | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_usa_ver.html b/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_usa_ver.html index 22025ad7..b23783cc 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_usa_ver.html +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_usa_ver.html @@ -508,10 +508,7 @@

{{ partida.numero_parte }}

{{ partida.descripcion }}

HTS Code: {{ partida.fraccion }} / Orig: {{ partida.origen or 'MEX' }}

-

- {% if partida.advalorem %}ADV: {{ partida.advalorem }}{% endif %} - {% if partida.preferencia %} / PREF: {{ partida.preferencia }}{% endif %} -

+

{{ partida.cantidad_importacion }}

From 9c0d007cf4abe9ace9f3f5883567099da64e4237 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Mon, 26 Jan 2026 17:44:16 -0600 Subject: [PATCH 47/55] Se creo la base del reporte --- .../importacion/packing_list/routes.py | 49 ++ .../importacion/packing_list/schemas.py | 88 +++ .../importacion/packing_list/service.py | 364 +++++++++++ .../reports/importacion/packing_list/task.py | 51 ++ .../packing_list/templates/packing_list.html | 615 ++++++++++++++++++ backend/api/v1/modules/a76/router.py | 8 + backend/core/celery_app.py | 3 +- .../dashboard/a76/reports/reports-invoices.ts | 44 +- .../invoices/pdf-progress-dialog.svelte | 7 +- .../routes/dashboard/invoices/+page.svelte | 32 +- frontend/test_bits.js | 7 + 11 files changed, 1257 insertions(+), 11 deletions(-) create mode 100644 backend/api/v1/modules/a76/reports/importacion/packing_list/routes.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/packing_list/schemas.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/packing_list/service.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/packing_list/task.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html create mode 100644 frontend/test_bits.js diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/routes.py b/backend/api/v1/modules/a76/reports/importacion/packing_list/routes.py new file mode 100644 index 00000000..54c4dacf --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/routes.py @@ -0,0 +1,49 @@ +from typing import Dict, Any +from fastapi import APIRouter, Depends, Query, Response, HTTPException +from sqlalchemy.orm import Session +from core.database import get_core_db +from core.security import get_current_user, validate_access_to_resource +from .service import PackingListService + +router = APIRouter() +service = PackingListService() + +from celery.result import AsyncResult +from core.celery_app import celery_app +from .task import generar_packing_list_async + +@router.get("/tasks/{task_id}") +async def get_task_status( + task_id: str, + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_core_db) +): + task_result = AsyncResult(task_id, app=celery_app) + + response = { + "task_id": task_id, + "state": task_result.state, + "result": None, + "info": None + } + + if task_result.state == 'FAILURE': + response["result"] = str(task_result.result) + elif task_result.state == 'SUCCESS': + response["result"] = task_result.result + elif task_result.state == 'PROCESSING': + response["info"] = task_result.info + + return response + +@router.post("/{invoice_id}/download-async") +async def trigger_download_packing_list( + invoice_id: int, + company_id: int = Query(..., description="ID de la empresa"), + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_core_db) +): + validate_access_to_resource(db, company_id, current_user) + + task = generar_packing_list_async.delay(invoice_id, company_id) + return {"task_id": task.id, "message": "Generación iniciada"} diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/schemas.py b/backend/api/v1/modules/a76/reports/importacion/packing_list/schemas.py new file mode 100644 index 00000000..cbbd0958 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/schemas.py @@ -0,0 +1,88 @@ +from typing import List, Optional, Union, Any +from pydantic import BaseModel, field_validator + +class ClienteSchema(BaseModel): + header: str + nombre: str + direccion: Optional[str] = "" + num_exterior: Optional[str] = "" + num_interior: Optional[str] = "" + colonia: Optional[str] = "" + codigo_postal: Optional[str] = "" + ciudad: Optional[str] = "" + estado: Optional[str] = "" + pais: Optional[str] = "" + tax_id: str + programa: Optional[str] = "" + autorizacion: Optional[str] = "" + prosec: Optional[str] = "" + reg_emp: Optional[str] = "" + cert: Optional[str] = "" + + @field_validator('direccion', 'nombre', mode='before') + @classmethod + def prevent_none(cls, v): + return v or "" + +class FacturaSchema(BaseModel): + numero: str + fecha: str + tipo_cambio: Union[float, str] + moneda: str + pedimento: str = "" + clave_pedimento: str = "" + remesa: str = "" + acuse_electronico: str = "" + agente_aduanal: str = "" + patente: str = "" + precinto: str = "" + regimen: str = "" + transportista: str = "" + scac: str = "" + caat: str = "" + incoterm: str = "" + transporte: str = "" + num_transporte: str = "" + placas: str = "" + placas_remolque: str = "" + licencia_conductor: str = "" + aduana: str = "" + destino: str = "" + observaciones: str = "" + +class PartidaSchema(BaseModel): + numero_parte: str + descripcion: str + fraccion: str + origen: str + + advalorem:Optional[str] = "" + preferencia:Optional[str] = "" + + cantidad_importacion: Union[float, str] + unidad_medida: str + cantidad_bultos: int + clave_bultos: str + peso_neto: Union[float, str] + peso_bruto: Union[float, str] + valor_costo_unitario: Union[float, str] = "" + valor_total: Union[float, str] = "" + +class TotalesSchema(BaseModel): + cantidad_total: Union[float, str] + bultos_total: int + clave_bultos: str = "" + peso_neto_total: Union[float, str] + peso_bruto_total: Union[float, str] + valor_total_total: Union[float, str] = "" + valor_total_dolares: Union[float, str] = "" + +class PackingListSchema(BaseModel): + cliente_proveedor: ClienteSchema + cliente_vendido: ClienteSchema + cliente_enviado: ClienteSchema + factura: FacturaSchema + partidas: List[PartidaSchema] + totales: TotalesSchema + logo_b64: Optional[str] = None + diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py new file mode 100644 index 00000000..a31a1eb2 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py @@ -0,0 +1,364 @@ +import shutil +import base64 +import pdfkit +from pathlib import Path +from decimal import Decimal +from typing import Tuple, List, Callable, Optional, Dict + +from jinja2 import Environment, FileSystemLoader, select_autoescape +from fastapi import HTTPException +from pydantic import ValidationError +from sqlalchemy.orm import Session + +# --- MODELOS --- +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics +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.line_items.models import LineItem +from api.v1.modules.a76.clients_and_providers.models import ( + ClientProvider, ClientProviderAddress, ClientProviderPrograms +) +from api.v1.modules.a76.parts.models import Part +from api.v1.modules.a76.pedmientos.models import Pedimentos +from api.v1.modules.a76.general_catalogs.company.models import Company +from api.v1.modules.a76.customs_brokers.models import CustomsBroker +from api.v1.modules.a76.items.models import Item + +# --- TRANSPORTATION MODELS --- +from api.v1.modules.a76.transportation.transporters.models import Transporter +from api.v1.modules.a76.transportation.vehicles.models import Vehicle +from api.v1.modules.a76.transportation.trailers.models import Trailer +from api.v1.modules.a76.transportation.drivers.models import Driver + +# --- MODELO DE FRACCIONES --- +from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction + +# --- SCHEMAS --- +from .schemas import ( + ClienteSchema, PartidaSchema, TotalesSchema, + FacturaSchema, PackingListSchema +) + +class PackingListService: + def __init__(self): + self.template_dir = Path(__file__).parent / "templates" + self.jinja_env = Environment( + loader=FileSystemLoader(self.template_dir), + autoescape=select_autoescape(['html', 'xml']) + ) + self.template = self.jinja_env.get_template('packing_list.html') + + def _get_wkhtmltopdf_config(self): + # List of possible paths + paths = [ + shutil.which("wkhtmltopdf"), + "/usr/local/bin/wkhtmltopdf", + "/usr/bin/wkhtmltopdf", + "C:\\Program Files\\wkhtmltopdf\\bin\\wkhtmltopdf.exe" + ] + + path = next((p for p in paths if p and Path(p).exists()), None) + + if not path: + # If we are in dev and cannot find it, try to mock it or raise clearer error + if shutil.which("echo"): + print("WARNING: wkhtmltopdf not found, PDF generation will fail.") + raise RuntimeError(f"wkhtmltopdf binary not found. Searched in: {paths}") + + return pdfkit.configuration(wkhtmltopdf=path) + + def formatear_numero(self, valor, decimales: int = 2): + if valor is None: return 0.0 + try: + return round(float(valor), decimales) + except: return 0.0 + + def _format_fraccion_fallback(self, fraccion_raw: str) -> str: + if not fraccion_raw or len(fraccion_raw) < 8: + return fraccion_raw or "" + return f"{fraccion_raw[:4]}.{fraccion_raw[4:6]}.{fraccion_raw[6:]}" + + def _obtener_datos_cliente(self, db: Session, client_id: int, rol: str) -> ClienteSchema: + main = db.query(ClientProvider).filter(ClientProvider.id == client_id).first() + if not main: + return ClienteSchema(header=rol, nombre="Desconocido", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="MEX") + + addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first() + prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first() + + return ClienteSchema( + header=rol, + nombre=(main.name or main.short_name) or "S/N", + direccion=(addr.streets or "") if addr else "", + num_exterior=(addr.exterior_number or "") if addr else "", + num_interior=(addr.interior_number or "") if addr else "", + colonia=(addr.neighborhood or "") if addr else "", + codigo_postal=(addr.postal_code or "") if addr else "", + ciudad=(addr.city or "") if addr else "", + estado=(addr.state or "") if addr else "", + pais=(addr.country or "MEX") if addr else "MEX", + tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""), + programa="IMMEX" if (prog and prog.program) else "", + autorizacion=prog.program_number if prog else "", + prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "", + reg_emp=prog.val_certified_company_registry if (prog and hasattr(prog, 'val_certified_company_registry')) else ( + prog.certified_company_registry if (prog and prog.certified_company_registry) else "" + ), + cert=prog.is_certified_company if (prog and prog.is_certified_company) else "" + ) + + def get_packing_list_data(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> PackingListSchema: + try: + if progress_callback: progress_callback(10, "Buscando factura...") + header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first() + if not header: raise HTTPException(status_code=404, detail="Factura no encontrada") + + compliance = header.compliance_mx + logistics = header.logistics if header.logistics else None + financials = header.financials if header.financials else None + + if progress_callback: progress_callback(20, "Obteniendo datos de pedimento...") + pedimento_id = compliance.pedimento_id if (compliance and compliance.pedimento_id) else header.related_doc_id + pedimento = db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() if pedimento_id else None + + if progress_callback: progress_callback(30, "Obteniendo cliente y proveedor...") + proveedor_id = compliance.provider_id if compliance else None + cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:") if proveedor_id else ClienteSchema(header="Proveedor", nombre="No Asignado", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="") + + nombre_agente = "" + if compliance and compliance.customs_broker_id: + broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first() + if broker: nombre_agente = broker.name + + company = db.query(Company).filter(Company.id == header.company_id).first() + # Datos Default (Company/Importer) + cliente_default = ClienteSchema( + header="Importador / consignatario:", + nombre=getattr(company, 'name', "Empresa Local"), + direccion="DOMICILIO FISCAL", + num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX", + tax_id=getattr(company, 'rfc', ""), + programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "") + ) + + # Left Side Logic (Consignatario / Sold To) + cliente_vendido = cliente_default + if compliance and compliance.sold_to_id: + raw_header = compliance.sold_to_header or "CONSIGNATARIO" + clean_header = raw_header.replace("_", " ").capitalize() + ":" + cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header) + + # Right Side Logic (Enviado A / Shipped To) + cliente_enviado = cliente_default + if compliance and compliance.shipped_to_id: + raw_header_shipped = compliance.shipped_to_header or "DESTINATARIO" + clean_header_shipped = raw_header_shipped.replace("_", " ").capitalize() + ":" + cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped) + + remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else "" + acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A" + + patente_val = "" + if pedimento and pedimento.license: + patente_val = pedimento.license + elif 'broker' in locals() and broker and broker.license: + patente_val = broker.license + + # --- Transport Data Fetching --- + transporte_txt = str(logistics.transport_type) if (logistics and logistics.transport_type) else "" + num_transporte_val = (logistics.trailer_num or "") if logistics else "" + + placas_val = (logistics.license_plate or "") if logistics else "" + placas_remolque_val = "" + transportista_val = (logistics.carrier_id or "") if logistics else "" + caat_val = "" + scac_val = "" + licencia_cond_val = "" + + if logistics: + # 1. Transporter (CAAT / SCAC) + if logistics.carrier_id: + transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first() + if transporter_obj: + caat_val = transporter_obj.caat_code or "" + scac_val = transporter_obj.transport_code or "" + transportista_val = transporter_obj.name or logistics.carrier_id + + # 2. Vehicle (Placas Tracto) + if logistics.transport_id: + veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first() + if veh_obj: + placas_val = veh_obj.plate_number or placas_val + elif logistics.vehicle_num: + veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.vehicle_num).first() + if veh_obj: + placas_val = veh_obj.plate_number or placas_val + + # 3. Trailer (Placas Remolque) + if logistics.trailer_num: + trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first() + if trl_obj: + placas_remolque_val = trl_obj.plate_number or "" + + # 4. Driver (License) + if logistics.carrier_id and logistics.driver_name: + drv_obj = db.query(Driver).filter( + Driver.transporter_key == logistics.carrier_id, + Driver.driver_name == logistics.driver_name + ).first() + if drv_obj: + licencia_cond_val = drv_obj.license_number or "" + + factura_schema = FacturaSchema( + numero=header.invoice_number or "S/N", + fecha=str(header.invoice_date) if header.invoice_date else "", + tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0), + moneda=getattr(header, 'currency', "USD") or "USD", + incoterm=(logistics.incoterm or "") if logistics else "", + observaciones=header.observation_es or header.observation_en or "", + pedimento=f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" if pedimento else "", + clave_pedimento=pedimento.pedimento_code if pedimento else "", + regimen=header.document_type or "", + patente=patente_val, + agente_aduanal=nombre_agente, + transporte=transporte_txt, + num_transporte=num_transporte_val, + placas=placas_val, + placas_remolque=placas_remolque_val, + transportista=transportista_val, + caat=caat_val, + scac=scac_val, + licencia_conductor=licencia_cond_val, + aduana=compliance.aduana if (compliance and compliance.aduana) else (pedimento.customs_office[:2] if (pedimento and pedimento.customs_office) else ""), + precinto=(logistics.seal_number or "") if logistics else "", + destino=(logistics.destination_goods or "") if logistics else "", + remesa=remesa_valor, acuse_electronico=acuse_valor + ) + + if progress_callback: progress_callback(50, "Procesando partidas...") + lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(Item.invoice_id == header.id).all() + partidas_list = [] + + for line in lines: + qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first() + part_master = db.query(Part).filter(Part.id == line.part_number).first() + + desc_final = "S/D" + num_parte_final = str(line.part_number or "S/N") + fraccion_raw = "" + origen_final = "MEX" + + if part_master: + desc_final = part_master.description_spanish or part_master.description_english or "Sin Desc." + num_parte_final = part_master.part_number + fraccion_raw = part_master.fraction if part_master.fraction else "" + + if part_master.fa_data and part_master.fa_data.origin_country: + origen_final = part_master.fa_data.origin_country + + fraccion_limpia = fraccion_raw.replace(".", "").strip() + if fraccion_limpia: + fraccion_limpia = fraccion_limpia[:8].zfill(8) + + fraccion_db = db.query(TariffFraction).filter(TariffFraction.code == fraccion_limpia).first() + + fraccion_imprimir = fraccion_raw + if fraccion_db: + fraccion_imprimir = fraccion_db.fraction or fraccion_raw + else: + fraccion_imprimir = self._format_fraccion_fallback(fraccion_limpia) + + # FOR PACKING LIST: FINANCIALS ARE HIDDEN/EMPTY + v_unitario = "" + v_total = "" + + partidas_list.append(PartidaSchema( + numero_parte=num_parte_final, + descripcion=desc_final, + fraccion=fraccion_imprimir, + origen=origen_final, + advalorem="", # Hidden + preferencia="", # Hidden + cantidad_importacion=qty.quantity if qty else 0, + unidad_medida=qty.weight_unit if qty else "PZA", + cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0, + clave_bultos=(qty.package_key or "") if qty else "", + peso_neto=qty.net_weight if qty else 0, + peso_bruto=qty.gross_weight if qty else 0, + valor_costo_unitario=v_unitario, # Hidden + valor_total=v_total # Hidden + )) + + totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio)) + + return PackingListSchema( + cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido, + cliente_enviado=cliente_enviado, factura=factura_schema, + partidas=partidas_list, totales=totales + ) + + except ValidationError as e: + print(f"Validation Error: {e.json()}") + raise HTTPException(status_code=500, detail=f"Schema Error: {e}") + except Exception as e: + print(f"Error Service A76: {e}") + raise HTTPException(status_code=500, detail=f"Error: {str(e)}") + + def calcular_totales(self, partidas: List[PartidaSchema], tipo_cambio: Decimal) -> TotalesSchema: + cant = sum(float(p.cantidad_importacion) for p in partidas) + # Financial totals hidden + peso_n = sum(float(p.peso_neto) for p in partidas) + peso_b = sum(float(p.peso_bruto) for p in partidas) + bultos = sum(p.cantidad_bultos for p in partidas) + + claves = [p.clave_bultos for p in partidas if p.clave_bultos] + clave_comun = max(set(claves), key=claves.count) if claves else "" + if bultos > 1 and clave_comun and not clave_comun.endswith("S"): clave_comun += "S" + + return TotalesSchema( + cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun, + peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b), + valor_total_total="", valor_total_dolares="" + ) + + def generate_pdf(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> Tuple[bytes, str]: + if progress_callback: progress_callback(5, "Iniciando servicio de reporte...") + data = self.get_packing_list_data(db, invoice_id, company_id, progress_callback) + + if progress_callback: progress_callback(80, "Renderizando plantilla...") + + # LOGO LOGIC + logo_b64 = None + try: + comp_logo = db.query(Company).filter(Company.id == company_id).first() + if comp_logo and comp_logo.logo: + p = Path(comp_logo.logo) + target_path = p + if not target_path.exists(): + fallback = Path(f"app_data/logos/{company_id}") / p.name + if fallback.exists(): + target_path = fallback + + if target_path.exists(): + with open(target_path, "rb") as image_file: + encoded_string = base64.b64encode(image_file.read()).decode('utf-8') + mime = "image/png" + if target_path.suffix.lower() in ['.jpg', '.jpeg']: mime = "image/jpeg" + logo_b64 = f"data:{mime};base64,{encoded_string}" + except Exception as e: + print(f"Error loading logo: {e}") + + data.logo_b64 = logo_b64 # Assign logo to schema + + context = data.model_dump() + html_content = self.template.render(**context) + + if progress_callback: progress_callback(90, "Generando PDF final...") + + options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None} + pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config()) + + if progress_callback: progress_callback(100, "Completado") + + filename = f"PackingList_{data.factura.numero}.pdf" + return pdf, filename diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/task.py b/backend/api/v1/modules/a76/reports/importacion/packing_list/task.py new file mode 100644 index 00000000..bf55991b --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/task.py @@ -0,0 +1,51 @@ +import base64 +from core.celery_app import celery_app +from core.database import CoreSessionLocal +from .service import PackingListService + +@celery_app.task(bind=True) +def generar_packing_list_async(self, invoice_id: int, company_id: int): + """ + Tarea asíncrona para generar el Packing List + """ + db = CoreSessionLocal() + try: + service = PackingListService() + + def update_progress(percent, message): + self.update_state( + state='PROCESSING', + meta={ + 'current': percent, + 'total': 100, + 'status': message + } + ) + + pdf_bytes, filename = service.generate_pdf(db, invoice_id, company_id, update_progress) + + # Codificar a base64 para enviar por JSON + pdf_b64 = base64.b64encode(pdf_bytes).decode('utf-8') + + return { + 'status': 'success', + 'file_name': filename, + 'content': pdf_b64, + 'media_type': 'application/pdf' + } + + except Exception as e: + print(f"Error en tarea Packing List: {e}") + import traceback + traceback.print_exc() + self.update_state( + state='FAILURE', + meta={ + 'exc_type': type(e).__name__, + 'exc_message': str(e), + 'custom': 'Error generating PDF' + } + ) + raise e + finally: + db.close() diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html b/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html new file mode 100644 index 00000000..06b9f725 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html @@ -0,0 +1,615 @@ + + + + + + Packing List - {{ factura.numero }} + + + + +
+
+
+

PACKING LIST / LISTA DE EMPAQUE

+
+

+
+
+

+


+
+
+
+
+ {% if logo_b64 %} +
+ +
+ {% endif %} +
+

{{ cliente_proveedor.header }}

+

{{ cliente_proveedor.nombre }}

+

{{ cliente_proveedor.direccion }} + {% if cliente_proveedor.num_exterior %} Ext: {{ cliente_proveedor.num_exterior }}{% endif %} + {% if cliente_proveedor.num_interior %} Int: {{ cliente_proveedor.num_interior }}{% endif %} +

+

{{ cliente_proveedor.colonia }} {% if cliente_proveedor.codigo_postal %} CP: {{ + cliente_proveedor.codigo_postal }}{% endif %}

+

{{ cliente_proveedor.ciudad }}, {{ cliente_proveedor.estado }}, {{ cliente_proveedor.pais }}

+

TAX ID: {{ cliente_proveedor.tax_id }} + {% if cliente_proveedor.programa and cliente_proveedor.programa != 'Ninguno' %} + {{ cliente_proveedor.programa }}: {{ cliente_proveedor.autorizacion }} + {% endif %} +

+


+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

FACTURA:

+
+

{{ factura.numero }}

+
+

Fecha:

+
+

{{ factura.fecha }}

+
+

T. Cambio:

+
+

{{ factura.tipo_cambio }}

+
+

Pedimento:

+
+

{{ factura.pedimento or '' }}

+
+

Clave:

+
+

{{ factura.clave_pedimento or '' }}

+
+

Remesa:

+
+

{{ factura.remesa or '' }}

+
+

Acuse:

+
+

{{ factura.acuse_electronico or 'N/A' }}

+
+

Agente Aduanal:

+

{{ factura.agente_aduanal or '' }}

+
+

Patente: {{ factura.patente or '' }}

+
+ Regimen:{{ + factura.regimen or '' }} + +

INCOTERM:

+

{{ factura.incoterm or '' }}

+
+ {% if factura.precinto %} +

Precinto: {{ factura.precinto }}

+ {% endif %} +
+

Aduana: {{ factura.aduana or '' }}

+
+ {% if factura.destino %} +

Destino: {{ factura.destino }}

+ {% endif %} +
+
+
+ +
+
+

{{ cliente_vendido.header }}

+

{{ cliente_vendido.nombre }}

+

{{ cliente_vendido.direccion }} + {% if cliente_vendido.num_exterior %} Ext: {{ cliente_vendido.num_exterior }}{% endif %} + {% if cliente_vendido.num_interior %} Int: {{ cliente_vendido.num_interior }}{% endif %} +

+

{{ cliente_vendido.colonia }} {% if cliente_vendido.codigo_postal %} CP: {{ + cliente_vendido.codigo_postal }}{% endif %}

+

{{ cliente_vendido.ciudad }}, {{ cliente_vendido.estado }}, {{ cliente_vendido.pais }} +

+

RFC: {{ cliente_vendido.tax_id }} + {% if cliente_vendido.programa and cliente_vendido.programa != 'Ninguno' %} + {{ cliente_vendido.programa }}: {{ cliente_vendido.autorizacion }} + {% endif %} +

+

+ {% if cliente_vendido.prosec %}PROSEC: {{ cliente_vendido.prosec }} {% endif %} + {% if cliente_vendido.reg_emp %}REG EMP: {{ cliente_vendido.reg_emp }} {% endif %} + {% if cliente_vendido.cert %}CERT: {{ cliente_vendido.cert }}{% endif %} +

+
+ +
+

{{ cliente_enviado.header }}

+

{{ cliente_enviado.nombre }}

+

{{ cliente_enviado.direccion }} + {% if cliente_enviado.num_exterior %} Ext: {{ cliente_enviado.num_exterior }}{% endif %} + {% if cliente_enviado.num_interior %} Int: {{ cliente_enviado.num_interior }}{% endif %} +

+

{{ cliente_enviado.colonia }} {% if cliente_enviado.codigo_postal %} CP: {{ + cliente_enviado.codigo_postal }}{% endif %}

+

{{ cliente_enviado.ciudad }}, {{ cliente_enviado.estado }}, {{ cliente_enviado.pais }} +

+

RFC: {{ cliente_enviado.tax_id }} + {% if cliente_enviado.programa and cliente_enviado.programa != 'Ninguno' %} + {{ cliente_enviado.programa }}: {{ cliente_enviado.autorizacion }} + {% endif %} +

+

+ {% if cliente_enviado.prosec %}PROSEC: {{ cliente_enviado.prosec }} {% endif %} + {% if cliente_enviado.reg_emp %}REG EMP: {{ cliente_enviado.reg_emp }} {% endif %} + {% if cliente_enviado.cert %}CERT: {{ cliente_enviado.cert }}{% endif %} +

+
+
+


+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for partida in partidas %} + + + + + + + + + + {% endfor %} + + + + + + + + + + + + + + + + + + + +
+

Transportista:

+
+

{{ factura.transportista or '' }}

+
+

SCAC: {{ factura.scac or '' }}

+
+

INCOTERM:

+
+

{{ factura.incoterm or '' }}

+
+

Aduana: {{ factura.aduana or '' }}

+
+

Transporte:

+
+

{{ factura.transporte or '' }}: {{ factura.num_transporte or '' }}

+
+

CAAT: {{ factura.caat or '' }}

+
+

Placas: {{ factura.placas or '' }} / Rem: {{ factura.placas_remolque or + '' }}

+
+

Chofer/Licencia:

+
+

{{ factura.licencia_conductor or 'N/A' }}

+
+

Línea

+
+

Número de Parte

+

Descripción

+
+

Comercial

+
+

Empaque

+
+

Peso (KGS)

+
+

Cantidad

+
+

U.M.

+
+

Tipo

+
+

Neto

+
+

Bruto

+
+

{{ loop.index }}

+
+

{{ partida.numero_parte }}

+

{{ partida.descripcion }}

+
+

{{ partida.cantidad_importacion }}

+
+

{{ partida.unidad_medida }}

+
+

+ {% if partida.cantidad_bultos != 0 %}{{ partida.cantidad_bultos }}{% endif %} + {{ partida.clave_bultos }} +

+
+

{{ partida.peso_neto }}

+
+

{{ partida.peso_bruto }}

+
+

+ Observaciones: + TOTALES +

+
+

{{ totales.cantidad_total }}

+
+

+ {% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %} + {{ totales.clave_bultos or '' }} +

+
+

{{ totales.peso_neto_total }}

+
+

{{ totales.peso_bruto_total }}

+
+

{{ factura.observaciones }}

+
+

+

{{ cliente_proveedor.nombre }}

+


+
+


+
+ + + \ No newline at end of file diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 24220d6d..6da0ba71 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -51,6 +51,8 @@ from api.v1.modules.public.reference_data.material_types.routes import router as # --- NUEVO IMPORT PARA REPORTES DE FACTURAS --- from .reports.importacion.facturas.routes import router as invoices_reports_router from .reports.importacion.consolidados.routes import router as consolidated_reports_router +from .reports.importacion.packing_list.routes import router as packing_list_router + # Router principal @@ -130,4 +132,10 @@ router.include_router( consolidated_reports_router, prefix="/a76/reports/importacion/consolidados", tags=["a76 / reports"] +) + +router.include_router( + packing_list_router, + prefix="/a76/reports/importacion/packing-lists", + tags=["a76 / reports"] ) \ No newline at end of file diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index c118db31..bc8bd492 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -10,7 +10,8 @@ celery_app = Celery( backend=valkey_url, include=[ "api.v1.modules.a76.reports.importacion.facturas.task", - "api.v1.modules.a76.reports.importacion.consolidados.task" + "api.v1.modules.a76.reports.importacion.consolidados.task", + "api.v1.modules.a76.reports.importacion.packing_list.task" ] # Ruta al módulo donde están las tareas ) diff --git a/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts b/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts index 33c619f7..92d12917 100644 --- a/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts @@ -1,15 +1,15 @@ -const BASE_URL = import.meta.env.VITE_API_URL || ''; +const BASE_URL = import.meta.env.VITE_API_URL || ''; export const invoicesReportsApi = { - + triggerPdfGeneration: async (invoiceId: number, companyId: number) => { const params = new URLSearchParams({ company_id: companyId.toString() }); const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/${invoiceId}/download-async?${params.toString()}`; - + const token = localStorage.getItem('access_token'); const response = await fetch(endpoint, { - method: 'POST', + method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' @@ -17,12 +17,12 @@ export const invoicesReportsApi = { }); if (!response.ok) throw new Error('Error al iniciar la generación'); - return await response.json(); + return await response.json(); }, getTaskStatus: async (taskId: string) => { - const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/tasks/${taskId}`; - + const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/tasks/${taskId}`; + const token = localStorage.getItem('access_token'); const response = await fetch(endpoint, { method: 'GET', @@ -31,5 +31,35 @@ export const invoicesReportsApi = { if (!response.ok) throw new Error('Error al consultar estado'); return await response.json(); + }, + + triggerPackingListGeneration: async (invoiceId: number, companyId: number) => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const endpoint = `${BASE_URL}/v1/a76/reports/importacion/packing-lists/${invoiceId}/download-async?${params.toString()}`; + + const token = localStorage.getItem('access_token'); + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) throw new Error('Error al iniciar la generación de Packing List'); + return await response.json(); + }, + + getPackingListTaskStatus: async (taskId: string) => { + const endpoint = `${BASE_URL}/v1/a76/reports/importacion/packing-lists/tasks/${taskId}`; + + const token = localStorage.getItem('access_token'); + const response = await fetch(endpoint, { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!response.ok) throw new Error('Error al consultar estado de Packing List'); + return await response.json(); } }; \ No newline at end of file diff --git a/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte index 36fc7e38..685c34a4 100644 --- a/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte @@ -63,9 +63,12 @@ } else if (response.state === 'FAILURE') { hasError = true; - statusMessage = "Error al generar el PDF"; + // Intenta mostrar el mensaje de error real si viene en 'result' + const errMsg = response.result ? String(response.result) : "Error desconocido"; + statusMessage = `Error: ${errMsg}`; stopPolling(); - toast.error("Falló la generación del PDF"); + toast.error(`Falló la generación: ${errMsg}`); + console.error("Task failed with result:", response); } } catch (error) { console.error("Error polling task status:", error); diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index 06086755..e4e04619 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -13,7 +13,7 @@ import type { PageData } from './$types'; import { browser } from '$app/environment'; import { companyStore } from '$lib/stores/company.svelte'; - import { Plus, RefreshCw, FileText, RotateCcw, Boxes } from 'lucide-svelte'; + import { Plus, RefreshCw, FileText, RotateCcw, Boxes, Package } from 'lucide-svelte'; // IMPORTANTE: Asegúrate de tener instalada svelte-sonner para las notificaciones import { toast } from "svelte-sonner"; @@ -394,6 +394,32 @@ console.error(error); toast.error("No se pudo iniciar la descarga del consolidado"); } + + } + + async function handleDownloadPackingList(invoice: any) { + if (!companyStore.activeCompany) { + toast.error("No hay empresa seleccionada"); + return; + } + + try { + // 1. Trigger: Start task in Celery + const { task_id } = await invoicesReportsApi.triggerPackingListGeneration( + invoice.id, + companyStore.activeCompany.id + ); + + // 2. Open progress dialog + currentTaskId = task_id; + // Use the specific status function for Packing List + currentStatusFunction = invoicesReportsApi.getPackingListTaskStatus; + showProgressDialog = true; + + } catch (error) { + console.error(error); + toast.error("No se pudo iniciar la descarga del Packing List"); + } } function onPdfComplete(result: any) { @@ -633,6 +659,10 @@ Consolidado +
diff --git a/frontend/test_bits.js b/frontend/test_bits.js new file mode 100644 index 00000000..6228649d --- /dev/null +++ b/frontend/test_bits.js @@ -0,0 +1,7 @@ +import { Dialog } from "bits-ui"; +console.log("Dialog is:", Dialog); +try { + console.log("Dialog.Root is:", Dialog.Root); +} catch (e) { + console.log("Error accessing Dialog.Root:", e.message); +} From 75fc4d56f945d1fea262e111ef98811ea4434dfd Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Mon, 26 Jan 2026 18:03:35 -0600 Subject: [PATCH 48/55] Confguracion de la plantilla --- .../packing_list/templates/packing_list.html | 281 ++++++++---------- 1 file changed, 124 insertions(+), 157 deletions(-) diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html b/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html index 06b9f725..4ed057d9 100644 --- a/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html @@ -313,7 +313,7 @@ -

FACTURA:

+

PACKING LIST / LISTA DE EMPAQUE:

{{ factura.numero }}

@@ -363,7 +363,7 @@ -

Agente Aduanal:

+

Mx custom broker / agente aduanal mexicano:

{{ factura.agente_aduanal or '' }}

@@ -451,164 +451,131 @@


- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% for partida in partidas %} - - - - - - - - - - {% endfor %} - + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - + + {% for partida in partidas %} + + + + + + + + + + + {% endfor %} + + + + + + + + + + + + + + + + + + + +
-

Transportista:

-
-

{{ factura.transportista or '' }}

-
-

SCAC: {{ factura.scac or '' }}

-
-

INCOTERM:

-
-

{{ factura.incoterm or '' }}

-
-

Aduana: {{ factura.aduana or '' }}

-
-

Transporte:

-
-

{{ factura.transporte or '' }}: {{ factura.num_transporte or '' }}

-
-

CAAT: {{ factura.caat or '' }}

-
-

Placas: {{ factura.placas or '' }} / Rem: {{ factura.placas_remolque or - '' }}

-
-

Chofer/Licencia:

-
-

{{ factura.licencia_conductor or 'N/A' }}

-
-

Línea

-
-

Número de Parte

-

Descripción

-
-

Comercial

-
-

Empaque

-
-

Peso (KGS)

-
-

Cantidad

-
-

U.M.

-
-

Tipo

-
-

Neto

-
-

Bruto

-
-

{{ loop.index }}

-
-

{{ partida.numero_parte }}

-

{{ partida.descripcion }}

-
-

{{ partida.cantidad_importacion }}

-
-

{{ partida.unidad_medida }}

-
-

- {% if partida.cantidad_bultos != 0 %}{{ partida.cantidad_bultos }}{% endif %} - {{ partida.clave_bultos }} -

-
-

{{ partida.peso_neto }}

-
-

{{ partida.peso_bruto }}

-
+

Línea

+
+

Número de Parte

+

Descripción

+
+

Comercial

+
+

Empaque

+
+

Peso (KGS)

+
+

Cantidad

+
+

U.M.

+
+

Cant.

+
+

Tipo

+
+

Neto

+
+

Bruto

+
-

- Observaciones: - TOTALES -

-
-

{{ totales.cantidad_total }}

-
-

- {% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %} - {{ totales.clave_bultos or '' }} -

-
-

{{ totales.peso_neto_total }}

-
-

{{ totales.peso_bruto_total }}

-
-

{{ factura.observaciones }}

-
-

-

{{ cliente_proveedor.nombre }}

-


-
-


-
+

{{ loop.index }}

+
+

{{ partida.numero_parte }}

+

{{ partida.descripcion }}

+
+

{{ partida.cantidad_importacion }}

+
+

{{ partida.unidad_medida }}

+
+

+ {% if partida.cantidad_bultos != 0 %}{{ partida.cantidad_bultos }}{% endif %} +

+
+

+ {{ partida.clave_bultos }} +

+
+

{{ partida.peso_neto }}

+
+

{{ partida.peso_bruto }}

+
+

+ Observaciones: + TOTALES +

+
+

{{ totales.cantidad_total }}

+
+

+ {% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %} +

+
+

+ {{ totales.clave_bultos or '' }} +

+
+

{{ totales.peso_neto_total }}

+
+

{{ totales.peso_bruto_total }}

+
+

{{ factura.observaciones }}

+
+

+

{{ cliente_proveedor.nombre }}

+


+
+


+
From 50504be30b4c833d8b74807f6f7bc47a7508a2ec Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Tue, 27 Jan 2026 10:26:07 -0600 Subject: [PATCH 49/55] Se pusieron los datos correctos en la tabla --- .../importacion/packing_list/schemas.py | 5 + .../importacion/packing_list/service.py | 59 ++- .../packing_list/templates/packing_list.html | 369 ++++++++---------- .../dropdown-menu/dropdown-menu-root.svelte | 7 + .../ui/dropdown-menu/dropdown-menu-sub.svelte | 7 + .../lib/components/ui/dropdown-menu/index.ts | 6 +- 6 files changed, 223 insertions(+), 230 deletions(-) create mode 100644 frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-root.svelte create mode 100644 frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/schemas.py b/backend/api/v1/modules/a76/reports/importacion/packing_list/schemas.py index cbbd0958..fcaaae40 100644 --- a/backend/api/v1/modules/a76/reports/importacion/packing_list/schemas.py +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/schemas.py @@ -54,6 +54,7 @@ class PartidaSchema(BaseModel): numero_parte: str descripcion: str fraccion: str + fraccion_americana: Optional[str] = "" origen: str advalorem:Optional[str] = "" @@ -65,6 +66,8 @@ class PartidaSchema(BaseModel): clave_bultos: str peso_neto: Union[float, str] peso_bruto: Union[float, str] + peso_neto_lbs: Union[float, str] = 0.0 + peso_bruto_lbs: Union[float, str] = 0.0 valor_costo_unitario: Union[float, str] = "" valor_total: Union[float, str] = "" @@ -74,6 +77,8 @@ class TotalesSchema(BaseModel): clave_bultos: str = "" peso_neto_total: Union[float, str] peso_bruto_total: Union[float, str] + peso_neto_total_lbs: Union[float, str] = 0.0 + peso_bruto_total_lbs: Union[float, str] = 0.0 valor_total_total: Union[float, str] = "" valor_total_dolares: Union[float, str] = "" diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py index a31a1eb2..43f0036d 100644 --- a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py @@ -3,7 +3,7 @@ import base64 import pdfkit from pathlib import Path from decimal import Decimal -from typing import Tuple, List, Callable, Optional, Dict +from typing import Tuple, List, Callable, Optional from jinja2 import Environment, FileSystemLoader, select_autoescape from fastapi import HTTPException @@ -12,9 +12,9 @@ from sqlalchemy.orm import Session # --- MODELOS --- from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics -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.line_items.models import LineItem +from api.v1.modules.a76.items.line_customs.models import LineCustom from api.v1.modules.a76.clients_and_providers.models import ( ClientProvider, ClientProviderAddress, ClientProviderPrograms ) @@ -60,7 +60,6 @@ class PackingListService: path = next((p for p in paths if p and Path(p).exists()), None) if not path: - # If we are in dev and cannot find it, try to mock it or raise clearer error if shutil.which("echo"): print("WARNING: wkhtmltopdf not found, PDF generation will fail.") raise RuntimeError(f"wkhtmltopdf binary not found. Searched in: {paths}") @@ -133,7 +132,7 @@ class PackingListService: company = db.query(Company).filter(Company.id == header.company_id).first() # Datos Default (Company/Importer) cliente_default = ClienteSchema( - header="Importador / consignatario:", + header="Importer / Consignee:", nombre=getattr(company, 'name', "Empresa Local"), direccion="DOMICILIO FISCAL", num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX", @@ -144,15 +143,18 @@ class PackingListService: # Left Side Logic (Consignatario / Sold To) cliente_vendido = cliente_default if compliance and compliance.sold_to_id: - raw_header = compliance.sold_to_header or "CONSIGNATARIO" - clean_header = raw_header.replace("_", " ").capitalize() + ":" + raw = (compliance.sold_to_header or "").upper() + if "CONSIGN" in raw: + clean_header = "Consignee / Consignatario:" + else: + clean_header = "Sold To / Vendido a:" + cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header) # Right Side Logic (Enviado A / Shipped To) cliente_enviado = cliente_default if compliance and compliance.shipped_to_id: - raw_header_shipped = compliance.shipped_to_header or "DESTINATARIO" - clean_header_shipped = raw_header_shipped.replace("_", " ").capitalize() + ":" + clean_header_shipped = "Shipped To / Enviado a:" cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped) remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else "" @@ -241,17 +243,45 @@ class PackingListService: for line in lines: qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first() + + # --- WEIGHT CALCULATION LOGIC --- + peso_neto_kg = 0.0 + peso_bruto_kg = 0.0 + peso_neto_lb = 0.0 + peso_bruto_lb = 0.0 + + if qty: + raw_net = float(qty.net_weight or 0) + raw_gross = float(qty.gross_weight or 0) + unit = (qty.weight_unit or "KG").upper() + + if unit == "LB" or unit == "LBS": + peso_neto_lb = raw_net + peso_bruto_lb = raw_gross + peso_neto_kg = raw_net / 2.20462 + peso_bruto_kg = raw_gross / 2.20462 + else: # Default KG + peso_neto_kg = raw_net + peso_bruto_kg = raw_gross + peso_neto_lb = raw_net * 2.20462 + peso_bruto_lb = raw_gross * 2.20462 + # -------------------------------- + + custom_obj = db.query(LineCustom).filter(LineCustom.item_line_id == line.id).first() part_master = db.query(Part).filter(Part.id == line.part_number).first() desc_final = "S/D" num_parte_final = str(line.part_number or "S/N") fraccion_raw = "" origen_final = "MEX" + uom_comercial = "PZA" # Default UOM if part_master: desc_final = part_master.description_spanish or part_master.description_english or "Sin Desc." num_parte_final = part_master.part_number fraccion_raw = part_master.fraction if part_master.fraction else "" + # Commercial UOM from Part Master + uom_comercial = part_master.unit_of_measure or "PZA" if part_master.fa_data and part_master.fa_data.origin_country: origen_final = part_master.fa_data.origin_country @@ -276,15 +306,18 @@ class PackingListService: numero_parte=num_parte_final, descripcion=desc_final, fraccion=fraccion_imprimir, + fraccion_americana=custom_obj.american_fraction if custom_obj and custom_obj.american_fraction else "", origen=origen_final, advalorem="", # Hidden preferencia="", # Hidden cantidad_importacion=qty.quantity if qty else 0, - unidad_medida=qty.weight_unit if qty else "PZA", + unidad_medida=uom_comercial, # Commercial UOM (PCS, EA) cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0, clave_bultos=(qty.package_key or "") if qty else "", - peso_neto=qty.net_weight if qty else 0, - peso_bruto=qty.gross_weight if qty else 0, + peso_neto=self.formatear_numero(peso_neto_kg), + peso_bruto=self.formatear_numero(peso_bruto_kg), + peso_neto_lbs=self.formatear_numero(peso_neto_lb), + peso_bruto_lbs=self.formatear_numero(peso_bruto_lb), valor_costo_unitario=v_unitario, # Hidden valor_total=v_total # Hidden )) @@ -309,6 +342,9 @@ class PackingListService: # Financial totals hidden peso_n = sum(float(p.peso_neto) for p in partidas) peso_b = sum(float(p.peso_bruto) for p in partidas) + peso_n_lbs = sum(float(p.peso_neto_lbs) for p in partidas) + peso_b_lbs = sum(float(p.peso_bruto_lbs) for p in partidas) + bultos = sum(p.cantidad_bultos for p in partidas) claves = [p.clave_bultos for p in partidas if p.clave_bultos] @@ -318,6 +354,7 @@ class PackingListService: return TotalesSchema( cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun, peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b), + peso_neto_total_lbs=self.formatear_numero(peso_n_lbs), peso_bruto_total_lbs=self.formatear_numero(peso_b_lbs), valor_total_total="", valor_total_dolares="" ) diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html b/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html index 4ed057d9..a82f2aaa 100644 --- a/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html @@ -278,8 +278,26 @@

-

-


+ + + + + + + + + + + +
+

PACKING LIST / LISTA DE EMPAQUE:

+
+

{{ factura.numero }}

+
+

MX CUSTOM BROKER / AGENTE ADUANAL MEXICANO:

+
+

{{ factura.agente_aduanal or '' }}

+
@@ -289,7 +307,7 @@
{% endif %} -
+

{{ cliente_proveedor.header }}

{{ cliente_proveedor.nombre }}

{{ cliente_proveedor.direccion }} @@ -308,97 +326,7 @@

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

PACKING LIST / LISTA DE EMPAQUE:

-
-

{{ factura.numero }}

-
-

Fecha:

-
-

{{ factura.fecha }}

-
-

T. Cambio:

-
-

{{ factura.tipo_cambio }}

-
-

Pedimento:

-
-

{{ factura.pedimento or '' }}

-
-

Clave:

-
-

{{ factura.clave_pedimento or '' }}

-
-

Remesa:

-
-

{{ factura.remesa or '' }}

-
-

Acuse:

-
-

{{ factura.acuse_electronico or 'N/A' }}

-
-

Mx custom broker / agente aduanal mexicano:

-

{{ factura.agente_aduanal or '' }}

-
-

Patente: {{ factura.patente or '' }}

-
- Regimen:{{ - factura.regimen or '' }} - -

INCOTERM:

-

{{ factura.incoterm or '' }}

-
- {% if factura.precinto %} -

Precinto: {{ factura.precinto }}

- {% endif %} -
-

Aduana: {{ factura.aduana or '' }}

-
- {% if factura.destino %} -

Destino: {{ factura.destino }}

- {% endif %} -
-
+
@@ -451,131 +379,140 @@


+ + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + {% for partida in partidas %} + + + + + + + + + + + {% endfor %} + - - {% for partida in partidas %} - - - - - - - - - - - {% endfor %} - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + +
+

Line / Línea

+
+

Part Number / Número de Parte

+

Description / Descripción

+
+

Quantity / Cantidad

+
+

Packing / Empaque

+
+

Weight / Peso (KGS)

+
+

Qty / Cant.

+
+

U.M.

+
+

Qty / Cant.

+
+

Type / Tipo

+
+

Net / Neto

+

(LBS / KGS)

+
+

Gross / Bruto

+

(LBS / KGS)

+
-

Línea

-
-

Número de Parte

-

Descripción

-
-

Comercial

-
-

Empaque

-
-

Peso (KGS)

-
-

Cantidad

-
-

U.M.

-
-

Cant.

-
-

Tipo

-
-

Neto

-
-

Bruto

-
+

{{ loop.index }}

+
+

{{ partida.numero_parte }}

+

{{ partida.descripcion }} / {{ partida.fraccion_americana }} / {{ partida.origen }} +

+
+

{{ partida.cantidad_importacion }}

+
+

{{ partida.unidad_medida }}

+
+

+ {% if partida.cantidad_bultos != 0 %}{{ partida.cantidad_bultos }}{% endif %} +

+
+

+ {{ partida.clave_bultos }} +

+
+

{{ partida.peso_neto_lbs }}

+

{{ partida.peso_neto }}

+
+

{{ partida.peso_bruto_lbs }}

+

{{ partida.peso_bruto }}

+
-

{{ loop.index }}

-
-

{{ partida.numero_parte }}

-

{{ partida.descripcion }}

-
-

{{ partida.cantidad_importacion }}

-
-

{{ partida.unidad_medida }}

-
-

- {% if partida.cantidad_bultos != 0 %}{{ partida.cantidad_bultos }}{% endif %} -

-
-

- {{ partida.clave_bultos }} -

-
-

{{ partida.peso_neto }}

-
-

{{ partida.peso_bruto }}

-
-

- Observaciones: - TOTALES -

-
-

{{ totales.cantidad_total }}

-
-

- {% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %} -

-
-

- {{ totales.clave_bultos or '' }} -

-
-

{{ totales.peso_neto_total }}

-
-

{{ totales.peso_bruto_total }}

-
-

{{ factura.observaciones }}

-
-

-

{{ cliente_proveedor.nombre }}

-


-
-


-
+

+ Observaciones: + TOTALES +

+
+

{{ totales.cantidad_total }}

+
+

+ {% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %} +

+
+

+ {{ totales.clave_bultos or '' }} +

+
+

{{ totales.peso_neto_total_lbs }} LBS

+

{{ totales.peso_neto_total }} KGS

+
+

{{ totales.peso_bruto_total_lbs }} LBS

+

{{ totales.peso_bruto_total }} KGS

+
+

{{ factura.observaciones }}

+
+

+

{{ cliente_proveedor.nombre }}

+


+
+


+
diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-root.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-root.svelte new file mode 100644 index 00000000..7ac64712 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-root.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte new file mode 100644 index 00000000..9b14b3ec --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/index.ts b/frontend/src/lib/components/ui/dropdown-menu/index.ts index 1cf9f701..9ac1bdd1 100644 --- a/frontend/src/lib/components/ui/dropdown-menu/index.ts +++ b/frontend/src/lib/components/ui/dropdown-menu/index.ts @@ -1,4 +1,4 @@ -import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; +// import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; import CheckboxItem from "./dropdown-menu-checkbox-item.svelte"; import Content from "./dropdown-menu-content.svelte"; import Group from "./dropdown-menu-group.svelte"; @@ -12,8 +12,8 @@ import Trigger from "./dropdown-menu-trigger.svelte"; import SubContent from "./dropdown-menu-sub-content.svelte"; import SubTrigger from "./dropdown-menu-sub-trigger.svelte"; import GroupHeading from "./dropdown-menu-group-heading.svelte"; -const Sub = DropdownMenuPrimitive.Sub; -const Root = DropdownMenuPrimitive.Root; +import Sub from "./dropdown-menu-sub.svelte"; +import Root from "./dropdown-menu-root.svelte"; export { CheckboxItem, From c97b9e3dafb0215419046d11d92f58eae2f9a2e1 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Tue, 27 Jan 2026 11:34:10 -0600 Subject: [PATCH 50/55] Se ajusto la tabla de quantity y de packeges, y se creo la relacion --- .../a76/general_catalogs/packages/models.py | 2 +- .../a76/items/line_quantities/models.py | 9 ++++++--- .../importacion/packing_list/service.py | 2 +- drop_column.sql | 2 ++ drop_desc_column.sql | 2 ++ fix_data.sql | 10 ++++++++++ fix_db_column.py | 18 ++++++++++++++++++ fix_schema.sql | 2 ++ 8 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 drop_column.sql create mode 100644 drop_desc_column.sql create mode 100644 fix_data.sql create mode 100644 fix_db_column.py create mode 100644 fix_schema.sql diff --git a/backend/api/v1/modules/a76/general_catalogs/packages/models.py b/backend/api/v1/modules/a76/general_catalogs/packages/models.py index f191fa94..92b07647 100644 --- a/backend/api/v1/modules/a76/general_catalogs/packages/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/packages/models.py @@ -19,7 +19,7 @@ class Package(Base, TenantScopedMixin, TimestampMixin): __table_args__ = ( PrimaryKeyConstraint("id", name="packages_pkey"), UniqueConstraint("tenant_id", "company_id", "key", name="packages_key_ukey"), - {"schema": "a76"}, + {"schema": "a76", "extend_existing": True}, ) id: Mapped[int] = mapped_column(Integer, primary_key=True) diff --git a/backend/api/v1/modules/a76/items/line_quantities/models.py b/backend/api/v1/modules/a76/items/line_quantities/models.py index c7a60558..cc588e1e 100644 --- a/backend/api/v1/modules/a76/items/line_quantities/models.py +++ b/backend/api/v1/modules/a76/items/line_quantities/models.py @@ -4,6 +4,8 @@ from sqlalchemy import String, Integer, Numeric, SmallInteger, ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship from core.database import Base +from api.v1.modules.a76.general_catalogs.packages.models import Package + if TYPE_CHECKING: from ..line_items.models import LineItem @@ -38,13 +40,14 @@ class LineQuantity(Base): net_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # PESONETO gross_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # PESOBRUTO + # Packaging - package_key: Mapped[Optional[str]] = mapped_column(String(5)) # CLAVEBULTOS + package_id: Mapped[Optional[int]] = mapped_column(ForeignKey("a76.packages.id")) package_quantity: Mapped[Optional[int]] = mapped_column(Integer) # CANTBULTOS - package_description: Mapped[Optional[str]] = mapped_column(String(40)) # DESCBULTOS container_quantity: Mapped[Optional[int]] = mapped_column(SmallInteger) # CANTBULCONT container_description: Mapped[Optional[str]] = mapped_column(String(40)) # DESCCONTENEDOR box_count: Mapped[Optional[str]] = mapped_column(String(30)) # NOCAJAS # Relationship (one-to-one) - line: Mapped["LineItem"] = relationship(back_populates="quantity") \ No newline at end of file + line: Mapped["LineItem"] = relationship(back_populates="quantity") + package_info: Mapped[Optional["Package"]] = relationship(Package) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py index 43f0036d..a6163592 100644 --- a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py @@ -313,7 +313,7 @@ class PackingListService: cantidad_importacion=qty.quantity if qty else 0, unidad_medida=uom_comercial, # Commercial UOM (PCS, EA) cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0, - clave_bultos=(qty.package_key or "") if qty else "", + clave_bultos=(qty.package_info.key if qty and qty.package_info else "") if qty else "", peso_neto=self.formatear_numero(peso_neto_kg), peso_bruto=self.formatear_numero(peso_bruto_kg), peso_neto_lbs=self.formatear_numero(peso_neto_lb), diff --git a/drop_column.sql b/drop_column.sql new file mode 100644 index 00000000..c0845c83 --- /dev/null +++ b/drop_column.sql @@ -0,0 +1,2 @@ +ALTER TABLE a76.item_line_quantities +DROP COLUMN IF EXISTS package_key; \ No newline at end of file diff --git a/drop_desc_column.sql b/drop_desc_column.sql new file mode 100644 index 00000000..e8e26aa2 --- /dev/null +++ b/drop_desc_column.sql @@ -0,0 +1,2 @@ +ALTER TABLE a76.item_line_quantities +DROP COLUMN IF EXISTS package_description; \ No newline at end of file diff --git a/fix_data.sql b/fix_data.sql new file mode 100644 index 00000000..b3003427 --- /dev/null +++ b/fix_data.sql @@ -0,0 +1,10 @@ +-- Actualizar el nuevo campo package_id usando el valor numérico guardado erróneamente en package_key +UPDATE a76.item_line_quantities +SET + package_id = CAST(package_key AS INTEGER) +WHERE + package_key ~ '^\d+$' + AND package_id IS NULL; + +-- Opcional: Limpiar el campo package_key si ya se migró (para evitar confusión futura, pero mejor dejarlo por seguridad) +-- UPDATE a76.item_line_quantities SET package_key = NULL WHERE package_id IS NOT NULL; \ No newline at end of file diff --git a/fix_db_column.py b/fix_db_column.py new file mode 100644 index 00000000..7145b2f4 --- /dev/null +++ b/fix_db_column.py @@ -0,0 +1,18 @@ +from sqlalchemy import text +from core.database import SessionLocal + +def add_column(): + db = SessionLocal() + try: + sql = text("ALTER TABLE a76.item_line_quantities ADD COLUMN IF NOT EXISTS package_id INTEGER REFERENCES a76.packages(id);") + db.execute(sql) + db.commit() + print("Successfully added package_id column.") + except Exception as e: + print(f"Error: {e}") + db.rollback() + finally: + db.close() + +if __name__ == "__main__": + add_column() diff --git a/fix_schema.sql b/fix_schema.sql new file mode 100644 index 00000000..7106b3c8 --- /dev/null +++ b/fix_schema.sql @@ -0,0 +1,2 @@ +ALTER TABLE a76.item_line_quantities +ADD COLUMN IF NOT EXISTS package_id INTEGER REFERENCES a76.packages (id); \ No newline at end of file From 782c759a66a34e5811db999de9934229c5194a0d Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Tue, 27 Jan 2026 11:37:24 -0600 Subject: [PATCH 51/55] Se borro archivo --- fix_db_column.py | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 fix_db_column.py diff --git a/fix_db_column.py b/fix_db_column.py deleted file mode 100644 index 7145b2f4..00000000 --- a/fix_db_column.py +++ /dev/null @@ -1,18 +0,0 @@ -from sqlalchemy import text -from core.database import SessionLocal - -def add_column(): - db = SessionLocal() - try: - sql = text("ALTER TABLE a76.item_line_quantities ADD COLUMN IF NOT EXISTS package_id INTEGER REFERENCES a76.packages(id);") - db.execute(sql) - db.commit() - print("Successfully added package_id column.") - except Exception as e: - print(f"Error: {e}") - db.rollback() - finally: - db.close() - -if __name__ == "__main__": - add_column() From 47c4984d3f94875b2033fbf171c5bc3980467bc5 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Thu, 29 Jan 2026 08:56:25 -0600 Subject: [PATCH 52/55] Se integro la plantilla de aviso de consolidado --- .../a76/items/line_quantities/schemas.py | 3 +- .../a76/reports/exportacion/__init__.py | 0 .../exportacion/aviso_consolidado/__init__.py | 0 .../exportacion/aviso_consolidado/routes.py | 46 +++ .../exportacion/aviso_consolidado/service.py | 305 +++++++++++++++ .../exportacion/aviso_consolidado/task.py | 50 +++ .../templates/avcon_exp.html | 357 ++++++++++++++++++ .../importacion/facturas/mex/service.py | 2 +- backend/api/v1/modules/a76/router.py | 7 + backend/core/celery_app.py | 3 +- backend/core/error_handlers.py | 3 +- .../a76/reports/reports-aviso-consolidado.ts | 35 ++ .../routes/dashboard/invoices/+page.svelte | 34 +- 13 files changed, 839 insertions(+), 6 deletions(-) create mode 100644 backend/api/v1/modules/a76/reports/exportacion/__init__.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/__init__.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/routes.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/templates/avcon_exp.html create mode 100644 frontend/src/lib/api/dashboard/a76/reports/reports-aviso-consolidado.ts diff --git a/backend/api/v1/modules/a76/items/line_quantities/schemas.py b/backend/api/v1/modules/a76/items/line_quantities/schemas.py index 71552342..3050551e 100644 --- a/backend/api/v1/modules/a76/items/line_quantities/schemas.py +++ b/backend/api/v1/modules/a76/items/line_quantities/schemas.py @@ -27,9 +27,8 @@ class LineQuantityBase(BaseModel): gross_weight: Optional[Decimal] = Field(None, description="Gross weight (PESOBRUTO)") # Packaging - package_key: Optional[str] = Field(None, max_length=5, description="Package key (CLAVEBULTOS)") + package_id: Optional[int] = Field(None, description="Package ID (GBultos)") package_quantity: Optional[int] = Field(None, description="Package quantity (CANTBULTOS)") - package_description: Optional[str] = Field(None, max_length=40, description="Package description (DESCBULTOS)") container_quantity: Optional[int] = Field(None, description="Container quantity (CANTBULCONT)") container_description: Optional[str] = Field(None, max_length=40, description="Container description (DESCCONTENEDOR)") box_count: Optional[str] = Field(None, max_length=30, description="Box count (NOCAJAS)") diff --git a/backend/api/v1/modules/a76/reports/exportacion/__init__.py b/backend/api/v1/modules/a76/reports/exportacion/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/__init__.py b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/routes.py b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/routes.py new file mode 100644 index 00000000..09cf878f --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/routes.py @@ -0,0 +1,46 @@ + +from typing import Dict, Any +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session +from celery.result import AsyncResult +from core.celery_app import celery_app +from core.database import get_core_db +from core.security import get_current_user, validate_access_to_resource +from .task import generar_pdf_aviso_consolidado_exp_async + +router = APIRouter() + +@router.get("/tasks/{task_id}") +async def get_task_status( + task_id: str, + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_core_db) +): + task_result = AsyncResult(task_id, app=celery_app) + + response = { + "task_id": task_id, + "state": task_result.state, + "result": None, + "info": None + } + + if task_result.state == 'FAILURE': + response["result"] = str(task_result.result) + elif task_result.state == 'SUCCESS': + response["result"] = task_result.result + elif task_result.state == 'PROCESSING': + response["info"] = task_result.info + + return response + +@router.post("/{invoice_id}/download-async") +async def trigger_descarga_aviso_consolidado_exp( + invoice_id: int, + company_id: int = Query(..., description="ID de la empresa"), + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_core_db) +): + validate_access_to_resource(db, company_id, current_user) + task = generar_pdf_aviso_consolidado_exp_async.delay(invoice_id, company_id) + return {"task_id": task.id, "message": "Generación iniciada"} diff --git a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py new file mode 100644 index 00000000..a85c0c82 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py @@ -0,0 +1,305 @@ + +import shutil +import base64 +import pdfkit +from pathlib import Path +from typing import Tuple, List, Callable, Optional, Dict, Any +from jinja2 import Environment, FileSystemLoader, select_autoescape +from fastapi import HTTPException +from sqlalchemy.orm import Session +from pydantic import BaseModel + +# --- MODELOS --- +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceComplianceMx, InvoiceFinancials, InvoiceLogistics +from api.v1.modules.a76.general_catalogs.company.models import Company +from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos +from api.v1.modules.a76.customs_brokers.models import CustomsBroker, CustomsBrokerPersonnel +from api.v1.modules.a76.clients_and_providers.models import ClientProvider, ClientProviderAddress, ClientProviderPrograms + +# --- SCHEMAS FOR TEMPLATE CONTEXT --- +class EmpresaSchema(BaseModel): + rfc: str + razon_social: str + direccion_completa: str + tax_id: Optional[str] = None # Extra info just in case + +class PersonaSchema(BaseModel): + nombre: str + rfc: str + curp: str + +class AvisoSchema(BaseModel): + pedimento_completo: str + tipo_operacion: str + clave_pedimento: str + acus_valor: str + aduana_seccion: str + numero_remesa: str + peso_bruto: str + codigo_aceptacion: str + codigo_barras_b64: Optional[str] = None + clave_seccion: str + marcas_numeros_bultos: str + candados: List[str] + vehiculo_placas: str + vehiculo_tipo: str + observaciones: str + numero_certificado: str + tipo_documento: str # NEW: Invoice Type + firma_electronica: str + +class AvisoConsolidadoContext(BaseModel): + aviso: AvisoSchema + empresa: EmpresaSchema + agente: PersonaSchema + mandatario: PersonaSchema + +class AvisoConsolidadoExportacionService: + def __init__(self): + self.template_dir = Path(__file__).parent / "templates" + self.jinja_env = Environment( + loader=FileSystemLoader(self.template_dir), + autoescape=select_autoescape(['html', 'xml']) + ) + self.template = self.jinja_env.get_template('avcon_exp.html') + + def _get_wkhtmltopdf_config(self): + path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf" + if not Path(path).exists(): + raise RuntimeError("wkhtmltopdf no encontrado.") + return pdfkit.configuration(wkhtmltopdf=path) + + def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> AvisoConsolidadoContext: + try: + if progress_callback: progress_callback(10, "Buscando factura...") + + # Fetch minimal real data if possible, or use placeholders as requested + header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first() + if not header: + # We can't strictly raise 404 if we want to support testing with non-existent IDs for pure UI check, + # but valid workflow requires a real invoice. Raising 404 is better practice. + raise HTTPException(status_code=404, detail="Factura no encontrada") + + company = db.query(Company).filter(Company.id == company_id).first() + + if progress_callback: progress_callback(30, "Preparando datos...") + + # --- FETCHING REAL DATA --- + + # 1. Compliance & Pedimento + compliance = header.compliance_mx + pedimento = None + if compliance and compliance.pedimento_id: + pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first() + + # Pedimento Completo Construction + pedimento_txt = "S/P" + clave_ped = "" + if pedimento: + # Format: YY OFF LIC NUMBER + year = pedimento.year or "" + office = pedimento.customs_office or "" + lic = pedimento.license or "" + num = pedimento.pedimento_number or "" + pedimento_txt = f"{year} {office} {lic} {num}" + clave_ped = pedimento.pedimento_code or "" + + # 2. Company Address + direccion_empresa = "DOMICILIO NO REGISTRADO" + if company and company.addresses: + # Try to find fiscal address or first available + addr = company.addresses[0] # Default + # TODO: Check if there's a specific flag for fiscal address in submodel + + parts = [] + if addr.street: parts.append(addr.street) + if addr.exterior_number: parts.append(f"No. {addr.exterior_number}") + if addr.interior_number: parts.append(f"Int. {addr.interior_number}") + if addr.neighborhood: parts.append(f"Col. {addr.neighborhood}") + if addr.postal_code: parts.append(f"CP {addr.postal_code}") + if addr.city: parts.append(addr.city) + if addr.state: parts.append(addr.state) + if addr.country: parts.append(addr.country) + + if parts: + direccion_empresa = ", ".join(parts).upper() + + # Determine Mexican Entity based on Operation Type + # IMP -> Client (Sold To/Consignee) + # EXP -> Company (Tenant) + + target_entity_data = { + "rfc": getattr(company, 'rfc', "") or "", + "razon_social": getattr(company, 'name', "") or "", + "direccion_completa": direccion_empresa + } + + op_type = header.operation_type.upper() if header.operation_type else "EXP" + + if op_type == "IMP" and compliance and compliance.sold_to_id: + # Fetch Client Data + client_id = compliance.sold_to_id + client_obj = db.query(ClientProvider).filter(ClientProvider.id == client_id).first() + if client_obj: + # Fetch Address + c_addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first() + # Fetch Fiscal Data (RFC) + c_prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first() + + c_rfc = "" + if c_prog and c_prog.tax_id: c_rfc = c_prog.tax_id + elif hasattr(client_obj, 'rfc'): c_rfc = client_obj.rfc + + c_dir_str = "DOMICILIO NO REGISTRADO" + if c_addr: + parts_c = [] + if c_addr.streets: parts_c.append(c_addr.streets) + if c_addr.exterior_number: parts_c.append(f"No. {c_addr.exterior_number}") + if c_addr.neighborhood: parts_c.append(f"Col. {c_addr.neighborhood}") + if c_addr.city: parts_c.append(c_addr.city) + if c_addr.state: parts_c.append(c_addr.state) + if c_addr.postal_code: parts_c.append(f"CP {c_addr.postal_code}") + if parts_c: + c_dir_str = ", ".join(parts_c).upper() + + target_entity_data = { + "rfc": c_rfc or "", + "razon_social": client_obj.name or client_obj.short_name or "", + "direccion_completa": c_dir_str + } + + empresa = EmpresaSchema( + rfc=target_entity_data["rfc"], + razon_social=target_entity_data["razon_social"], + direccion_completa=target_entity_data["direccion_completa"] + ) + + + + # 3. Datos Aviso (Invoice/Compliance/Logistics/Financials) + financials = header.financials + logistics = header.logistics + + # Peso Bruto + peso_bruto_val = "0.0" + if financials and financials.gross_weight: + peso_bruto_val = f"{financials.gross_weight:,.2f}" + elif pedimento and pedimento.gross_weight: + peso_bruto_val = f"{pedimento.gross_weight:,.2f}" + + # Candados (Seals) + candados_list = [] + if logistics and logistics.seal_number: + # Split by comma or space if multiple + candados_list = [s.strip() for s in logistics.seal_number.replace(',', ' ').split() if s.strip()] + + # Vehiculo + placas_val = "" + tipo_veh_val = "" + if logistics: + placas_val = logistics.license_plate or logistics.vehicle_num or logistics.trailer_num or "" + tipo_veh_val = logistics.transport_type or "" + + aviso = AvisoSchema( + pedimento_completo=pedimento_txt, + tipo_operacion=header.operation_type.upper() if header.operation_type else "EXP", + clave_pedimento=clave_ped, + acus_valor=compliance.edocument if (compliance and compliance.edocument) else "", + aduana_seccion=compliance.aduana if (compliance and compliance.aduana) else "", + numero_remesa=str(compliance.remesa) if (compliance and compliance.remesa) else "", + peso_bruto=peso_bruto_val, + codigo_aceptacion="", # TODO: Clarify source. Using empty for now or Edocument? + codigo_barras_b64=None, + clave_seccion=compliance.aduana if (compliance and compliance.aduana) else "", # Using Aduana as Section Key + marcas_numeros_bultos=f"{financials.bundle_count} BULTOS" if (financials and financials.bundle_count) else "1 BULTOS", + candados=candados_list, + vehiculo_placas=placas_val, + vehiculo_tipo=tipo_veh_val, + observaciones=header.observation_es or "", + numero_certificado=compliance.certificate_number if (compliance and compliance.certificate_number) else "", + tipo_documento=header.document_type or "FACTURA", # Default + firma_electronica=compliance.electronic_signature if (compliance and compliance.electronic_signature) else "" + ) + + # 4. Agente Aduanal + nombre_agente = "" + rfc_agente = "" + curp_agente = "" + + if compliance and compliance.customs_broker_id: + broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first() + if broker: + nombre_agente = broker.name or "" + rfc_agente = broker.tax_id or "" + curp_agente = broker.personal_id or "" + + agente = PersonaSchema( + nombre=nombre_agente, + rfc=rfc_agente, + curp=curp_agente + ) + + # 5. Mandatario (CustomsBrokerPersonnel) + mandatario = PersonaSchema(nombre="", rfc="", curp="") + + if broker: + # Try to find personnel associated with this broker + # Using direct query to ensure specific order if needed, typically just the first valid one + personnel = db.query(CustomsBrokerPersonnel).filter( + CustomsBrokerPersonnel.customs_broker_id == broker.id + ).first() + + if personnel: + # Construct name if main field is empty + full_name = personnel.name + if not full_name: + parts = [] + if personnel.first_name: parts.append(personnel.first_name) + if personnel.last_name: parts.append(personnel.last_name) + if personnel.middle_name: parts.append(personnel.middle_name) + full_name = " ".join(parts) + + mandatario = PersonaSchema( + nombre=full_name or "", + rfc=personnel.tax_id or "", + curp=personnel.personal_id or "" + ) + + return AvisoConsolidadoContext( + aviso=aviso, + empresa=empresa, + agente=agente, + mandatario=mandatario + ) + + except Exception as e: + print(f"Error Service A76 Export Aviso Consolidado: {e}") + raise HTTPException(status_code=500, detail=f"Error: {str(e)}") + + def generar_pdf(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> Tuple[bytes, str, str]: + if progress_callback: progress_callback(5, "Iniciando servicio de reporte...") + + datos = self.obtener_datos(db, invoice_id, company_id, progress_callback) + + if progress_callback: progress_callback(80, "Renderizando plantilla...") + + context = datos.model_dump() + html_content = self.template.render(**context) + nombre = f"AvisoConsolidado_Exp_{invoice_id}.pdf" + + if progress_callback: progress_callback(90, "Generando PDF final...") + + options = { + 'page-size': 'Letter', + 'margin-top': '0.5in', + 'margin-right': '0.5in', + 'margin-bottom': '0.5in', + 'margin-left': '0.5in', + 'encoding': "UTF-8", + 'enable-local-file-access': None + } + + pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config()) + + if progress_callback: progress_callback(100, "Completado") + return pdf, nombre, "application/pdf" diff --git a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py new file mode 100644 index 00000000..651919d4 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py @@ -0,0 +1,50 @@ + +import base64 +import logging +from core.celery_app import celery_app +from core.database import CoreSessionLocal + +from .service import AvisoConsolidadoExportacionService + +logger = logging.getLogger(__name__) + +@celery_app.task(name="generar_pdf_aviso_consolidado_exp_async", bind=True) +def generar_pdf_aviso_consolidado_exp_async(self, invoice_id: int, company_id: int): + # 1. Abrimos conexión a la DB + db = CoreSessionLocal() + try: + logger.info(f"Worker procesando Aviso Consolidado Exp {invoice_id}...") + + # 2. Instanciamos el servicio + service = AvisoConsolidadoExportacionService() + + # Update state to PROCESSING + self.update_state(state='PROCESSING', meta={'current': 5, 'total': 100, 'status': 'Iniciando generación...'}) + + def progress_callback(progress: int, status: str): + self.update_state(state='PROCESSING', meta={'current': progress, 'total': 100, 'status': status}) + + # 3. Generamos los bytes del PDF + pdf_bytes, nombre, media_type = service.generar_pdf( + db=db, + invoice_id=invoice_id, + company_id=company_id, + progress_callback=progress_callback + ) + + # 4. Codificamos a base64 + pdf_base64 = base64.b64encode(pdf_bytes).decode('utf-8') + + return { + "status": "success", + "file_name": nombre, + "content": pdf_base64, + "media_type": media_type + } + + except Exception as e: + logger.error(f"Error en Celery Worker Aviso Consolidado Exp: {str(e)}") + return {"status": "error", "message": str(e)} + + finally: + db.close() diff --git a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/templates/avcon_exp.html b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/templates/avcon_exp.html new file mode 100644 index 00000000..64a41575 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/templates/avcon_exp.html @@ -0,0 +1,357 @@ + + + + + + Aviso Consolidado - {{ aviso.pedimento_completo }} + + + + + + + + + +
+

AVISO CONSOLIDADO

+
+

Página 1 de 1

+
+ + + + + + + + +
+ NUM. PEDIMENTO: + {{ aviso.pedimento_completo }} + + T. OPER: + {{ aviso.tipo_operacion }} + + CVE. PEDIMENTO: + {{ aviso.clave_pedimento }} + + CERTIFICACIONES +
+ TIPO: {{ aviso.tipo_documento }} +
+ + + + + + + + + + + + + + + + + +
+ NUMERO DE ACUSE DE VALOR: + {{ aviso.acus_valor }} + +

 

+
+ ADUANA E/S: + {{ aviso.aduana_seccion }} + + NUM. REMESA: + {{ aviso.numero_remesa }} + + PESO BRUTO: + {{ aviso.peso_bruto }} +
DATOS DEL IMPORTADOR/EXPORTADOR
+
+

RFC:

+

{{ empresa.rfc }}

+
+
+

NOMBRE, DENOMINACION O RAZON SOCIAL:

+

{{ empresa.razon_social }}

+

{{ empresa.direccion_completa }}

+
+
+ + + + + + + +
+

CODIGO DE ACEPTACION:

+

{{ aviso.codigo_aceptacion }}

+
+

CODIGO DE BARRAS

+
+ {% if aviso.codigo_barras_b64 %} + + {% else %} +


+ {% endif %} +
+
+

CLAVE DE LA SECCION ADUANERA DE DESPACHO:

+

{{ aviso.clave_seccion }}

+
+ + + + + + + + +
MARCAS, NUMEROS Y TOTAL DE BULTOS: +
+

{{ aviso.marcas_numeros_bultos }}

+
+ + + + + + + + + + +
NUMERO DE CANDADO: + {{ aviso.candados[0] if aviso.candados|length > 0 }}{{ aviso.candados[1] if aviso.candados|length > 1 }}{{ aviso.candados[2] if aviso.candados|length > 2 }}{{ aviso.candados[3] if aviso.candados|length > 3 }}{{ aviso.candados[4] if aviso.candados|length > 4 }}
+ + + + + + + + + + + + + + +
1RA. REVISION +
2DA. REVISION +
+ + + + + + + + +
NUMERO/TIPO:{{ aviso.vehiculo_placas }}{{ aviso.vehiculo_tipo }}
+ + + + + + + + +
OBSERVACIONES
+

{{ aviso.observaciones }}

+
+ + + + + +
+

AGENTE ADUANAL, APODERADO ADUANAL:

+ +
+ NOMBRE: + {{ agente.nombre }} +
+ +
+
+ RFC: + {{ agente.rfc }} +
+
+ CURP: + {{ agente.curp }} +
+
+ +
+ MANDATARIO/PERSONA AUTORIZADA: +
+ +
+ NOMBRE: + {{ mandatario.nombre }} +
+ +
+
+ RFC: + {{ mandatario.rfc }} +
+
+ CURP: + {{ mandatario.curp }} +
+
+ +
+ NUMERO DE SERIE DEL CERTIFICADO: + {{ aviso.numero_certificado }} +
+
+ e.firma: +

{{ aviso.firma_electronica }}

+
+
+ +

*********************************************************************** FIN DE LA + IMPRESION ***********************************************************************

+ + + + \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py index 200a3942..dee88bdd 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py @@ -316,7 +316,7 @@ class FacturaImportacionMexService: cantidad_importacion=self.formatear_numero(qty.quantity if qty else 0), unidad_medida=qty.weight_unit if qty else "PZA", cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0, - clave_bultos=(qty.package_key or "") if qty else "", + clave_bultos=(qty.package_info.key if (qty and qty.package_info and qty.package_info.key) else "PZA"), peso_neto=self.formatear_numero(qty.net_weight if qty else 0), peso_bruto=self.formatear_numero(qty.gross_weight if qty else 0), valor_costo_unitario=self.formatear_numero(v_unitario), diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 6da0ba71..01c8c57d 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -52,6 +52,7 @@ from api.v1.modules.public.reference_data.material_types.routes import router as from .reports.importacion.facturas.routes import router as invoices_reports_router 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 @@ -138,4 +139,10 @@ router.include_router( packing_list_router, prefix="/a76/reports/importacion/packing-lists", tags=["a76 / reports"] +) + +router.include_router( + aviso_consolidado_export_router, + prefix="/a76/reports/exportacion/aviso_consolidado", + tags=["a76 / reports"] ) \ No newline at end of file diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index bc8bd492..3e41aa5f 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -11,7 +11,8 @@ celery_app = Celery( include=[ "api.v1.modules.a76.reports.importacion.facturas.task", "api.v1.modules.a76.reports.importacion.consolidados.task", - "api.v1.modules.a76.reports.importacion.packing_list.task" + "api.v1.modules.a76.reports.importacion.packing_list.task", + "api.v1.modules.a76.reports.exportacion.aviso_consolidado.task" ] # Ruta al módulo donde están las tareas ) diff --git a/backend/core/error_handlers.py b/backend/core/error_handlers.py index 4fe62025..e1f7783a 100644 --- a/backend/core/error_handlers.py +++ b/backend/core/error_handlers.py @@ -7,6 +7,7 @@ from typing import Any, Dict from fastapi import Request, status from fastapi.responses import JSONResponse +from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from sqlalchemy.exc import IntegrityError, SQLAlchemyError @@ -37,7 +38,7 @@ async def base_exception_handler( return JSONResponse( status_code=exc.status_code, - content=exc.to_dict(), + content=jsonable_encoder(exc.to_dict()), ) diff --git a/frontend/src/lib/api/dashboard/a76/reports/reports-aviso-consolidado.ts b/frontend/src/lib/api/dashboard/a76/reports/reports-aviso-consolidado.ts new file mode 100644 index 00000000..363b1daf --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-aviso-consolidado.ts @@ -0,0 +1,35 @@ + +const BASE_URL = import.meta.env.VITE_API_URL || ''; + +export const avisoConsolidadoReportsApi = { + + triggerPdfGeneration: async (invoiceId: number, companyId: number) => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const endpoint = `${BASE_URL}/v1/a76/reports/exportacion/aviso_consolidado/${invoiceId}/download-async?${params.toString()}`; + + const token = localStorage.getItem('access_token'); + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) throw new Error('Error al iniciar la generación del Aviso Consolidado'); + return await response.json(); + }, + + getTaskStatus: async (taskId: string) => { + const endpoint = `${BASE_URL}/v1/a76/reports/exportacion/aviso_consolidado/tasks/${taskId}`; + + const token = localStorage.getItem('access_token'); + const response = await fetch(endpoint, { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!response.ok) throw new Error('Error al consultar estado del Aviso Consolidado'); + return await response.json(); + } +}; diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index e4e04619..146cd50b 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -372,6 +372,8 @@ } } + import { avisoConsolidadoReportsApi } from '$lib/api/dashboard/a76/reports/reports-aviso-consolidado'; + async function handleDownloadConsolidated(invoice: any) { if (!companyStore.activeCompany) { toast.error("No hay empresa seleccionada"); @@ -379,7 +381,7 @@ } try { - // 1. Trigger: Iniciar la tarea en Celery (Consolidado) + // 1. Trigger: Iniciar la tarea en Celery (Consolidado Importación) const { task_id } = await consolidatedReportsApi.triggerPdfGeneration( invoice.id, companyStore.activeCompany.id @@ -394,7 +396,30 @@ console.error(error); toast.error("No se pudo iniciar la descarga del consolidado"); } + } + async function handleDownloadAvisoConsolidado(invoice: any) { + if (!companyStore.activeCompany) { + toast.error("No hay empresa seleccionada"); + return; + } + + try { + // 1. Trigger: Iniciar la tarea en Celery (Aviso Consolidado Exportación) + const { task_id } = await avisoConsolidadoReportsApi.triggerPdfGeneration( + invoice.id, + companyStore.activeCompany.id + ); + + // 2. Abrir diálogo de progreso + currentTaskId = task_id; + currentStatusFunction = avisoConsolidadoReportsApi.getTaskStatus; + showProgressDialog = true; + + } catch (error) { + console.error(error); + toast.error("No se pudo iniciar la descarga del Aviso Consolidado"); + } } async function handleDownloadPackingList(invoice: any) { @@ -655,10 +680,17 @@ Factura + + + +