2026-10-13 22:00:54 -05:00
import { HttpErrorResponse , httpResource } from '@angular/common/http' ;
2026-10-14 14:37:01 -05:00
import { NgTemplateOutlet } from '@angular/common' ;
2026-10-19 14:47:23 -05:00
import { ChangeDetectionStrategy , Component , computed , effect , inject , linkedSignal , signal , untracked } from '@angular/core' ;
2026-10-13 22:00:54 -05:00
import { toSignal } from '@angular/core/rxjs-interop' ;
import { ActivatedRoute , Router , RouterLink } from '@angular/router' ;
import type { SlListResponse } from '@basic-erp/sap-types' ;
import { map } from 'rxjs' ;
import { API_URL , apiErrorMessage } from '../../core/api' ;
2026-10-19 14:40:14 -05:00
import { formatNumber , todayIso } from '../../core/format' ;
2026-10-19 12:53:23 -05:00
import { CompanySettingsService } from '../../core/company/company-settings' ;
2026-10-13 22:00:54 -05:00
import { SlDocumentApi } from '../../core/sl/sl-document' ;
2026-10-19 12:53:23 -05:00
import { CheckboxField , CheckboxInput , DateField , LookupField , NumberField , NumberInput , SboField , SelectField , SelectInput , TextField , TextInput , TextareaField } from '../../shared/form' ;
2026-10-14 14:37:01 -05:00
import type { GridLine , GridMultiAdd } from '../../shared/grid/grid.types' ;
2026-10-13 22:00:54 -05:00
import { LinesGrid } from '../../shared/grid/lines-grid' ;
import type { VhLookup } from '../../shared/value-help/value-help.types' ;
2026-10-19 14:50:33 -05:00
import { Icon , MenuButton , type MenuItem , SboWindow , Tab , Tabs , Toolbar , type StatusMessage , type WindowBadge } from '../../shared/window' ;
2026-10-14 11:59:07 -05:00
import { ValueHelp } from '../../shared/value-help/value-help' ;
import type { ListRow } from '../../shared/list/list.types' ;
2026-10-13 22:00:54 -05:00
import {
type DocRecord ,
type NavTarget ,
computeTotals ,
2026-10-19 12:46:56 -05:00
EXPENSES_COLLECTION ,
type ExpenseRow ,
2026-10-13 22:00:54 -05:00
createPayload ,
2026-10-19 12:46:56 -05:00
expenseRowsOf ,
expensesChanged ,
expensesPayload ,
2026-10-13 22:00:54 -05:00
isBlankLine ,
isDocumentReadOnly ,
taxRateMap ,
updatePayload ,
2026-10-19 12:46:56 -05:00
validateExpenses ,
2026-10-14 14:37:01 -05:00
validateLayout ,
2026-10-13 22:00:54 -05:00
} from './document-model' ;
2026-10-19 12:19:18 -05:00
import { SERVICE_DOC_TYPE } from './document-layouts' ;
2026-10-19 14:47:23 -05:00
import { type ComputedSource , type GroupCell , type HeaderField , PARTNER_COLUMNS , WAREHOUSE_LOOKUP , hasCurrency , hasExpenses , hasFolio , hasPartnerData , hasSeries , headerKeysOf , keysOfFields , requiredHeaderOf } from './document-layouts' ;
2026-10-14 11:59:07 -05:00
import { type CopyOrigin , copyFromDocument , copySources , copyTargets } from './document-copy' ;
import { DOCUMENT_SCREENS , type DocumentScreen , docNum , documentStatus , menuLabel } from './document-screens' ;
2026-10-16 11:07:38 -05:00
import { OBJECT_TYPES } from './document-objects' ;
2026-10-16 12:25:01 -05:00
import { detailHint , validateLineDetails } from './line-details' ;
2026-10-19 12:46:56 -05:00
import { ITEMS_MULTI_ADD , LINE_COLUMNS } from './line-columns' ;
import { RowsTable } from '../../shared/grid/rows-table' ;
import type { DetailColumn } from '../../shared/grid/grid.types' ;
2026-10-13 22:00:54 -05:00
2026-10-19 14:40:14 -05:00
const today = () : string => todayIso ();
2026-10-16 12:18:09 -05:00
const DATE_KEYS = new Set ([ 'DocDate' , 'DocDueDate' , 'RequriedDate' , 'TaxDate' , 'DueDate' ]);
2026-10-14 09:13:53 -05:00
/** SAP devuelve las fechas como `2026-01-05T00:00:00Z`; el campo de fecha y el guardado usan `2026-01-05`. */
const dateOnly = ( v : unknown ) : string => ( typeof v === 'string' ? v . slice ( 0 , 10 ) : '' );
2026-10-14 14:37:01 -05:00
const pickHeader = ( doc : DocRecord | undefined , keys : readonly string [], blank : DocRecord ) : DocRecord => {
if ( ! doc ) return blank ;
2026-10-14 09:13:53 -05:00
return Object . fromEntries (
2026-10-14 14:48:50 -05:00
keys . map (( k ) => [
k ,
DATE_KEYS . has ( k ) ? dateOnly ( doc [ k ]) : k === 'FolioNumber' ? text ( doc [ k ]) : ( doc [ k ] ?? ( k === 'DiscountPercent' ? 0 : '' )),
]),
2026-10-14 09:13:53 -05:00
);
2026-10-13 22:00:54 -05:00
};
const text = ( v : unknown ) : string => ( v === null || v === undefined ? '' : String ( v ));
2026-10-14 14:48:50 -05:00
/** El folio se edita como texto; SAP lo espera como número. */
const folioAsNumber = ( h : DocRecord ) : DocRecord => {
const folio = h [ 'FolioNumber' ];
if ( typeof folio !== 'string' ) return h ;
return { ... h , FolioNumber : folio.trim () === '' ? null : Number ( folio ) };
};
2026-10-13 22:00:54 -05:00
/**
* Formulario de un documento de ventas o compras: `nuevo` (vacío) o `:docEntry` (ver / editar).
* Los cerrados y cancelados quedan en solo lectura. Al modificar se envía el `odata.etag` leído
* (`If-Match`): si otro usuario cambió el documento, SAP responde 412 y se avisa sin pisarlo.
*/
2026-10-13 20:01:34 -05:00
@Component ({
selector : 'sbo-document-form-page' ,
changeDetection : ChangeDetectionStrategy.OnPush ,
2026-10-19 14:50:33 -05:00
imports : [ Tab , Tabs , CheckboxField , CheckboxInput , DateField , Icon , LinesGrid , LookupField , MenuButton , NgTemplateOutlet , NumberField , NumberInput , RouterLink , RowsTable , SboField , SelectField , SelectInput , SboWindow , TextField , TextInput , TextareaField , Toolbar , ValueHelp ],
2026-10-13 22:00:54 -05:00
templateUrl : './document-form.page.html' ,
2026-10-13 20:01:34 -05:00
styles : `
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
p { margin: 0; color: var(--quiet); }
a { color: var(--focus); }
2026-10-13 22:00:54 -05:00
.totals { display: grid; gap: 2px; }
2026-10-19 12:46:56 -05:00
.exp h3 { margin: 0 0 4px; font-size: 13px; font-weight: 600; }
.exp .addrow { margin: 6px 0 0; }
2026-10-19 12:19:18 -05:00
.ask { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 4px 0 6px; padding: 4px 8px; background: var(--warn-bg); border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); }
2026-10-13 20:01:34 -05:00
` ,
})
export class DocumentFormPage {
private readonly route = inject ( ActivatedRoute );
2026-10-13 22:00:54 -05:00
private readonly router = inject ( Router );
private readonly api = inject ( SlDocumentApi );
2026-10-19 12:53:23 -05:00
/** La empresa usa redondeo (`OADM.RoundMthd`): solo entonces se ofrece la fila «Redondeo». */
2026-10-19 14:40:14 -05:00
private readonly settings = inject ( CompanySettingsService );
protected readonly roundingEnabled = this . settings . roundingEnabled ;
2026-10-13 22:00:54 -05:00
2026-10-13 20:01:34 -05:00
protected readonly title = this . route . snapshot . data [ 'title' ] as string ;
protected readonly screen = this . route . snapshot . data [ 'screen' ] as DocumentScreen ;
2026-10-13 22:00:54 -05:00
protected readonly partner = this . screen . partner ;
2026-10-14 15:10:40 -05:00
/** Qué campos y columnas lleva este documento (lo declara su pantalla); el formulario solo lo dibuja. */
2026-10-14 14:37:01 -05:00
protected readonly layout = this . screen . layout ;
2026-10-14 15:10:40 -05:00
private readonly lineSpec = this . layout . lines ;
2026-10-19 12:19:18 -05:00
/** Si el documento es de servicio, las columnas, los campos que se guardan y las reglas de las líneas son otras. */
protected readonly serviceMode = computed (() => !! this . layout . service && this . header ()[ 'DocType' ] === SERVICE_DOC_TYPE );
private readonly spec = computed (() => ( this . serviceMode () ? this . layout . service ! . lines : this.lineSpec ));
2026-10-14 15:10:40 -05:00
private readonly headerKeys : readonly string [] = headerKeysOf ( this . layout );
private readonly hasFolio = hasFolio ( this . layout );
private readonly blankHeader : DocRecord = this . layout . newHeader ();
2026-10-19 12:46:56 -05:00
private readonly hasExpenses = hasExpenses ( this . layout );
2026-10-19 14:40:14 -05:00
private readonly hasCurrency = hasCurrency ( this . layout );
2026-10-14 14:37:01 -05:00
private pick ( doc : DocRecord | undefined ) : DocRecord {
2026-10-19 12:46:56 -05:00
return pickHeader ( doc , this . headerKeys , this . blankHeader );
2026-10-14 14:37:01 -05:00
}
private docLines ( doc : DocRecord | undefined ) : GridLine [] {
return ( doc ? .[ this . lineSpec . key ] as GridLine [] | undefined ) ?? [];
}
2026-10-13 22:00:54 -05:00
protected readonly docEntry = toSignal (
this . route . paramMap . pipe ( map (( p ) => ( p . has ( 'docEntry' ) ? Number ( p . get ( 'docEntry' )) : null ))),
{ initialValue : this.route.snapshot.paramMap.has ( 'docEntry' ) ? Number ( this . route . snapshot . paramMap . get ( 'docEntry' )) : null },
);
protected readonly isNew = computed (() => this . docEntry () === null );
// --- datos ---------------------------------------------------------------------------------
private readonly docResource = httpResource < DocRecord >(() => {
const id = this . docEntry ();
return id === null ? undefined : ` ${ API_URL } /sl/ ${ this . screen . resource } / ${ id } ` ;
});
private readonly taxResource = httpResource < SlListResponse < DocRecord >>(() =>
this . partner
? { url : ` ${ API_URL } /sl/ ${ this . partner . taxResource } ` , params : { $select : 'Code,Rate' , $top : 500 } }
: undefined ,
);
2026-10-19 12:46:56 -05:00
/** Gastos adicionales de SAP (flete, seguro…): sus nombres se muestran junto al código de cada fila. */
2026-10-14 16:09:06 -05:00
private readonly expensesResource = httpResource < SlListResponse < DocRecord >>(() =>
2026-10-19 12:46:56 -05:00
this . hasExpenses ? { url : ` ${ API_URL } /sl/AdditionalExpenses` , params : { $select : 'ExpensCode,Name' , $top : 200 } } : undefined ,
2026-10-14 16:09:06 -05:00
);
2026-10-19 12:46:56 -05:00
private readonly expenseNames = computed (
() => new Map (( this . expensesResource . hasValue () ? this . expensesResource . value (). value : []). map (( e ) => [ String ( e [ 'ExpensCode' ]), text ( e [ 'Name' ])])),
2026-10-14 16:09:06 -05:00
);
2026-10-19 12:46:56 -05:00
2026-10-19 14:40:14 -05:00
// --- moneda y tipo de cambio ---------------------------------------------------------------
private readonly currenciesResource = httpResource < SlListResponse < DocRecord >>(() =>
this . hasCurrency ? { url : ` ${ API_URL } /sl/Currencies` , params : { $select : 'Code,Name' , $top : 100 } } : undefined ,
);
/** Moneda local primero («Moneda local») y después las demás. */
protected readonly currencyOptions = computed (() => {
const local = this . settings . localCurrency ();
const others = ( this . currenciesResource . hasValue () ? this . currenciesResource . value (). value : [])
. filter (( c ) => text ( c [ 'Code' ]) !== local )
. map (( c ) => ({ value : text ( c [ 'Code' ]), label : ` ${ text ( c [ 'Code' ]) } · ${ text ( c [ 'Name' ]) } ` }));
return [{ value : local , label : 'Moneda local' }, ... others ];
});
protected readonly currencyValue = computed (() => text ( this . header ()[ 'DocCurrency' ]) || this . settings . localCurrency ());
/** El documento está en la moneda local (o la empresa aún no informa cuál es): no lleva tipo de cambio. */
protected readonly isLocalCurrency = computed (() => ! text ( this . header ()[ 'DocCurrency' ]) || this . currencyValue () === this . settings . localCurrency ());
protected onCurrency ( code : string ) : void {
this . header . update (( h ) => ({ ... h , DocCurrency : code , DocRate : '' }));
this . message . set ( null );
if ( code !== this . settings . localCurrency ()) void this . fetchRate ( code );
}
/** El tipo de cambio de la moneda en la fecha del documento; se puede corregir a mano. */
private async fetchRate ( currency : string ) : Promise < void > {
try {
const rate = await this . api . currencyRate ( currency , text ( this . header ()[ 'DocDate' ]) || today ());
if ( text ( this . header ()[ 'DocCurrency' ]) === currency ) this . header . update (( h ) => ({ ... h , DocRate : rate }));
} catch ( e ) {
this . message . set ({ kind : 'warn' , text : `No se pudo obtener el tipo de cambio de ${ currency } : ${ apiErrorMessage ( e ) } ` });
}
}
2026-10-19 12:46:56 -05:00
/** Filas de gastos adicionales; se reinician al cargar o recargar el documento. */
protected readonly expenses = linkedSignal < ExpenseRow [] >(() => expenseRowsOf ( this . doc ()));
protected readonly expenseColumns : readonly DetailColumn [] = [
{
key : 'ExpenseCode' ,
title : 'Gasto' ,
type : 'lookup' ,
required : true ,
width : '90px' ,
lookup : {
title : 'Gastos adicionales' ,
resource : 'AdditionalExpenses' ,
orderby : 'ExpensCode' ,
valueKey : 'ExpensCode' ,
valueType : 'number' ,
columns : [
{ key : 'ExpensCode' , title : 'Código' , width : '90px' , mono : true },
{ key : 'Name' , title : 'Nombre' },
],
},
},
{ key : '_Name' , title : 'Nombre' , type : 'text' , width : '170px' , computed : ( row ) => this . expenseNames (). get ( String ( row [ 'ExpenseCode' ] ?? '' )) ?? '' },
{ key : 'LineTotal' , title : 'Importe' , type : 'number' , decimals : 2 , required : true , width : '110px' },
{
key : 'TaxCode' ,
title : 'Impuesto' ,
type : 'lookup' ,
width : '100px' ,
lookup : {
...( this . layout . columns . find (( c ) => c . key === 'TaxCode' ) ? . lookup ? . config ?? LINE_COLUMNS . tax . lookup ! . config ),
},
},
{ key : 'Remarks' , title : 'Comentarios' , type : 'text' , width : '180px' },
];
protected setExpenses ( rows : ExpenseRow []) : void {
this . expenses . set ( rows );
this . message . set ( null );
}
protected addExpense () : void {
this . setExpenses ([... this . expenses (), {}]);
}
2026-10-14 16:09:06 -05:00
2026-10-16 11:07:38 -05:00
/** Series de numeración de este tipo de documento (acción `SeriesService_GetDocumentSeries` de SAP). */
private readonly hasSeries = hasSeries ( this . layout );
private readonly objectType = OBJECT_TYPES [ this . screen . path ];
private readonly seriesResource = httpResource < SlListResponse < DocRecord >>(() =>
this . hasSeries && this . objectType
? {
url : ` ${ API_URL } /sl/SeriesService_GetDocumentSeries` ,
method : 'POST' ,
body : { DocumentTypeParams : { Document : String ( this . objectType ) } },
}
: undefined ,
);
protected readonly seriesValue = computed < number | null >(() => Number ( this . header ()[ 'Series' ]) || null );
/** Series elegibles: del subtipo de la pantalla y sin bloquear; la del documento abierto se incluye siempre. */
protected readonly seriesOptions = computed (() => {
const subType = this . layout . seriesSubType ?? '--' ;
const rows = this . seriesResource . hasValue () ? this . seriesResource . value (). value : [];
const options = rows
. filter (( r ) => r [ 'DocumentSubType' ] === subType && r [ 'Locked' ] !== 'tYES' )
. map (( r ) => ({ value : Number ( r [ 'Series' ]), label : text ( r [ 'Name' ]) || String ( r [ 'Series' ]) }));
const current = Number ( this . doc () ? .[ 'Series' ]);
if ( current && ! options . some (( o ) => o . value === current )) {
const known = rows . find (( r ) => Number ( r [ 'Series' ]) === current );
options . unshift ({ value : current , label : text ( known ? .[ 'Name' ]) || `Serie ${ current } ` });
}
return options ;
});
2026-10-13 22:00:54 -05:00
protected readonly doc = computed (() => ( this . docResource . hasValue () ? this . docResource . value () : undefined ));
protected readonly loading = computed (() => this . docResource . isLoading ());
protected readonly loadError = computed (() => {
const err = this . docResource . error ();
return err ? ( err instanceof HttpErrorResponse && err . status === 404 ? 'El documento no existe.' : apiErrorMessage ( err )) : '' ;
});
/** Valores editables; se reinician al cargar o recargar el documento. */
2026-10-14 14:37:01 -05:00
protected readonly header = linkedSignal < DocRecord >(() => this . pick ( this . doc ()));
protected readonly lines = linkedSignal < readonly GridLine [] >(() => this . docLines ( this . doc ()). map (( l ) => ({ ... l })));
2026-10-13 22:00:54 -05:00
protected readonly partnerName = linkedSignal (() => text ( this . doc () ? .[ 'CardName' ]));
2026-10-14 14:48:50 -05:00
/** RUT del socio: lo completa SAP desde el maestro; aquí solo se muestra. */
protected readonly partnerTaxId = linkedSignal (() => text ( this . doc () ? .[ 'FederalTaxID' ]));
2026-10-13 22:00:54 -05:00
// --- estado --------------------------------------------------------------------------------
2026-10-14 15:10:40 -05:00
protected readonly readOnly = computed (() => isDocumentReadOnly ( this . doc ()) || ( !! this . layout . immutable && ! this . isNew ()));
2026-10-13 22:00:54 -05:00
protected readonly saving = signal ( false );
private readonly message = signal < StatusMessage | null >(
(() : StatusMessage | null => {
const created = ( this . router . currentNavigation () ? . extras . state as { created? : string } | undefined ) ? . created ;
return created ? { kind : 'ok' , text : `Documento ${ created } creado.` } : null ;
})(),
);
protected readonly conflict = signal ( false );
protected readonly status = computed < StatusMessage >(() => {
if ( this . loading ()) return { kind : 'info' , text : 'Cargando…' };
if ( this . loadError ()) return { kind : 'err' , text : this.loadError () };
const message = this . message ();
if ( message ) return message ;
2026-10-14 14:37:01 -05:00
if ( this . readOnly ()) {
2026-10-14 15:10:40 -05:00
const posted = this . layout . immutable && ! isDocumentReadOnly ( this . doc ());
2026-10-14 14:37:01 -05:00
return { kind : 'info' , text : posted ? 'Documento contabilizado: solo lectura.' : 'Documento cerrado o cancelado: solo lectura.' };
}
if ( this . isNew ()) {
2026-10-14 15:10:40 -05:00
return { kind : 'info' , text : this.partner ? `Indique el ${ this . partner . label . toLowerCase () } y las líneas.` : 'Complete la cabecera y las líneas.' };
2026-10-14 14:37:01 -05:00
}
2026-10-13 22:00:54 -05:00
return { kind : 'info' , text : this.dirty () ? 'Hay cambios sin guardar.' : '' };
});
protected readonly badge = computed < WindowBadge | undefined >(() => {
if ( this . isNew ()) return { label : 'Nueva' , tone : 'info' };
const d = this . doc ();
return d ? documentStatus ( d ) : undefined ;
});
protected readonly windowTitle = computed (() => {
const num = this . doc () ? .[ 'DocNum' ];
return num === undefined ? this . title : ` ${ this . title } · ${ text ( num ) } ` ;
});
protected readonly dirty = computed (() => {
const d = this . doc ();
2026-10-19 12:46:56 -05:00
if ( ! d ) return text ( this . header ()[ 'CardCode' ]) !== '' || this . lines (). some (( l ) => ! isBlankLine ( l , this . serviceMode ())) || expensesPayload ( this . expenses ()). length > 0 ;
2026-10-19 12:19:18 -05:00
const { body } = updatePayload ( this . pick ( d ), this . header (), this . docLines ( d ), this . lines (), this . spec ());
2026-10-19 12:46:56 -05:00
return Object . keys ( body ). length > 0 || ( this . hasExpenses && expensesChanged ( d , this . expenses ()));
2026-10-13 22:00:54 -05:00
});
// --- totales -------------------------------------------------------------------------------
private readonly rates = computed (() =>
taxRateMap ( this . taxResource . hasValue () ? this . taxResource . value (). value : []),
);
protected readonly totals = computed (() =>
2026-10-19 12:53:23 -05:00
computeTotals ( this . lines (), this . rates (), Number ( this . header ()[ 'DiscountPercent' ] ?? 0 ), this . expenses (), this . header ()[ 'Rounding' ] === 'tYES' ? Number ( this . header ()[ 'RoundingDiffAmount' ] ?? 0 ) : 0 ),
2026-10-13 22:00:54 -05:00
);
protected readonly fmt = ( n : number ) : string => formatNumber ( n , 2 );
2026-10-14 11:59:07 -05:00
// --- copiar de / a ----------------------------------------------------------------------------
private readonly copyOrigin = ( this . router . currentNavigation () ? . extras . state as { copyFrom? : CopyOrigin } | undefined ) ? . copyFrom ;
protected readonly copyListFor = signal < string | null >( null );
/** "Copiar a": solo desde un documento guardado, abierto y sin cambios pendientes. */
protected readonly copyToItems = computed < MenuItem [] >(() => {
const blocked = this . isNew () || this . readOnly () || this . dirty ();
return copyTargets ( this . screen . path ). map (( p ) => ({ id : p , label : menuLabel ( p ), disabled : blocked }));
});
protected readonly copyFromItems = computed < MenuItem [] >(() =>
copySources ( this . screen . path ). map (( p ) => ({ id : p , label : menuLabel ( p ), disabled : ! this . isNew () })),
);
2026-10-14 12:00:58 -05:00
protected readonly copyListTitle = computed (() => menuLabel ( this . copyListFor () ?? '' ));
2026-10-14 11:59:07 -05:00
protected readonly copyListScreen = computed (() => DOCUMENT_SCREENS . find (( s ) => s . path === this . copyListFor ()));
/** Documentos abiertos del origen elegido, del mismo socio si ya se indicó. */
protected readonly copyListFilter = computed (() => {
const card = text ( this . header ()[ 'CardCode' ]);
return [ `DocumentStatus eq 'bost_Open'` , `Cancelled eq 'tNO'` , ...( card ? [ `CardCode eq ' ${ card . replace ( /'/g , "''" ) } '` ] : [])]. join ( ' and ' );
});
2026-10-16 12:20:26 -05:00
protected readonly copyListColumns = this . partner
? ([
docNum ,
{ key : 'CardCode' , title : this.partner.label , width : '120px' , mono : true },
{ key : 'CardName' , title : 'Nombre' },
{ key : 'DocDate' , title : 'Fecha' , type : 'date' , align : 'center' , width : '105px' },
] as const )
: ([
docNum ,
{ key : 'FromWarehouse' , title : 'Almacén origen' , width : '130px' , mono : true },
{ key : 'ToWarehouse' , title : 'Almacén destino' , width : '130px' , mono : true },
{ key : 'DocDate' , title : 'Fecha' , type : 'date' , align : 'center' , width : '105px' },
] as const );
2026-10-14 11:59:07 -05:00
protected copyTo ( path : string ) : void {
const docEntry = this . docEntry ();
if ( docEntry === null ) return ;
const origin : CopyOrigin = { path : this.screen.path , docEntry };
void this . router . navigateByUrl ( ` ${ path } /nuevo` , { state : { copyFrom : origin } });
}
protected copyFrom ( path : string ) : void {
this . copyListFor . set ( path );
}
protected async copyPicked ( rows : ListRow []) : Promise < void > {
const path = this . copyListFor ();
this . copyListFor . set ( null );
const docEntry = rows [ 0 ] ? .[ 'DocEntry' ];
if ( path && typeof docEntry === 'number' ) await this . loadCopy ({ path , docEntry });
}
/** Trae el documento origen y vuelca sus datos y líneas abiertas en el formulario. */
private async loadCopy ( origin : CopyOrigin ) : Promise < void > {
const source = DOCUMENT_SCREENS . find (( s ) => s . path === origin . path );
if ( ! source ) return ;
try {
const doc = await this . api . get ( source . resource , origin . docEntry );
2026-10-16 12:20:26 -05:00
const copied = copyFromDocument ( doc , origin . path , menuLabel ( origin . path ), source . layout . lines . key );
2026-10-14 11:59:07 -05:00
const card = text ( this . header ()[ 'CardCode' ]);
if ( card && card !== text ( copied . header [ 'CardCode' ])) {
this . message . set ({ kind : 'warn' , text : `El documento es de ${ text ( copied . header [ 'CardCode' ]) } ; este es de ${ card } .` });
return ;
}
if ( ! copied . lines . length ) {
this . message . set ({ kind : 'warn' , text : 'El documento no tiene líneas abiertas para copiar.' });
return ;
}
2026-10-19 12:19:18 -05:00
// el tipo (artículo / servicio) del origen: si este documento ya tiene líneas de otro tipo no se mezclan
const copiedType = text ( copied . header [ 'DocType' ]);
if ( this . layout . service && copiedType && copiedType !== text ( this . header ()[ 'DocType' ])) {
if ( this . lines (). some (( l ) => ! isBlankLine ( l , this . serviceMode ()))) {
this . message . set ({ kind : 'warn' , text : `El documento es de ${ copiedType === SERVICE_DOC_TYPE ? 'servicio' : 'artículos' } y este tiene líneas de otro tipo.` });
return ;
}
this . header . update (( h ) => ({ ... h , DocType : copiedType }));
}
2026-10-14 11:59:07 -05:00
if ( ! card ) {
this . header . update (( h ) => ({ ... h , ... copied . header }));
this . partnerName . set ( text ( doc [ 'CardName' ]));
2026-10-14 14:48:50 -05:00
this . partnerTaxId . set ( text ( doc [ 'FederalTaxID' ]));
2026-10-19 14:47:23 -05:00
this . partnerKey . set ( text ( copied . header [ 'CardCode' ]));
2026-10-14 11:59:07 -05:00
}
2026-10-19 12:19:18 -05:00
this . lines . update (( ls ) => [... ls . filter (( l ) => ! isBlankLine ( l , this . serviceMode ())), ... copied . lines ]);
2026-10-14 11:59:07 -05:00
this . message . set ({
kind : 'ok' ,
text : `Copiado de ${ menuLabel ( origin . path ). toLowerCase () } ${ text ( doc [ 'DocNum' ]) }${ copied . skipped ? ` ( ${ copied . skipped } línea(s) cerradas no se copian)` : '' } . Revise y pulse Crear.` ,
});
} catch ( e ) {
this . message . set ({ kind : 'err' , text : apiErrorMessage ( e ) });
}
}
2026-10-13 22:00:54 -05:00
// --- configuración de la vista --------------------------------------------------------------
2026-10-19 12:19:18 -05:00
protected readonly columns = computed (() => ( this . serviceMode () ? this . layout . service ! . columns : this.layout.columns ));
2026-10-14 15:39:53 -05:00
private readonly itemColumn = this . layout . columns . find (( c ) => c . key === 'ItemCode' ) ?? this . layout . columns [ 0 ];
2026-10-19 12:19:18 -05:00
protected readonly newLine = () => ( this . serviceMode () ? this . layout . service ! . newLine () : this . layout . newLine ( this . header ()));
2026-10-16 11:07:38 -05:00
/** Recalcula la línea con la tasa de cada impuesto (precio con y sin impuesto, total). */
2026-10-16 12:25:01 -05:00
protected readonly detailHint = detailHint ;
2026-10-19 12:19:18 -05:00
protected readonly recalc = computed (() =>
! this . serviceMode () && this . layout . recalc ? ( line : GridLine , key : string ) => this . layout . recalc ! ( line , key , this . rates ()) : undefined ,
);
2026-10-14 15:10:40 -05:00
protected readonly multi : GridMultiAdd = {
... ITEMS_MULTI_ADD ,
2026-10-14 15:39:53 -05:00
lookup : this.itemColumn.lookup ! . config ,
toLine : ( item ) => this . itemColumn . lookup ! . apply ( item , this . layout . newLine ( this . header ())),
};
protected readonly warehouseLookup : VhLookup = {
... WAREHOUSE_LOOKUP ,
columns : this.layout.lookups?.warehouse ?? WAREHOUSE_LOOKUP . columns ,
2026-10-14 15:10:40 -05:00
};
2026-10-13 22:00:54 -05:00
protected readonly partnerLookup : VhLookup = {
2026-10-19 14:47:23 -05:00
title : ! this . partner ? 'Socios de negocio' : this . partner . type === 'cSupplier' ? 'Proveedores' : 'Clientes' ,
2026-10-13 22:00:54 -05:00
resource : 'BusinessPartners' ,
2026-10-19 14:47:23 -05:00
// un documento con socio de un solo tipo lo filtra; los demás (transferencias de stock) admiten cualquiera
...( this . partner ? { baseFilter : `CardType eq ' ${ this . partner . type } '` } : {}),
2026-10-13 22:00:54 -05:00
orderby : 'CardCode' ,
2026-10-14 15:39:53 -05:00
columns : this.layout.lookups?.partner ?? PARTNER_COLUMNS ,
2026-10-19 14:40:14 -05:00
extraFields : [ 'FederalTaxID' , 'Currency' ],
2026-10-13 22:00:54 -05:00
};
protected str = text ;
2026-10-16 11:14:52 -05:00
private readonly lockedKeys = new Set ( this . layout . lockedKeys ?? []);
/** El campo se ve pero no se edita (una extensión de cliente lo dejó de solo lectura). */
protected locked ( f : HeaderField ) : boolean {
2026-10-19 14:40:14 -05:00
// la moneda de un documento ya creado no se cambia
if ( f . type === 'currency' && ! this . isNew ()) return true ;
2026-10-19 12:19:18 -05:00
// el tipo (artículo / servicio) no se cambia en un documento ya creado
if ( f . type === 'select' && f . key === 'DocType' && ! this . isNew ()) return true ;
2026-10-16 11:14:52 -05:00
return this . lockedKeys . size > 0 && ( keysOfFields ([ f ]). some (( k ) => this . lockedKeys . has ( k )) || ( f . type === 'computed' && this . lockedKeys . has ( f . source )));
}
2026-10-14 15:10:40 -05:00
/** Valor de una fila de solo lectura (número del documento, datos del socio, totales). */
protected computedValue ( source : ComputedSource ) : string {
switch ( source ) {
case 'docNum' :
return text ( this . doc () ? .[ 'DocNum' ]);
case 'partnerName' :
return this . partnerName ();
case 'partnerTaxId' :
return this . partnerTaxId ();
case 'subtotal' :
return this . fmt ( this . totals (). subtotal );
2026-10-19 12:46:56 -05:00
case 'expenses' :
return this . fmt ( this . totals (). expenses );
2026-10-14 15:10:40 -05:00
case 'tax' :
return this . fmt ( this . totals (). tax );
2026-10-19 13:03:32 -05:00
case 'downPayment' :
return this . fmt ( Number ( this . doc () ? .[ 'DownPaymentAmount' ] ?? 0 ));
case 'applied' :
return this . fmt ( Number ( this . doc () ? .[ 'PaidToDate' ] ?? 0 ));
case 'balance' : {
const d = this . doc ();
return this . fmt ( d ? Number ( d [ 'DocTotal' ] ?? 0 ) - Number ( d [ 'PaidToDate' ] ?? 0 ) : 0 );
}
2026-10-14 15:10:40 -05:00
case 'total' :
return this . fmt ( this . totals (). total );
}
}
2026-10-14 11:59:07 -05:00
constructor () {
if ( this . copyOrigin ) void this . loadCopy ( this . copyOrigin );
2026-10-19 14:47:23 -05:00
// al cargarse los datos del socio de un documento nuevo, sus direcciones por defecto
effect (() => {
const data = this . partnerData ();
if ( ! data || ! this . isNew ()) return ;
untracked (() => {
2026-10-19 14:50:33 -05:00
// lo que el socio trae por defecto (como en SAP): direcciones, condiciones y forma de pago, cuenta asociada
const defaults : Record < string , unknown > = {
ShipToCode : text ( data [ 'ShipToDefault' ]),
PayToCode : text ( data [ 'BilltoDefault' ]),
PaymentGroupCode : data [ 'PayTermsGrpCode' ] ?? '' ,
PaymentMethod : text ( data [ 'PeymentMethodCode' ]),
ControlAccount : text ( data [ 'DebitorAccount' ]),
};
this . header . update (( h ) => {
const next = { ... h };
for ( const [ key , value ] of Object . entries ( defaults )) if ( this . headerKeys . includes ( key ) && text ( next [ key ]) === '' && text ( value ) !== '' ) next [ key ] = value ;
return next ;
});
2026-10-19 14:47:23 -05:00
});
});
2026-10-14 11:59:07 -05:00
}
2026-10-13 22:00:54 -05:00
protected setHeader ( key : string , value : unknown ) : void {
2026-10-19 12:19:18 -05:00
if ( key === 'DocType' && this . layout . service ) return this . onDocType ( String ( value ));
2026-10-13 22:00:54 -05:00
this . header . update (( h ) => ({ ... h , [ key ] : value }));
this . message . set ( null );
2026-10-19 14:40:14 -05:00
// otra fecha del documento, otro tipo de cambio
if ( key === 'DocDate' && this . isNew () && ! this . isLocalCurrency ()) void this . fetchRate ( this . currencyValue ());
2026-10-13 22:00:54 -05:00
}
2026-10-19 12:19:18 -05:00
/** Tipo al que se quiere cambiar y que espera confirmación porque hay líneas escritas que se descartarían. */
protected readonly pendingType = signal < string | null >( null );
2026-10-19 14:47:23 -05:00
// --- datos del socio: personas de contacto y direcciones ---------------------------------------
private readonly hasPartnerData = hasPartnerData ( this . layout );
/** Código del socio cuyos datos se cargan: el del documento abierto o el que se acaba de elegir. */
protected readonly partnerKey = linkedSignal (() => text ( this . doc () ? .[ 'CardCode' ]));
private readonly partnerDetail = httpResource < DocRecord >(() => {
const key = this . partnerKey ();
return key && this . hasPartnerData
2026-10-19 14:50:33 -05:00
? { url : ` ${ API_URL } /sl/BusinessPartners/ ${ encodeURIComponent ( key ) } ` , params : { $select : 'CardCode,BPAddresses,ContactEmployees,ShipToDefault,BilltoDefault,PayTermsGrpCode,PeymentMethodCode,DebitorAccount' } }
2026-10-19 14:47:23 -05:00
: undefined ;
});
private readonly partnerData = computed (() => ( this . partnerDetail . hasValue () ? this . partnerDetail . value () : undefined ));
private readonly addresses = computed (() => ( this . partnerData () ? .[ 'BPAddresses' ] as DocRecord [] | undefined ) ?? []);
protected readonly contactOptions = computed (() =>
(( this . partnerData () ? .[ 'ContactEmployees' ] as DocRecord [] | undefined ) ?? []). map (( c ) => ({ value : Number ( c [ 'InternalCode' ]), label : text ( c [ 'Name' ]) })),
);
/** Direcciones del socio de ese lado (envío o facturación), por su nombre. */
protected readonly addressOptions = ( side : 'ship' | 'bill' ) => {
const type = side === 'ship' ? 'bo_ShipTo' : 'bo_BillTo' ;
return this . addresses (). filter (( a ) => a [ 'AddressType' ] === type ). map (( a ) => ({ value : text ( a [ 'AddressName' ]), label : text ( a [ 'AddressName' ]) }));
};
protected readonly shipOptions = computed (() => this . addressOptions ( 'ship' ));
protected readonly billOptions = computed (() => this . addressOptions ( 'bill' ));
/** El texto de la dirección elegida: el que SAP guardó en el documento o, al elegir otra, el armado con los datos de la dirección del socio. */
protected addressText ( f : { key : string ; side : 'ship' | 'bill' }) : string {
const code = text ( this . header ()[ f . key ]);
const found = this . addresses (). find (( a ) => text ( a [ 'AddressName' ]) === code && a [ 'AddressType' ] === ( f . side === 'ship' ? 'bo_ShipTo' : 'bo_BillTo' ));
if ( found ) return [ found [ 'Street' ], found [ 'Block' ], found [ 'ZipCode' ], found [ 'City' ], found [ 'County' ], found [ 'State' ]]. map ( text ). filter (( p ) => p !== '' ). join ( ', ' );
const saved = this . doc () ? .[ f . side === 'ship' ? 'Address' : 'Address2' ];
return code === text ( this . doc () ? .[ f . key ]) ? text ( saved ). replace ( /\r/g , '\n' ) : '' ;
}
/** Un código numérico de SAP (lista de precios, empleado) que se escribe o elige como texto se guarda como número. */
protected typedLookup ( f : { lookup : { valueType? : string } }, value : string ) : string | number {
return f . lookup . valueType === 'number' && /^-?\d+$/ . test ( value . trim ()) ? Number ( value ) : value ;
}
2026-10-19 14:35:54 -05:00
/** La celda de un grupo de campos (el indicador) cuya lista de valores está abierta. */
protected readonly groupCell = signal < GroupCell | null >( null );
protected groupPicked ( rows : ListRow []) : void {
const cell = this . groupCell ();
const row = rows [ 0 ];
if ( cell ? . kind === 'lookup' && row ) {
this . setHeader ( cell . key , row [ cell . lookup . valueKey ?? cell . lookup . columns [ 0 ] ? . key ?? '' ] ?? '' );
}
this . groupCell . set ( null );
}
2026-10-19 12:19:18 -05:00
/** Cambiar entre artículo y servicio cambia las columnas de línea: las líneas escritas no sirven y se pide permiso para descartarlas. */
private onDocType ( type : string ) : void {
if ( type === text ( this . header ()[ 'DocType' ])) return ;
if ( this . lines (). some (( l ) => ! isBlankLine ( l , this . serviceMode ()))) {
this . pendingType . set ( type );
return ;
}
this . applyDocType ( type );
}
protected applyDocType ( type : string ) : void {
this . pendingType . set ( null );
this . header . update (( h ) => ({ ... h , DocType : type }));
this . lines . set ([]);
this . message . set ( null );
}
protected readonly pendingTypeLabel = computed (() => ( this . pendingType () === SERVICE_DOC_TYPE ? 'Servicio' : 'Artículo' ));
protected readonly lineCount = computed (() => this . lines (). filter (( l ) => ! isBlankLine ( l , this . serviceMode ())). length );
2026-10-14 11:42:51 -05:00
/** Al escribir otro código el nombre anterior ya no vale; se completa al salir del campo o al elegir de la lista. */
protected onPartnerText ( code : string ) : void {
if ( code === text ( this . header ()[ 'CardCode' ])) return ;
this . setHeader ( 'CardCode' , code );
this . partnerName . set ( '' );
2026-10-14 14:48:50 -05:00
this . partnerTaxId . set ( '' );
2026-10-19 14:47:23 -05:00
this . partnerKey . set ( '' );
// la persona de contacto y las direcciones eran del socio anterior
this . header . update (( h ) => ({ ... h , ContactPerson : '' , ShipToCode : '' , PayToCode : '' }));
2026-10-14 11:42:51 -05:00
}
protected notify ( textMessage : string ) : void {
this . message . set ({ kind : 'warn' , text : textMessage });
}
2026-10-13 22:00:54 -05:00
protected onPartner ( row : Record < string , unknown >) : void {
2026-10-19 14:47:23 -05:00
this . partnerKey . set ( text ( row [ 'CardCode' ]));
2026-10-13 22:00:54 -05:00
this . partnerName . set ( text ( row [ 'CardName' ]));
2026-10-14 14:48:50 -05:00
this . partnerTaxId . set ( text ( row [ 'FederalTaxID' ]));
2026-10-19 14:40:14 -05:00
// el documento toma la moneda del socio (si es una sola y no es la local), como en SAP
const currency = text ( row [ 'Currency' ]);
if ( this . isNew () && this . hasCurrency && currency && currency !== '##' && currency !== this . currencyValue () && this . isLocalCurrency ()) this . onCurrency ( currency );
2026-10-19 12:28:56 -05:00
// elegido el socio, el documento nuevo arranca con una línea lista para escribir
if ( this . isNew () && ! this . readOnly () && this . lines (). length === 0 ) this . lines . set ([ this . newLine ()]);
2026-10-13 22:00:54 -05:00
}
protected setLines ( lines : readonly GridLine []) : void {
this . lines . set ( lines );
this . message . set ( null );
}
// --- acciones ------------------------------------------------------------------------------
protected async go ( target : NavTarget ) : Promise < void > {
try {
2026-10-15 17:13:33 -05:00
const entry = await this . api . neighbour ( this . screen . resource , target , this . docEntry (), this . screen . baseFilter );
2026-10-13 22:00:54 -05:00
if ( entry === null ) {
this . message . set ({ kind : 'warn' , text : 'No hay más documentos en esa dirección.' });
return ;
}
this . message . set ( null );
await this . router . navigate ([ '..' , entry ], { relativeTo : this.route });
} catch ( e ) {
this . message . set ({ kind : 'err' , text : apiErrorMessage ( e ) });
}
}
protected async save () : Promise < void > {
if ( this . saving () || this . readOnly ()) return ;
2026-10-19 12:19:18 -05:00
const errors = validateLayout ( this . header (), this . lines (), requiredHeaderOf ( this . layout ), this . layout . requiredLine , this . serviceMode ());
if ( this . layout . details ? . length && ! this . serviceMode ()) errors . push (... validateLineDetails ( this . lines ()));
2026-10-14 15:10:40 -05:00
if ( this . hasFolio && ! /^\d*$/ . test ( text ( this . header ()[ 'FolioNumber' ]). trim ())) errors . push ( 'El número de folio debe ser numérico.' );
2026-10-19 12:46:56 -05:00
if ( this . hasExpenses ) errors . push (... validateExpenses ( this . expenses ()));
2026-10-13 22:00:54 -05:00
if ( errors . length ) {
this . message . set ({ kind : 'err' , text : errors [ 0 ] });
return ;
}
this . saving . set ( true );
this . conflict . set ( false );
try {
const d = this . doc ();
if ( ! d ) {
2026-10-14 15:10:40 -05:00
const header = folioAsNumber ( this . layout . beforeCreate ? .( this . header ()) ?? this . header ());
2026-10-19 14:40:14 -05:00
// en la moneda local no se envía moneda ni tipo de cambio: SAP usa los suyos
if ( this . isLocalCurrency ()) {
delete header [ 'DocCurrency' ];
delete header [ 'DocRate' ];
}
2026-10-19 12:53:23 -05:00
// sin redondeo marcado no se envía nada: SAP calcula el suyo
if ( header [ 'Rounding' ] !== 'tYES' ) {
delete header [ 'Rounding' ];
delete header [ 'RoundingDiffAmount' ];
}
2026-10-19 12:46:56 -05:00
const body = createPayload ( header , this . lines (), this . spec ());
if ( this . hasExpenses && expensesPayload ( this . expenses ()). length ) body [ EXPENSES_COLLECTION ] = expensesPayload ( this . expenses ());
2026-10-14 16:09:06 -05:00
const created = await this . api . create ( this . screen . resource , body );
2026-10-13 22:00:54 -05:00
await this . router . navigate ([ '..' , created [ 'DocEntry' ]], {
relativeTo : this.route ,
state : { created : text ( created [ 'DocNum' ]) },
});
return ;
}
2026-10-19 12:19:18 -05:00
const update = updatePayload ( this . pick ( d ), this . header (), this . docLines ( d ), this . lines (), this . spec ());
2026-10-19 12:46:56 -05:00
const body = folioAsNumber ( update . body );
2026-10-14 16:09:06 -05:00
let replaceLines = update . replaceLines ;
2026-10-19 12:46:56 -05:00
if ( this . hasExpenses && expensesChanged ( d , this . expenses ())) {
body [ EXPENSES_COLLECTION ] = expensesPayload ( this . expenses ());
replaceLines = true ; // SAP reemplaza la colección entera
2026-10-14 16:09:06 -05:00
}
2026-10-13 22:00:54 -05:00
if ( ! Object . keys ( body ). length ) {
this . message . set ({ kind : 'info' , text : 'No hay cambios que guardar.' });
return ;
}
await this . api . update ( this . screen . resource , this . docEntry () ! , body , text ( d [ 'odata.etag' ]) || undefined , replaceLines );
this . message . set ({ kind : 'ok' , text : 'Documento actualizado.' });
this . docResource . reload ();
} catch ( e ) {
if ( e instanceof HttpErrorResponse && e . status === 412 ) {
this . conflict . set ( true );
this . message . set ({
kind : 'warn' ,
text : 'Otro usuario modificó este documento. Recárguelo para ver sus cambios; los suyos no se guardaron.' ,
});
} else {
this . message . set ({ kind : 'err' , text : apiErrorMessage ( e ) });
}
} finally {
this . saving . set ( false );
}
}
protected reload () : void {
this . conflict . set ( false );
this . message . set ( null );
this . docResource . reload ();
}
2026-10-13 20:01:34 -05:00
}