diff --git a/backend/api/v1/modules/a76/items/schemas.py b/backend/api/v1/modules/a76/items/schemas.py index b54c914b..62072356 100644 --- a/backend/api/v1/modules/a76/items/schemas.py +++ b/backend/api/v1/modules/a76/items/schemas.py @@ -42,6 +42,11 @@ from api.v1.modules.a24.fa.fa_item_lines.dto import ( FaLineItemUpdateDTO, FaLineItemResponseDTO, ) +from .series.schemas import ( + SerieCreate, + SerieUpdate, + SerieResponse, +) # ============================================================================ @@ -230,6 +235,9 @@ class LineItemCreate(LineItemBase): fa_data: Optional[FaLineItemCreateDTO] = Field( None, description="Fixed Asset data for this line" ) + series: Optional[list[SerieCreate]] = Field( + None, description="Series data for this line (multiple per line)" + ) class LineItemUpdate(LineItemBase): @@ -256,6 +264,9 @@ class LineItemUpdate(LineItemBase): fa_data: Optional[FaLineItemUpdateDTO] = Field( None, description="Fixed Asset data for this line" ) + series: Optional[list[SerieUpdate]] = Field( + None, description="Series data for this line (replace all)" + ) class LineItemResponse(LineItemBase): @@ -283,6 +294,7 @@ class LineItemResponse(LineItemBase): description: Optional[LineDescriptionResponse] = None reference: Optional[LineReferenceResponse] = None fa_data: Optional[FaLineItemResponseDTO] = None + series: Optional[list[SerieResponse]] = None # Fields populated from relationships class_code: Optional[str] = None diff --git a/backend/api/v1/modules/a76/items/series/schemas.py b/backend/api/v1/modules/a76/items/series/schemas.py new file mode 100644 index 00000000..e967a1d2 --- /dev/null +++ b/backend/api/v1/modules/a76/items/series/schemas.py @@ -0,0 +1,27 @@ +from typing import Optional +from pydantic import BaseModel, Field, ConfigDict + + +class SerieBase(BaseModel): + row: Optional[int] = Field(None, description="Serie row number (RENGLON)") + serial_numbers: Optional[str] = Field(None, max_length=50, description="Serial number (SERIEEXPO)") + model: Optional[str] = Field(None, max_length=50, description="Model (MODELOEXPO)") + sub_model: Optional[str] = Field(None, max_length=50, description="Sub model (SUBMODELOEXPO)") + brand: Optional[str] = Field(None, max_length=50, description="Brand (MARCA)") + expo_brad: Optional[str] = Field(None, max_length=50, description="Expo brand (MARCAEXPO)") + number_id: Optional[str] = Field(None, max_length=25, description="Number ID (NUMIDEXPO)") + + +class SerieCreate(SerieBase): + pass + + +class SerieUpdate(SerieBase): + pass + + +class SerieResponse(SerieBase): + id: int + line_item_id: int + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/items/service.py b/backend/api/v1/modules/a76/items/service.py index f75fa1d6..64631a43 100644 --- a/backend/api/v1/modules/a76/items/service.py +++ b/backend/api/v1/modules/a76/items/service.py @@ -35,6 +35,7 @@ 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 LineItem +from .series.models import Serie from api.v1.modules.a76.invoices.models import InvoiceHeader from api.v1.modules.a76.parts.models import Part @@ -170,12 +171,45 @@ class ItemService: ) db.add(FaLineItem(**fa_dict)) + # Serie data (list: multiple series per line) + if hasattr(line_data, "series") and line_data.series: + series_list = ( + line_data.series + if isinstance(line_data.series, list) + else [line_data.series] + ) + for s in series_list: + serie_dict = ( + s.model_dump(exclude_unset=True) + if hasattr(s, "model_dump") + else (dict(s) if isinstance(s, dict) else {}) + ) + if not serie_dict: + continue + serie_dict["line_item_id"] = line.id + serie_dict["tenant_id"] = tenant_id + serie_dict["company_id"] = company_id + if serie_dict.get("row") is None: + serie_dict["row"] = 1 + db.add(Serie(**serie_dict)) + + @staticmethod + def _attach_series(db: Session, item: LineItem) -> None: + """Query and attach all Serie rows for this item as a list.""" + series = ( + db.query(Serie) + .filter(Serie.line_item_id == item.id) + .order_by(Serie.row, Serie.id) + .all() + ) + item.series = list(series) + @staticmethod def get_by_id( db: Session, item_id: int, tenant_id: int, company_id: int ) -> Optional[LineItem]: """Get an item by ID with tenant/company validation""" - return ( + result = ( db.query(LineItem) .options( joinedload(LineItem.financial), @@ -194,6 +228,9 @@ class ItemService: ) .first() ) + if result: + ItemService._attach_series(db, result) + return result @staticmethod def get_all( @@ -244,6 +281,8 @@ class ItemService: total = query.count() items = query.offset(skip).limit(limit).all() + for item in items: + ItemService._attach_series(db, item) return items, total @staticmethod @@ -275,6 +314,8 @@ class ItemService: total = query.count() items = query.offset(skip).limit(limit).all() + for item in items: + ItemService._attach_series(db, item) return items, total @staticmethod @@ -359,6 +400,7 @@ class ItemService: "description", "reference", "fa_data", + "series", } ) @@ -383,6 +425,7 @@ class ItemService: db.commit() db.refresh(db_item) + ItemService._attach_series(db, db_item) return db_item except IntegrityError as e: @@ -489,6 +532,7 @@ class ItemService: "description", "reference", "fa_data", + "series", }, exclude_unset=True, ) @@ -512,6 +556,7 @@ class ItemService: LineReference.item_line_id == db_item.id ).delete() db.query(FaLineItem).filter(FaLineItem.id == db_item.id).delete() + db.query(Serie).filter(Serie.line_item_id == db_item.id).delete() db.flush() # Create new nested data @@ -524,6 +569,7 @@ class ItemService: db.commit() db.refresh(db_item) + ItemService._attach_series(db, db_item) return db_item except HTTPException: diff --git a/frontend/src/lib/api/dashboard/a76/items.ts b/frontend/src/lib/api/dashboard/a76/items.ts index 28e77fcd..d833b4b5 100644 --- a/frontend/src/lib/api/dashboard/a76/items.ts +++ b/frontend/src/lib/api/dashboard/a76/items.ts @@ -88,6 +88,18 @@ export interface LineReferences { serie_id?: number; } +export interface Serie { + id?: number; + line_item_id?: number; + row?: number; + serial_numbers?: string; + model?: string; + sub_model?: string; + brand?: string; + expo_brad?: string; + number_id?: string; +} + export interface FaLineItem { id?: number; tenant_id?: number; @@ -183,7 +195,8 @@ export interface Item { quantity?: LineQuantities; description?: LineDescriptions; reference?: LineReferences; - fa_data?: FaLineItem; // Fixed Asset specific data + fa_data?: FaLineItem; // Fixed Asset specific data + series?: Serie[]; // Series data (multiple per line) } export interface ItemListResponse { 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 97b1757e..6cd59fe2 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 @@ -39,6 +39,15 @@ // Acceso directo a la primera línea para evitar repeticiones en el HTML let line = $derived(editingItem); + + // Ensure series is always an array when sheet is open + $effect(() => { + if (open && editingItem) { + if (!Array.isArray(editingItem.series)) { + editingItem.series = editingItem.series != null ? [editingItem.series] : []; + } + } + }); @@ -149,7 +158,12 @@ - + diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte index 240a6c50..f362937f 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte @@ -1,24 +1,328 @@
- Serial Numbers - -
- - + Series + +
+
+ + +
+
+ +
+ + + + Línea + Serie + Modelo + Sub modelo + Núm. ID + Acciones + + + + {#if seriesList.length === 0} + + + No hay series registradas. Usa "Nueva serie" para agregar una. + + + {:else} + {#each seriesList as serie, i (i)} + hasSerial && selectForEdit(i)} + > + {serie.row ?? i + 1} + + {serie.serial_numbers || '-'} + + + {serie.model || '-'} + + + {serie.sub_model || '-'} + + {serie.number_id || '-'} + + + + + {/each} + {/if} + + +
+ + + {#if currentSerie && selectedSeriesIndex !== null} +
+
+ + {selectedSeriesIndex >= seriesList.length - 1 && !currentSerie?.id + ? 'Nueva serie' + : `Editar serie (línea ${currentSerie.row ?? selectedSeriesIndex + 1})`} + + +
+
+
+ +
+ {invoiceNumber || '-'} +
+
+
+ +
+ {invoiceLine || '-'} +
+
+
+ + +
+
+ + handleSerieInput(e, 'serial_numbers')} + /> +
+
+ + handleSerieInput(e, 'model')} + /> +
+
+ +
+ {partNumber || '-'} +
+
+
+ + handleSerieInput(e, 'sub_model')} + /> +
+
+ + handleSerieInput(e, 'number_id')} + /> +
+
+
+ {/if} +
- You can enter multiple serial numbers, one per line + {#if !hasSerial} + Los datos capturados se conservan, pero la edición queda deshabilitada mientras "Lleva serie" esté apagado. + {/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 ee9a66b4..5c50968d 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 @@ -315,7 +315,8 @@ }, reference: { serie_id: undefined - } + }, + series: [] }; } @@ -544,6 +545,8 @@ selectedItem = lineData.full_item; // Deep clone and normalize numeric values editingItem = normalizeItemData(JSON.parse(JSON.stringify(lineData.full_item))); + if (!editingItem.series) editingItem.series = []; + else if (!Array.isArray(editingItem.series)) editingItem.series = [editingItem.series]; // Guardar una copia del estado original para restaurar al cancelar originalItemData = JSON.parse(JSON.stringify(editingItem)); // Enrich with descriptive data diff --git a/frontend/src/lib/utils/items-logic.ts b/frontend/src/lib/utils/items-logic.ts index 9d577c11..aa95881b 100644 --- a/frontend/src/lib/utils/items-logic.ts +++ b/frontend/src/lib/utils/items-logic.ts @@ -46,6 +46,8 @@ export function cleanLineData(line: any) { delete cleaned.part_description_en; delete cleaned.unit_code; delete cleaned.unit_description; + delete cleaned.includes_subitems; + delete cleaned.payment_method_description; // Only delete part_number if it's the string code from UI, but schema expects int ID. // In this codebase, if part_number is populated from existing data, it's an ID. @@ -58,12 +60,27 @@ export function cleanLineData(line: any) { if (!hasValues(cleaned.customs)) delete cleaned.customs; } + if (cleaned.fa_data) { + delete cleaned.fa_data.includes_subitems; + if (!hasValues(cleaned.fa_data)) delete cleaned.fa_data; + } + // Remove empty nested objects if (cleaned.financial && !hasValues(cleaned.financial)) delete cleaned.financial; if (cleaned.quantity && !hasValues(cleaned.quantity)) delete cleaned.quantity; if (cleaned.description && !hasValues(cleaned.description)) delete cleaned.description; if (cleaned.reference && !hasValues(cleaned.reference)) delete cleaned.reference; - if (cleaned.fa_data && !hasValues(cleaned.fa_data)) delete cleaned.fa_data; + if (cleaned.series != null) { + const arr = Array.isArray(cleaned.series) ? cleaned.series : [cleaned.series]; + cleaned.series = arr + .map((s: any) => { + if (!s || typeof s !== 'object') return null; + const { id, line_item_id, ...rest } = s; + return hasValues(rest) ? rest : null; + }) + .filter((s: any) => s != null); + if (cleaned.series.length === 0) delete cleaned.series; + } return cleaned; }