diff --git a/backend/api/v1/modules/a24/fa/fa_item_lines/models.py b/backend/api/v1/modules/a24/fa/fa_item_lines/models.py index 3ca32851..75496b34 100644 --- a/backend/api/v1/modules/a24/fa/fa_item_lines/models.py +++ b/backend/api/v1/modules/a24/fa/fa_item_lines/models.py @@ -16,7 +16,7 @@ from api.v1.common.base_models import TenantScopedMixin, TimestampMixin from core.database import Base if TYPE_CHECKING: - from api.v1.modules.a76.items.line_items.models import LineItem + from api.v1.modules.a76.items.models import LineItem class FaLineItem(Base, TenantScopedMixin, TimestampMixin): diff --git a/backend/api/v1/modules/a24/fa/fa_item_lines/service.py b/backend/api/v1/modules/a24/fa/fa_item_lines/service.py index f60feec0..890307bf 100644 --- a/backend/api/v1/modules/a24/fa/fa_item_lines/service.py +++ b/backend/api/v1/modules/a24/fa/fa_item_lines/service.py @@ -96,7 +96,7 @@ class FaLineItemService: """Crear una nueva línea de activo fijo""" try: # Verificar que la línea base existe en a76.item_lines - from api.v1.modules.a76.items.line_items.models import LineItem + from api.v1.modules.a76.items.models import LineItem base_line_item = ( db.query(LineItem) diff --git a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py index 90386af6..a690343d 100644 --- a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py +++ b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py @@ -7,7 +7,7 @@ from api.v1.modules.a76.clients_and_providers.models import ClientProvider from api.v1.modules.a76.customs_brokers.models import CustomsBroker from api.v1.modules.public.reference_data.incoterms.models import Incoterm from ....models import InvoiceComplianceMx -from api.v1.modules.a76.items.models import Item +from api.v1.modules.a76.items.models import LineItem from api.v1.modules.public.reference_data.currency_types.models import CurrencyType from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection from ....models import TransportType, Currency, WeightUnit @@ -435,11 +435,11 @@ def validate_common( # Only check for existing items during update operations (when invoice has an id) if hasattr(invoice, "id"): has_items = ( - db.query(Item) + db.query(LineItem) .filter( - Item.invoice_id == invoice.id, - Item.tenant_id == tenant_id, - Item.company_id == company_id, + LineItem.invoice_id == invoice.id, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, ) .first() ) diff --git a/backend/api/v1/modules/a76/items/__init__.py b/backend/api/v1/modules/a76/items/__init__.py index a66bfe89..42d7abcf 100644 --- a/backend/api/v1/modules/a76/items/__init__.py +++ b/backend/api/v1/modules/a76/items/__init__.py @@ -4,16 +4,14 @@ Items module - Annex 76 Compliance # Import models in correct order to avoid circular dependencies # LineItem must be imported before models that reference it -from .line_items.models import LineItem from .line_financials.models import LineFinancial from .line_quantities.models import LineQuantity from .line_customs.models import LineCustom from .line_descriptions.models import LineDescription from .line_references.models import LineReference -from .models import Item, CTMReceipt, SubassemblyEntry +from .models import LineItem, CTMReceipt, SubassemblyEntry __all__ = [ - "Item", "LineItem", "LineFinancial", "LineQuantity", diff --git a/backend/api/v1/modules/a76/items/common/common_validators.py b/backend/api/v1/modules/a76/items/common/common_validators.py index 06e0b449..4397f4fd 100644 --- a/backend/api/v1/modules/a76/items/common/common_validators.py +++ b/backend/api/v1/modules/a76/items/common/common_validators.py @@ -1,6 +1,6 @@ from sqlalchemy import func from core.exceptions import ErrorCollector -from ..line_items import models +from .. import models from sqlalchemy.orm import Session @@ -21,11 +21,11 @@ def item_exists(db: Session, item_line: int, tenant_id: int, company_id: int): def count_items(db: Session, invoice_id: int, tenant_id: int, company_id: int): count = ( db.query(func.count()) - .select_from(models.Item) + .select_from(models.LineItem) .filter( - models.Item.invoice_id == invoice_id, - models.Item.tenant_id == tenant_id, - models.Item.company_id == company_id, + models.LineItem.invoice_id == invoice_id, + models.LineItem.tenant_id == tenant_id, + models.LineItem.company_id == company_id, ) .scalar() ) diff --git a/backend/api/v1/modules/a76/items/imports/temporary/validators/common.py b/backend/api/v1/modules/a76/items/imports/temporary/validators/common.py index 3cbe5958..f1600bff 100644 --- a/backend/api/v1/modules/a76/items/imports/temporary/validators/common.py +++ b/backend/api/v1/modules/a76/items/imports/temporary/validators/common.py @@ -6,8 +6,7 @@ from sqlalchemy import func from ....common.fractions import search_fraction_preference from ....common.common_validators import item_exists -from ....models import Item -from ....line_items.models import LineItem +from ....models import LineItem from ....line_customs.models import FractionType, LineCustom from api.v1.modules.a76.items.schemas import LineItemCreate from api.v1.modules.a76.invoices.models import InvoiceHeader @@ -26,21 +25,13 @@ from api.v1.modules.a76.general_catalogs.company.models import Company def validate_common( db: Session, line: LineItemCreate, - invoice_id: int, # Para creación, se pasa directamente; para update, se consulta del item tenant_id: int, company_id: int, errors: ErrorCollector, line_number: int, ): - # Para updates, line.item_id existe; para creates, es None - item_header = None - if line.item_id: - item_header = db.query(Item).filter(Item.id == line.item_id).first() - if item_header: - invoice_id = item_header.invoice_id - invoice: InvoiceHeader = invoice_exists_by_id( - db, invoice_id, tenant_id, company_id, errors + db, line.invoice_id, tenant_id, company_id, errors ) line_item: LineItem = item_exists(db, line.line_number, tenant_id, company_id) @@ -164,7 +155,7 @@ def validate_common( code="PACKAGE_QUANTITY_MUST_BE_GREATER_THAN_ZERO", ) else: - if (line.quantity.package_quantity or line.quantity.package_quantity > 0) and not line.quantity.package_id: + if line.quantity.package_quantity and (line.quantity.package_quantity > 0 and not line.quantity.package_id): errors.add_error( field=f"line[{line_number}].quantity.package_id", message="El paquete es obligatorio cuando se proporciona la cantidad de paquetes.", @@ -301,8 +292,8 @@ def validate_common( code="AMERICAN_FRACTION_NOT_FOUND", ) - if item_header and item_header.order: - if len(item_header.order) > 20: + if line.order: + if len(line.order) > 20: errors.add_error( field=f"item.order", message="El campo orden no debe exceder los 20 caracteres.", 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 66024a5e..70dc5245 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 @@ -4,7 +4,7 @@ from sqlalchemy.orm import Session from ....common.common_validators import count_items from core.exceptions import ErrorCollector -from ....line_items.models import LineItem +from ....models import LineItem from ....line_financials.models import LineFinancial from ....line_financials.schemas import LineFinancialCreate from ....line_quantities.models import LineQuantity @@ -15,7 +15,7 @@ from ....line_descriptions.models import LineDescription from ....line_descriptions.schemas import LineDescriptionCreate from ....line_references.models import LineReference from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem -from ....models import Item +from ....models import LineItem from api.v1.modules.a76.invoices.models import InvoiceHeader from api.v1.modules.a76.classes.models import Class from api.v1.modules.a76.general_catalogs.packages.models import Package @@ -27,8 +27,7 @@ from .common import validate_common def validate_create( db: Session, - line, # LineItemCreate schema (Pydantic) - invoice_id: int, # Passed from service + line: LineItem, # LineItemCreate schema (Pydantic) tenant_id: int, company_id: int, errors: ErrorCollector, @@ -42,16 +41,7 @@ def validate_create( line: LineItemCreate schema with nested data (financial, quantity, customs, etc.) invoice_id: ID of the invoice this line belongs to fa_data: FaLineItemCreateDTO or None (None for INV system) - """ - # Inicializar nested schemas si no existen (para poder validar y modificar) - if not line.financial: - line.financial = LineFinancialCreate() - if not line.quantity: - line.quantity = LineQuantityCreate() - if not line.customs: - line.customs = LineCustomCreate() - if not line.description: - line.description = LineDescriptionCreate() + """ # Access fa_data safely fa_data = getattr(line, "fa_data", None) @@ -101,8 +91,8 @@ def validate_create( principal_item_exists = db.query( exists().where( (LineItem.id == FaLineItem.id) - & (LineItem.item_id == Item.id) - & (Item.invoice_id == invoice_id) + & (LineItem.id == LineItem.id) + & (LineItem.invoice_id == line.invoice_id) & (LineItem.line_number == line_number) & (FaLineItem.is_subitem == False) & (FaLineItem.contains_subitems == True) @@ -131,14 +121,14 @@ def validate_create( code="SUBITEM_NUMBER_INVALID", ) - validate_common(db, line, invoice_id, tenant_id, company_id, errors, line_number) + validate_common(db, line, tenant_id, company_id, errors, line_number) if not errors.has_errors(): # Obtener la factura para acceder a tipo de cambio, moneda y peso invoice: InvoiceHeader = ( db.query(InvoiceHeader) .filter( - InvoiceHeader.id == invoice_id, + InvoiceHeader.id == line.invoice_id, InvoiceHeader.tenant_id == tenant_id, InvoiceHeader.company_id == company_id, ) 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 eec6f443..1f5b917e 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 @@ -3,8 +3,7 @@ from sqlalchemy.orm import Session from api.v1.modules.a76.invoices.common.common_validators import invoice_exists from core.exceptions import ErrorCollector -from ....line_items.models import LineItem -from ....models import Item +from ....models import LineItem from api.v1.modules.a76.invoices.models import InvoiceHeader from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import ( USTariffFraction, @@ -15,8 +14,7 @@ from .common import validate_common def validate_update( db: Session, line: LineItem, - existing_line: LineItem, - invoice_id: int, # Passed from service + existing_line: LineItem, tenant_id: int, company_id: int, errors: ErrorCollector, @@ -26,14 +24,14 @@ def validate_update( Validar y procesar actualización parcial de línea de importación temporal. Si un campo no se proporciona, se mantiene el valor existente. """ - validate_common(db, line, invoice_id, tenant_id, company_id, errors, line_number) + validate_common(db, line, tenant_id, company_id, errors, line_number) if not errors.has_errors(): # Obtener la factura para acceder a tipo de cambio, moneda y peso invoice: InvoiceHeader = ( db.query(InvoiceHeader) .filter( - InvoiceHeader.id == invoice_id, + InvoiceHeader.id == line.invoice_id, InvoiceHeader.tenant_id == tenant_id, InvoiceHeader.company_id == company_id ) @@ -81,6 +79,8 @@ def validate_update( # Mantener peso existente line.quantity.net_weight = existing_line.quantity.net_weight + print(f"After weight conversion: net_weight={line.quantity.net_weight}, gross_weight={line.quantity.gross_weight}, weight_type={invoice_weight_type}") + # Convertir peso bruto si se proporcionó if line.quantity.gross_weight is not None: gross_weight_input = line.quantity.gross_weight @@ -141,8 +141,8 @@ def validate_update( line.customs.advalorem_american = existing_line.customs.advalorem_american # Orden de compra - if not line.reference.purchase_order: - line.reference.purchase_order = existing_line.reference.purchase_order + if not line.order: + line.order = existing_line.order # Descripciones if not line.description.description_spanish: @@ -176,8 +176,8 @@ def validate_update( # Número de parte - if not line.part_number: - line.part_number = existing_line.part_number + if not line.part_number_id: + line.part_number_id = existing_line.part_number_id # Pago de impuesto if line.tax_payment is None: diff --git a/backend/api/v1/modules/a76/items/line_customs/models.py b/backend/api/v1/modules/a76/items/line_customs/models.py index 2b400ed2..0830b768 100644 --- a/backend/api/v1/modules/a76/items/line_customs/models.py +++ b/backend/api/v1/modules/a76/items/line_customs/models.py @@ -5,7 +5,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from core.database import Base if TYPE_CHECKING: - from ..line_items.models import LineItem + from ..models import LineItem class FractionType: """Enumeration for fraction types""" diff --git a/backend/api/v1/modules/a76/items/line_descriptions/models.py b/backend/api/v1/modules/a76/items/line_descriptions/models.py index d8c7ecbf..85eaa6b0 100644 --- a/backend/api/v1/modules/a76/items/line_descriptions/models.py +++ b/backend/api/v1/modules/a76/items/line_descriptions/models.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from core.database import Base if TYPE_CHECKING: - from ..line_items.models import LineItem + from ..models import LineItem class LineDescription(Base): """ diff --git a/backend/api/v1/modules/a76/items/line_financials/models.py b/backend/api/v1/modules/a76/items/line_financials/models.py index eb24934f..3edaaf7d 100644 --- a/backend/api/v1/modules/a76/items/line_financials/models.py +++ b/backend/api/v1/modules/a76/items/line_financials/models.py @@ -5,7 +5,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from core.database import Base if TYPE_CHECKING: - from ..line_items.models import LineItem + from ..models import LineItem class LineFinancial(Base): """ diff --git a/backend/api/v1/modules/a76/items/line_items/__init__.py b/backend/api/v1/modules/a76/items/line_items/__init__.py deleted file mode 100644 index 7f7ae971..00000000 --- a/backend/api/v1/modules/a76/items/line_items/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Line items module""" -from .models import LineItem -from .schemas import ( - LineItemBase, - LineItemCreate, - LineItemUpdate, - LineItemResponse, -) - -__all__ = [ - "LineItem", - "LineItemBase", - "LineItemCreate", - "LineItemUpdate", - "LineItemResponse", -] diff --git a/backend/api/v1/modules/a76/items/line_items/models.py b/backend/api/v1/modules/a76/items/line_items/models.py deleted file mode 100644 index 5192f46f..00000000 --- a/backend/api/v1/modules/a76/items/line_items/models.py +++ /dev/null @@ -1,200 +0,0 @@ -from decimal import Decimal -from typing import Optional, TYPE_CHECKING -from sqlalchemy import ( - Boolean, - String, - Integer, - Numeric, - SmallInteger, - ForeignKey, - ForeignKeyConstraint, -) -from sqlalchemy.orm import Mapped, mapped_column, relationship -from api.v1.common.base_models import TenantScopedMixin, TimestampMixin -from core.database import Base - -from api.v1.modules.a76.classes.models import Class -from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure - -if TYPE_CHECKING: - from ..models import Item - from ..line_financials.models import LineFinancial - from ..line_quantities.models import LineQuantity - from ..line_customs.models import LineCustom - from ..line_descriptions.models import LineDescription - from ..line_references.models import LineReference - from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem - - -class LineItem(Base, TenantScopedMixin, TimestampMixin): - """ - Unified line items for all items - Consolidates all line-level data from Q and S tables - """ - - __tablename__ = "item_lines" - __table_args__ = ({"schema": "a76"},) - - id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - item_id: Mapped[int] = mapped_column(ForeignKey("a76.items.id")) - line_number: Mapped[int] = mapped_column(Integer) # LINEAIMPO/LINEAEXPO/LINEA - - # Part identification - part_number: Mapped[Optional[int]] = mapped_column( - Integer, ForeignKey("a76.parts.id") - ) # NUMPARTE - 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") - ) # CLASE - - # Unit of measure - unit_of_measure: Mapped[Optional[int]] = mapped_column( - ForeignKey("a76.units_of_measure.id") - ) # UNIDADMEDIDA/UNIMED - alternate_unit: Mapped[Optional[int]] = mapped_column( - ForeignKey("a76.units_of_measure.id") - ) # UNIMEDALTERNA - uma_key: Mapped[Optional[str]] = mapped_column(String(2)) # CLAVEUMA - auxiliary_unit: Mapped[Optional[str]] = mapped_column(String(5)) # UNIMEDAUXILIAR - - # Permits and certificates - permit_number: Mapped[Optional[str]] = mapped_column(String(20)) # NUMPERMISO - page_line: Mapped[Optional[str]] = mapped_column(String(10)) # PAGRENGLON - has_certificate: Mapped[Optional[bool]] = mapped_column( - Boolean - ) # TIENECO/CERTORIGEN - certificate_number: Mapped[Optional[str]] = mapped_column( - String(10) - ) # NOCERTIFICADO - octave_permit: Mapped[Optional[str]] = mapped_column(String(20)) # PERMISOROCTAVA - permits_ped: Mapped[Optional[str]] = mapped_column(String(500)) # PERMISOSPED - - # FDA - has_fda_code: Mapped[Optional[bool]] = mapped_column(Boolean) # LLEVACODFDA - fda_key: Mapped[Optional[str]] = mapped_column(String(10)) # CLAVEFDA - - # Special flags - is_military_mcia: Mapped[Optional[bool]] = mapped_column(Boolean) # ESMCIAMILITAR - - # IV32 (Tax identification) - iv32_type_key: Mapped[Optional[str]] = mapped_column(String(5)) # CLAVETIPOIV32 - iv32_number: Mapped[Optional[str]] = mapped_column(String(35)) # NUMEROIV32 - - # IN CASE OF EXPO - scrap_invoice: Mapped[Optional[str]] = mapped_column(String(15)) # FACTURASCRAP - consecutive_destination: Mapped[Optional[int]] = mapped_column( - Integer - ) # CONSECUTIVODES - ctm_section: Mapped[Optional[str]] = mapped_column(String(3)) # APARTADOCTM - - # Tax payment - tax_payment: Mapped[Optional[bool]] = mapped_column(Boolean) # PAGOIMPUESTO - payment_method: Mapped[Optional[str]] = mapped_column( - String(9) - ) # FORMAPAGO/FORMAPAGOTIGI - igi_amount: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # MONTOIGI - igi_payment_method: Mapped[Optional[str]] = mapped_column( - String(9) - ) # FORMAPAGOTIGI - - # FCC - fcc_key: Mapped[Optional[str]] = mapped_column(String(30)) # CLAVEFCC - - # Valuation method - valuation_method: Mapped[Optional[str]] = mapped_column(String(2)) # METVALOR - valuation_determined_value: Mapped[Optional[Decimal]] = mapped_column( - Numeric(29, 8) - ) # METVALORVALORDETERMINADO/METVALORACIONVALORDETERMINADO - valuation_reason: Mapped[Optional[str]] = mapped_column( - String(500) - ) # METVALORMOTIVODEUSO/METVALORACIONMOTIVODEUSO - - # Container rules - container_rule: Mapped[Optional[str]] = mapped_column(String(50)) # CONTENEDORREGLA - container_parts_ii: Mapped[Optional[str]] = mapped_column( - String(50) - ) # CONTENEDORPARTESII - - # APHIS - consecutive_aphis: Mapped[Optional[int]] = mapped_column( - Integer - ) # CONSECUTIVOAPHIS - - # BOM/Commercial - bom_version: Mapped[Optional[int]] = mapped_column(Integer) # VERSIONBOM - bill_version: Mapped[Optional[int]] = mapped_column(Integer) # VERSIONBILL - - # TLCAN value - tlcan_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORTLCAN - - # Identifier - identifier: Mapped[Optional[str]] = mapped_column(String(2)) # IDENTIFICADOR - - # Validation fields - validation_zero: Mapped[Optional[int]] = mapped_column(Integer) # VALIDACIONZERO - validation_one: Mapped[Optional[int]] = mapped_column(Integer) # VALIDACIONUNO - - # Material type - material_type: Mapped[Optional[str]] = mapped_column( - String(50) - ) # TIPOMAT/TIPODENUMPARTE - - # Order concept - order_type: Mapped[Optional[str]] = mapped_column(String(50)) # TIPODEORDEN - line_concept: Mapped[Optional[str]] = mapped_column( - String(50) - ) # CONCEPTODELAPARTIDA - - # Review dispatch - review_dispatch: Mapped[Optional[str]] = mapped_column(String(10)) # REVISARDESP - - # Take component from PT - take_component_pt: Mapped[Optional[int]] = mapped_column(Integer) # TOMARCOMOPT - - # Pallet - pallet2: Mapped[Optional[int]] = mapped_column(SmallInteger) # PALLET2 - - # Wildcard field - wildcard_field: Mapped[Optional[str]] = mapped_column(String(100)) # CAMPOCOMODIN - - # Relationships - item: Mapped["Item"] = relationship(back_populates="lines") - financial: Mapped[Optional["LineFinancial"]] = relationship( - back_populates="line", cascade="all, delete-orphan", uselist=False - ) - quantity: Mapped[Optional["LineQuantity"]] = relationship( - back_populates="line", cascade="all, delete-orphan", uselist=False - ) - customs: Mapped[Optional["LineCustom"]] = relationship( - back_populates="line", cascade="all, delete-orphan", uselist=False - ) - description: Mapped[Optional["LineDescription"]] = relationship( - back_populates="line", cascade="all, delete-orphan", uselist=False - ) - reference: Mapped[Optional["LineReference"]] = relationship( - back_populates="line", cascade="all, delete-orphan", uselist=False - ) - class_info: Mapped[Optional["Class"]] = relationship( - "api.v1.modules.a76.classes.models.Class", - foreign_keys=[class_id], - viewonly=True, - ) - unit_of_measure_info: Mapped[Optional["UnitOfMeasure"]] = relationship( - "api.v1.modules.a76.general_catalogs.units_of_measure.models.UnitOfMeasure", - foreign_keys=[unit_of_measure], - viewonly=True, - ) - fa_data: Mapped[Optional["FaLineItem"]] = relationship( - "FaLineItem", - back_populates="master_info", - cascade="all, delete-orphan", - uselist=False, - ) - part_info: Mapped[Optional["api.v1.modules.a76.parts.models.Part"]] = relationship( - "api.v1.modules.a76.parts.models.Part", - foreign_keys=[part_number], - viewonly=True, - ) diff --git a/backend/api/v1/modules/a76/items/line_items/schemas.py b/backend/api/v1/modules/a76/items/line_items/schemas.py deleted file mode 100644 index 86e0b168..00000000 --- a/backend/api/v1/modules/a76/items/line_items/schemas.py +++ /dev/null @@ -1,293 +0,0 @@ -from decimal import Decimal -from typing import Optional, Any -from pydantic import BaseModel, Field, ConfigDict, field_validator, model_validator - -# Import nested schemas -from ..line_customs.schemas import ( - LineCustomCreate, - LineCustomUpdate, - LineCustomResponse, -) -from ..line_descriptions.schemas import ( - LineDescriptionCreate, - LineDescriptionUpdate, - LineDescriptionResponse, -) -from ..line_quantities.schemas import ( - LineQuantityCreate, - LineQuantityUpdate, - LineQuantityResponse, -) -from ..line_financials.schemas import ( - LineFinancialCreate, - LineFinancialUpdate, - LineFinancialResponse, -) -from ..line_references.schemas import ( - LineReferenceCreate, - LineReferenceUpdate, - LineReferenceResponse, -) - -from api.v1.modules.a24.fa.fa_item_lines.dto import ( - FaLineItemCreateDTO, - FaLineItemUpdateDTO, - FaLineItemResponseDTO, -) - - -# ============================================================================ -# LINE ITEM SCHEMAS -# ============================================================================ - - -class LineItemBase(BaseModel): - """Base schema for line items""" - - model_config = ConfigDict(populate_by_name=True) - - item_id: int = Field(..., description="ID of the parent item") - line_number: int = Field(..., description="Line number") - - # Part identification - 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", - alias="component_part_number", - serialization_alias="component_part_number_id", - ) - 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") - uma_key: Optional[str] = Field(None, max_length=2, description="UMA key") - auxiliary_unit: Optional[str] = Field( - None, max_length=5, description="Auxiliary unit" - ) - - # Permits and certificates - permit_number: Optional[str] = Field( - None, max_length=20, description="Permit number" - ) - page_line: Optional[str] = Field(None, max_length=10, description="Page line") - has_certificate: Optional[bool] = Field(None, description="Has certificate") - certificate_number: Optional[str] = Field( - None, max_length=10, description="Certificate number" - ) - octave_permit: Optional[str] = Field( - None, max_length=20, description="Octave permit" - ) - permits_ped: Optional[str] = Field(None, max_length=500, description="PED permits") - - # FDA - has_fda_code: Optional[bool] = Field(None, description="Has FDA code") - fda_key: Optional[str] = Field(None, max_length=10, description="FDA key") - - # Special flags - is_military_mcia: Optional[bool] = Field( - None, description="Is military merchandise" - ) - - # IV32 - iv32_type_key: Optional[str] = Field( - None, max_length=5, description="IV32 type key" - ) - iv32_number: Optional[str] = Field(None, max_length=35, description="IV32 number") - - # Export specific - scrap_invoice: Optional[str] = Field( - None, max_length=15, description="Scrap invoice" - ) - consecutive_destination: Optional[int] = Field( - None, description="Consecutive destination" - ) - ctm_section: Optional[str] = Field(None, max_length=3, description="CTM section") - - # Tax payment - tax_payment: Optional[bool] = Field(None, description="Tax payment") - payment_method: Optional[str] = Field( - None, max_length=9, description="Payment method" - ) - igi_amount: Optional[Decimal] = Field(None, description="IGI amount") - igi_payment_method: Optional[str] = Field( - None, max_length=9, description="IGI payment method" - ) - - # FCC - fcc_key: Optional[str] = Field(None, max_length=30, description="FCC key") - - # Valuation method - valuation_method: Optional[str] = Field( - None, max_length=2, description="Valuation method" - ) - valuation_determined_value: Optional[Decimal] = Field( - None, description="Valuation determined value" - ) - valuation_reason: Optional[str] = Field( - None, max_length=500, description="Valuation reason" - ) - - # Container rules - container_rule: Optional[str] = Field( - None, max_length=50, description="Container rule" - ) - container_parts_ii: Optional[str] = Field( - None, max_length=50, description="Container parts II" - ) - - # APHIS - consecutive_aphis: Optional[int] = Field(None, description="Consecutive APHIS") - - # BOM/Commercial - bom_version: Optional[int] = Field(None, description="BOM version") - bill_version: Optional[int] = Field(None, description="Bill version") - - # TLCAN value - tlcan_value: Optional[Decimal] = Field(None, description="TLCAN value") - - # Identifier - identifier: Optional[str] = Field(None, max_length=2, description="Identifier") - - # Validation fields - validation_zero: Optional[int] = Field(None, description="Validation zero") - validation_one: Optional[int] = Field(None, description="Validation one") - - # Material type - material_type: Optional[str] = Field( - None, max_length=50, description="Material type" - ) - - # Order concept - order_type: Optional[str] = Field(None, max_length=50, description="Order type") - line_concept: Optional[str] = Field(None, max_length=50, description="Line concept") - - # Review dispatch - review_dispatch: Optional[str] = Field( - None, max_length=10, description="Review dispatch" - ) - - # Take component from PT - take_component_pt: Optional[int] = Field(None, description="Take component from PT") - - # Pallet - pallet2: Optional[int] = Field(None, description="Pallet 2") - - # Wildcard field - wildcard_field: Optional[str] = Field( - None, max_length=100, description="Wildcard field" - ) - - -class LineItemCreate(LineItemBase): - """Schema for creating line item with all nested data""" - - # Override base fields - estos se asignan automáticamente en el service - item_id: Optional[int] = Field( - None, description="ID of the parent item (auto-assigned)" - ) - line_number: Optional[int] = Field(None, description="Line number (auto-assigned)") - - financial: Optional[LineFinancialCreate] = Field( - None, description="Financial data for this line" - ) - quantity: Optional[LineQuantityCreate] = Field( - None, description="Quantity data for this line" - ) - customs: Optional[LineCustomCreate] = Field( - None, description="Customs data for this line" - ) - description: Optional[LineDescriptionCreate] = Field( - None, description="Description data for this line" - ) - reference: Optional[LineReferenceCreate] = Field( - None, description="Reference data for this line" - ) - fa_data: Optional[FaLineItemCreateDTO] = Field( - None, description="Fixed Asset data for this line" - ) - - -class LineItemUpdate(LineItemBase): - """Schema for updating line item with all nested data""" - - # Override base fields - todos opcionales en updates - item_id: Optional[int] = Field(None, description="ID of the parent item") - line_number: Optional[int] = Field(None, description="Line number") - financial: Optional[LineFinancialUpdate] = Field( - None, description="Financial data for this line" - ) - quantity: Optional[LineQuantityUpdate] = Field( - None, description="Quantity data for this line" - ) - customs: Optional[LineCustomUpdate] = Field( - None, description="Customs data for this line" - ) - description: Optional[LineDescriptionUpdate] = Field( - None, description="Description data for this line" - ) - reference: Optional[LineReferenceUpdate] = Field( - None, description="Reference data for this line" - ) - fa_data: Optional[FaLineItemUpdateDTO] = Field( - None, description="Fixed Asset data for this line" - ) - - -class LineItemResponse(LineItemBase): - """Schema for line item response with all nested data""" - - id: int - item_id: int - financial: Optional[LineFinancialResponse] = None - quantity: Optional[LineQuantityResponse] = None - customs: Optional[LineCustomResponse] = None - description: Optional[LineDescriptionResponse] = None - reference: Optional[LineReferenceResponse] = None - fa_data: Optional[FaLineItemResponseDTO] = None - - # Fields populated from relationships - class_code: Optional[str] = None - class_description: Optional[str] = None - unit_of_measure_code: Optional[str] = None - - model_config = ConfigDict(from_attributes=True) - - @model_validator(mode="before") - @classmethod - def extract_relationship_info(cls, data: Any) -> Any: - """Extract class_code, class_description and unit_of_measure_code from relationships""" - if isinstance(data, dict): - return data - - # It's an ORM object - result = {} - for key in cls.model_fields.keys(): - 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 - result["class_description"] = data.class_info.description_es - - # Extract unit of measure code - if ( - hasattr(data, "unit_of_measure_info") - and data.unit_of_measure_info is not None - ): - result["unit_of_measure_code"] = data.unit_of_measure_info.code - - return result 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 24831f9d..d21e8835 100644 --- a/backend/api/v1/modules/a76/items/line_quantities/models.py +++ b/backend/api/v1/modules/a76/items/line_quantities/models.py @@ -7,7 +7,7 @@ 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 + from ..models import LineItem class LineQuantity(Base): """ diff --git a/backend/api/v1/modules/a76/items/line_references/models.py b/backend/api/v1/modules/a76/items/line_references/models.py index 1402795c..608d0c83 100644 --- a/backend/api/v1/modules/a76/items/line_references/models.py +++ b/backend/api/v1/modules/a76/items/line_references/models.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from core.database import Base if TYPE_CHECKING: - from ..line_items.models import LineItem + from ..models import LineItem class LineReference(Base): """ diff --git a/backend/api/v1/modules/a76/items/models.py b/backend/api/v1/modules/a76/items/models.py index 53e75cfd..ebc92a2e 100644 --- a/backend/api/v1/modules/a76/items/models.py +++ b/backend/api/v1/modules/a76/items/models.py @@ -3,26 +3,45 @@ Normalized Database Schema for SCAF (Fixed Assets) and SCAII (Parts Inventory) SQLAlchemy v2 - Annex 24 Compliance """ -from typing import Optional, TYPE_CHECKING, List -from sqlalchemy import Boolean, String, Integer, ForeignKey +from typing import Optional, TYPE_CHECKING +from core.database import Base +from decimal import Decimal +from sqlalchemy import ( + Boolean, + String, + Integer, + Numeric, + SmallInteger, + ForeignKey +) from sqlalchemy.orm import Mapped, mapped_column, relationship from api.v1.common.base_models import TenantScopedMixin, TimestampMixin from core.database import Base -if TYPE_CHECKING: - from .line_items.models import LineItem +from api.v1.modules.a76.classes.models import Class +from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure + +if TYPE_CHECKING: + from .line_financials.models import LineFinancial + from .line_quantities.models import LineQuantity + from .line_customs.models import LineCustom + from .line_descriptions.models import LineDescription + from .line_references.models import LineReference + from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem + from api.v1.modules.a76.parts.models import Part + from api.v1.modules.a76.invoices.models import InvoiceHeader # ============================================================================ # CORE ENTITIES # ============================================================================ -class Item(Base, TenantScopedMixin, TimestampMixin): +class LineItem(Base, TenantScopedMixin, TimestampMixin): """ Unified item header table for all import/export operations Consolidates headers from both SCAF and SCAII systems """ - __tablename__ = "items" + __tablename__ = "item_lines" __table_args__ = { "schema": "a76", } @@ -30,6 +49,129 @@ class Item(Base, TenantScopedMixin, TimestampMixin): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) invoice_id: Mapped[int] = mapped_column(ForeignKey("a76.invoice_header.id")) # CONSECUTIVO + line_number: Mapped[int] = mapped_column(Integer) # LINEAIMPO/LINEAEXPO/LINEA + + # Part identification + part_number_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("a76.parts.id") + ) # NUMPARTE + component_part_number_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("a76.parts.id") + ) # NUMPARTECOM + class_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("a76.classes.id") + ) # CLASE + + # Unit of measure + unit_of_measure: Mapped[Optional[int]] = mapped_column( + ForeignKey("a76.units_of_measure.id") + ) # UNIDADMEDIDA/UNIMED + alternate_unit: Mapped[Optional[int]] = mapped_column( + ForeignKey("a76.units_of_measure.id") + ) # UNIMEDALTERNA + uma_key: Mapped[Optional[str]] = mapped_column(String(2)) # CLAVEUMA + auxiliary_unit: Mapped[Optional[str]] = mapped_column(String(5)) # UNIMEDAUXILIAR + + # Permits and certificates + permit_number: Mapped[Optional[str]] = mapped_column(String(20)) # NUMPERMISO + page_line: Mapped[Optional[str]] = mapped_column(String(10)) # PAGRENGLON + has_certificate: Mapped[Optional[bool]] = mapped_column( + Boolean + ) # TIENECO/CERTORIGEN + certificate_number: Mapped[Optional[str]] = mapped_column( + String(10) + ) # NOCERTIFICADO + octave_permit: Mapped[Optional[str]] = mapped_column(String(20)) # PERMISOROCTAVA + permits_ped: Mapped[Optional[str]] = mapped_column(String(500)) # PERMISOSPED + + # FDA + has_fda_code: Mapped[Optional[bool]] = mapped_column(Boolean) # LLEVACODFDA + fda_key: Mapped[Optional[str]] = mapped_column(String(10)) # CLAVEFDA + + # Special flags + is_military_mcia: Mapped[Optional[bool]] = mapped_column(Boolean) # ESMCIAMILITAR + + # IV32 (Tax identification) + iv32_type_key: Mapped[Optional[str]] = mapped_column(String(5)) # CLAVETIPOIV32 + iv32_number: Mapped[Optional[str]] = mapped_column(String(35)) # NUMEROIV32 + + # IN CASE OF EXPO + scrap_invoice: Mapped[Optional[str]] = mapped_column(String(15)) # FACTURASCRAP + consecutive_destination: Mapped[Optional[int]] = mapped_column( + Integer + ) # CONSECUTIVODES + ctm_section: Mapped[Optional[str]] = mapped_column(String(3)) # APARTADOCTM + + # Tax payment + tax_payment: Mapped[Optional[bool]] = mapped_column(Boolean) # PAGOIMPUESTO + payment_method: Mapped[Optional[str]] = mapped_column( + String(9) + ) # FORMAPAGO/FORMAPAGOTIGI + igi_amount: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # MONTOIGI + igi_payment_method: Mapped[Optional[str]] = mapped_column( + String(9) + ) # FORMAPAGOTIGI + + # FCC + fcc_key: Mapped[Optional[str]] = mapped_column(String(30)) # CLAVEFCC + + # Valuation method + valuation_method: Mapped[Optional[str]] = mapped_column(String(2)) # METVALOR + valuation_determined_value: Mapped[Optional[Decimal]] = mapped_column( + Numeric(29, 8) + ) # METVALORVALORDETERMINADO/METVALORACIONVALORDETERMINADO + valuation_reason: Mapped[Optional[str]] = mapped_column( + String(500) + ) # METVALORMOTIVODEUSO/METVALORACIONMOTIVODEUSO + + # Container rules + container_rule: Mapped[Optional[str]] = mapped_column(String(50)) # CONTENEDORREGLA + container_parts_ii: Mapped[Optional[str]] = mapped_column( + String(50) + ) # CONTENEDORPARTESII + + # APHIS + consecutive_aphis: Mapped[Optional[int]] = mapped_column( + Integer + ) # CONSECUTIVOAPHIS + + # BOM/Commercial + bom_version: Mapped[Optional[int]] = mapped_column(Integer) # VERSIONBOM + bill_version: Mapped[Optional[int]] = mapped_column(Integer) # VERSIONBILL + + # TLCAN value + tlcan_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORTLCAN + + # Identifier + identifier: Mapped[Optional[str]] = mapped_column(String(2)) # IDENTIFICADOR + + # Validation fields + validation_zero: Mapped[Optional[int]] = mapped_column(Integer) # VALIDACIONZERO + validation_one: Mapped[Optional[int]] = mapped_column(Integer) # VALIDACIONUNO + + # Material type + material_type: Mapped[Optional[str]] = mapped_column( + String(50) + ) # TIPOMAT/TIPODENUMPARTE + + # Order concept + order_type: Mapped[Optional[str]] = mapped_column(String(50)) # TIPODEORDEN + line_concept: Mapped[Optional[str]] = mapped_column( + String(50) + ) # CONCEPTODELAPARTIDA + + # Review dispatch + review_dispatch: Mapped[Optional[str]] = mapped_column(String(10)) # REVISARDESP + + # Take component from PT + take_component_pt: Mapped[Optional[int]] = mapped_column(Integer) # TOMARCOMOPT + + # Pallet + pallet2: Mapped[Optional[int]] = mapped_column(SmallInteger) # PALLET2 + + # Wildcard field + wildcard_field: Mapped[Optional[str]] = mapped_column(String(100)) # CAMPOCOMODIN + # Item references reference_number: Mapped[Optional[str]] = mapped_column( String(20)) # NUMREFERENCIA @@ -48,12 +190,46 @@ class Item(Base, TenantScopedMixin, TimestampMixin): warehouse: Mapped[Optional[str]] = mapped_column(String(30)) # BODEGA location: Mapped[Optional[str]] = mapped_column( String(200)) # LOCALIZACION - - # Relationships (one-to-many) - lines: Mapped[List["LineItem"]] = relationship( - "LineItem", back_populates="item", cascade="all, delete-orphan") invoice: Mapped["InvoiceHeader"] = relationship("InvoiceHeader") + + # Relationships + financial: Mapped[Optional["LineFinancial"]] = relationship( + back_populates="line", cascade="all, delete-orphan", uselist=False + ) + quantity: Mapped[Optional["LineQuantity"]] = relationship( + back_populates="line", cascade="all, delete-orphan", uselist=False + ) + customs: Mapped[Optional["LineCustom"]] = relationship( + back_populates="line", cascade="all, delete-orphan", uselist=False + ) + description: Mapped[Optional["LineDescription"]] = relationship( + back_populates="line", cascade="all, delete-orphan", uselist=False + ) + reference: Mapped[Optional["LineReference"]] = relationship( + back_populates="line", cascade="all, delete-orphan", uselist=False + ) + class_info: Mapped[Optional["Class"]] = relationship( + "api.v1.modules.a76.classes.models.Class", + foreign_keys=[class_id], + viewonly=True, + ) + unit_of_measure_info: Mapped[Optional["UnitOfMeasure"]] = relationship( + "api.v1.modules.a76.general_catalogs.units_of_measure.models.UnitOfMeasure", + foreign_keys=[unit_of_measure], + viewonly=True, + ) + fa_data: Mapped[Optional["FaLineItem"]] = relationship( + "FaLineItem", + back_populates="master_info", + cascade="all, delete-orphan", + uselist=False, + ) + part_info: Mapped[Optional["Part"]] = relationship( + "Part", + foreign_keys=[part_number_id], + viewonly=True, + ) # ============================================================================ # SUPPORTING TABLES @@ -189,8 +365,8 @@ MIGRATION STRATEGY FROM ORIGINAL TABLES TO NORMALIZED SCHEMA: 7. QUERYING EXAMPLES: ```python # Get all imports (both systems) - session.query(Item).filter( - Item.item_type.in_(['IMPORT', 'EQUIPMENT_IMPORT_TEMP', 'EQUIPMENT_IMPORT_DEF']) + session.query(LineItem).filter( + LineItem.item_type.in_(['IMPORT', 'EQUIPMENT_IMPORT_TEMP', 'EQUIPMENT_IMPORT_DEF']) ) # Get all lines for a specific part across all items @@ -199,8 +375,8 @@ MIGRATION STRATEGY FROM ORIGINAL TABLES TO NORMALIZED SCHEMA: ) # Get SCAF equipment with depreciation - session.query(Item).join(LineItem).filter( - Item.system_origin == 'SCAF', + session.query(LineItem).join(LineItem).filter( + LineItem.system_origin == 'SCAF', LineItem.value_depreciated_usd.isnot(None) ) ``` diff --git a/backend/api/v1/modules/a76/items/routes.py b/backend/api/v1/modules/a76/items/routes.py index 4bf677e3..ee1c9e14 100644 --- a/backend/api/v1/modules/a76/items/routes.py +++ b/backend/api/v1/modules/a76/items/routes.py @@ -11,10 +11,10 @@ from core.database import get_core_db from core.security import get_current_user, validate_access_to_resource from .schemas import ( - ItemCreate, - ItemUpdate, - ItemResponse, - ItemListResponse, + LineItemCreate, + LineItemUpdate, + LineItemResponse, + LineItemListResponse, ) from .service import ItemService @@ -25,9 +25,9 @@ router = APIRouter(prefix="/items", tags=["Items"]) # ITEM CRUD ENDPOINTS # ============================================================================ -@router.post("/", response_model=ItemResponse, status_code=status.HTTP_201_CREATED) +@router.post("/", response_model=LineItemResponse, status_code=status.HTTP_201_CREATED) async def create_item( - item_data: ItemCreate, + item_data: LineItemCreate, company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), @@ -36,7 +36,6 @@ async def create_item( Create a new item with multiple line items and their nested data The item follows a one-to-many relationship structure: - - Item has many LineItems - Each LineItem has one LineFinancial - Each LineItem has one LineQuantity - Each LineItem has one LineCustoms @@ -49,7 +48,7 @@ async def create_item( return service.create(db, item_data, tenant_id, company_id) -@router.get("/{item_id}", response_model=ItemResponse) +@router.get("/{item_id}", response_model=LineItemResponse) async def get_item( item_id: int = Path(..., description="Item ID"), company_id: int = Query(..., description="Company ID"), @@ -70,7 +69,7 @@ async def get_item( return item -@router.get("/", response_model=ItemListResponse) +@router.get("/", response_model=LineItemListResponse) async def list_items( company_id: int = Query(..., description="Company ID"), skip: int = Query(0, ge=0, description="Number of records to skip"), @@ -109,7 +108,7 @@ async def list_items( items, total = service.get_all( db, tenant_id, company_id, skip, limit, filters) - return ItemListResponse( + return LineItemListResponse( total=total, items=items, skip=skip, @@ -117,10 +116,10 @@ async def list_items( ) -@router.put("/{item_id}", response_model=ItemResponse) +@router.put("/{item_id}", response_model=LineItemResponse) async def update_item( item_id: int = Path(..., description="Item ID"), - item_data: ItemUpdate = ..., + item_data: LineItemUpdate = ..., company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), @@ -168,7 +167,7 @@ async def delete_item( # ADDITIONAL ENDPOINTS FOR INVOICE # ============================================================================ -@router.get("/invoice/{invoice_id}/items", response_model=ItemListResponse) +@router.get("/invoice/{invoice_id}/items", response_model=LineItemListResponse) async def get_items_by_invoice( invoice_id: int = Path(..., description="Invoice ID"), company_id: int = Query(..., description="Company ID"), @@ -186,7 +185,7 @@ async def get_items_by_invoice( items, total = service.get_by_invoice( db, invoice_id, tenant_id, company_id, skip, limit) - return ItemListResponse( + return LineItemListResponse( total=total, items=items, skip=skip, diff --git a/backend/api/v1/modules/a76/items/schemas.py b/backend/api/v1/modules/a76/items/schemas.py index 3503bab1..2c94f679 100644 --- a/backend/api/v1/modules/a76/items/schemas.py +++ b/backend/api/v1/modules/a76/items/schemas.py @@ -1,19 +1,46 @@ """ Schemas for Items and related entities Complete nested one-to-one structure: -Item -> LineItem -> LineFinancial -> LineQuantity -> LineCustoms -> LineDescription -> LineReference +LineItem -> LineFinancial -> LineQuantity -> LineCustoms -> LineDescription -> LineReference """ -from typing import Optional +from typing import Any, Optional from datetime import datetime from decimal import Decimal -from pydantic import BaseModel, Field, ConfigDict +from pydantic import BaseModel, Field, ConfigDict, model_validator # Import schemas from individual modules -from .line_items.schemas import ( - LineItemCreate, - LineItemUpdate, - LineItemResponse +# Import nested schemas +from .line_customs.schemas import ( + LineCustomCreate, + LineCustomUpdate, + LineCustomResponse, +) +from .line_descriptions.schemas import ( + LineDescriptionCreate, + LineDescriptionUpdate, + LineDescriptionResponse, +) +from .line_quantities.schemas import ( + LineQuantityCreate, + LineQuantityUpdate, + LineQuantityResponse, +) +from .line_financials.schemas import ( + LineFinancialCreate, + LineFinancialUpdate, + LineFinancialResponse, +) +from .line_references.schemas import ( + LineReferenceCreate, + LineReferenceUpdate, + LineReferenceResponse, +) + +from api.v1.modules.a24.fa.fa_item_lines.dto import ( + FaLineItemCreateDTO, + FaLineItemUpdateDTO, + FaLineItemResponseDTO, ) @@ -21,53 +48,294 @@ from .line_items.schemas import ( # ITEM SCHEMAS # ============================================================================ -class ItemBase(BaseModel): + +class LineItemBase(BaseModel): """Base schema for items""" + + model_config = ConfigDict(populate_by_name=True) + invoice_id: int = Field(..., description="Invoice ID") + line_number: int = Field(..., description="Line number") + + # Part identification + 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", + alias="component_part_number", + serialization_alias="component_part_number_id", + ) + 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") + uma_key: Optional[str] = Field(None, max_length=2, description="UMA key") + auxiliary_unit: Optional[str] = Field( + None, max_length=5, description="Auxiliary unit" + ) + + # Permits and certificates + permit_number: Optional[str] = Field( + None, max_length=20, description="Permit number" + ) + page_line: Optional[str] = Field(None, max_length=10, description="Page line") + has_certificate: Optional[bool] = Field(None, description="Has certificate") + certificate_number: Optional[str] = Field( + None, max_length=10, description="Certificate number" + ) + octave_permit: Optional[str] = Field( + None, max_length=20, description="Octave permit" + ) + permits_ped: Optional[str] = Field(None, max_length=500, description="PED permits") + + # FDA + has_fda_code: Optional[bool] = Field(None, description="Has FDA code") + fda_key: Optional[str] = Field(None, max_length=10, description="FDA key") + + # Special flags + is_military_mcia: Optional[bool] = Field( + None, description="Is military merchandise" + ) + + # IV32 + iv32_type_key: Optional[str] = Field( + None, max_length=5, description="IV32 type key" + ) + iv32_number: Optional[str] = Field(None, max_length=35, description="IV32 number") + + # Export specific + scrap_invoice: Optional[str] = Field( + None, max_length=15, description="Scrap invoice" + ) + consecutive_destination: Optional[int] = Field( + None, description="Consecutive destination" + ) + ctm_section: Optional[str] = Field(None, max_length=3, description="CTM section") + + # Tax payment + tax_payment: Optional[bool] = Field(None, description="Tax payment") + payment_method: Optional[str] = Field( + None, max_length=9, description="Payment method" + ) + igi_amount: Optional[Decimal] = Field(None, description="IGI amount") + igi_payment_method: Optional[str] = Field( + None, max_length=9, description="IGI payment method" + ) + + # FCC + fcc_key: Optional[str] = Field(None, max_length=30, description="FCC key") + + # Valuation method + valuation_method: Optional[str] = Field( + None, max_length=2, description="Valuation method" + ) + valuation_determined_value: Optional[Decimal] = Field( + None, description="Valuation determined value" + ) + valuation_reason: Optional[str] = Field( + None, max_length=500, description="Valuation reason" + ) + + # Container rules + container_rule: Optional[str] = Field( + None, max_length=50, description="Container rule" + ) + container_parts_ii: Optional[str] = Field( + None, max_length=50, description="Container parts II" + ) + + # APHIS + consecutive_aphis: Optional[int] = Field(None, description="Consecutive APHIS") + + # BOM/Commercial + bom_version: Optional[int] = Field(None, description="BOM version") + bill_version: Optional[int] = Field(None, description="Bill version") + + # TLCAN value + tlcan_value: Optional[Decimal] = Field(None, description="TLCAN value") + + # Identifier + identifier: Optional[str] = Field(None, max_length=2, description="Identifier") + + # Validation fields + validation_zero: Optional[int] = Field(None, description="Validation zero") + validation_one: Optional[int] = Field(None, description="Validation one") + + # Material type + material_type: Optional[str] = Field( + None, max_length=50, description="Material type" + ) + + # Order concept + order_type: Optional[str] = Field(None, max_length=50, description="Order type") + line_concept: Optional[str] = Field(None, max_length=50, description="Line concept") + + # Review dispatch + review_dispatch: Optional[str] = Field( + None, max_length=10, description="Review dispatch" + ) + + # Take component from PT + take_component_pt: Optional[int] = Field(None, description="Take component from PT") + + # Pallet + pallet2: Optional[int] = Field(None, description="Pallet 2") + + # Wildcard field + wildcard_field: Optional[str] = Field( + None, max_length=100, description="Wildcard field" + ) reference_number: Optional[str] = Field( - None, max_length=20, description="Reference number") + None, max_length=20, description="Reference number" + ) order: Optional[str] = Field(None, max_length=50, description="Order") - guide_number: Optional[str] = Field( - None, max_length=50, description="Guide number") + guide_number: Optional[str] = Field(None, max_length=50, description="Guide number") # Dates - depreciation_date: Optional[int] = Field( - None, description="Depreciation date") + depreciation_date: Optional[int] = Field(None, description="Depreciation date") # Administrative fields rectification: Optional[int] = Field(None, description="Rectification") - warehouse: Optional[str] = Field( - None, max_length=30, description="Warehouse") - location: Optional[str] = Field( - None, max_length=200, description="Location") + warehouse: Optional[str] = Field(None, max_length=30, description="Warehouse") + location: Optional[str] = Field(None, max_length=200, description="Location") -class ItemCreate(ItemBase): +class LineItemCreate(LineItemBase): """Schema for creating item with nested lines (one-to-many)""" - lines: Optional[list[LineItemCreate]] = Field( - default=[], description="List of line items") + + # Override base fields - estos se asignan automáticamente en el service + line_number: Optional[int] = Field(None, description="Line number (auto-assigned)") + + financial: Optional[LineFinancialCreate] = Field( + None, description="Financial data for this line" + ) + quantity: Optional[LineQuantityCreate] = Field( + None, description="Quantity data for this line" + ) + customs: Optional[LineCustomCreate] = Field( + None, description="Customs data for this line" + ) + description: Optional[LineDescriptionCreate] = Field( + None, description="Description data for this line" + ) + reference: Optional[LineReferenceCreate] = Field( + None, description="Reference data for this line" + ) + fa_data: Optional[FaLineItemCreateDTO] = Field( + None, description="Fixed Asset data for this line" + ) -class ItemUpdate(ItemBase): +class LineItemUpdate(LineItemBase): """Schema for updating item""" + invoice_id: Optional[int] = Field(None, description="Invoice ID") - lines: Optional[list[LineItemUpdate]] = Field( - None, description="List of line items to update") + # Override base fields - todos opcionales en updates + line_number: Optional[int] = Field(None, description="Line number") + financial: Optional[LineFinancialUpdate] = Field( + None, description="Financial data for this line" + ) + quantity: Optional[LineQuantityUpdate] = Field( + None, description="Quantity data for this line" + ) + customs: Optional[LineCustomUpdate] = Field( + None, description="Customs data for this line" + ) + description: Optional[LineDescriptionUpdate] = Field( + None, description="Description data for this line" + ) + reference: Optional[LineReferenceUpdate] = Field( + None, description="Reference data for this line" + ) + fa_data: Optional[FaLineItemUpdateDTO] = Field( + None, description="Fixed Asset data for this line" + ) -class ItemResponse(ItemBase): - """Schema for item response with nested data (one-to-many)""" +class LineItemResponse(BaseModel): + """Schema for single item response""" + id: int - lines: list[LineItemResponse] = Field( - default=[], description="List of line items") + invoice_id: int + line_number: int - model_config = ConfigDict(from_attributes=True) + # Part identification + part_number_id: Optional[int] = Field( + None, alias="part_number", serialization_alias="part_number_id" + ) + component_part_number_id: Optional[int] = Field( + None, + alias="component_part_number", + serialization_alias="component_part_number_id", + ) + class_id: Optional[int] = None + + # Nested data + financial: Optional[LineFinancialResponse] = None + quantity: Optional[LineQuantityResponse] = None + customs: Optional[LineCustomResponse] = None + description: Optional[LineDescriptionResponse] = None + reference: Optional[LineReferenceResponse] = None + fa_data: Optional[FaLineItemResponseDTO] = None + + # Fields populated from relationships + class_code: Optional[str] = None + class_description: Optional[str] = None + unit_of_measure_code: Optional[str] = None + + # Additional fields that might be present + reference_number: Optional[str] = None + order: Optional[str] = None + warehouse: Optional[str] = None + location: Optional[str] = None + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + @model_validator(mode="before") + @classmethod + def extract_relationship_info(cls, data: Any) -> Any: + """Extract class_code, class_description and unit_of_measure_code from relationships""" + if isinstance(data, dict): + return data + + # It's an ORM object + result = {} + for key in cls.model_fields.keys(): + 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 + result["class_description"] = data.class_info.description_es + + # Extract unit of measure code + if ( + hasattr(data, "unit_of_measure_info") + and data.unit_of_measure_info is not None + ): + result["unit_of_measure_code"] = data.unit_of_measure_info.code + + return result -class ItemListResponse(BaseModel): +class LineItemListResponse(BaseModel): """Schema for paginated item list""" + total: int = Field(..., description="Total number of items") - items: list[ItemResponse] = Field(..., description="List of items") + items: list[LineItemResponse] = Field(..., description="List of items") skip: int = Field(..., description="Number of skipped items") limit: int = Field(..., description="Maximum items per page") diff --git a/backend/api/v1/modules/a76/items/service.py b/backend/api/v1/modules/a76/items/service.py index 63d1d25e..1e1f884d 100644 --- a/backend/api/v1/modules/a76/items/service.py +++ b/backend/api/v1/modules/a76/items/service.py @@ -1,12 +1,15 @@ """ Service layer for Items business logic -Handles CRUD operations for Item with complete one-to-one relationships: -Item -> LineItem -> LineFinancial - -> LineQuantity - -> LineCustoms - -> LineDescription - -> LineReference - -> FaLineItem (Fixed Assets - a24) +Handles CRUD operations for LineItem with complete one-to-one relationships: +LineItem -> LineFinancial + -> LineQuantity + -> LineCustoms + -> LineDescription + -> LineReference + -> FaLineItem (Fixed Assets - a24) + +After refactoring: LineItem is the main entity, representing a single line item in an invoice. +There is no intermediate Item entity anymore. Each LineItem belongs directly to an InvoiceHeader. """ import logging @@ -24,17 +27,14 @@ from core.exceptions import ErrorCollector from .imports.temporary.validators.create import validate_create from .imports.temporary.validators.update import validate_update -from api.v1.modules.a76.items.line_items.schemas import LineItemCreate, LineItemUpdate - -from .schemas import ItemCreate, ItemUpdate -from .line_items.models import LineItem +from .schemas import LineItemCreate, LineItemUpdate from .line_financials.models import LineFinancial from .line_quantities.models import LineQuantity from .line_customs.models import LineCustom from .line_descriptions.models import LineDescription from .line_references.models import LineReference from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem -from .models import Item +from .models import LineItem from api.v1.modules.a76.invoices.models import InvoiceHeader logger = logging.getLogger(__name__) @@ -52,8 +52,7 @@ class ItemService: max_line = ( db.query(func.max(LineItem.line_number)) - .join(Item, LineItem.item_id == Item.id) - .filter(Item.invoice_id == invoice_id) + .filter(LineItem.invoice_id == invoice_id) .scalar() ) @@ -62,12 +61,15 @@ class ItemService: @staticmethod def _renumber_all_invoice_lines(db: Session, invoice_id: int) -> None: """Renumber all line_items for a given invoice to be consecutive (1, 2, 3, ...).""" - items = db.query(Item).filter(Item.invoice_id == invoice_id).all() - all_lines = [line for item in items for line in item.lines] - all_lines.sort(key=lambda x: x.line_number if x.line_number else 0) + items = ( + db.query(LineItem) + .filter(LineItem.invoice_id == invoice_id) + .order_by(LineItem.line_number) + .all() + ) - for idx, line in enumerate(all_lines, start=1): - line.line_number = idx + for idx, item in enumerate(items, start=1): + item.line_number = idx @staticmethod def _lock_invoice( @@ -143,24 +145,24 @@ class ItemService: @staticmethod def get_by_id( db: Session, item_id: int, tenant_id: int, company_id: int - ) -> Optional[Item]: + ) -> Optional[LineItem]: """Get an item by ID with tenant/company validation""" return ( - db.query(Item) + db.query(LineItem) .options( - joinedload(Item.lines).joinedload(LineItem.financial), - joinedload(Item.lines).joinedload(LineItem.quantity), - joinedload(Item.lines).joinedload(LineItem.customs), - joinedload(Item.lines).joinedload(LineItem.description), - joinedload(Item.lines).joinedload(LineItem.reference), - joinedload(Item.lines).joinedload(LineItem.class_info), - joinedload(Item.lines).joinedload(LineItem.unit_of_measure_info), - joinedload(Item.lines).joinedload(LineItem.fa_data), + joinedload(LineItem.financial), + joinedload(LineItem.quantity), + joinedload(LineItem.customs), + joinedload(LineItem.description), + joinedload(LineItem.reference), + joinedload(LineItem.class_info), + joinedload(LineItem.unit_of_measure_info), + joinedload(LineItem.fa_data), ) .filter( - Item.id == item_id, - Item.tenant_id == tenant_id, - Item.company_id == company_id, + LineItem.id == item_id, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, ) .first() ) @@ -173,42 +175,42 @@ class ItemService: skip: int = 0, limit: int = 100, filters: Optional[dict] = None, - ) -> Tuple[List[Item], int]: + ) -> Tuple[List[LineItem], int]: """Get all items for a tenant/company with pagination and optional filters""" query = ( - db.query(Item) + db.query(LineItem) .options( - joinedload(Item.lines).joinedload(LineItem.financial), - joinedload(Item.lines).joinedload(LineItem.quantity), - joinedload(Item.lines).joinedload(LineItem.customs), - joinedload(Item.lines).joinedload(LineItem.description), - joinedload(Item.lines).joinedload(LineItem.reference), - joinedload(Item.lines).joinedload(LineItem.class_info), - joinedload(Item.lines).joinedload(LineItem.unit_of_measure_info), - joinedload(Item.lines).joinedload(LineItem.fa_data), + joinedload(LineItem.financial), + joinedload(LineItem.quantity), + joinedload(LineItem.customs), + joinedload(LineItem.description), + joinedload(LineItem.reference), + joinedload(LineItem.class_info), + joinedload(LineItem.unit_of_measure_info), + joinedload(LineItem.fa_data), ) .filter( - Item.tenant_id == tenant_id, - Item.company_id == company_id, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, ) ) # Apply filters if provided if filters: if filters.get("invoice_id"): - query = query.filter(Item.invoice_id == filters["invoice_id"]) + query = query.filter(LineItem.invoice_id == filters["invoice_id"]) if filters.get("item_type"): - query = query.filter(Item.item_type == filters["item_type"]) + query = query.filter(LineItem.item_type == filters["item_type"]) if filters.get("system_origin"): - query = query.filter(Item.system_origin == filters["system_origin"]) + query = query.filter(LineItem.system_origin == filters["system_origin"]) if filters.get("search"): search_term = f"%{filters['search']}%" query = query.filter( or_( - Item.invoice_id.ilike(search_term), - Item.reference_number.ilike(search_term), - Item.order.ilike(search_term), - Item.guide_number.ilike(search_term), + LineItem.invoice_id.ilike(search_term), + LineItem.reference_number.ilike(search_term), + LineItem.order.ilike(search_term), + LineItem.guide_number.ilike(search_term), ) ) @@ -224,22 +226,22 @@ class ItemService: company_id: int, skip: int = 0, limit: int = 100, - ) -> Tuple[List[Item], int]: + ) -> Tuple[List[LineItem], int]: """Get all items for a specific invoice""" query = ( - db.query(Item) + db.query(LineItem) .options( - joinedload(Item.lines).joinedload(LineItem.financial), - joinedload(Item.lines).joinedload(LineItem.quantity), - joinedload(Item.lines).joinedload(LineItem.customs), - joinedload(Item.lines).joinedload(LineItem.description), - joinedload(Item.lines).joinedload(LineItem.reference), - joinedload(Item.lines).joinedload(LineItem.fa_data), + joinedload(LineItem.financial), + joinedload(LineItem.quantity), + joinedload(LineItem.customs), + joinedload(LineItem.description), + joinedload(LineItem.reference), + joinedload(LineItem.fa_data), ) .filter( - Item.invoice_id == invoice_id, - Item.tenant_id == tenant_id, - Item.company_id == company_id, + LineItem.invoice_id == invoice_id, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, ) ) @@ -250,10 +252,10 @@ class ItemService: @staticmethod def create( db: Session, - item_data: ItemCreate, + item_data: LineItemCreate, tenant_id: int, company_id: int, - ) -> Item: + ) -> LineItem: """Create a new item with all related nested data (multiple lines)""" # Validaciones con ErrorCollector @@ -271,109 +273,74 @@ class ItemService: if not invoice_updated(db, item_data.invoice_id, tenant_id, company_id, errors): errors.raise_if_errors("Error al crear el item") - # Lock invoice and pre-calculate line_numbers + # Lock invoice and calculate line number if not ItemService._lock_invoice( db, item_data.invoice_id, tenant_id, company_id, errors ): errors.raise_if_errors("Error al crear el item") - line_numbers = [] - if item_data.lines: - starting_line = ItemService._get_next_line_number(db, item_data.invoice_id) - line_numbers = [starting_line + i for i in range(len(item_data.lines))] + # Calculate the next line number for this single item + line_number = ItemService._get_next_line_number(db, 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): - line_number = line_numbers[idx] # Usar el line_number calculado + # Validar el item + validate_create( + db, + item_data, # Schema Pydantic completo + tenant_id, + company_id, + errors, + line_number, + ) - validate_create( - db, - line_data, # Schema Pydantic completo - item_data.invoice_id, # invoice_id - tenant_id, - company_id, - errors, - line_number, - ) + # Validaciones adicionales específicas del negocio + if item_data.fa_data and item_data.fa_data.is_subitem is None: + errors.add_required_error(field=f"fa_data.is_subitem") - # Validaciones adicionales específicas del negocio - if line_data.fa_data and line_data.fa_data.is_subitem is None: - errors.add_required_error( - field=f"lines[{line_number}].fa_data.is_subitem" - ) + if item_data.fa_data and item_data.fa_data.subitem_number is None: + errors.add_required_error(field=f"fa_data.subitem_number") - if line_data.fa_data and line_data.fa_data.subitem_number is None: - errors.add_required_error( - field=f"lines[{line_number}].fa_data.subitem_number" - ) - - # Validar apóstrofes en número de parte - if line_data.part_number_id and "'" in str(line_data.part_number_id): - errors.add_error( - field=f"lines[{line_number}].part_number", - message=f"Advertencia: El Número de Parte contiene apóstrofes y serán omitidos", - code="WARNING_APOSTROPHE", - ) + # Validar apóstrofes en número de parte + if item_data.part_number_id and "'" in str(item_data.part_number_id): + errors.add_error( + field=f"part_number", + message=f"Advertencia: El Número de Parte contiene apóstrofes y serán omitidos", + code="WARNING_APOSTROPHE", + ) # 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 [] - item_dict = item_data.model_dump(exclude={"lines"}) + # Prepare item data + item_dict = item_data.model_dump( + exclude={ + "financial", + "quantity", + "customs", + "description", + "reference", + "fa_data", + } + ) - # Add tenant and company - item_dict["tenant_id"] = tenant_id - item_dict["company_id"] = company_id + # Add tenant, company and line number + item_dict.update( + { + "tenant_id": tenant_id, + "company_id": company_id, + "line_number": line_number, + } + ) # Create the item - db_item = Item(**item_dict) + db_item = LineItem(**item_dict) db.add(db_item) db.flush() # Get the item ID - # Create line items if provided - for idx, line_data in enumerate(lines_data): - line_dict = line_data.model_dump( - exclude={ - "financial", - "quantity", - "customs", - "description", - "reference", - "fa_data", - } - ) - line_dict.update( - { - "item_id": db_item.id, - "tenant_id": tenant_id, - "company_id": company_id, - "line_number": ( - line_numbers[idx] - if line_numbers - else ItemService._get_next_line_number( - db, item_data.invoice_id - ) - ), - } - ) - - # Map schema field names to model field names - line_dict["part_number"] = line_dict.pop("part_number_id", None) - line_dict["component_part_number"] = line_dict.pop( - "component_part_number_id", None - ) - - db_line = LineItem(**line_dict) - db.add(db_line) - db.flush() - - # Create all nested data - ItemService._create_line_nested_data( - db, db_line, line_data, tenant_id, company_id - ) + # Create all nested data + ItemService._create_line_nested_data( + db, db_item, item_data, tenant_id, company_id + ) db.commit() db.refresh(db_item) @@ -384,27 +351,27 @@ class ItemService: logger.error(f"Error creating item: {e}") raise HTTPException( status_code=400, - detail="Item creation failed - integrity constraint violated", + detail="LineItem creation failed - integrity constraint violated", ) except Exception as e: db.rollback() - logger.error(f"Unexpected error creating item: {e}") - raise HTTPException(status_code=500, detail="Error creating item") + logger.error(f"Unexpected error creating LineItem: {e}") + raise HTTPException(status_code=500, detail="Error creating LineItem") @staticmethod def update( db: Session, item_id: int, - item_data: ItemUpdate, + item_data: LineItemUpdate, tenant_id: int, company_id: int, - ) -> Item: + ) -> LineItem: """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") + raise HTTPException(status_code=404, detail="LineItem not found") # Validaciones con ErrorCollector errors = ErrorCollector() @@ -418,143 +385,103 @@ class ItemService: ): errors.raise_if_errors("Error al actualizar el item") - # Pre-calcular line_numbers para cada línea (en update, las líneas se renumeran desde 1) - line_numbers = [] - if item_data.lines: - line_numbers = [i + 1 for i in range(len(item_data.lines))] + # Validar el item que se va a actualizar + validate_update( + db, + item_data, # Schema de update + db_item, # LineItem existente en DB + tenant_id, + company_id, + errors, + db_item.line_number, + ) - # Validar cada line item que se va a actualizar - if item_data.lines: - for idx, line_data in enumerate(item_data.lines): - line_number = line_numbers[idx] # Usar el line_number calculado + # Validaciones adicionales específicas del negocio - # Si el line tiene ID, es actualización; si no, es creación - 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, - ) - if existing_line: - # Validar update con línea existente - validate_update( - db, - line_data, # Schema de update - existing_line, # LineItem existente en DB - invoice_id_to_lock, # invoice_id - tenant_id, - company_id, - errors, - line_number, - ) - else: - # Es un nuevo line item, validar como creación - validate_create( - db, - line_data, # Schema Pydantic completo - invoice_id_to_lock, # invoice_id - tenant_id, - company_id, - errors, - line_number, - ) + # Validar apóstrofes en número de parte + if item_data.part_number_id and "'" in str(item_data.part_number_id): + errors.add_error( + field=f"part_number", + message=f"Advertencia: El Número de Parte contiene apóstrofes y serán omitidos", + solution=None, + code="WARNING_APOSTROPHE", + ) - # Validaciones adicionales específicas del negocio - # (Aplican tanto para crear como actualizar) + # Validar tipo de partida + if hasattr(item_data, "item_type") and item_data.item_type: + tipo_partida = item_data.item_type + if tipo_partida and tipo_partida not in ["N", "S"]: + errors.add_error( + field=f"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), + ) - # Validar apóstrofes en número de parte - if line_data.part_number_id and "'" in str(line_data.part_number_id): + # Si es subpartida (S), debe tener partida principal + if tipo_partida == "S": + if not hasattr(item_data, "main_line_id") or not item_data.main_line_id: errors.add_error( - field=f"lines[{line_number}].part_number", - message=f"Advertencia: El Número de Parte contiene apóstrofes y serán omitidos", + field=f"main_line_id", + message="Las subpartidas (tipo 'S') deben tener una partida principal", solution=None, - code="WARNING_APOSTROPHE", + code="MISSING_MAIN_LINE", ) - # Validar tipo de partida - if hasattr(line_data, "item_type"): - tipo_partida = line_data.item_type - if tipo_partida and tipo_partida not in ["N", "S"]: - errors.add_error( - field=f"lines[{line_number}].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), - ) - - # 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 - ): - errors.add_error( - field=f"lines[{line_number}].main_line_id", - message="Las subpartidas (tipo 'S') deben tener una partida principal", - solution=None, - code="MISSING_MAIN_LINE", - ) - # Si hay errores, lanzar excepción ANTES de actualizar errors.raise_if_errors("Error al actualizar el item") try: + # Get item data excluding nested objects + item_dict = item_data.model_dump( + exclude={ + "financial", + "quantity", + "customs", + "description", + "reference", + "fa_data", + }, + exclude_unset=True, + ) - # Extract lines data - lines_data = item_data.lines - item_dict = item_data.model_dump(exclude={"lines"}, exclude_unset=True) + # Map schema field names to model field names + if "part_number_id" in item_dict: + item_dict["part_number"] = item_dict.pop("part_number_id") + if "component_part_number_id" in item_dict: + item_dict["component_part_number"] = item_dict.pop( + "component_part_number_id" + ) # Update item fields for key, value in item_dict.items(): setattr(db_item, key, value) - # Update lines if provided (replace all lines) - if lines_data is not None: - # Delete existing lines (cascade will handle nested data) - for existing_line in db_item.lines: - db.delete(existing_line) - db.flush() + # Delete existing nested data + db.query(LineFinancial).filter( + LineFinancial.item_line_id == db_item.id + ).delete() + db.query(LineQuantity).filter( + LineQuantity.item_line_id == db_item.id + ).delete() + db.query(LineCustom).filter(LineCustom.item_line_id == db_item.id).delete() + db.query(LineDescription).filter( + LineDescription.item_line_id == db_item.id + ).delete() + db.query(LineReference).filter( + LineReference.item_line_id == db_item.id + ).delete() + db.query(FaLineItem).filter(FaLineItem.id == db_item.id).delete() + db.flush() - # Create new lines - for idx, line_data in enumerate(lines_data): - line_dict = line_data.model_dump( - exclude={ - "financial", - "quantity", - "customs", - "description", - "reference", - "fa_data", - }, - exclude_unset=True, - ) - line_dict.update( - { - "item_id": db_item.id, - "tenant_id": tenant_id, - "company_id": company_id, - "line_number": idx + 1, - } - ) + # Create new nested data + ItemService._create_line_nested_data( + db, db_item, item_data, tenant_id, company_id + ) - # Map schema field names to model field names - line_dict["part_number"] = line_dict.pop("part_number_id", None) - line_dict["component_part_number"] = line_dict.pop( - "component_part_number_id", None - ) - - db_line = LineItem(**line_dict) - db.add(db_line) - db.flush() - - # Create all nested data - ItemService._create_line_nested_data( - db, db_line, line_data, tenant_id, company_id - ) - - # Renumber all lines for this invoice to ensure consecutive numbering - ItemService._renumber_all_invoice_lines(db, db_item.invoice_id) + # Renumber all lines for this invoice to ensure consecutive numbering + ItemService._renumber_all_invoice_lines(db, db_item.invoice_id) db.commit() db.refresh(db_item) 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 index 9f96a81e..4364dea8 100644 --- a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py +++ b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py @@ -17,7 +17,7 @@ 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.pedmientos.models.pedimento_validation import PedimentoValidation from api.v1.modules.a76.customs_brokers.models import CustomsBroker, CustomsBrokerPersonnel -from api.v1.modules.a76.items.models import Item +from api.v1.modules.a76.items.models import LineItem from api.v1.modules.a76.clients_and_providers.models import ClientProvider, ClientProviderAddress, ClientProviderPrograms # --- SCHEMAS FOR TEMPLATE CONTEXT --- @@ -186,7 +186,7 @@ class AvisoConsolidadoExportacionService: logistics = header.logistics # Fetch Items associated with this invoice (MOVED UP FOR WEIGHT CALCULATION) - items = db.query(Item).filter(Item.invoice_id == invoice_id).all() + items = db.query(LineItem).filter(LineItem.invoice_id == invoice_id).all() # Peso Bruto peso_bruto_val = "0.0" @@ -194,14 +194,13 @@ class AvisoConsolidadoExportacionService: # Calculate sum from items first if items: - for item in items: - if item.lines: - for line in item.lines: - if line.quantity and line.quantity.gross_weight: - try: - calculated_gross_weight += float(line.quantity.gross_weight) - except (ValueError, TypeError): - pass + for item in items: + for line in item: + if line.quantity and line.quantity.gross_weight: + try: + calculated_gross_weight += float(line.quantity.gross_weight) + except (ValueError, TypeError): + pass if financials and financials.gross_weight and float(financials.gross_weight) > 0: peso_bruto_val = f"{financials.gross_weight:,.2f}" @@ -379,20 +378,19 @@ class AvisoConsolidadoExportacionService: cant_total = 0.0 if items: - for item in items: - if item.lines: - for line in item.lines: - # Priority: Quantity (UMA or Standard) - q = 0.0 - if line.quantity: - try: - if line.quantity.quantity_uma is not None: - q = float(line.quantity.quantity_uma) - elif line.quantity.quantity is not None: - q = float(line.quantity.quantity) - except (ValueError, TypeError): - q = 0.0 - cant_total += q + for item in items: + for line in item: + # Priority: Quantity (UMA or Standard) + q = 0.0 + if line.quantity: + try: + if line.quantity.quantity_uma is not None: + q = float(line.quantity.quantity_uma) + elif line.quantity.quantity is not None: + q = float(line.quantity.quantity) + except (ValueError, TypeError): + q = 0.0 + cant_total += q # Format: 15 chars, 3 decimals? Actually Clarion LINEPRINT usually just prints the text. # Clarion 'CLIP(FORMAT(Loc:CantTotal,@n015.3))' removes spaces. diff --git a/backend/api/v1/modules/a76/reports/exportacion/descargo/service.py b/backend/api/v1/modules/a76/reports/exportacion/descargo/service.py index 57a8d479..960ffb30 100644 --- a/backend/api/v1/modules/a76/reports/exportacion/descargo/service.py +++ b/backend/api/v1/modules/a76/reports/exportacion/descargo/service.py @@ -15,10 +15,9 @@ from datetime import datetime # --- MODELOS (Imported from system for Header info) --- from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceComplianceMx from api.v1.modules.a76.general_catalogs.company.models import Company -from api.v1.modules.a76.items.line_items.models import LineItem from api.v1.modules.a76.items.line_quantities.models import LineQuantity from api.v1.modules.a76.parts.models import Part -from api.v1.modules.a76.items.models import Item +from api.v1.modules.a76.items.models import LineItem from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates from api.v1.modules.core.tenants.models import Tenant @@ -40,8 +39,8 @@ class FIFOAssignmentService: Returns a list of calculated discharges. """ # 1. Get Export Lines - export_lines = db.query(LineItem).join(Item).filter( - Item.invoice_id == invoice_id + export_lines = db.query(LineItem).join(LineItem).filter( + LineItem.invoice_id == invoice_id ).options( joinedload(LineItem.quantity), joinedload(LineItem.description), @@ -58,7 +57,7 @@ class FIFOAssignmentService: if qty_needed <= 0: continue - part_number = exp_line.part_number + part_number = exp_line.part_number_id if not part_number: self._log(f"Skipping line {exp_line.id}, no part number") continue @@ -79,10 +78,10 @@ class FIFOAssignmentService: # 2. Find Import Candidates (FIFO order by payment date) # Use outerjoin for pedimento dates to avoid filtering out candidates with missing dates - candidates = db.query(LineItem).join(Item).join(InvoiceHeader)\ + candidates = db.query(LineItem).join(InvoiceHeader)\ .join(InvoiceComplianceMx).join(InvoiceComplianceMx.pedimento).outerjoin(Pedimentos.pedimento_dates)\ .filter( - LineItem.part_number == part_number, + LineItem.part_number_id == part_number, InvoiceHeader.operation_type == 'imp', # Assuming 'imp' is the value for Import based on Enum ).order_by( PedimentoDates.payment_date.asc() @@ -90,7 +89,7 @@ class FIFOAssignmentService: joinedload(LineItem.quantity), joinedload(LineItem.customs), joinedload(LineItem.financial), - joinedload(LineItem.item).joinedload(Item.invoice).joinedload(InvoiceHeader.compliance_mx).joinedload(InvoiceComplianceMx.pedimento).joinedload(Pedimentos.pedimento_dates) + joinedload(LineItem.item).joinedload(LineItem.invoice).joinedload(InvoiceHeader.compliance_mx).joinedload(InvoiceComplianceMx.pedimento).joinedload(Pedimentos.pedimento_dates) ).all() self._log(f"Found {len(candidates)} candidates for {part_number}") @@ -259,16 +258,15 @@ class DescargaReportService: # --- 2. Obtener Líneas de Exportación (Lo que necesitamos cubrir) --- if progress_callback: progress_callback(20, "Obteniendo items a exportar...") - export_lines = db.query(LineItem).filter( - LineItem.item_id == Item.id, - Item.invoice_id == invoice_id + export_lines = db.query(LineItem).filter( + LineItem.invoice_id == invoice_id ).options( joinedload(LineItem.quantity), joinedload(LineItem.customs), joinedload(LineItem.description), joinedload(LineItem.unit_of_measure_info), joinedload(LineItem.part_info) - ).join(Item).all() + ).join(LineItem).all() items_reporte = [] 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 b27d5498..a4aeebc0 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 @@ -17,7 +17,7 @@ from api.v1.modules.a76.invoices.models import ( ) 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.models import LineItem from api.v1.modules.a76.clients_and_providers.models import ( ClientProvider, ClientProviderAddress, @@ -27,7 +27,7 @@ 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 LineItem # --- TRANSPORTATION MODELS --- from api.v1.modules.a76.transportation.transporters.models import Transporter @@ -482,8 +482,7 @@ class ConsolidadoImportacionMexService: lines = ( db.query(LineItem) - .join(Item, LineItem.item_id == Item.id) - .filter(Item.invoice_id.in_(target_invoice_ids)) + .filter(LineItem.invoice_id.in_(target_invoice_ids)) .all() ) @@ -532,7 +531,7 @@ class ConsolidadoImportacionMexService: .filter(LineFinancial.item_line_id == line.id) .first() ) - part_master = db.query(Part).filter(Part.id == line.part_number).first() + part_master = db.query(Part).filter(Part.id == line.part_number_id).first() # --- Resolver Identificadores (MOVED INSIDE MAIN LOOP) --- us_fraction_raw = "" 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 index 8dde918d..3170184a 100644 --- 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 @@ -13,7 +13,6 @@ from sqlalchemy.orm import Session 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 ) @@ -21,7 +20,7 @@ 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 LineItem # --- TRANSPORTATION MODELS --- from api.v1.modules.a76.transportation.transporters.models import Transporter @@ -314,8 +313,8 @@ class ConsolidadoImportacionMexService: # 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) + lines = db.query(LineItem).filter( + LineItem.invoice_id.in_(target_invoice_ids) ).all() partidas_list = [] @@ -350,7 +349,7 @@ class ConsolidadoImportacionMexService: 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() + part_master = db.query(Part).filter(Part.id == line.part_number_id).first() # --- Resolver Identificadores (MOVED INSIDE MAIN LOOP) --- us_fraction_raw = "" 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 71cea05e..24b46ea1 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 @@ -13,7 +13,6 @@ from sqlalchemy.orm import Session 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, @@ -23,7 +22,7 @@ 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 LineItem # --- TRANSPORTATION MODELS --- from api.v1.modules.a76.transportation.transporters.models import Transporter @@ -422,9 +421,8 @@ class FacturaImportacionMexService: 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) + db.query(LineItem) + .filter(LineItem.invoice_id == header.id) .all() ) partidas_list = [] @@ -440,10 +438,10 @@ class FacturaImportacionMexService: .filter(LineFinancial.item_line_id == line.id) .first() ) - part_master = db.query(Part).filter(Part.id == line.part_number).first() + part_master = db.query(Part).filter(Part.id == line.part_number_id).first() desc_final = "S/D" - num_parte_final = str(line.part_number or "S/N") + num_parte_final = str(line.part_number_id or "S/N") fraccion_raw = "" origen_final = "MEX" 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 index 69f49a02..d558f504 100644 --- 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 @@ -13,7 +13,6 @@ from sqlalchemy.orm import Session 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 ) @@ -21,7 +20,7 @@ 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 LineItem # --- TRANSPORTATION MODELS --- from api.v1.modules.a76.transportation.transporters.models import Transporter @@ -227,16 +226,16 @@ class FacturaImportacionMexService: ) 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() + lines = db.query(LineItem).filter(LineItem.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() + part_master = db.query(Part).filter(Part.id == line.part_number_id).first() desc_final = "S/D" - num_parte_final = str(line.part_number or "S/N") + num_parte_final = str(line.part_number_id or "S/N") fraccion_raw = "" origen_final = "MEX" 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 fe840c97..a4e6712e 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 @@ -13,7 +13,6 @@ from sqlalchemy.orm import Session 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 ) @@ -21,7 +20,7 @@ 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 LineItem # --- TRANSPORTATION MODELS --- from api.v1.modules.a76.transportation.transporters.models import Transporter @@ -278,16 +277,16 @@ class FacturaImportacionUsaService: ) 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() + lines = db.query(LineItem).filter(LineItem.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() + part_master = db.query(Part).filter(Part.id == line.part_number_id).first() desc_final = "N/D" - num_parte_final = str(line.part_number or "N/A") + num_parte_final = str(line.part_nupart_number_idmber or "N/A") fraccion_raw = "" origen_final = "MEX" 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 90f873e9..b88cb83c 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 @@ -13,7 +13,6 @@ from sqlalchemy.orm import Session # --- MODELOS --- from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics 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 @@ -22,7 +21,7 @@ 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 LineItem # --- TRANSPORTATION MODELS --- from api.v1.modules.a76.transportation.transporters.models import Transporter @@ -238,7 +237,7 @@ class PackingListService: ) 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() + lines = db.query(LineItem).filter(LineItem.invoice_id == header.id).all() partidas_list = [] for line in lines: @@ -268,10 +267,10 @@ class PackingListService: # -------------------------------- 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() + part_master = db.query(Part).filter(Part.id == line.part_number_id).first() desc_final = "S/D" - num_parte_final = str(line.part_number or "S/N") + num_parte_final = str(line.part_number_id or "S/N") fraccion_raw = "" origen_final = "MEX" uom_comercial = "PZA" # Default UOM diff --git a/backend/api/v1/modules/core/dashboard/service.py b/backend/api/v1/modules/core/dashboard/service.py index 9c10d7a7..64c00dba 100644 --- a/backend/api/v1/modules/core/dashboard/service.py +++ b/backend/api/v1/modules/core/dashboard/service.py @@ -12,7 +12,7 @@ from api.v1.modules.a76.invoices.models import ( InvoiceComplianceMx, OperationType, ) -from api.v1.modules.a76.items.models import Item +from api.v1.modules.a76.items.models import LineItem from api.v1.modules.a76.clients_and_providers.models import ( ClientProvider, ClientOrProviderEnum, @@ -244,9 +244,9 @@ class DashboardService: def get_items_metrics(self) -> KPIMetric: """Obtiene métricas de items/productos""" total_items = ( - self.db.query(func.count(Item.id)) + self.db.query(func.count(LineItem.id)) .filter( - Item.tenant_id == self.tenant_id, Item.company_id == self.company_id + LineItem.tenant_id == self.tenant_id, LineItem.company_id == self.company_id ) .scalar() or 0 diff --git a/backend/main.py b/backend/main.py index ba49f3c3..af6c9199 100644 --- a/backend/main.py +++ b/backend/main.py @@ -23,7 +23,7 @@ from fastapi.staticfiles import StaticFiles from pathlib import Path # Importar modelos para registrar con SQLAlchemy -from api.v1.modules.a76.items.models import Item +from api.v1.modules.a76.items.models import LineItem from api.v1.modules.a76.items.series.models import Serie from api.v1.modules.a76.parts.models import Part from api.v1.modules.a24.fa.fa_parts.models import FaPart @@ -113,7 +113,7 @@ from api.v1.modules.a76.audit_log.events import register_audit_listeners from api.v1.modules.a76.clients_and_providers.models import ClientProvider from api.v1.modules.a76.customs_brokers.models import CustomsBroker from api.v1.modules.a76.parts.models import Part -from api.v1.modules.a76.items.models import Item +from api.v1.modules.a76.items.models import LineItem from api.v1.modules.a76.general_catalogs.company.models import Company # Reference Data @@ -187,7 +187,7 @@ def register_audit(): Pedimentos, InvoiceHeader, InvoiceSalesDetails, - Item, + LineItem, # Sidebar Core Modules ClientProvider, CustomsBroker, diff --git a/frontend/src/lib/api/dashboard/a76/items.ts b/frontend/src/lib/api/dashboard/a76/items.ts index f81b1821..7164476c 100644 --- a/frontend/src/lib/api/dashboard/a76/items.ts +++ b/frontend/src/lib/api/dashboard/a76/items.ts @@ -122,9 +122,9 @@ export interface FaLineItem { updated_at?: string; } -export interface LineItem { - id?: number; - item_id?: number; +export interface Item { + id?: number; + invoice_id: number; line_number: number; // Identification @@ -162,20 +162,7 @@ export interface LineItem { class_description?: string; // Computed field from unit_of_measure_info relation - unit_of_measure_code?: string; - - // Nested relations (Singular names to match backend Pydantic models) - customs?: LineCustoms; - financial?: LineFinancials; - quantity?: LineQuantities; - description?: LineDescriptions; - reference?: LineReferences; - fa_data?: FaLineItem; // Fixed Asset specific data -} - -export interface Item { - id?: number; - invoice_id: number; + unit_of_measure_code?: string; reference_number?: string; order?: string; guide_number?: string; @@ -185,8 +172,14 @@ export interface Item { location?: string; created_at?: string; updated_at?: string; - - lines?: LineItem[]; + + // Nested relations (Singular names to match backend Pydantic models) + customs?: LineCustoms; + financial?: LineFinancials; + quantity?: LineQuantities; + description?: LineDescriptions; + reference?: LineReferences; + fa_data?: FaLineItem; // Fixed Asset specific data } export interface ItemListResponse { @@ -196,28 +189,11 @@ export interface ItemListResponse { limit: number; } -export interface CreateItemData { +export interface CreateItemData extends Omit { invoice_id: number; - reference_number?: string; - order?: string; - guide_number?: string; - depreciation_date?: number; - rectification?: number; - warehouse?: string; - location?: string; - lines?: LineItem[]; } -export interface UpdateItemData { - reference_number?: string; - order?: string; - guide_number?: string; - depreciation_date?: number; - rectification?: boolean; - warehouse?: string; - location?: string; - lines?: LineItem[]; -} +export interface UpdateItemData extends Partial> {} /** * API para Items 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 5540a4da..8bc0f930 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 @@ -5,13 +5,13 @@ 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'; + import type { Item, LineDescriptions } from '$lib/api/dashboard/a76/items'; let { lineItem = $bindable(), descriptions = $bindable() }: { - lineItem: LineItem; + lineItem: Partial; descriptions: LineDescriptions; } = $props(); 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 2928b846..0927c059 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 @@ -38,7 +38,7 @@ } = $props(); // Acceso directo a la primera línea para evitar repeticiones en el HTML - let line = $derived(editingItem.lines?.[0]); + let line = $derived(editingItem); @@ -77,10 +77,10 @@
@@ -94,8 +94,8 @@
@@ -126,36 +126,36 @@
- + - + - + 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 0bf16009..e34ca647 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 @@ -7,7 +7,7 @@ import CountryDialog from './country-dialog.svelte'; 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 type { Item, LineQuantities, LineFinancials, LineCustoms } from '$lib/api/dashboard/a76/items'; import type { Invoice } from '$lib/api/dashboard/a76/invoices'; import { companyStore } from '$lib/stores/company.svelte'; @@ -18,7 +18,7 @@ customs = $bindable(), invoice }: { - lineItem: LineItem; + lineItem: Partial; quantities: LineQuantities; financials: LineFinancials; customs: LineCustoms; @@ -50,13 +50,6 @@ // Track previous class_id to detect changes let previousClassId = $state(undefined); - // Initialize from existing data - $effect(() => { - if (lineItem.class_code) { - // Do nothing, it's already set - } - }); - // Watch for class_id changes and update descriptions automatically $effect(() => { const currentClassId = lineItem.class_id; @@ -247,7 +240,7 @@
- + USD
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte index e3111bde..8a6e7541 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte @@ -5,14 +5,14 @@ import { Checkbox } from '$lib/components/ui/checkbox'; import { Button } from '$lib/components/ui/button'; import { Folder } from 'lucide-svelte'; - import type { LineItem, LineDescriptions } from '$lib/api/dashboard/a76/items'; + import type { Item, LineDescriptions } from '$lib/api/dashboard/a76/items'; import PaymentMethodDialog from './payment-method-dialog.svelte'; let { lineItem = $bindable(), descriptions = $bindable() }: { - lineItem: LineItem; + lineItem: Partial; descriptions: LineDescriptions; } = $props(); @@ -26,6 +26,14 @@ lineItem.has_certificate = val === 'si'; } + // Initialize boolean fields to prevent bind:checked={undefined} error + if (lineItem.is_military_mcia === undefined) { + lineItem.is_military_mcia = false; + } + if (descriptions.consider_a31 === undefined) { + descriptions.consider_a31 = false; + } + let paymentMethodDialogOpen = $state(false); let payment_method_description = $state(''); diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-identifiers.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-identifiers.svelte index dd5d1e5e..9b77f822 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-identifiers.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-identifiers.svelte @@ -1,9 +1,9 @@
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte index 0729433b..a4a10bb3 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte @@ -55,20 +55,19 @@ ); // Derived state for easier binding and safety - let line = $derived(editingItem.lines?.[0]); + let line = $derived(editingItem); // Initialize missing nested objects if they don't exist $effect(() => { if (open && editingItem) { - if (!editingItem.lines) editingItem.lines = [{ line_number: 1 } as any]; - if (editingItem.lines[0] && !editingItem.lines[0].quantity) - editingItem.lines[0].quantity = {} as any; - if (editingItem.lines[0] && !editingItem.lines[0].financial) - editingItem.lines[0].financial = {} as any; - if (editingItem.lines[0] && !editingItem.lines[0].customs) - editingItem.lines[0].customs = {} as any; - if (editingItem.lines[0] && !editingItem.lines[0].description) - editingItem.lines[0].description = {} as any; + if (editingItem && !editingItem.quantity) + editingItem.quantity = {} as any; + if (editingItem && !editingItem.financial) + editingItem.financial = {} as any; + if (editingItem && !editingItem.customs) + editingItem.customs = {} as any; + if (editingItem && !editingItem.description) + editingItem.description = {} as any; } }); @@ -86,9 +85,9 @@ {isEditMode ? 'Modifica los campos del inventario y guarda los cambios.' : 'Completa la información del nuevo item de inventario.'} - {#if editingItem.lines && editingItem.lines.length > 1} + {#if editingItem} - {editingItem.lines.length} items en esta partida + {editingItem} items en esta partida {/if} 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 82d25695..9b16498a 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 @@ -74,26 +74,23 @@ // 4. Derived Values (Ordered correctly to avoid TDZ) const flattenedLines = $derived.by(() => { const sourceItems = items?.length ? items : formData?.items || []; - return (sourceItems || []).flatMap((item: any, itemIndex: number) => { - const lines = item?.lines || []; - return lines.map((line: any, idx: number) => ({ - ...line, - id: line?.id || `${item?.id || itemIndex}-line-${line?.line_number ?? idx + 1}`, - line_number: line?.line_number ?? idx + 1, - reference_number: line?.reference_number ?? item?.reference_number, - is_subitem: line?.is_subitem ?? false, - class_code: line?.class_code ?? line?.class_id, - class_description: - line?.class_description || - line?.description?.description_spanish || - line?.description?.description_english || - '', - unit_of_measure_code: line?.quantity?.unit_of_measure || line?.unit_of_measure, - fa_data: line?.fa_data || {}, - warehouse: line?.warehouse || item?.warehouse, - full_item: item - })); - }); + return (sourceItems || []).map((item: any, itemIndex: number) => ({ + ...item, + id: item?.id || `item-${itemIndex}`, + line_number: item?.line_number ?? itemIndex + 1, + reference_number: item?.reference_number, + is_subitem: item?.is_subitem ?? false, + class_code: item?.class_code ?? item?.class_id, + class_description: + item?.class_description || + item?.description?.description_spanish || + item?.description?.description_english || + '', + unit_of_measure_code: item?.quantity?.unit_of_measure || item?.unit_of_measure, + fa_data: item?.fa_data || {}, + warehouse: item?.warehouse, + full_item: item + })); }); const isAllSelected = $derived( @@ -248,80 +245,76 @@ // Auto-asignar valores desde la factura con estructura completa editingItem = { invoice_id: invoice?.id, + line_number: 1, + // LineItem fields + part_number: undefined, + component_part_number: undefined, + class_id: undefined, + identifier: undefined, + unit_of_measure: undefined, + alternate_unit: undefined, + permit_number: undefined, + page_line: undefined, + has_certificate: false, + certificate_number: undefined, + tax_payment: false, + payment_method: undefined, + igi_amount: undefined, + is_military_mcia: false, + wildcard_field: undefined, reference_number: '', order: invoice?.purchase_order || '', warehouse: '', - location: '', - lines: [ - { - line_number: 1, - // LineItem fields - part_number: undefined, - component_part_number: undefined, - class_id: undefined, - identifier: undefined, - unit_of_measure: undefined, - alternate_unit: undefined, - permit_number: undefined, - page_line: undefined, - has_certificate: false, - certificate_number: undefined, - tax_payment: false, - payment_method: undefined, - igi_amount: undefined, - is_military_mcia: false, - wildcard_field: undefined, - // Nested relations - financial: { - unit_cost_usd: undefined, - unit_cost_mxn: undefined, - unit_cost_capture: undefined, - unit_cost_commercial_usd: undefined, - value_usd: undefined, - value_mxn: undefined, - value_returned_usd: undefined, - value_returned_mxn: undefined, - customs_value_usd: undefined - }, - quantity: { - quantity: undefined, - unit_of_measure: undefined, - quantity_temp_export: undefined, - quantity_returned: undefined, - net_weight: undefined, - gross_weight: undefined, - package_id: undefined, - package_quantity: undefined, - package_description: undefined - }, - customs: { - fraction: undefined, - fraction_type: undefined, - american_fraction: undefined, - origin_country: undefined, - destination_country: undefined, - advalorem: undefined, - advalorem_american: undefined, - sector: undefined - }, - description: { - description_spanish: undefined, - description_english: undefined, - extra_description: undefined, - additional_info_spanish: undefined, - brand: undefined, - model: undefined, - has_serial: false, - eighth_rule_fraction: undefined, - eighth_rule_line: undefined, - consider_a31: false, - machinery_location: undefined - }, - reference: { - serie_id: undefined - } - } - ] + location: '', + // Nested relations + financial: { + unit_cost_usd: undefined, + unit_cost_mxn: undefined, + unit_cost_capture: undefined, + unit_cost_commercial_usd: undefined, + value_usd: undefined, + value_mxn: undefined, + value_returned_usd: undefined, + value_returned_mxn: undefined, + customs_value_usd: undefined + }, + quantity: { + quantity: undefined, + unit_of_measure: undefined, + quantity_temp_export: undefined, + quantity_returned: undefined, + net_weight: undefined, + gross_weight: undefined, + package_id: undefined, + package_quantity: undefined, + package_description: undefined + }, + customs: { + fraction: undefined, + fraction_type: undefined, + american_fraction: undefined, + origin_country: undefined, + destination_country: undefined, + advalorem: undefined, + advalorem_american: undefined, + sector: undefined + }, + description: { + description_spanish: undefined, + description_english: undefined, + extra_description: undefined, + additional_info_spanish: undefined, + brand: undefined, + model: undefined, + has_serial: false, + eighth_rule_fraction: undefined, + eighth_rule_line: undefined, + consider_a31: false, + machinery_location: undefined + }, + reference: { + serie_id: undefined + } }; } @@ -376,14 +369,14 @@ function saveItemToPreset() { // Sanitizar datos para la plantilla - const cleanedItem = JSON.parse(JSON.stringify(editingItem)); + let cleanedItem = JSON.parse(JSON.stringify(editingItem)); - // Limpiar líneas para asegurar que son compatibles - if (cleanedItem.lines) { - cleanedItem.lines = cleanedItem.lines.map((line: any) => ({ - ...cleanLineData(line), + // Limpiar item para asegurar que es compatible + if (cleanedItem) { + cleanedItem = { + ...cleanLineData(cleanedItem), id: undefined // Las plantillas no deben tener IDs reales - })); + }; } if (editingBuilderIndex !== null) { @@ -409,35 +402,30 @@ function cloneItemForPreset(item: Item) { const { id, tenant_id, company_id, created_at, updated_at, temp_id, ...rest } = item as any; return { - ...rest, + ...sanitizeLineForPreset(rest), id: undefined, - invoice_id: undefined, - lines: (item.lines || []).map(sanitizeLineForPreset) + invoice_id: undefined }; } function buildManualItem(draft: any, index: number) { - return { + return cleanLineData({ id: undefined, temp_id: undefined, invoice_id: undefined, reference_number: draft.reference_number || undefined, - lines: [ - cleanLineData({ - line_number: index + 1, - description: { - description_spanish: draft.description || 'Sin descripción' - }, - quantity: { - quantity: Number(draft.quantity) || 0 - }, - financial: { - unit_cost_usd: - draft.unit_cost_usd !== null ? Number(draft.unit_cost_usd) || 0 : undefined - } - }) - ] - }; + line_number: index + 1, + description: { + description_spanish: draft.description || 'Sin descripción' + }, + quantity: { + quantity: Number(draft.quantity) || 0 + }, + financial: { + unit_cost_usd: + draft.unit_cost_usd !== null ? Number(draft.unit_cost_usd) || 0 : undefined + } + }); } function handleAddManualItem() { @@ -466,15 +454,11 @@ } // Inject into active sheet if open + // No se pueden inyectar múltiples líneas en un item, ya que ahora un item ES una línea if (showItemSheet) { - const presetLines = selectedPreset.items.flatMap((item: any) => item.lines || []); - const cleanedNewLines = presetLines.map((line: any) => ({ - ...sanitizeLineForPreset(line), - id: undefined // Force new IDs - })); - - editingItem.lines = [...(editingItem.lines || []), ...cleanedNewLines]; - toast.success('Líneas inyectadas en la partida actual'); + toast.warning('No se puede inyectar plantilla en modo edición', { + description: 'Las plantillas solo se pueden aplicar directamente a la factura' + }); showUsePresetDialog = false; return; } @@ -517,26 +501,19 @@ isSavingPreset = true; try { - // We group everything as ONE Partida Template for injection - const lines = builderItems.flatMap((item: Item, idx: number) => { - return (item.lines || []).map((line: any) => ({ - ...cleanLineData(line), - line_number: line.line_number || idx + 1, // Ensure line_number is present + // Each item in builderItems is already a LineItem (no nested lines) + const cleanedItems = builderItems.map((item: Item, idx: number) => { + return { + ...cleanLineData(item), + line_number: item.line_number || idx + 1, id: undefined // Ensure no IDs are saved in the preset - })); + }; }); - const payloadItems = [ - { - reference_number: builderItems[0]?.reference_number || undefined, - lines: lines - } - ] as any; - await itemPresetsApi.create(activeCompanyId, { name: createPresetName.trim(), description: createPresetDescription.trim() || undefined, - items: payloadItems + items: cleanedItems }); createPresetName = ''; @@ -569,22 +546,20 @@ // 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]; + if (!item || !activeCompanyId) return; // Load class data - if (line.class_id) { + if (item.class_id) { try { const response = await fetch( - `/api-sveltekit/classes/${line.class_id}?company_id=${activeCompanyId}`, + `/api-sveltekit/classes/${item.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; + (item as any).class_code = classData.class_code; + (item as any).class_unit_of_measure = classData.unit_of_measure; + (item as any).class_description = classData.description_es || classData.description_en; } } catch (error) { console.error('Error loading class data:', error); @@ -592,17 +567,17 @@ } // Load part number data - if (line.part_number) { + if (item.part_number) { try { const response = await fetch( - `/api-sveltekit/parts/${line.part_number}?company_id=${activeCompanyId}`, + `/api-sveltekit/parts/${item.part_number}?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; + (item as any).part_number = partData.part_number; + (item as any).part_description_es = partData.description_spanish; + (item as any).part_description_en = partData.description_english; } } catch (error) { console.error('Error loading part data:', error); @@ -610,16 +585,16 @@ } // Load unit of measure data - if (line.unit_of_measure) { + if (item.unit_of_measure) { try { - const response = await fetch(`/api-sveltekit/units-of-measure/${line.unit_of_measure}`, { + const response = await fetch(`/api-sveltekit/units-of-measure/${item.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; + (item as any).unit_code = unitData.code; + (item as any).unit_description = unitData.description || unitData.description_en; } } catch (error) { console.error('Error loading unit data:', error); @@ -627,17 +602,17 @@ } // Load country data (if needed) - if (line.customs?.origin_country) { + if (item.customs?.origin_country) { try { const response = await fetch( - `/api-sveltekit/countries?search=${line.customs.origin_country}`, + `/api-sveltekit/countries?search=${item.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 = + (item.customs as any).origin_country_name = country.description || country.description_en; } } @@ -647,17 +622,17 @@ } // Load fraction data (if needed) - if (line.customs?.fraction) { + if (item.customs?.fraction) { try { const response = await fetch( - `/api-sveltekit/tariff-fractions?search=${line.customs.fraction}`, + `/api-sveltekit/tariff-fractions?search=${item.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; + (item.customs as any).fraction_description = fraction.description; } } } catch (error) { @@ -666,8 +641,8 @@ } // Load package data (if needed) - const packageId = line.quantity?.package_id; - if (packageId && line.quantity) { + const packageId = item.quantity?.package_id; + if (packageId && item.quantity) { try { const response = await fetch('/api-sveltekit/packages', { method: 'GET', @@ -679,9 +654,9 @@ if (Array.isArray(packages)) { const pkg = packages.find((p: any) => p.id === packageId); if (pkg) { - (line.quantity as any).package_description = pkg.description_es || pkg.description_en || pkg.key; - (line.quantity as any).package_key = pkg.key; - (line.quantity as any).package_weight_unit = pkg.weight_unit || 0; + (item.quantity as any).package_description = pkg.description_es || pkg.description_en || pkg.key; + (item.quantity as any).package_key = pkg.key; + (item.quantity as any).package_weight_unit = pkg.weight_unit || 0; } } } @@ -691,7 +666,7 @@ } // Load payment method description (if needed) - if (line.payment_method) { + if (item.payment_method) { try { const response = await fetch('/api-sveltekit/payment-methods', { method: 'GET', @@ -701,9 +676,9 @@ const data = await response.json(); const methods = data.items || data.data || data; if (Array.isArray(methods)) { - const method = methods.find((m: any) => m.key === line.payment_method); + const method = methods.find((m: any) => m.key === item.payment_method); if (method) { - (line as any).payment_method_description = method.description; + (item as any).payment_method_description = method.description; } } } @@ -715,58 +690,56 @@ // Normalize numeric values from strings to numbers function normalizeItemData(item: Partial): Partial { - if (item.lines && item.lines.length > 0) { - item.lines = item.lines.map((line) => { - const normalizedLine = { ...line }; + if (item) { + const normalizedItem = { ...item }; - // Normalize financials - if (normalizedLine.financial) { - normalizedLine.financial = { - ...normalizedLine.financial, - unit_cost_usd: - normalizedLine.financial.unit_cost_usd != null - ? Number(normalizedLine.financial.unit_cost_usd) - : undefined, - unit_cost_mxn: - normalizedLine.financial.unit_cost_mxn != null - ? Number(normalizedLine.financial.unit_cost_mxn) - : undefined, - value_usd: - normalizedLine.financial.value_usd != null - ? Number(normalizedLine.financial.value_usd) - : undefined, - value_mxn: - normalizedLine.financial.value_mxn != null - ? Number(normalizedLine.financial.value_mxn) - : undefined - }; - } + // Normalize financials + if (normalizedItem.financial) { + normalizedItem.financial = { + ...normalizedItem.financial, + unit_cost_usd: + normalizedItem.financial.unit_cost_usd != null + ? Number(normalizedItem.financial.unit_cost_usd) + : undefined, + unit_cost_mxn: + normalizedItem.financial.unit_cost_mxn != null + ? Number(normalizedItem.financial.unit_cost_mxn) + : undefined, + value_usd: + normalizedItem.financial.value_usd != null + ? Number(normalizedItem.financial.value_usd) + : undefined, + value_mxn: + normalizedItem.financial.value_mxn != null + ? Number(normalizedItem.financial.value_mxn) + : undefined + }; + } - // Normalize quantities - if (normalizedLine.quantity) { - normalizedLine.quantity = { - ...normalizedLine.quantity, - quantity: - normalizedLine.quantity.quantity != null - ? Number(normalizedLine.quantity.quantity) - : undefined, - net_weight: - normalizedLine.quantity.net_weight != null - ? Number(normalizedLine.quantity.net_weight) - : undefined, - gross_weight: - normalizedLine.quantity.gross_weight != null - ? Number(normalizedLine.quantity.gross_weight) - : undefined, - package_quantity: - normalizedLine.quantity.package_quantity != null - ? Number(normalizedLine.quantity.package_quantity) - : undefined - }; - } + // Normalize quantities + if (normalizedItem.quantity) { + normalizedItem.quantity = { + ...normalizedItem.quantity, + quantity: + normalizedItem.quantity.quantity != null + ? Number(normalizedItem.quantity.quantity) + : undefined, + net_weight: + normalizedItem.quantity.net_weight != null + ? Number(normalizedItem.quantity.net_weight) + : undefined, + gross_weight: + normalizedItem.quantity.gross_weight != null + ? Number(normalizedItem.quantity.gross_weight) + : undefined, + package_quantity: + normalizedItem.quantity.package_quantity != null + ? Number(normalizedItem.quantity.package_quantity) + : undefined + }; + } - return normalizedLine; - }); + return normalizedItem; } return item; @@ -782,16 +755,12 @@ isSaving = true; try { - // Clean lines data before sending - const cleanedLines = (editingItem.lines || []).map(cleanLineData); + // Clean item data before sending + const cleanedItem = cleanLineData(editingItem); const response = await itemsApi.create(activeCompanyId, { - invoice_id: invoice.id, - reference_number: editingItem.reference_number, - order: editingItem.order, - warehouse: editingItem.warehouse, - location: editingItem.location, - lines: cleanedLines + ...cleanedItem, + invoice_id: invoice.id }); // Verificar si hay errores de validación @@ -851,16 +820,10 @@ isSaving = true; try { - // Clean lines data before sending - const cleanedLines = (editingItem.lines || []).map(cleanLineData); + // Clean item data before sending + const cleanedItem = cleanLineData(editingItem); - const response = await itemsApi.update(selectedItem.id, activeCompanyId, { - reference_number: editingItem.reference_number, - order: editingItem.order, - warehouse: editingItem.warehouse, - location: editingItem.location, - lines: cleanedLines - }); + const response = await itemsApi.update(selectedItem.id, activeCompanyId, cleanedItem); // Verificar si hay errores de validación if ('error' in response) { @@ -916,52 +879,51 @@ function saveItem() { // Validar campos obligatorios antes de guardar - const line = editingItem.lines?.[0]; const missingFields: string[] = []; - if (!line) { + if (!editingItem) { toast.warning('Error de datos', { - description: 'No se encontró información de la línea del item' + description: 'No se encontró información del item' }); return; } // 1. Clase - if (!line.class_id) { + if (!editingItem.class_id) { missingFields.push('Clase'); } // 2. Cantidad - if (!line.quantity?.quantity || line.quantity.quantity <= 0) { + if (!editingItem.quantity?.quantity || editingItem.quantity.quantity <= 0) { missingFields.push('Cantidad'); } // 3. Unidad de Medida - if (!line.unit_of_measure) { + if (!editingItem.unit_of_measure) { missingFields.push('U.M. (Unidad de Medida)'); } // 4. Costo Unitario (al menos uno debe estar presente) const hasCost = - line.financial?.unit_cost_usd || - line.financial?.unit_cost_mxn || - line.financial?.unit_cost_capture; + editingItem.financial?.unit_cost_usd || + editingItem.financial?.unit_cost_mxn || + editingItem.financial?.unit_cost_capture; if (!hasCost) { missingFields.push('Costo Unitario (USD, MXN o Captura)'); } // 5. País de Origen - if (!line.customs?.origin_country) { + if (!editingItem.customs?.origin_country) { missingFields.push('País de Origen'); } // 6. Tipo de Tarifa - if (!line.customs?.fraction_type) { + if (!editingItem.customs?.fraction_type) { missingFields.push('Tipo de Tarifa'); } // 7. Descripción en Español - if (!line.description?.description_spanish?.trim()) { + if (!editingItem.description?.description_spanish?.trim()) { missingFields.push('Descripción en Español'); } @@ -1453,7 +1415,7 @@
- {item.lines?.[0]?.description?.description_spanish || + {item?.description?.description_spanish || 'Sin descripción'} {#if item.reference_number} @@ -1464,10 +1426,10 @@
- {item.lines?.[0]?.quantity?.quantity || 0} + {item?.quantity?.quantity || 0} - ${(item.lines?.[0]?.financial?.unit_cost_usd || 0).toLocaleString( + ${(item?.financial?.unit_cost_usd || 0).toLocaleString( undefined, { minimumFractionDigits: 2 } )} @@ -1584,7 +1546,7 @@
- {item.lines?.[0]?.description?.description_spanish || 'Sin descripción'} + {item?.[0]?.description?.description_spanish || 'Sin descripción'} Ref: {item.reference_number || '-'} - {item.lines?.[0]?.quantity?.quantity || 0} + {item?.[0]?.quantity?.quantity || 0}
diff --git a/frontend/src/lib/utils/items-logic.ts b/frontend/src/lib/utils/items-logic.ts index c29a87c6..9d577c11 100644 --- a/frontend/src/lib/utils/items-logic.ts +++ b/frontend/src/lib/utils/items-logic.ts @@ -1,7 +1,6 @@ export interface Item { id?: number; - lines?: any[]; [key: string]: any; } @@ -71,36 +70,34 @@ export function cleanLineData(line: any) { // Normalize numeric values from strings to numbers (for editing) export function normalizeItemData(item: Partial): Partial { - if (item.lines && item.lines.length > 0) { - item.lines = item.lines.map((line) => { - const normalizedLine = { ...line }; + if (item) { + const normalizedItem = { ...item }; - // Normalize financials - if (normalizedLine.financial) { - const f = normalizedLine.financial; - normalizedLine.financial = { - ...f, - unit_cost_usd: f.unit_cost_usd != null ? Number(f.unit_cost_usd) : undefined, - unit_cost_mxn: f.unit_cost_mxn != null ? Number(f.unit_cost_mxn) : undefined, - value_usd: f.value_usd != null ? Number(f.value_usd) : undefined, - value_mxn: f.value_mxn != null ? Number(f.value_mxn) : undefined - }; - } + // Normalize financials + if (normalizedItem.financial) { + const f = normalizedItem.financial; + normalizedItem.financial = { + ...f, + unit_cost_usd: f.unit_cost_usd != null ? Number(f.unit_cost_usd) : undefined, + unit_cost_mxn: f.unit_cost_mxn != null ? Number(f.unit_cost_mxn) : undefined, + value_usd: f.value_usd != null ? Number(f.value_usd) : undefined, + value_mxn: f.value_mxn != null ? Number(f.value_mxn) : undefined + }; + } - // Normalize quantities - if (normalizedLine.quantity) { - const q = normalizedLine.quantity; - normalizedLine.quantity = { - ...q, - quantity: q.quantity != null ? Number(q.quantity) : undefined, - net_weight: q.net_weight != null ? Number(q.net_weight) : undefined, - gross_weight: q.gross_weight != null ? Number(q.gross_weight) : undefined, - package_quantity: q.package_quantity != null ? Number(q.package_quantity) : undefined - }; - } + // Normalize quantities + if (normalizedItem.quantity) { + const q = normalizedItem.quantity; + normalizedItem.quantity = { + ...q, + quantity: q.quantity != null ? Number(q.quantity) : undefined, + net_weight: q.net_weight != null ? Number(q.net_weight) : undefined, + gross_weight: q.gross_weight != null ? Number(q.gross_weight) : undefined, + package_quantity: q.package_quantity != null ? Number(q.package_quantity) : undefined + }; + } - return normalizedLine; - }); + return normalizedItem; } return item; diff --git a/frontend/src/routes/dashboard/invoices/items/presets/+page.svelte b/frontend/src/routes/dashboard/invoices/items/presets/+page.svelte index 41c8a056..3fc358b1 100644 --- a/frontend/src/routes/dashboard/invoices/items/presets/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/items/presets/+page.svelte @@ -153,17 +153,13 @@ function handleAddItem() { isItemEditMode = false; editingItemIndex = null; - editingItem = { - lines: [ - { - line_number: (selectedPreset.items?.length || 0) + 1, - quantity: { quantity: 1 }, - financial: { unit_cost_usd: 0 }, - customs: {}, - description: {}, - reference: {} - } - ] + editingItem = { + line_number: (selectedPreset.items?.length || 0) + 1, + quantity: { quantity: 1 }, + financial: { unit_cost_usd: 0 }, + customs: {}, + description: {}, + reference: {} }; showItemSheet = true; } @@ -463,7 +459,7 @@
- {item.lines?.[0]?.description?.description_spanish || 'Sin descripción'} + {item?.description?.description_spanish || 'Sin descripción'} {#if item.reference_number} @@ -473,12 +469,12 @@
{item.lines?.[0]?.quantity?.quantity || 0}{item?.quantity?.quantity || 0} - ${(item.lines?.[0]?.financial?.unit_cost_usd || 0).toLocaleString()} + ${(item?.financial?.unit_cost_usd || 0).toLocaleString()}