feat: Implement Fixed Asset (FA) line item management

- Added new schemas for FA line items in the backend, including creation, update, and response DTOs.
- Updated existing line item schemas to include FA data.
- Modified database models to reflect new table names for line quantities and references.
- Enhanced ItemService to handle FA data during item creation and updates.
- Introduced new routes and service layer for FA line items, including CRUD operations.
- Updated frontend components to support FA line item data, including new fields and UI adjustments.
- Implemented data flattening for improved item display in the dashboard.
This commit is contained in:
2026-01-12 12:23:40 -06:00
parent 6845dcd670
commit 8dab1d6e46
19 changed files with 852 additions and 173 deletions

View File

@@ -1,3 +1,4 @@
from sqlalchemy import func
from sqlalchemy.orm import Session
from .... import schemas
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
@@ -206,7 +207,7 @@ def validate_common(
exchange_rate_exists = (
db.query(ExchangeRate)
.filter(
ExchangeRate.date == invoice.invoice_date,
func.date(ExchangeRate.date) == invoice.invoice_date,
ExchangeRate.tenant_id == tenant_id,
ExchangeRate.company_id == company_id,
)
@@ -220,6 +221,8 @@ def validate_common(
code="EXCHANGE_RATE_NOT_FOUND",
value=invoice.financials.exchange_rate,
)
else:
invoice.financials.exchange_rate = exchange_rate_exists.value
if invoice.compliance_mx.is_regime_change:
if invoice.document_type in ["EXD", "ETE", "ETR"]:

View File

@@ -12,7 +12,7 @@ class LineCustom(Base):
Customs details for line items
Consolidates all line-level data from Q and S tables
"""
__tablename__ = "line_customs"
__tablename__ = "item_line_customs"
__table_args__ = {
"schema": "a76",
}

View File

@@ -11,7 +11,7 @@ class LineDescription(Base):
Description details for line items
Consolidates all line-level data from Q and S tables
"""
__tablename__ = "line_descriptions"
__tablename__ = "item_line_descriptions"
__table_args__ = {
"schema": "a76",
}

View File

@@ -12,7 +12,7 @@ class LineFinancial(Base):
Financial details for line items
Consolidates all line-level data from Q and S tables
"""
__tablename__ = "line_financials"
__tablename__ = "item_line_financials"
__table_args__ = {
"schema": "a76",
}

View File

@@ -12,6 +12,11 @@ if TYPE_CHECKING:
from ..line_customs.models import LineCustom
from ..line_descriptions.models import LineDescription
from ..line_references.models import LineReference
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.units_of_measure.models import (
UnitOfMeasure,
)
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
class LineItem(Base, TenantScopedMixin, TimestampMixin):
@@ -19,6 +24,7 @@ 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",
@@ -26,154 +32,152 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin):
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
line_number: Mapped[int] = mapped_column(Integer) # LINEAIMPO/LINEAEXPO/LINEA
# Part identification
part_number: Mapped[Optional[str]] = mapped_column(
ForeignKey("a76.parts.id")) # NUMPARTE
component_part_number: Mapped[Optional[str]] = mapped_column(
ForeignKey("a76.parts.id")) # NUMPARTECOM
class_code: Mapped[Optional[str]] = mapped_column(
ForeignKey("a76.classes.id")) # CLASE
part_number_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("a76.parts.id")
) # NUMPARTE
component_part_number_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("a76.parts.id")
) # NUMPARTECOM
class_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("a76.classes.id")
) # CLASE
# Unit of measure
unit_of_measure: Mapped[Optional[str]] = mapped_column(
ForeignKey("a76.units_of_measure.id")) # UNIDADMEDIDA/UNIMED
ForeignKey("a76.units_of_measure.id")
) # UNIDADMEDIDA/UNIMED
alternate_unit: Mapped[Optional[str]] = mapped_column(
ForeignKey("a76.units_of_measure.id")) # UNIMEDALTERNA
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
auxiliary_unit: Mapped[Optional[str]] = mapped_column(String(5)) # UNIMEDAUXILIAR
# Permits and certificates
permit_number: Mapped[Optional[str]] = mapped_column(
String(20)) # NUMPERMISO
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
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
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
# Subitem flags
is_subitem: Mapped[Optional[bool]] = mapped_column(Boolean) # ESSUBPARTIDA
contains_subitems: Mapped[Optional[bool]
] = mapped_column(Boolean) # CONTIENESUBP
includes_subitems: Mapped[Optional[bool]] = mapped_column(
Boolean) # INCUYESUBPARTIDAS
subitem_number: Mapped[Optional[bool]
] = mapped_column(Boolean) # SUBPARTIDA
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
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
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
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
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
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
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_method: Mapped[Optional[str]] = mapped_column(String(2)) # METVALOR
valuation_determined_value: Mapped[Optional[Decimal]] = mapped_column(
Numeric(29, 8)) # METVALORVALORDETERMINADO/METVALORACIONVALORDETERMINADO
Numeric(29, 8)
) # METVALORVALORDETERMINADO/METVALORACIONVALORDETERMINADO
valuation_reason: Mapped[Optional[str]] = mapped_column(
String(500)) # METVALORMOTIVODEUSO/METVALORACIONMOTIVODEUSO
String(500)
) # METVALORMOTIVODEUSO/METVALORACIONMOTIVODEUSO
# Container rules
container_rule: Mapped[Optional[str]] = mapped_column(
String(50)) # CONTENEDORREGLA
container_rule: Mapped[Optional[str]] = mapped_column(String(50)) # CONTENEDORREGLA
container_parts_ii: Mapped[Optional[str]] = mapped_column(
String(50)) # CONTENEDORPARTESII
String(50)
) # CONTENEDORPARTESII
# APHIS
consecutive_aphis: Mapped[Optional[int]] = mapped_column(
Integer) # CONSECUTIVOAPHIS
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
tlcan_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORTLCAN
# Identifier
identifier: Mapped[Optional[str]] = mapped_column(
String(2)) # IDENTIFICADOR
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
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
String(50)
) # TIPOMAT/TIPODENUMPARTE
# Order concept
order_type: Mapped[Optional[str]] = mapped_column(
String(50)) # TIPODEORDEN
order_type: Mapped[Optional[str]] = mapped_column(String(50)) # TIPODEORDEN
line_concept: Mapped[Optional[str]] = mapped_column(
String(50)) # CONCEPTODELAPARTIDA
String(50)
) # CONCEPTODELAPARTIDA
# Review dispatch
review_dispatch: Mapped[Optional[str]] = mapped_column(
String(10)) # REVISARDESP
review_dispatch: Mapped[Optional[str]] = mapped_column(String(10)) # REVISARDESP
# Take component from PT
take_component_pt: Mapped[Optional[int]
] = mapped_column(Integer) # TOMARCOMOPT
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
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)
back_populates="line", cascade="all, delete-orphan", uselist=False
)
quantity: Mapped[Optional["LineQuantity"]] = relationship(
back_populates="line", cascade="all, delete-orphan", uselist=False)
back_populates="line", cascade="all, delete-orphan", uselist=False
)
customs: Mapped[Optional["LineCustom"]] = relationship(
back_populates="line", cascade="all, delete-orphan", uselist=False)
back_populates="line", cascade="all, delete-orphan", uselist=False
)
description: Mapped[Optional["LineDescription"]] = relationship(
back_populates="line", cascade="all, delete-orphan", uselist=False)
back_populates="line", cascade="all, delete-orphan", uselist=False
)
reference: Mapped[Optional["LineReference"]] = relationship(
back_populates="line", cascade="all, delete-orphan", uselist=False)
back_populates="line", cascade="all, delete-orphan", uselist=False
)
class_info: Mapped[Optional["Class"]] = relationship(
foreign_keys=[class_id], viewonly=True
)
unit_of_measure_info: Mapped[Optional["UnitOfMeasure"]] = relationship(
foreign_keys=[unit_of_measure], viewonly=True
)
fa_data: Mapped[Optional["FaLineItem"]] = relationship(
"FaLineItem", back_populates="master_info", uselist=False
)

View File

@@ -1,6 +1,6 @@
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, Field, ConfigDict, field_validator
from typing import Optional, Any
from pydantic import BaseModel, Field, ConfigDict, field_validator, model_validator
# Import nested schemas
from ..line_customs.schemas import (
@@ -29,6 +29,13 @@ from ..line_references.schemas import (
LineReferenceResponse,
)
from api.v1.modules.a24.fa.fa_item_lines.dto import (
FaLineItemCreateDTO,
FaLineItemUpdateDTO,
FaLineItemResponseDTO,
)
# ============================================================================
# LINE ITEM SCHEMAS
# ============================================================================
@@ -40,16 +47,13 @@ class LineItemBase(BaseModel):
line_number: int = Field(..., description="Line number")
# Part identification
part_number: Optional[str] = Field(None, max_length=50, description="Part number")
component_part_number: Optional[str] = Field(
None, max_length=50, description="Component part number"
part_number_id: Optional[int] = Field(None, description="Part number")
component_part_number_id: Optional[int] = Field(
None, description="Component part number"
)
class_code: Optional[str] = Field(None, max_length=20, description="Class code")
class_id: Optional[int] = Field(None, description="Class code")
@field_validator(
"class_code",
"part_number",
"component_part_number",
"unit_of_measure",
"alternate_unit",
mode="before",
@@ -91,12 +95,6 @@ class LineItemBase(BaseModel):
has_fda_code: Optional[bool] = Field(None, description="Has FDA code")
fda_key: Optional[str] = Field(None, max_length=10, description="FDA key")
# Subitem flags
is_subitem: Optional[bool] = Field(None, description="Is subitem")
contains_subitems: Optional[bool] = Field(None, description="Contains subitems")
includes_subitems: Optional[bool] = Field(None, description="Includes subitems")
subitem_number: Optional[bool] = Field(None, description="Subitem number")
# Special flags
is_military_mcia: Optional[bool] = Field(
None, description="Is military merchandise"
@@ -210,6 +208,9 @@ class LineItemCreate(LineItemBase):
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):
@@ -231,6 +232,9 @@ class LineItemUpdate(LineItemBase):
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):
@@ -243,5 +247,38 @@ class LineItemResponse(LineItemBase):
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)
# 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

View File

@@ -12,7 +12,7 @@ class LineQuantity(Base):
Quantity details for line items
Consolidates all line-level data from Q and S tables
"""
__tablename__ = "line_quantities"
__tablename__ = "item_line_quantities"
__table_args__ = {
"schema": "a76",
}

View File

@@ -11,7 +11,7 @@ class LineReference(Base):
Reference details for line items
Consolidates all line-level data from Q and S tables
"""
__tablename__ = "line_references"
__tablename__ = "item_line_references"
__table_args__ = {
"schema": "a76",
}

View File

@@ -6,6 +6,7 @@ Item -> LineItem -> LineFinancial
-> LineCustoms
-> LineDescription
-> LineReference
-> FaLineItem (Fixed Assets - a24)
"""
import logging
@@ -24,6 +25,7 @@ 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
logger = logging.getLogger(__name__)
@@ -47,6 +49,9 @@ class ItemService:
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),
)
.filter(
Item.id == item_id,
@@ -74,6 +79,9 @@ class ItemService:
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),
)
.filter(
Item.tenant_id == tenant_id,
@@ -122,6 +130,7 @@ class ItemService:
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),
)
.filter(
Item.invoice_id == invoice_id,
@@ -145,7 +154,7 @@ class ItemService:
try:
# Extract lines data
lines_data = item_data.lines or []
item_dict = item_data.model_dump(exclude={"lines"})
item_dict = item_data.model_dump(exclude={"lines"})
# Add tenant and company
item_dict["tenant_id"] = tenant_id
@@ -154,16 +163,17 @@ class ItemService:
# Create the item
db_item = Item(**item_dict)
db.add(db_item)
db.flush() # Get the item ID
db.flush() # Get the item ID
# Create line items if provided
for idx, line_data in enumerate(lines_data):
for idx, line_data in enumerate(lines_data):
# Extract nested data from line
financial_data = line_data.financial
quantity_data = line_data.quantity
customs_data = line_data.customs
description_data = line_data.description
reference_data = line_data.reference
reference_data = line_data.reference
fa_data = line_data.fa_data
line_dict = line_data.model_dump(
exclude={
@@ -172,6 +182,7 @@ class ItemService:
"customs",
"description",
"reference",
"fa_data",
}
)
line_dict["item_id"] = db_item.id
@@ -181,45 +192,54 @@ class ItemService:
# Create line item
db_line = LineItem(**line_dict)
db.add(db_line)
db.flush() # Get the line ID
db.flush() # Get the line ID
# Create financial data if provided
if financial_data:
financial_dict = financial_data.model_dump()
financial_dict["item_line_id"] = db_line.id
db_financial = LineFinancial(**financial_dict)
db.add(db_financial)
db.add(db_financial)
# Create quantity data if provided
if quantity_data:
quantity_dict = quantity_data.model_dump()
quantity_dict["item_line_id"] = db_line.id
db_quantity = LineQuantity(**quantity_dict)
db.add(db_quantity)
db.add(db_quantity)
# Create customs data if provided
if customs_data:
customs_dict = customs_data.model_dump()
customs_dict["item_line_id"] = db_line.id
db_customs = LineCustom(**customs_dict)
db.add(db_customs)
db.add(db_customs)
# Create description data if provided
if description_data:
description_dict = description_data.model_dump()
description_dict["item_line_id"] = db_line.id
db_description = LineDescription(**description_dict)
db.add(db_description)
db.add(db_description)
# Create reference data if provided
if reference_data:
reference_dict = reference_data.model_dump()
reference_dict["item_line_id"] = db_line.id
db_reference = LineReference(**reference_dict)
db.add(db_reference)
db.add(db_reference)
# Create FA data if provided
if fa_data:
fa_dict = fa_data.model_dump()
fa_dict["id"] = db_line.id # FA table uses same ID as line item
fa_dict["tenant_id"] = tenant_id
fa_dict["company_id"] = company_id
db_fa = FaLineItem(**fa_dict)
db.add(db_fa)
db.commit()
db.refresh(db_item)
db.refresh(db_item)
return db_item
except IntegrityError as e:
@@ -272,6 +292,7 @@ class ItemService:
customs_data = line_data.customs
description_data = line_data.description
reference_data = line_data.reference
fa_data = line_data.fa_data
line_dict = line_data.model_dump(
exclude={
@@ -280,6 +301,7 @@ class ItemService:
"customs",
"description",
"reference",
"fa_data",
},
exclude_unset=True,
)
@@ -319,6 +341,14 @@ class ItemService:
reference_dict["item_line_id"] = db_line.id
db.add(LineReference(**reference_dict))
# Create FA data if provided
if fa_data is not None:
fa_dict = fa_data.model_dump(exclude_unset=True)
fa_dict["id"] = db_line.id # FA table uses same ID as line item
fa_dict["tenant_id"] = tenant_id
fa_dict["company_id"] = company_id
db.add(FaLineItem(**fa_dict))
db.commit()
db.refresh(db_item)
return db_item