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:
1 parent
57424432f9
commit
74743503f0
7 files changed
+168
-4
No files matched your search
@@ -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 = (
|
||||
|
||||
Reference in new issue
Block a user