feat: pestaña Stock por almacén en el lote (consulta estándar batch-stock del módulo sapdb; sin base configurada no aparece)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5db5de65ad
commit
6887f9e474
11 files changed
+207
-7
No files matched your search
@@ -4,6 +4,7 @@ import { Router } from '@angular/router';
|
||||
import type { LoginRequest, LoginResponse } from '@basic-erp/sap-types';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { API_URL } from '../api';
|
||||
import { SapDbApi } from '../sl/sapdb-api';
|
||||
import { MenuService } from '../menu/menu.service';
|
||||
import { SessionStore } from './session.store';
|
||||
|
||||
@@ -12,11 +13,13 @@ export class AuthService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly session = inject(SessionStore);
|
||||
private readonly menu = inject(MenuService);
|
||||
private readonly sapdb = inject(SapDbApi);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
async login(credentials: LoginRequest): Promise<void> {
|
||||
const res = await firstValueFrom(this.http.post<LoginResponse>(`${API_URL}/auth/login`, credentials));
|
||||
this.menu.reset();
|
||||
this.sapdb.reset();
|
||||
this.session.set(res);
|
||||
}
|
||||
|
||||
@@ -44,5 +47,6 @@ export class AuthService {
|
||||
private end(): void {
|
||||
this.session.clear();
|
||||
this.menu.reset();
|
||||
this.sapdb.reset();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { API_URL } from '../api';
|
||||
|
||||
/** Respuesta de una consulta a la base de SAP. */
|
||||
export interface SapDbResult {
|
||||
rows: Record<string, unknown>[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consultas registradas de solo lectura a la base de SAP (`/api/sapdb`). Sin base configurada o sin permiso, la API
|
||||
* responde 503 / 403 y la consulta simplemente no está disponible (las pantallas ocultan lo que dependa de ella).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SapDbApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private names?: Promise<Set<string>>;
|
||||
|
||||
/** La consulta existe para este usuario y la base está configurada. Se pregunta una vez por sesión. */
|
||||
available(name: string): Promise<boolean> {
|
||||
this.names ??= firstValueFrom(this.http.get<{ name: string }[]>(`${API_URL}/sapdb/queries`))
|
||||
.then((list) => new Set(list.map((q) => q.name)))
|
||||
.catch(() => new Set<string>());
|
||||
return this.names.then((set) => set.has(name));
|
||||
}
|
||||
|
||||
/** Al iniciar o cerrar sesión: otro usuario puede tener otros permisos. */
|
||||
reset(): void {
|
||||
this.names = undefined;
|
||||
}
|
||||
|
||||
run(name: string, params: Record<string, unknown>): Promise<SapDbResult> {
|
||||
return firstValueFrom(this.http.post<SapDbResult>(`${API_URL}/sapdb/queries/${encodeURIComponent(name)}`, { params }));
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,25 @@ export const LOTES_SCREENS: readonly MasterScreen[] = [
|
||||
field({ type: 'date', key: 'ExpirationDate', label: 'Fecha de vencimiento' }),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stock',
|
||||
label: 'Stock por almacén',
|
||||
left: [],
|
||||
right: [],
|
||||
query: {
|
||||
name: 'batch-stock',
|
||||
params: (d) => ({ absEntry: d['DocEntry'] }),
|
||||
hideEmptyBy: ['Quantity', 'Committed', 'Counted'],
|
||||
hideEmptyLabel: 'Solo almacenes con cantidad, comprometido o contado',
|
||||
columns: [
|
||||
{ key: 'WarehouseCode', title: 'Almacén', type: 'text', width: '110px' },
|
||||
{ key: 'WarehouseName', title: 'Nombre', type: 'text', width: '260px' },
|
||||
{ key: 'Quantity', title: 'En stock', type: 'number', width: '110px', total: true },
|
||||
{ key: 'Committed', title: 'Comprometido', type: 'number', width: '110px', total: true },
|
||||
{ key: 'Counted', title: 'Contado', type: 'number', width: '110px', total: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ id: 'detalles', label: 'Detalles', left: [field({ type: 'textarea', key: 'Details', label: 'Detalles' })], right: [] },
|
||||
],
|
||||
newHeader: () => ({}),
|
||||
|
||||
@@ -49,11 +49,18 @@ describe('Lotes (BatchNumberDetails)', () => {
|
||||
navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
|
||||
fixture = TestBed.createComponent(MasterFormPage);
|
||||
await tick();
|
||||
// la pestaña de stock depende de una consulta a la base de SAP: ¿está configurada y el usuario puede ejecutarla?
|
||||
const queries = ctrl.match('/api/sapdb/queries');
|
||||
for (const q of queries) queriesAnswer === 'fail' ? q.flush({ message: 'x' }, { status: 503, statusText: 'Service Unavailable' }) : q.flush([{ name: 'ping' }, { name: 'batch-stock' }]);
|
||||
if (key !== null) ctrl.expectOne(`/api/sl/BatchNumberDetails/${key}`).flush(doc ?? {});
|
||||
await tick();
|
||||
}
|
||||
|
||||
beforeEach(() => TestBed.resetTestingModule());
|
||||
let queriesAnswer: 'ok' | 'fail' = 'ok';
|
||||
beforeEach(() => {
|
||||
queriesAnswer = 'ok';
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
it('la pantalla usa solo campos estándar, clave numérica y no se crea desde aquí', () => {
|
||||
expect(screen.keyField).toBe('DocEntry');
|
||||
@@ -70,7 +77,7 @@ describe('Lotes (BatchNumberDetails)', () => {
|
||||
expect(readonly('2090456')).toBe(true);
|
||||
expect(readonly('10101070119011602')).toBe(true);
|
||||
expect(readonly('CATGUT SIMPLE 6/0')).toBe(true);
|
||||
expect([...el().querySelectorAll('[role=tab]')].map((t) => t.textContent?.trim())).toEqual(['General', 'Detalles']);
|
||||
expect([...el().querySelectorAll('[role=tab]')].map((t) => t.textContent?.trim())).toEqual(['General', 'Stock por almacén', 'Detalles']);
|
||||
});
|
||||
|
||||
it('las fechas que trae SAP con hora se ven en el selector de fecha', async () => {
|
||||
@@ -79,6 +86,33 @@ describe('Lotes (BatchNumberDetails)', () => {
|
||||
expect(dates).toEqual(['2026-01-05', '2025-12-01', '2028-12-01']);
|
||||
});
|
||||
|
||||
it('sin base de datos de SAP configurada (o sin permiso) no hay pestaña de stock', async () => {
|
||||
queriesAnswer = 'fail';
|
||||
await open('74701', lote());
|
||||
expect([...el().querySelectorAll('[role=tab]')].map((t) => t.textContent?.trim())).toEqual(['General', 'Detalles']);
|
||||
});
|
||||
|
||||
it('el stock del lote se pide por su número interno y se totaliza', async () => {
|
||||
await open('74701', lote());
|
||||
(el().querySelectorAll('[role=tab]')[1] as HTMLElement).click();
|
||||
await tick();
|
||||
const req = ctrl.expectOne('/api/sapdb/queries/batch-stock');
|
||||
expect(req.request.body).toEqual({ params: { absEntry: 74701 } });
|
||||
req.flush({
|
||||
truncated: false,
|
||||
rows: [
|
||||
{ WarehouseCode: '201', WarehouseName: 'APROBADOS DRO', Quantity: '7.500000', Committed: '0.000000', Counted: '0.000000' },
|
||||
{ WarehouseCode: '100', WarehouseName: 'CUARENTENA INSUMOS', Quantity: '2.500000', Committed: '1.000000', Counted: '0.000000' },
|
||||
{ WarehouseCode: '150', WarehouseName: 'PRODUCTOS EN PROCESO LAB', Quantity: '0.000000', Committed: '0.000000', Counted: '0.000000' },
|
||||
],
|
||||
});
|
||||
await tick();
|
||||
const tab = el().querySelectorAll('sbo-tab')[1];
|
||||
// los almacenes sin cantidades se ocultan; el total suma todos
|
||||
expect([...tab.querySelectorAll('tbody tr')].map((r) => r.querySelector('input')?.value)).toEqual(['201', '100']);
|
||||
expect(tab.querySelector('thead tr:nth-child(2)')?.textContent).toContain('10');
|
||||
});
|
||||
|
||||
it('un lote bloqueado se ve como tal', async () => {
|
||||
await open('74701', lote({ Status: 'bdsStatus_Locked' }));
|
||||
expect(el().querySelector('.badge.tone-warn')?.textContent?.trim()).toBe('Bloqueado');
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
|
||||
<sbo-tabs ariaLabel="Datos del registro">
|
||||
@for (tab of layout.tabs; track tab.id) {
|
||||
@for (tab of tabs(); track tab.id) {
|
||||
<sbo-tab [label]="tab.label">
|
||||
<div class="sbo-hdr">
|
||||
<div class="sbo-col">
|
||||
@@ -41,6 +41,11 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (tab.query; as q) {
|
||||
@if (doc(); as d) {
|
||||
<sbo-query-table [query]="q.name" [params]="q.params(d)" [columns]="q.columns" [hideEmptyBy]="q.hideEmptyBy" [hideEmptyLabel]="q.hideEmptyLabel ?? 'Ocultar las filas en 0'" />
|
||||
}
|
||||
}
|
||||
@if (tab.collection; as c) {
|
||||
<sbo-rows-table
|
||||
[columns]="c.columns"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { HttpErrorResponse, httpResource } from '@angular/common/http';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal, signal } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal, resource, signal } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { map } from 'rxjs';
|
||||
import { API_URL, apiErrorMessage } from '../../core/api';
|
||||
import { SapDbApi } from '../../core/sl/sapdb-api';
|
||||
import { SlMasterApi } from '../../core/sl/sl-master';
|
||||
import { QueryTable } from '../../shared/grid/query-table';
|
||||
import { RowsTable } from '../../shared/grid/rows-table';
|
||||
import { Icon, SboWindow, Tab, Tabs, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
|
||||
import { updatePayload, type DocRecord } from '../documents/document-model';
|
||||
@@ -38,7 +40,7 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
|
||||
@Component({
|
||||
selector: 'sbo-master-form-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ChartTreePanel, Icon, MasterField, RouterLink, RowsTable, SboWindow, Tab, Tabs, Toolbar],
|
||||
imports: [ChartTreePanel, Icon, MasterField, QueryTable, RouterLink, RowsTable, SboWindow, Tab, Tabs, Toolbar],
|
||||
templateUrl: './master-form.page.html',
|
||||
styles: `
|
||||
.addrow { margin: 6px 0 0; }
|
||||
@@ -66,6 +68,22 @@ export class MasterFormPage {
|
||||
/** SAP no crea este registro desde aquí (los lotes): sin botón «Nuevo» y `/nuevo` vuelve a la lista. */
|
||||
protected readonly creatable = this.screen.creatable !== false;
|
||||
|
||||
/** Pestañas que dependen de una consulta a la base de SAP: solo se muestran si la base está configurada y el usuario puede ejecutarla. */
|
||||
private readonly sapdb = inject(SapDbApi);
|
||||
private readonly queryTabs = this.layout.tabs.filter((t) => t.query);
|
||||
private readonly shownQueryTabs = resource({
|
||||
loader: async () => {
|
||||
const shown = new Set<string>();
|
||||
for (const t of this.queryTabs) if (await this.sapdb.available(t.query!.name)) shown.add(t.id);
|
||||
return shown;
|
||||
},
|
||||
});
|
||||
protected readonly tabs = computed(() => {
|
||||
if (!this.queryTabs.length) return this.layout.tabs;
|
||||
const shown = this.shownQueryTabs.hasValue() ? this.shownQueryTabs.value() : new Set<string>();
|
||||
return this.layout.tabs.filter((t) => !t.query || shown.has(t.id));
|
||||
});
|
||||
|
||||
constructor() {
|
||||
if (!this.creatable && this.isNew()) void this.router.navigate(['..'], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
@@ -40,6 +40,17 @@ export interface MasterTab extends FieldColumns {
|
||||
id: string;
|
||||
label: string;
|
||||
collection?: MasterCollection;
|
||||
/**
|
||||
* Una tabla de solo lectura con el resultado de una consulta registrada a la base de SAP (`/api/sapdb`; el stock de un
|
||||
* lote por almacén). Si la base no está configurada o el usuario no tiene permiso, la pestaña no se muestra.
|
||||
*/
|
||||
query?: {
|
||||
name: string;
|
||||
params: (doc: DocRecord) => Record<string, unknown>;
|
||||
columns: readonly DetailColumn[];
|
||||
hideEmptyBy?: readonly string[];
|
||||
hideEmptyLabel?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, input, resource } from '@angular/core';
|
||||
import { apiErrorMessage } from '../../core/api';
|
||||
import { SapDbApi } from '../../core/sl/sapdb-api';
|
||||
import type { DetailColumn } from './grid.types';
|
||||
import { RowsTable } from './rows-table';
|
||||
|
||||
/** Tabla de solo lectura con el resultado de una consulta registrada a la base de SAP (el stock de un lote por almacén). */
|
||||
@Component({
|
||||
selector: 'sbo-query-table',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RowsTable],
|
||||
template: `
|
||||
@if (result.error()) {
|
||||
<p class="err" role="alert">{{ error() }}</p>
|
||||
} @else if (result.isLoading()) {
|
||||
<p class="hint">Consultando…</p>
|
||||
} @else {
|
||||
<sbo-rows-table [columns]="columns()" [rows]="rows()" [readonly]="true" [removable]="false" [hideEmptyBy]="hideEmptyBy() ?? null" [hideEmptyLabel]="hideEmptyLabel()" emptyText="Sin datos." />
|
||||
@if (result.value()?.truncated) {
|
||||
<p class="hint">Hay más filas de las que se muestran.</p>
|
||||
}
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
:host { display: block; }
|
||||
.hint { margin: 6px 0; color: var(--quiet); }
|
||||
.err { margin: 6px 0; color: var(--err); }
|
||||
`,
|
||||
})
|
||||
export class QueryTable {
|
||||
private readonly api = inject(SapDbApi);
|
||||
|
||||
readonly query = input.required<string>();
|
||||
readonly params = input.required<Record<string, unknown>>();
|
||||
readonly columns = input.required<readonly DetailColumn[]>();
|
||||
readonly hideEmptyBy = input<readonly string[]>();
|
||||
readonly hideEmptyLabel = input('Ocultar las filas en 0');
|
||||
|
||||
/** La consulta y sus parámetros como texto: quien nos usa puede armar un objeto nuevo en cada ciclo y no por eso se vuelve a consultar. */
|
||||
private readonly request = computed(() => JSON.stringify({ query: this.query(), params: this.params() }));
|
||||
|
||||
protected readonly result = resource({
|
||||
params: () => this.request(),
|
||||
loader: ({ params }) => {
|
||||
const r = JSON.parse(params) as { query: string; params: Record<string, unknown> };
|
||||
return this.api.run(r.query, r.params);
|
||||
},
|
||||
});
|
||||
protected readonly rows = computed(() => (this.result.hasValue() ? this.result.value().rows : []));
|
||||
protected readonly error = computed(() => apiErrorMessage(this.result.error()));
|
||||
}
|
||||
Reference in new issue
Block a user