feat(web): ver documentos cancelados y sus documentos de cancelación (filtro, estado y botón para ir de uno al otro)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 15:15:03 -05:00
1 parent 57424432f9
commit 74743503f0
7 files changed
+168 -4

No files matched your search

+8
View File
@@ -41,6 +41,14 @@ export class SlDocumentApi {
return firstValueFrom(this.http.post<number>(`${API_URL}/sl/SBOBobService_GetCurrencyRate`, { Currency: currency, Date: date }));
}
/** `DocEntry` del primer documento que cumple el filtro (en el orden dado), o `null` si no hay. */
async findOne(resource: string, filter: string, orderby: string): Promise<number | null> {
const params = new HttpParams().set('$select', 'DocEntry').set('$filter', filter).set('$orderby', orderby).set('$top', 1);
const res = await firstValueFrom(this.http.get<SlListResponse<DocRecord>>(this.url(resource), { params }));
const entry = res.value[0]?.['DocEntry'];
return typeof entry === 'number' ? entry : null;
}
/** Crea el documento; SAP devuelve el documento creado (con `DocEntry` y `DocNum`). */
create(resource: string, body: DocRecord): Promise<DocRecord> {
return firstValueFrom(this.http.post<DocRecord>(this.url(resource), body));
@@ -11,7 +11,7 @@ export const COMPRAS_SCREENS: readonly DocumentScreen[] = [
resource: 'PurchaseRequests',
createLabel: 'Nueva solicitud',
orderby: 'DocEntry desc',
extraFields: ['Cancelled'],
extraFields: ['Cancelled', 'CancelStatus'],
columns: [
docNum,
statusColumn,
@@ -0,0 +1,101 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DocumentFormPage } from './document-form.page';
import { DOCUMENT_SCREENS } from './document-screens';
import { documentStatus } from './screen-kit';
describe('estado de los documentos cancelados', () => {
it('el cancelado y su documento de cancelación se distinguen del resto', () => {
expect(documentStatus({ Cancelled: 'tYES', DocumentStatus: 'bost_Close' })).toEqual({ label: 'Cancelado', tone: 'err' });
expect(documentStatus({ Cancelled: 'tNO', CancelStatus: 'csCancellation', DocumentStatus: 'bost_Close' })).toEqual({ label: 'Cancelación', tone: 'warn' });
expect(documentStatus({ Cancelled: 'tNO', CancelStatus: 'csNo', DocumentStatus: 'bost_Close' })).toEqual({ label: 'Cerrado', tone: 'muted' });
});
it('las listas de ventas y compras filtran por cancelación y piden CancelStatus', () => {
for (const path of ['/ventas/facturas', '/compras/facturas', '/ventas/notas-credito']) {
const s = DOCUMENT_SCREENS.find((x) => x.path === path)!;
const f = s.filters.find((x) => x.key === 'CancelStatus');
expect(f && f.type === 'select' && f.options.map((o) => o.value), path).toEqual(['csYes', 'csCancellation']);
expect(s.extraFields, path).toContain('CancelStatus');
}
expect(DOCUMENT_SCREENS.find((s) => s.path === '/inventario/entradas')!.filters.some((x) => x.key === 'CancelStatus')).toBe(false);
});
});
describe('del documento cancelado a su cancelación y al revés', () => {
let fixture: ComponentFixture<DocumentFormPage>;
let ctrl: HttpTestingController;
let navigate: ReturnType<typeof vi.spyOn>;
const el = () => fixture.nativeElement as HTMLElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const page = () => fixture.componentInstance as any;
const tick = async () => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
async function open(doc: Record<string, unknown>) {
const screen = DOCUMENT_SCREENS.find((s) => s.path === '/compras/facturas')!;
const route = { snapshot: { data: { screen, title: 'Documento' }, paramMap: convertToParamMap({ docEntry: '5' }) }, paramMap: of(convertToParamMap({ docEntry: '5' })) };
await TestBed.configureTestingModule({
imports: [DocumentFormPage],
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
fixture = TestBed.createComponent(DocumentFormPage);
await tick();
ctrl.expectOne((r) => r.url === '/api/sl/SalesTaxCodes').flush({ value: [] });
ctrl.expectOne((r) => r.url === '/api/sl/AdditionalExpenses').flush({ value: [] });
ctrl.expectOne((r) => r.url === '/api/sl/Currencies').flush({ value: [] });
ctrl.expectOne('/api/sl/PurchaseInvoices/5').flush({ DocEntry: 5, DocNum: 260013973, CardCode: 'P1', DocDate: '2026-10-19T00:00:00Z', DocumentLines: [], DocumentStatus: 'bost_Close', ...doc });
await tick();
}
const button = () => [...el().querySelectorAll<HTMLButtonElement>('.actions button')].find((b) => /cancelaci|cancelado/i.test(b.textContent ?? ''));
beforeEach(() => TestBed.resetTestingModule());
it('un documento normal no ofrece ver cancelaciones', async () => {
await open({ Cancelled: 'tNO', CancelStatus: 'csNo' });
expect(button()).toBeUndefined();
});
it('el cancelado busca su documento de cancelación por su número en los comentarios del otro', async () => {
await open({ Cancelled: 'tYES', CancelStatus: 'csYes' });
expect(button()!.textContent).toContain('Ver su cancelación');
button()!.click();
await tick();
const req = ctrl.expectOne((r) => r.url === '/api/sl/PurchaseInvoices' && (r.params.get('$filter') ?? '').includes("contains(Comments,'260013973')"));
expect(req.request.params.get('$filter')).toContain("CancelStatus eq 'csCancellation' and CardCode eq 'P1'");
req.flush({ value: [{ DocEntry: 6 }] });
await tick();
expect(navigate).toHaveBeenCalledWith(['..', 6], expect.anything());
});
it('el documento de cancelación abre el documento que nombra en sus comentarios', async () => {
await open({ Cancelled: 'tNO', CancelStatus: 'csCancellation', Comments: 'COPAS Basado en Fact.proveedores 260013972.' });
expect(button()!.textContent).toContain('Ver el documento cancelado');
button()!.click();
await tick();
const req = ctrl.expectOne((r) => r.url === '/api/sl/PurchaseInvoices' && (r.params.get('$filter') ?? '').includes('DocNum eq 260013972'));
req.flush({ value: [{ DocEntry: 4 }] });
await tick();
expect(navigate).toHaveBeenCalledWith(['..', 4], expect.anything());
});
it('si no encuentra la pareja lo avisa', async () => {
await open({ Cancelled: 'tYES', CancelStatus: 'csYes' });
button()!.click();
await tick();
ctrl.expectOne((r) => (r.params.get('$filter') ?? '').includes('contains(Comments')).flush({ value: [] });
await tick();
ctrl.expectOne((r) => (r.params.get('$filter') ?? '').includes('DocEntry gt 5')).flush({ value: [] });
await tick();
expect(el().querySelector('.status')!.textContent).toContain('No se encontró su documento de cancelación');
});
});
@@ -82,6 +82,9 @@
<button sboActions class="btn" type="button" (click)="reload()">Recargar</button>
}
<a sboActions class="btn" routerLink="..">{{ readOnly() ? 'Cerrar' : 'Cancelar' }}</a>
@if (cancellation(); as c) {
<button sboActions class="btn" type="button" (click)="openCancellationPair()">{{ c === 'cancelled' ? 'Ver su cancelación' : 'Ver el documento cancelado' }}</button>
}
@if (copyFromItems().length) {
<sbo-menu-button sboActionsEnd label="Copiar de" [items]="copyFromItems()" (picked)="copyFrom($event)" />
}
@@ -517,6 +517,46 @@ export class DocumentFormPage {
/** 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);
// --- cancelación: del documento cancelado a su documento de cancelación y al revés ---------------
/** El documento abierto es un cancelado o el documento de cancelación que SAP creó al cancelarlo. */
protected readonly cancellation = computed<'cancelled' | 'reversal' | null>(() => {
const d = this.doc();
return d?.['Cancelled'] === 'tYES' ? 'cancelled' : d?.['CancelStatus'] === 'csCancellation' ? 'reversal' : null;
});
/**
* Abre el otro documento de la pareja. SAP no guarda el enlace en un campo: el documento de cancelación nombra al
* cancelado en sus comentarios («Basado en … 260013973.») y es posterior y del mismo socio; así se busca.
*/
protected async openCancellationPair(): Promise<void> {
const d = this.doc();
const mine = this.cancellation();
if (!d || !mine) return;
const card = `CardCode eq '${text(d['CardCode']).replace(/'/g, "''")}'`;
const base = this.screen.baseFilter;
const withBase = (f: string): string => (base ? `(${base}) and ${f}` : f);
try {
let entry: number | null;
if (mine === 'cancelled') {
entry = await this.api.findOne(this.screen.resource, withBase(`CancelStatus eq 'csCancellation' and ${card} and contains(Comments,'${text(d['DocNum'])}')`), 'DocEntry asc');
entry ??= await this.api.findOne(this.screen.resource, withBase(`CancelStatus eq 'csCancellation' and ${card} and DocEntry gt ${this.docEntry()}`), 'DocEntry asc');
} else {
// el número del cancelado es el último que nombran los comentarios («Basado en Fact.proveedores 260013973.»)
const named = [...text(d['Comments']).matchAll(/(\d{5,})/g)].at(-1)?.[1];
entry = named ? await this.api.findOne(this.screen.resource, withBase(`Cancelled eq 'tYES' and ${card} and DocNum eq ${named}`), 'DocEntry desc') : null;
entry ??= await this.api.findOne(this.screen.resource, withBase(`Cancelled eq 'tYES' and ${card} and DocEntry lt ${this.docEntry()}`), 'DocEntry desc');
}
if (entry === null) {
this.message.set({ kind: 'warn', text: mine === 'cancelled' ? 'No se encontró su documento de cancelación.' : 'No se encontró el documento que cancela.' });
return;
}
await this.router.navigate(['..', entry], { relativeTo: this.route });
} catch (e) {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
}
}
// --- cuenta asociada: de activo en ventas y de pasivo en compras ------------------------------------
/**
@@ -39,6 +39,8 @@ export const menuLabel = (path: string): string =>
export function documentStatus(row: ListRow): { label: string; tone: Tone } | undefined {
if (row['Cancelled'] === 'tYES') return { label: 'Cancelado', tone: 'err' };
// el documento que SAP crea al cancelar otro (su reverso): también está cerrado, pero no es un documento más
if (row['CancelStatus'] === 'csCancellation') return { label: 'Cancelación', tone: 'warn' };
switch (row['DocumentStatus']) {
case 'bost_Open':
return { label: 'Abierto', tone: 'ok' };
@@ -129,6 +131,16 @@ export const docTypeFilter: ListFilter = {
{ value: 'dDocument_Service', label: 'Servicio' },
],
};
/** Cancelaciones: los documentos cancelados y los documentos de cancelación que SAP crea al cancelarlos (por defecto, todos). */
export const cancelFilter: ListFilter = {
type: 'select',
key: 'CancelStatus',
label: 'Cancelación',
options: [
{ value: 'csYes', label: 'Cancelados' },
{ value: 'csCancellation', label: 'Documentos de cancelación' },
],
};
export const dateFilter: ListFilter = { type: 'date', key: 'DocDate', label: 'Fecha contab.' };
export const currencyFilter: ListFilter = {
type: 'multi',
@@ -195,7 +207,7 @@ export const marketing = (
partner: path.startsWith('/ventas')
? { label: partner, type: 'cCustomer', taxResource: 'SalesTaxCodes' }
: { label: partner, type: 'cSupplier', taxResource: 'SalesTaxCodes' },
extraFields: ['Cancelled', 'DocTotalFc', 'DocCurrency'],
extraFields: ['Cancelled', 'CancelStatus', 'DocTotalFc', 'DocCurrency'],
columns: [
docNum,
statusColumn,
@@ -207,7 +219,7 @@ export const marketing = (
{ key: 'DocDueDate', title: 'Fecha venc.', type: 'date', align: 'center', width: '115px' },
{ key: 'DocTotal', title: 'Total', align: 'right', width: '140px', format: documentTotal },
],
filters: [statusFilter, docTypeFilter, currencyFilter, dateFilter],
filters: [statusFilter, cancelFilter, docTypeFilter, currencyFilter, dateFilter],
});
export const inventory = (