feature/pestana-series-en-partidas

This commit is contained in:
hreyes
2026-03-11 09:52:15 -06:00
parent 3692fd890d
commit 003d0d54d3
8 changed files with 454 additions and 18 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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:

View File

@@ -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 {

View File

@@ -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] : [];
}
}
});
</script>
<Sheet.Root bind:open={open}>
@@ -149,7 +158,12 @@
</Tabs.Content>
<Tabs.Content value="series" class="m-0 focus-visible:outline-none">
<TabSeries bind:descriptions={editingItem.description!} />
<TabSeries
bind:descriptions={editingItem.description!}
bind:series={editingItem.series!}
lineItem={editingItem}
{invoice}
/>
</Tabs.Content>
<Tabs.Content value="etiquetado" class="m-0 focus-visible:outline-none">

View File

@@ -1,24 +1,328 @@
<script lang="ts">
import * as Table from '$lib/components/ui/table';
import { Input } from '$lib/components/ui/input';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import type { LineDescriptions } from '$lib/api/dashboard/a76/items';
import { Button } from '$lib/components/ui/button';
import { Plus, Pencil } from 'lucide-svelte';
import type { Item, LineDescriptions, Serie } from '$lib/api/dashboard/a76/items';
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
let { descriptions = $bindable() }: { descriptions: LineDescriptions } = $props();
let {
descriptions = $bindable(),
series = $bindable(),
lineItem,
invoice
}: {
descriptions: LineDescriptions;
series: Serie[] | Serie;
lineItem: Partial<Item>;
invoice: Invoice | null;
} = $props();
// Normalize to array for display and mutations
const seriesList = $derived(
Array.isArray(series) ? series : series != null ? [series] : []
);
let selectedSeriesIndex = $state<number | null>(null);
// Ensure series is array in parent when we mutate (for bindable)
function ensureSeriesArray(): Serie[] {
const arr = Array.isArray(series) ? series : series != null ? [series] : [];
if (!Array.isArray(series)) {
series = arr;
}
return arr;
}
function addNewSerie() {
const arr = ensureSeriesArray();
const newSerie: Serie = {
row: arr.length + 1,
serial_numbers: '',
model: '',
sub_model: '',
number_id: ''
};
series = [...arr, newSerie];
selectedSeriesIndex = (series as Serie[]).length - 1;
}
function selectForEdit(index: number) {
selectedSeriesIndex = index;
}
function clearSelection() {
selectedSeriesIndex = null;
}
// Current serie being edited (reference into the array)
const currentSerie = $derived(
selectedSeriesIndex !== null && seriesList[selectedSeriesIndex] != null
? seriesList[selectedSeriesIndex]
: null
);
// Ensure descriptions.has_serial has default
$effect(() => {
if (descriptions && descriptions.has_serial === undefined) {
descriptions.has_serial = false;
}
});
// Ensure current serie has defaults for form fields
$effect(() => {
const s = currentSerie;
if (s) {
if (s.serial_numbers === undefined) s.serial_numbers = '';
if (s.model === undefined) s.model = '';
if (s.sub_model === undefined) s.sub_model = '';
if (s.number_id === undefined) s.number_id = '';
if (s.row === undefined) s.row = 1;
}
});
const hasSerial = $derived(Boolean(descriptions?.has_serial));
const invoiceNumber = $derived(invoice?.invoice_number || '');
const invoiceLine = $derived(lineItem?.line_number != null ? String(lineItem.line_number) : '');
const partNumber = $derived((lineItem as any)?.part_number_display || lineItem?.part_number || '');
// Restricción cantidad: no más series que la cantidad de la partida (paridad CSV DEF/EXPO)
const maxSeriesAllowed = $derived(
lineItem?.quantity?.quantity != null && Number(lineItem.quantity.quantity) >= 0
? Number(lineItem.quantity.quantity)
: Infinity
);
const canAddNewSerie = $derived(hasSerial && seriesList.length < maxSeriesAllowed);
// Normalizar texto: comas y saltos de línea → espacio, colapsar espacios (SACARCOMASENTERS)
function normalizeSerieText(val: string): string {
if (val == null) return '';
return String(val)
.replace(/,/g, ' ')
.replace(/\r\n/g, ' ')
.replace(/\n/g, ' ')
.replace(/\r/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function handleSerieInput(
e: Event & { currentTarget: HTMLInputElement },
field: 'serial_numbers' | 'model' | 'sub_model' | 'number_id'
) {
const s = currentSerie;
if (!s) return;
const raw = e.currentTarget.value;
const normalized = normalizeSerieText(raw);
(s as any)[field] = normalized;
if (normalized !== raw) {
e.currentTarget.value = normalized;
e.currentTarget.setSelectionRange(normalized.length, normalized.length);
}
}
function clampRowToInteger() {
const s = currentSerie;
if (!s) return;
const n = Number(s.row);
const clamped = Math.max(1, Math.floor(Number.isFinite(n) ? n : 1));
s.row = clamped;
}
</script>
<fieldset class="border rounded-md p-3 space-y-3">
<legend class="text-xs font-semibold px-2 uppercase">Serial Numbers</legend>
<div class="space-y-2">
<Label for="series" class="text-xs">Serial Numbers:</Label>
<textarea
id="series"
bind:value={descriptions.extra_description}
class="flex min-h-[100px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder="Enter serial numbers, one per line..."
></textarea>
<legend class="text-xs font-semibold px-2 uppercase">Series</legend>
<div class="flex items-center justify-between gap-2 flex-wrap">
<div class="flex items-center space-x-1.5">
<Checkbox id="has_serial" bind:checked={descriptions.has_serial} />
<Label for="has_serial" class="text-xs font-normal">Lleva serie (LLEVASERIE)</Label>
</div>
<Button
type="button"
variant="outline"
size="sm"
class="h-7 text-xs"
onclick={addNewSerie}
disabled={!canAddNewSerie}
>
<Plus class="w-3.5 h-3.5 mr-1" />
Nueva serie
</Button>
</div>
<!-- Table of all series for this item -->
<div class="rounded-md border overflow-hidden">
<Table.Root>
<Table.Header>
<Table.Row class="bg-muted/50">
<Table.Head class="w-12 text-center text-xs">Línea</Table.Head>
<Table.Head class="text-xs">Serie</Table.Head>
<Table.Head class="text-xs">Modelo</Table.Head>
<Table.Head class="text-xs">Sub modelo</Table.Head>
<Table.Head class="text-xs">Núm. ID</Table.Head>
<Table.Head class="w-[80px] text-right text-xs">Acciones</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if seriesList.length === 0}
<Table.Row>
<Table.Cell colspan={6} class="py-4 text-center text-muted-foreground text-xs">
No hay series registradas. Usa "Nueva serie" para agregar una.
</Table.Cell>
</Table.Row>
{:else}
{#each seriesList as serie, i (i)}
<Table.Row
class="cursor-pointer hover:bg-muted/50 {selectedSeriesIndex === i
? 'bg-primary/10 dark:bg-primary/20'
: ''} {!hasSerial ? 'opacity-70' : ''}"
onclick={() => hasSerial && selectForEdit(i)}
>
<Table.Cell class="text-center text-xs font-medium">{serie.row ?? i + 1}</Table.Cell>
<Table.Cell class="text-xs max-w-[120px] truncate" title={serie.serial_numbers}>
{serie.serial_numbers || '-'}
</Table.Cell>
<Table.Cell class="text-xs max-w-[100px] truncate" title={serie.model}>
{serie.model || '-'}
</Table.Cell>
<Table.Cell class="text-xs max-w-[100px] truncate" title={serie.sub_model}>
{serie.sub_model || '-'}
</Table.Cell>
<Table.Cell class="text-xs">{serie.number_id || '-'}</Table.Cell>
<Table.Cell class="text-right">
<Button
type="button"
variant="ghost"
size="icon"
class="h-7 w-7"
disabled={!hasSerial}
onclick={(e) => {
e.stopPropagation();
if (hasSerial) selectForEdit(i);
}}
>
<Pencil class="w-3.5 h-3.5" />
</Button>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
<!-- Form for selected or new serie -->
{#if currentSerie && selectedSeriesIndex !== null}
<div class="mt-3 pt-3 border-t space-y-3">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-muted-foreground">
{selectedSeriesIndex >= seriesList.length - 1 && !currentSerie?.id
? 'Nueva serie'
: `Editar serie (línea ${currentSerie.row ?? selectedSeriesIndex + 1})`}
</span>
<Button type="button" variant="ghost" size="sm" class="h-6 text-xs" onclick={clearSelection}>
Cerrar formulario
</Button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="space-y-2">
<Label for="invoice_number" class="text-xs">NUMERO FACTURA:</Label>
<div
id="invoice_number"
class="flex h-8 w-full items-center rounded-md border border-input bg-muted/40 px-3 text-sm text-foreground"
>
{invoiceNumber || '-'}
</div>
</div>
<div class="space-y-2">
<Label for="invoice_line" class="text-xs">LINEA FACTURA:</Label>
<div
id="invoice_line"
class="flex h-8 w-full items-center rounded-md border border-input bg-muted/40 px-3 text-sm text-foreground"
>
{invoiceLine || '-'}
</div>
</div>
<div class="space-y-2">
<Label for="series_row" class="text-xs">LINEA SERIE:</Label>
<Input
id="series_row"
bind:value={currentSerie.row}
class="h-8 text-sm"
type="number"
min={1}
step={1}
disabled={!hasSerial}
onblur={clampRowToInteger}
/>
</div>
<div class="space-y-2">
<Label for="serial_number" class="text-xs">SERIE:</Label>
<Input
id="serial_number"
value={currentSerie.serial_numbers ?? ''}
class="h-8 text-sm"
maxlength={50}
placeholder="Número de serie..."
disabled={!hasSerial}
oninput={(e) => handleSerieInput(e, 'serial_numbers')}
/>
</div>
<div class="space-y-2">
<Label for="series_model" class="text-xs">MODELO:</Label>
<Input
id="series_model"
value={currentSerie.model ?? ''}
class="h-8 text-sm"
maxlength={50}
placeholder="Modelo..."
disabled={!hasSerial}
oninput={(e) => handleSerieInput(e, 'model')}
/>
</div>
<div class="space-y-2">
<Label for="part_number" class="text-xs">NUM PARTE:</Label>
<div
id="part_number"
class="flex h-8 w-full items-center rounded-md border border-input bg-muted/40 px-3 text-sm text-foreground"
>
{partNumber || '-'}
</div>
</div>
<div class="space-y-2">
<Label for="sub_model" class="text-xs">SUB MODELO:</Label>
<Input
id="sub_model"
value={currentSerie.sub_model ?? ''}
class="h-8 text-sm"
maxlength={50}
placeholder="Sub modelo..."
disabled={!hasSerial}
oninput={(e) => handleSerieInput(e, 'sub_model')}
/>
</div>
<div class="space-y-2">
<Label for="number_id" class="text-xs">NUMERO ID:</Label>
<Input
id="number_id"
value={currentSerie.number_id ?? ''}
class="h-8 text-sm"
maxlength={25}
placeholder="Número ID..."
disabled={!hasSerial}
oninput={(e) => handleSerieInput(e, 'number_id')}
/>
</div>
</div>
</div>
{/if}
<div class="text-xs text-muted-foreground">
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}
</div>
</fieldset>

View File

@@ -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

View File

@@ -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;
}