feat(web): documentos de ventas y compras con tipo Artículo / Servicio (columnas de línea, validación, guardado y copiado)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 12:19:18 -05:00
1 parent 68d2cfe6d0
commit 2e74bccd65
11 files changed
+260 -25

No files matched your search

@@ -28,6 +28,7 @@ import {
updatePayload,
validateLayout,
} from './document-model';
import { SERVICE_DOC_TYPE } from './document-layouts';
import { type ComputedSource, type HeaderField, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasFolio, hasFreight, hasSeries, headerKeysOf, keysOfFields, requiredHeaderOf } from './document-layouts';
import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy';
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
@@ -71,6 +72,7 @@ const folioAsNumber = (h: DocRecord): DocRecord => {
p { margin: 0; color: var(--quiet); }
a { color: var(--focus); }
.totals { display: grid; gap: 2px; }
.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); }
`,
})
export class DocumentFormPage {
@@ -84,6 +86,9 @@ export class DocumentFormPage {
/** Qué campos y columnas lleva este documento (lo declara su pantalla); el formulario solo lo dibuja. */
protected readonly layout = this.screen.layout;
private readonly lineSpec = this.layout.lines;
/** 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));
private readonly headerKeys: readonly string[] = headerKeysOf(this.layout);
private readonly hasFolio = hasFolio(this.layout);
private readonly blankHeader: DocRecord = this.layout.newHeader();
@@ -213,8 +218,8 @@ export class DocumentFormPage {
protected readonly dirty = computed(() => {
const d = this.doc();
if (!d) return text(this.header()['CardCode']) !== '' || this.lines().some((l) => !isBlankLine(l));
const { body } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec);
if (!d) return text(this.header()['CardCode']) !== '' || this.lines().some((l) => !isBlankLine(l, this.serviceMode()));
const { body } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.spec());
return Object.keys(body).length > 0;
});
@@ -296,12 +301,21 @@ export class DocumentFormPage {
this.message.set({ kind: 'warn', text: 'El documento no tiene líneas abiertas para copiar.' });
return;
}
// 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 }));
}
if (!card) {
this.header.update((h) => ({ ...h, ...copied.header }));
this.partnerName.set(text(doc['CardName']));
this.partnerTaxId.set(text(doc['FederalTaxID']));
}
this.lines.update((ls) => [...ls.filter((l) => !isBlankLine(l)), ...copied.lines]);
this.lines.update((ls) => [...ls.filter((l) => !isBlankLine(l, this.serviceMode())), ...copied.lines]);
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.`,
@@ -313,12 +327,14 @@ export class DocumentFormPage {
// --- configuración de la vista --------------------------------------------------------------
protected readonly columns = this.layout.columns;
protected readonly columns = computed(() => (this.serviceMode() ? this.layout.service!.columns : this.layout.columns));
private readonly itemColumn = this.layout.columns.find((c) => c.key === 'ItemCode') ?? this.layout.columns[0];
protected readonly newLine = () => this.layout.newLine(this.header());
protected readonly newLine = () => (this.serviceMode() ? this.layout.service!.newLine() : this.layout.newLine(this.header()));
/** Recalcula la línea con la tasa de cada impuesto (precio con y sin impuesto, total). */
protected readonly detailHint = detailHint;
protected readonly recalc = this.layout.recalc ? (line: GridLine, key: string) => this.layout.recalc!(line, key, this.rates()) : undefined;
protected readonly recalc = computed(() =>
!this.serviceMode() && this.layout.recalc ? (line: GridLine, key: string) => this.layout.recalc!(line, key, this.rates()) : undefined,
);
protected readonly multi: GridMultiAdd = {
...ITEMS_MULTI_ADD,
lookup: this.itemColumn.lookup!.config,
@@ -342,6 +358,8 @@ export class DocumentFormPage {
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 {
// el tipo (artículo / servicio) no se cambia en un documento ya creado
if (f.type === 'select' && f.key === 'DocType' && !this.isNew()) return true;
return this.lockedKeys.size > 0 && (keysOfFields([f]).some((k) => this.lockedKeys.has(k)) || (f.type === 'computed' && this.lockedKeys.has(f.source)));
}
@@ -368,10 +386,34 @@ export class DocumentFormPage {
}
protected setHeader(key: string, value: unknown): void {
if (key === 'DocType' && this.layout.service) return this.onDocType(String(value));
this.header.update((h) => ({ ...h, [key]: value }));
this.message.set(null);
}
/** 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);
/** 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);
/** 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;
@@ -412,8 +454,8 @@ export class DocumentFormPage {
protected async save(): Promise<void> {
if (this.saving() || this.readOnly()) return;
const errors = validateLayout(this.header(), this.lines(), requiredHeaderOf(this.layout), this.layout.requiredLine);
if (this.layout.details?.length) errors.push(...validateLineDetails(this.lines()));
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()));
if (this.hasFolio && !/^\d*$/.test(text(this.header()['FolioNumber']).trim())) errors.push('El número de folio debe ser numérico.');
if (errors.length) {
this.message.set({ kind: 'err', text: errors[0] });
@@ -426,8 +468,8 @@ export class DocumentFormPage {
if (!d) {
const header = folioAsNumber(this.layout.beforeCreate?.(this.header()) ?? this.header());
const body = this.hasFreight
? freightPayload(createPayload(header, this.lines(), this.lineSpec), this.header(), this.defaultExpense(), 'create').body
: createPayload(header, this.lines(), this.lineSpec);
? freightPayload(createPayload(header, this.lines(), this.spec()), this.header(), this.defaultExpense(), 'create').body
: createPayload(header, this.lines(), this.spec());
const created = await this.api.create(this.screen.resource, body);
await this.router.navigate(['..', created['DocEntry']], {
relativeTo: this.route,
@@ -435,7 +477,7 @@ export class DocumentFormPage {
});
return;
}
const update = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec);
const update = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.spec());
let body = folioAsNumber(update.body);
let replaceLines = update.replaceLines;
if (this.hasFreight) {