Aplicación web de Devolución a Proveedores para SAP Business One

Reemplaza al addon .NET Framework (SolucionLibros). Genera Devoluciones de mercancías
enlazadas a sus Entradas por compra a partir de un listado pegado desde Excel.

- libs/shared (@dp/shared): modelos y lógica de dominio pura (FIFO, consolidación por
  proveedor, parseo del portapapeles), portada del addon.
- apps/api (NestJS): cliente del Service Layer con cola por sesión, login con la sesión de
  SAP, metadatos (UDT/UDF/UDO) y botón Reconstruir, catálogos, planificaciones (pegar,
  procesar, reprocesar, marcar líneas, crear Devoluciones, cerrar), listado paginado con
  filtros y descarga de PDF de la Guía de despacho desde BES. Sirve también la web
  compilada, con HTTPS opcional.
- apps/web (Angular): login, Configuración → Metadatos y Planificaciones (listado,
  nueva, detalle).
- deploy/windows y scripts/publicar.mjs: despliegue como servicio de Windows con WinSW.

Verificado contra el Service Layer de SQL Server. Sin probar aún: SAP HANA y la
instalación del servicio en Windows (ver CLAUDE.md, sección "Sin verificar").

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
georgeandClaude Sonnet 5.5 committed 2026-10-13 08:09:07 -05:00
commit 287b187c14
126 files changed
+20033

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+44
View File
@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}
+20
View File
@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}
+42
View File
@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}
+92
View File
@@ -0,0 +1,92 @@
# Web — Devolución a Proveedores
Frontend en **Angular 22** (componentes standalone, signals, sin zone.js, CSS) de la aplicación
Devolución a Proveedores para SAP Business One. Consume la API NestJS de [`../api`](../api/README.md).
La documentación general del proyecto (decisiones, flujo, estado) está en
[`../../CLAUDE.md`](../../CLAUDE.md). Este archivo cubre solo cómo ejecutar y mantener la web.
## Puesta en marcha
Desde la raíz del monorepo (`devolucion-proveedores-web/`), con la API ya levantada:
```bash
npm install
npm run dev:web # compila @dp/shared y levanta Angular en http://localhost:4200
```
`ng serve` redirige `/api` a `http://localhost:3000` (ver `src/proxy.conf.json`), así que no hace falta
configurar CORS en desarrollo. Con Fortinet, exportar antes `NODE_OPTIONS=--use-system-ca` y
`NG_CLI_ANALYTICS=false`.
> **Importante:** `@dp/shared` se consume compilado. Si lo cambia, ejecute `npm run build:shared`
> **y reinicie `ng serve`**: Vite sirve una copia vieja y la página falla con *"does not provide an
> export named …"*. Los tests no lo detectan.
## Scripts
| Comando | Qué hace |
|---|---|
| `ng serve` / `npm start` | Servidor de desarrollo con recarga. |
| `ng build` | Compila a `dist/web`. |
| `ng test` | Tests (Vitest + jsdom). Para una sola pasada: `npx ng test --watch=false`. |
## En producción
La web **no se despliega por separado**: `ng build` genera `dist/web/browser` y la API (NestJS) la sirve
junto con `/api`, por HTTPS y en el mismo origen. Por eso las llamadas son siempre relativas (`/api/...`).
`npm run publicar` (en la raíz) arma la carpeta de despliegue; ver
[`../../deploy/windows/DEPLOY.md`](../../deploy/windows/DEPLOY.md).
## Pantallas
| Ruta | Pantalla |
|---|---|
| `/login` | Usuario y contraseña de SAP B1, con la casilla **"Verificar los metadatos de SAP al ingresar"**. |
| `/planificaciones` | Listado con filtros (estado, almacén, fechas) y paginación (**Atrás** / **Siguiente**, filas por página). Los filtros y la página quedan en la URL. |
| `/planificaciones/nueva` | Pasos 1 y 2: elegir almacén y fecha, pegar el listado desde Excel (botón *Pegar desde el portapapeles* o `Ctrl/Cmd+V`), validar y procesar (FIFO). Con `?reemplaza=<id>` rehace una planificación. |
| `/planificaciones/:docEntry` | Pasos 3 y 4: datos de las Devoluciones y transporte, grilla Padre/Hijo con casillas, **Crear devoluciones**, **Rehacer FIFO**, **Descargar PDF** y **Cerrar planificación**. |
| `/configuracion/metadatos` | Estado de tablas, campos y objeto de usuario en SAP, y el botón **Reconstruir**. |
Flujo de rutas: `authGuard` exige sesión; `metadataGuard` verifica los metadatos una vez por sesión y,
si falta algo, lleva a *Configuración → Metadatos* (se omite si el usuario desmarcó la casilla del login).
Una respuesta 401 en cualquier llamada devuelve al login.
## Estructura
```
src/app/
├── core/ servicios HTTP (auth, metadata, planificaciones), guards, interceptor y errores
├── layout/ shell (barra superior) y stepper de los 4 pasos
└── pages/
├── login/
├── metadatos/
└── planificaciones/ listado · nueva/ · detalle/
```
Cada página tiene su `.ts`, `.html` y `.css`; los tests (`*.spec.ts`) están junto a lo que prueban.
La lógica de negocio pura (FIFO, qué líneas se pueden marcar, consolidación por proveedor) se importa
de `@dp/shared`, la misma que usa la API.
## Cosas que conviene saber
- **El navegador solo conoce la cookie `dp_sid`**; la sesión de SAP vive en el servidor y se pierde si
la API se reinicia (habrá que volver a iniciar sesión).
- **Descarga de PDF**: la API devuelve los archivos en base64; la pantalla arma `Blob`s y enlaces
(`URL.createObjectURL`). Con un solo archivo lo descarga directo; con varios deja un enlace por archivo.
- **Pegar desde el portapapeles** usa `navigator.clipboard.readText()`: solo funciona en `localhost` o
HTTPS, y el navegador pide permiso. Si lo niega, se puede pegar a mano dentro del cuadro de texto.
- **Estilos en CSS**, no SCSS: en macOS 12 el compilador nativo de Sass exige macOS 14 o superior. Las
variables de color y espaciado están en `src/styles.css`.
- **Tests sin zone.js**: tras un `flush` de `HttpTestingController` hay que esperar con
`await new Promise((r) => setTimeout(r))` y llamar a `fixture.detectChanges()`; `whenStable()` no
alcanza para las promesas de `firstValueFrom`.
## Diagnóstico rápido
| Síntoma | Causa probable |
|---|---|
| Pantalla en blanco con *"does not provide an export named …"* en la consola | `@dp/shared` cambió y `ng serve` quedó con la copia vieja. Reiniciarlo. |
| Vuelve al login sin avisar | Sesión de SAP vencida o API reiniciada. |
| `http proxy error: /api/… ECONNREFUSED` en la terminal | La API no está levantada (o se está reiniciando). |
| La lista de almacenes sale vacía | Mirar el recuadro rojo de la pantalla: indica qué llamada falló. |
+81
View File
@@ -0,0 +1,81 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"web": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "css"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "css",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "web:build:production"
},
"development": {
"buildTarget": "web:build:development"
}
},
"defaultConfiguration": "development",
"options": {
"proxyConfig": "src/proxy.conf.json"
}
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}
+33
View File
@@ -0,0 +1,33 @@
{
"name": "web",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@10.9.9",
"dependencies": {
"@angular/common": "^22.2.0",
"@angular/compiler": "^22.2.0",
"@angular/core": "^22.2.0",
"@angular/forms": "^22.2.0",
"@angular/platform-browser": "^22.2.0",
"@angular/router": "^22.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"@dp/shared": "*"
},
"devDependencies": {
"@angular/build": "^22.2.2",
"@angular/cli": "^22.2.2",
"@angular/compiler-cli": "^22.2.0",
"jsdom": "^30.0.0",
"prettier": "^3.8.1",
"typescript": "~6.0.2",
"vitest": "^5.0.3"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+13
View File
@@ -0,0 +1,13 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { authInterceptor } from './core/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
],
};
+31
View File
@@ -0,0 +1,31 @@
import { Routes } from '@angular/router';
import { authGuard, guestGuard, metadataGuard } from './core/guards';
import { Shell } from './layout/shell';
import { LoginPage } from './pages/login/login';
import { MetadatosPage } from './pages/metadatos/metadatos';
import { PlanificacionDetallePage } from './pages/planificaciones/detalle/detalle';
import { NuevaPlanificacionPage } from './pages/planificaciones/nueva/nueva';
import { PlanificacionesPage } from './pages/planificaciones/planificaciones';
export const routes: Routes = [
{ path: 'login', component: LoginPage, canActivate: [guestGuard] },
{
path: '',
component: Shell,
canActivate: [authGuard],
children: [
{ path: 'configuracion/metadatos', component: MetadatosPage },
{
path: '',
canActivate: [metadataGuard],
children: [
{ path: '', pathMatch: 'full', redirectTo: 'planificaciones' },
{ path: 'planificaciones', component: PlanificacionesPage },
{ path: 'planificaciones/nueva', component: NuevaPlanificacionPage },
{ path: 'planificaciones/:docEntry', component: PlanificacionDetallePage },
],
},
],
},
{ path: '**', redirectTo: '' },
];
+76
View File
@@ -0,0 +1,76 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter, Router } from '@angular/router';
import { App } from './app';
import { routes } from './app.routes';
import { MetadataService } from './core/metadata.service';
const tick = () => new Promise((r) => setTimeout(r));
describe('App', () => {
let http: HttpTestingController;
let router: Router;
beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter(routes), provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
http = TestBed.inject(HttpTestingController);
router = TestBed.inject(Router);
});
it('sin sesión redirige al login', async () => {
TestBed.createComponent(App);
const navegacion = router.navigateByUrl('/planificaciones');
await tick();
http.expectOne('/api/auth/me').flush({ message: 'Debe iniciar sesión.' }, { status: 401, statusText: 'Unauthorized' });
await navegacion;
expect(router.url).toBe('/login');
});
it('con sesión y metadatos incompletos lleva a Configuración', async () => {
TestBed.createComponent(App);
const navegacion = router.navigateByUrl('/planificaciones');
await tick();
http.expectOne('/api/auth/me').flush({ userName: 'manager', companyDb: 'TEST', version: '1000' });
await tick();
http.expectOne('/api/metadata/status').flush({ completo: false, elementos: [] });
await navegacion;
expect(router.url).toBe('/configuracion/metadatos');
});
it('con la verificación de metadatos desactivada entra sin consultarla', async () => {
TestBed.inject(MetadataService).fijarVerificacionAlIngresar(false);
TestBed.createComponent(App);
const navegacion = router.navigateByUrl('/planificaciones');
await tick();
http.expectOne('/api/auth/me').flush({ userName: 'manager', companyDb: 'TEST', version: '1000' });
await navegacion;
http.expectNone('/api/metadata/status');
expect(router.url).toBe('/planificaciones');
});
it('desactivada pero con un estado ya conocido incompleto, igual lleva a Configuración', async () => {
const metadata = TestBed.inject(MetadataService);
metadata.fijarVerificacionAlIngresar(false);
metadata.estado.set({ completo: false, elementos: [] });
TestBed.createComponent(App);
const navegacion = router.navigateByUrl('/planificaciones');
await tick();
http.expectOne('/api/auth/me').flush({ userName: 'manager', companyDb: 'TEST', version: '1000' });
await navegacion;
expect(router.url).toBe('/configuracion/metadatos');
});
it('la elección del login se recuerda en el navegador', () => {
const metadata = TestBed.inject(MetadataService);
expect(metadata.verificarAlIngresar()).toBe(true);
metadata.fijarVerificacionAlIngresar(false);
expect(localStorage.getItem('dp.verificarMetadatos')).toBe('false');
});
});
+9
View File
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
imports: [RouterOutlet],
selector: 'app-root',
template: '<router-outlet />',
})
export class App {}
+25
View File
@@ -0,0 +1,25 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthService } from './auth.service';
import { MetadataService } from './metadata.service';
/** Si la sesión de SAP expiró (401) en cualquier llamada, vuelve al login. */
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const metadata = inject(MetadataService);
const router = inject(Router);
return next(req).pipe(
catchError((error: unknown) => {
const esAuth = req.url.startsWith('/api/auth/');
if (error instanceof HttpErrorResponse && error.status === 401 && !esAuth) {
auth.limpiar();
metadata.olvidar();
void router.navigateByUrl('/login');
}
return throwError(() => error);
}),
);
};
+48
View File
@@ -0,0 +1,48 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import type { InfoConexion, LoginRequest, UsuarioSesion } from '@dp/shared';
import { firstValueFrom } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private verificada = false;
readonly usuario = signal<UsuarioSesion | null>(null);
readonly autenticado = computed(() => this.usuario() !== null);
info(): Promise<InfoConexion> {
return firstValueFrom(this.http.get<InfoConexion>('/api/auth/info'));
}
async login(datos: LoginRequest): Promise<void> {
this.usuario.set(await firstValueFrom(this.http.post<UsuarioSesion>('/api/auth/login', datos)));
this.verificada = true;
}
async logout(): Promise<void> {
try {
await firstValueFrom(this.http.post<void>('/api/auth/logout', {}));
} finally {
this.limpiar();
}
}
/** Recupera la sesión existente (cookie) la primera vez; después usa la que está en memoria. */
async asegurarSesion(): Promise<boolean> {
if (!this.verificada) {
this.verificada = true;
try {
this.usuario.set(await firstValueFrom(this.http.get<UsuarioSesion>('/api/auth/me')));
} catch {
this.usuario.set(null);
}
}
return this.autenticado();
}
limpiar(): void {
this.usuario.set(null);
this.verificada = true;
}
}
+36
View File
@@ -0,0 +1,36 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
import { MetadataService } from './metadata.service';
/** Exige sesión de SAP; si no hay, al login. */
export const authGuard: CanActivateFn = async () => {
const router = inject(Router);
return (await inject(AuthService).asegurarSesion()) || router.parseUrl('/login');
};
/** Con sesión activa no tiene sentido volver al login. */
export const guestGuard: CanActivateFn = async () => {
const router = inject(Router);
return !(await inject(AuthService).asegurarSesion()) || router.parseUrl('/');
};
/**
* Antes de usar el proceso, verifica que existan en SAP las tablas, campos y objeto de
* usuario. Se consulta una vez por sesión (y otra vez tras reconstruir); si falta algo,
* lleva a la pantalla de metadatos. Si el usuario desactivó la verificación en el login y
* todavía no hay un estado conocido, se omite (Configuración → Metadatos sigue disponible).
*/
export const metadataGuard: CanActivateFn = async () => {
const router = inject(Router);
const metadata = inject(MetadataService);
if (!metadata.estado() && !metadata.verificarAlIngresar()) return true;
try {
const estado = metadata.estado() ?? (await metadata.verificar());
return estado.completo || router.parseUrl('/configuracion/metadatos');
} catch {
return router.parseUrl('/configuracion/metadatos');
}
};
+13
View File
@@ -0,0 +1,13 @@
import { HttpErrorResponse } from '@angular/common/http';
/** Mensaje legible de un error de la API (incluye el código SAP si viene). */
export function mensajeDeError(error: unknown): string {
if (error instanceof HttpErrorResponse) {
if (error.status === 0) return 'No se pudo conectar con el servidor.';
const body = error.error as { message?: string | string[]; sapCode?: number | string } | null;
const mensaje = Array.isArray(body?.message) ? body.message.join(' ') : body?.message;
if (mensaje) return body?.sapCode != null ? `${mensaje} (SAP ${body.sapCode})` : mensaje;
return `Error ${error.status}: ${error.statusText}`;
}
return error instanceof Error ? error.message : 'Error inesperado.';
}
+41
View File
@@ -0,0 +1,41 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import type { EstadoMetadatos, OpcionesReconstruccion, ResultadoReconstruccion } from '@dp/shared';
import { firstValueFrom } from 'rxjs';
const CLAVE_VERIFICAR = 'dp.verificarMetadatos';
@Injectable({ providedIn: 'root' })
export class MetadataService {
private readonly http = inject(HttpClient);
/**
* Si es true (por defecto), tras cada login se verifica en SAP que existan tablas, campos y objeto.
* El usuario lo elige en el login; se recuerda en este navegador.
*/
readonly verificarAlIngresar = signal(localStorage.getItem(CLAVE_VERIFICAR) !== 'false');
/** Último estado leído de la base SAP; null hasta la primera verificación de la sesión. */
readonly estado = signal<EstadoMetadatos | null>(null);
async verificar(): Promise<EstadoMetadatos> {
const estado = await firstValueFrom(this.http.get<EstadoMetadatos>('/api/metadata/status'));
this.estado.set(estado);
return estado;
}
async reconstruir(opciones: OpcionesReconstruccion): Promise<ResultadoReconstruccion> {
const resultado = await firstValueFrom(this.http.post<ResultadoReconstruccion>('/api/metadata/rebuild', opciones));
this.estado.set(resultado.estado);
return resultado;
}
fijarVerificacionAlIngresar(valor: boolean): void {
this.verificarAlIngresar.set(valor);
localStorage.setItem(CLAVE_VERIFICAR, String(valor));
}
olvidar(): void {
this.estado.set(null);
}
}
@@ -0,0 +1,84 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import type {
Almacen,
CabeceraEditable,
CatalogosDevolucion,
FiltrosPlanificaciones,
PaginaPlanificaciones,
Planificacion,
RespuestaAbierta,
ResultadoCrearDevoluciones,
ResultadoPdf,
ResultadoPegadoApi,
ResultadoProcesar,
ResumenPlanificacion,
} from '@dp/shared';
import { firstValueFrom } from 'rxjs';
/** Llamadas a la API de planificaciones y catálogos. */
@Injectable({ providedIn: 'root' })
export class PlanificacionesService {
private readonly http = inject(HttpClient);
catalogos(): Promise<CatalogosDevolucion> {
return firstValueFrom(this.http.get<CatalogosDevolucion>('/api/catalogos'));
}
/** Solo los almacenes (más liviano que el catálogo completo). */
almacenes(): Promise<Almacen[]> {
return firstValueFrom(this.http.get<Almacen[]>('/api/catalogos/almacenes'));
}
/** Una página del listado; los filtros vacíos no se envían. */
listar(filtros: FiltrosPlanificaciones = {}): Promise<PaginaPlanificaciones> {
let params = new HttpParams();
for (const [clave, valor] of Object.entries(filtros)) {
if (valor !== undefined && valor !== '') params = params.set(clave, String(valor));
}
return firstValueFrom(this.http.get<PaginaPlanificaciones>('/api/planificaciones', { params }));
}
/** La planificación abierta o null si no hay ninguna. */
async abierta(): Promise<ResumenPlanificacion | null> {
return (await firstValueFrom(this.http.get<RespuestaAbierta>('/api/planificaciones/abierta'))).abierta;
}
obtener(docEntry: number): Promise<Planificacion> {
return firstValueFrom(this.http.get<Planificacion>(`/api/planificaciones/${docEntry}`));
}
pegar(whsCode: string, texto: string): Promise<ResultadoPegadoApi> {
return firstValueFrom(this.http.post<ResultadoPegadoApi>('/api/planificaciones/pegar', { whsCode, texto }));
}
procesar(datos: { whsCode: string; docDate: string; texto: string }): Promise<ResultadoProcesar> {
return firstValueFrom(this.http.post<ResultadoProcesar>('/api/planificaciones', datos));
}
/** Rehace el FIFO de una planificación abierta sin Devoluciones: crea una nueva y cierra la anterior. */
reprocesar(docEntry: number, datos: { whsCode: string; docDate: string; texto: string }): Promise<ResultadoProcesar> {
return firstValueFrom(this.http.post<ResultadoProcesar>(`/api/planificaciones/${docEntry}/reprocesar`, datos));
}
actualizarCabecera(docEntry: number, cambios: CabeceraEditable): Promise<Planificacion> {
return firstValueFrom(this.http.patch<Planificacion>(`/api/planificaciones/${docEntry}/cabecera`, cambios));
}
cambiarSeleccion(docEntry: number, lineId: number, applied: boolean): Promise<Planificacion> {
return firstValueFrom(this.http.patch<Planificacion>(`/api/planificaciones/${docEntry}/lineas/${lineId}`, { applied }));
}
crearDevoluciones(docEntry: number): Promise<ResultadoCrearDevoluciones> {
return firstValueFrom(this.http.post<ResultadoCrearDevoluciones>(`/api/planificaciones/${docEntry}/devoluciones`, {}));
}
/** Pide a BES el PDF de la Guía de despacho de cada Devolución de la planificación. */
pdf(docEntry: number, unir: boolean): Promise<ResultadoPdf> {
return firstValueFrom(this.http.post<ResultadoPdf>(`/api/planificaciones/${docEntry}/pdf`, { unir }));
}
cerrar(docEntry: number): Promise<Planificacion> {
return firstValueFrom(this.http.post<Planificacion>(`/api/planificaciones/${docEntry}/cerrar`, {}));
}
}
+82
View File
@@ -0,0 +1,82 @@
import { Component, inject } from '@angular/core';
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { AuthService } from '../core/auth.service';
import { MetadataService } from '../core/metadata.service';
@Component({
selector: 'app-shell',
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
<header class="barra">
<strong class="marca">Devolución a Proveedores</strong>
<nav>
<a routerLink="/planificaciones" routerLinkActive="activo">Planificaciones</a>
<a routerLink="/configuracion/metadatos" routerLinkActive="activo">Configuración</a>
</nav>
@if (auth.usuario(); as u) {
<div class="usuario">
<span>{{ u.userName }}</span>
<span class="muted">· {{ u.companyDb }}</span>
<button class="btn" type="button" (click)="salir()">Salir</button>
</div>
}
</header>
<main class="contenido">
<router-outlet />
</main>
`,
styles: `
.barra {
display: flex;
align-items: center;
gap: 24px;
height: 56px;
padding: 0 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.marca {
font-size: 15px;
}
nav {
display: flex;
gap: 4px;
flex: 1;
}
nav a {
padding: 6px 12px;
border-radius: 6px;
color: var(--text-muted);
text-decoration: none;
font-weight: 500;
}
nav a:hover {
background: var(--surface-alt);
}
nav a.activo {
background: var(--primary-soft);
color: var(--primary);
}
.usuario {
display: flex;
align-items: center;
gap: 8px;
}
.contenido {
max-width: 1280px;
margin: 0 auto;
padding: 24px;
}
`,
})
export class Shell {
protected readonly auth = inject(AuthService);
private readonly metadata = inject(MetadataService);
private readonly router = inject(Router);
async salir(): Promise<void> {
await this.auth.logout();
this.metadata.olvidar();
await this.router.navigateByUrl('/login');
}
}
+67
View File
@@ -0,0 +1,67 @@
import { Component, input } from '@angular/core';
export const PASOS_PROCESO = ['Pegar', 'Procesar', 'Crear devoluciones', 'Descargar PDF'] as const;
/** Indicador de los 4 pasos del proceso. `actual` va de 1 a 4; los anteriores salen como hechos. */
@Component({
selector: 'app-stepper',
template: `
<ol>
@for (paso of pasos; track paso; let i = $index) {
<li [class.hecho]="i + 1 < actual()" [class.actual]="i + 1 === actual()">
<span class="numero">{{ i + 1 < actual() ? '✓' : i + 1 }}</span>
<span>{{ paso }}</span>
</li>
}
</ol>
`,
styles: `
ol {
list-style: none;
display: flex;
gap: 8px;
margin: 0;
padding: 0;
}
li {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text-muted);
font-weight: 500;
}
.numero {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--neutral-soft);
font-size: 12px;
font-weight: 600;
}
li.actual {
border-color: var(--primary);
color: var(--primary);
}
li.actual .numero {
background: var(--primary);
color: #fff;
}
li.hecho {
color: var(--ok);
}
li.hecho .numero {
background: var(--ok-soft);
}
`,
})
export class Stepper {
readonly actual = input.required<number>();
protected readonly pasos = PASOS_PROCESO;
}
+49
View File
@@ -0,0 +1,49 @@
.pantalla {
min-height: 100vh;
display: grid;
place-items: center;
padding: 16px;
}
form {
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
gap: 16px;
padding: 28px;
}
header p {
margin: 4px 0 0;
}
.sociedad {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
background: var(--surface-alt);
border: 1px solid var(--border);
}
.btn {
height: 40px;
}
.opcion {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
input {
margin-top: 3px;
}
small {
display: block;
font-size: 12px;
}
}
+42
View File
@@ -0,0 +1,42 @@
<main class="pantalla">
<form class="card" [formGroup]="form" (ngSubmit)="ingresar()">
<header>
<h1>Devolución a Proveedores</h1>
<p class="muted">Ingrese con su usuario de SAP Business One</p>
</header>
@if (info(); as i) {
<div class="sociedad">
<span class="muted">Sociedad</span>
<strong>{{ i.companyDb }}</strong>
<span class="chip neutral">{{ i.dbType }}</span>
</div>
}
<div class="field">
<label for="userName">Usuario</label>
<input id="userName" formControlName="userName" autocomplete="username" autofocus />
</div>
<div class="field">
<label for="password">Contraseña</label>
<input id="password" type="password" formControlName="password" autocomplete="current-password" />
</div>
<label class="opcion">
<input type="checkbox" formControlName="verificarMetadatos" />
<span>
Verificar los metadatos de SAP al ingresar
<small class="muted">Desactivarlo agiliza el ingreso. Puede verificarlos cuando quiera en Configuración → Metadatos.</small>
</span>
</label>
@if (error(); as e) {
<div class="alert error" role="alert">{{ e }}</div>
}
<button class="btn primary" type="submit" [disabled]="form.invalid || enviando()">
{{ enviando() ? 'Ingresando…' : 'Ingresar' }}
</button>
</form>
</main>
+57
View File
@@ -0,0 +1,57 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import type { InfoConexion } from '@dp/shared';
import { AuthService } from '../../core/auth.service';
import { mensajeDeError } from '../../core/http-error';
import { MetadataService } from '../../core/metadata.service';
@Component({
selector: 'app-login',
imports: [ReactiveFormsModule],
templateUrl: './login.html',
styleUrl: './login.css',
})
export class LoginPage implements OnInit {
private readonly auth = inject(AuthService);
private readonly metadata = inject(MetadataService);
private readonly router = inject(Router);
protected readonly info = signal<InfoConexion | null>(null);
protected readonly error = signal<string | null>(null);
protected readonly enviando = signal(false);
protected readonly form = inject(FormBuilder).nonNullable.group({
userName: ['', Validators.required],
password: ['', Validators.required],
verificarMetadatos: [this.metadata.verificarAlIngresar()],
});
async ngOnInit(): Promise<void> {
try {
this.info.set(await this.auth.info());
} catch (e) {
this.error.set(mensajeDeError(e));
}
}
async ingresar(): Promise<void> {
if (this.form.invalid || this.enviando()) return;
this.enviando.set(true);
this.error.set(null);
try {
const { userName, password, verificarMetadatos } = this.form.getRawValue();
await this.auth.login({ userName, password });
// Salvo que el usuario lo desactive, tras cada login se verifica que existan tablas, campos y objeto en SAP.
this.metadata.fijarVerificacionAlIngresar(verificarMetadatos);
this.metadata.olvidar();
await this.router.navigateByUrl('/');
} catch (e) {
this.error.set(mensajeDeError(e));
this.form.controls.password.reset();
} finally {
this.enviando.set(false);
}
}
}
@@ -0,0 +1,39 @@
:host {
display: flex;
flex-direction: column;
gap: 16px;
}
.encabezado {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
p {
margin: 4px 0 0;
}
}
.acciones {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
p {
margin: 4px 0 8px;
}
}
.check {
display: flex;
align-items: center;
gap: 8px;
color: var(--error);
font-weight: 500;
}
.card h2 {
margin-bottom: 12px;
}
@@ -0,0 +1,101 @@
<section class="encabezado">
<div>
<h1>Metadatos de SAP</h1>
<p class="muted">Tablas, campos y objeto de usuario que el proceso necesita en la base de SAP Business One.</p>
</div>
<button class="btn" type="button" (click)="verificar()" [disabled]="cargando() || reconstruyendo()">
{{ cargando() ? 'Verificando…' : 'Volver a verificar' }}
</button>
</section>
@if (error(); as e) {
<div class="alert error" role="alert">{{ e }}</div>
}
@if (estado(); as est) {
@if (est.completo) {
<div class="alert ok">
La base está al día.
<a routerLink="/planificaciones">Ir a Planificaciones</a>
</div>
} @else {
<div class="alert warn">
Faltan elementos o hay diferencias en la base SAP. El proceso no se puede usar hasta corregirlos.
@if (faltanDependencias()) {
<br />Hay dependencias externas faltantes: las crea otro addon o la localización, no esta aplicación.
}
</div>
}
@if (pendientes().length > 0) {
<section class="card acciones">
<div>
<h2>Reconstruir</h2>
<p class="muted">
Crea en SAP lo que falta y corrige las diferencias ({{ pendientes().length }} elemento(s)).
</p>
@if (hayDestructivos()) {
<label class="check">
<input type="checkbox" [ngModel]="permitirDestructivos()" (ngModelChange)="permitirDestructivos.set($event)" />
Autorizar cambios que borran datos (campos con tipo distinto y campos sobrantes)
</label>
}
</div>
<button class="btn primary" type="button" (click)="reconstruir()" [disabled]="reconstruyendo() || cargando()">
{{ reconstruyendo() ? 'Reconstruyendo…' : 'Reconstruir' }}
</button>
</section>
}
@if (pasos(); as ps) {
<section class="card">
<h2>Resultado</h2>
@if (ps.length === 0) {
<p class="muted">No había nada que hacer.</p>
} @else {
<table class="grid">
<thead>
<tr><th>Acción</th><th>Elemento</th><th>Resultado</th></tr>
</thead>
<tbody>
@for (p of ps; track $index) {
<tr>
<td>{{ p.accion }}</td>
<td><code>{{ p.nombre }}</code></td>
<td>
<span class="chip" [class.ok]="p.ok" [class.falta]="!p.ok">{{ p.ok ? 'OK' : 'Error' }}</span>
@if (p.mensaje) {
<span class="muted"> {{ p.mensaje }}</span>
}
</td>
</tr>
}
</tbody>
</table>
}
</section>
}
@for (g of grupos(); track g.tipo) {
<section class="card">
<h2>{{ g.titulo }}</h2>
<table class="grid">
<thead>
<tr><th>Nombre</th><th>Descripción</th><th>Estado</th><th>Detalle</th></tr>
</thead>
<tbody>
@for (e of g.elementos; track e.nombre) {
<tr>
<td><code>{{ e.nombre }}</code></td>
<td>{{ e.descripcion }}</td>
<td><span class="chip" [class]="claseEstado(e)">{{ e.estado }}</span></td>
<td class="muted">{{ e.detalle }}</td>
</tr>
}
</tbody>
</table>
</section>
}
} @else if (cargando()) {
<p class="muted">Verificando la base SAP…</p>
}
@@ -0,0 +1,87 @@
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import type { EstadoMetadato, ResultadoPaso } from '@dp/shared';
import { mensajeDeError } from '../../core/http-error';
import { MetadataService } from '../../core/metadata.service';
const GRUPOS: { tipo: EstadoMetadato['tipo']; titulo: string }[] = [
{ tipo: 'tabla', titulo: 'Tablas de usuario' },
{ tipo: 'campo', titulo: 'Campos de usuario' },
{ tipo: 'objeto', titulo: 'Objeto de usuario' },
{ tipo: 'dependencia', titulo: 'Dependencias externas (solo verificación)' },
];
@Component({
selector: 'app-metadatos',
imports: [FormsModule, RouterLink],
templateUrl: './metadatos.html',
styleUrl: './metadatos.css',
})
export class MetadatosPage implements OnInit {
private readonly metadata = inject(MetadataService);
protected readonly estado = this.metadata.estado;
protected readonly cargando = signal(false);
protected readonly reconstruyendo = signal(false);
protected readonly error = signal<string | null>(null);
protected readonly pasos = signal<ResultadoPaso[] | null>(null);
protected readonly permitirDestructivos = signal(false);
protected readonly grupos = computed(() => {
const elementos = this.estado()?.elementos ?? [];
return GRUPOS.map((g) => ({ ...g, elementos: elementos.filter((e) => e.tipo === g.tipo) })).filter(
(g) => g.elementos.length > 0,
);
});
protected readonly pendientes = computed(() =>
(this.estado()?.elementos ?? []).filter((e) => e.estado !== 'OK' && e.tipo !== 'dependencia'),
);
protected readonly hayDestructivos = computed(() => this.pendientes().some((e) => e.destructivo));
protected readonly faltanDependencias = computed(() =>
(this.estado()?.elementos ?? []).some((e) => e.tipo === 'dependencia' && e.estado !== 'OK'),
);
async ngOnInit(): Promise<void> {
await this.verificar();
}
async verificar(): Promise<void> {
this.cargando.set(true);
this.error.set(null);
try {
await this.metadata.verificar();
} catch (e) {
this.error.set(mensajeDeError(e));
} finally {
this.cargando.set(false);
}
}
async reconstruir(): Promise<void> {
const destructivo = this.permitirDestructivos() && this.hayDestructivos();
const pregunta = destructivo
? 'Se crearán los elementos que faltan y se BORRARÁN y volverán a crear los campos marcados. Los datos de esas columnas se perderán. ¿Desea continuar?'
: 'Se crearán y corregirán en SAP los elementos que faltan. ¿Desea continuar?';
if (!confirm(pregunta)) return;
this.reconstruyendo.set(true);
this.error.set(null);
this.pasos.set(null);
try {
const resultado = await this.metadata.reconstruir({ permitirDestructivos: this.permitirDestructivos() });
this.pasos.set(resultado.pasos);
} catch (e) {
this.error.set(mensajeDeError(e));
} finally {
this.reconstruyendo.set(false);
}
}
protected claseEstado(e: EstadoMetadato): string {
return e.estado.toLowerCase();
}
}
@@ -0,0 +1,108 @@
:host {
display: flex;
flex-direction: column;
gap: 16px;
}
.encabezado {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
p {
margin: 4px 0 0;
}
}
a.btn {
text-decoration: none;
}
form.card {
display: flex;
flex-direction: column;
gap: 12px;
}
h3 {
font-size: 14px;
margin-top: 4px;
}
.rejilla {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 12px 16px;
}
.nota {
margin: 0;
font-size: 12px;
}
.acciones {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.titulo-grilla {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
}
.scroll {
overflow-x: auto;
}
tr.padre td {
background: var(--surface-alt);
font-weight: 600;
}
tr.hijo.deshabilitada td {
color: var(--text-muted);
}
.centro {
text-align: center;
width: 40px;
}
.texto-error {
color: var(--error);
}
.pie {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.alert ul {
margin: 4px 0 0;
padding-left: 20px;
}
.barra-pdf {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 12px;
}
.opcion-pdf {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.nota a.btn {
display: inline-flex;
}
@@ -0,0 +1,289 @@
@if (cargando()) {
<p class="muted">Cargando…</p>
}
@if (error(); as e) {
<div class="alert error" role="alert">{{ e }}</div>
}
@if (cabecera(); as c) {
<section class="encabezado">
<div>
<h1>Planificación Nº {{ c.docNum }}</h1>
<p class="muted">
Almacén {{ c.whsCode }} ·
<span class="chip" [class.neutral]="c.status === 'O'" [class.ok]="c.status === 'C'">
{{ c.status === 'O' ? 'Abierta' : 'Cerrada' }}
</span>
</p>
</div>
<a class="btn" routerLink="/planificaciones">Volver</a>
</section>
<app-stepper [actual]="paso()" />
@if (advertencias().length > 0) {
<div class="alert warn">
<strong>Avisos del procesamiento:</strong>
<ul>
@for (a of advertencias(); track $index) {
<li>{{ a }}</li>
}
</ul>
</div>
}
@if (aviso(); as a) {
<div class="alert ok">{{ a }}</div>
}
<form class="card" [formGroup]="form" (ngSubmit)="guardarCabecera()">
<h2>Datos de las Devoluciones</h2>
<div class="rejilla">
<div class="field">
<label for="docDate">Fecha</label>
<input id="docDate" type="date" formControlName="docDate" />
</div>
<div class="field">
<label for="series">Serie</label>
<select id="series" formControlName="series">
<option [ngValue]="0" disabled>Seleccione…</option>
@for (s of catalogos()?.series ?? []; track s.series) {
<option [ngValue]="s.series">{{ s.name }}</option>
}
</select>
</div>
<div class="field">
<label for="tipoDespacho">Tipo de despacho</label>
<select id="tipoDespacho" formControlName="tipoDespacho">
<option value="" disabled>Seleccione…</option>
@for (o of catalogos()?.tiposDespacho ?? []; track o.value) {
<option [value]="o.value">{{ o.value }} · {{ o.description }}</option>
}
</select>
</div>
<div class="field">
<label for="indicadorTraslado">Indicador de traslado</label>
<select id="indicadorTraslado" formControlName="indicadorTraslado">
<option value="" disabled>Seleccione…</option>
@for (o of catalogos()?.indicadoresTraslado ?? []; track o.value) {
<option [value]="o.value">{{ o.value }} · {{ o.description }}</option>
}
</select>
</div>
<div class="field">
<label for="printingLines">Líneas por documento</label>
<input id="printingLines" type="number" min="1" formControlName="printingLines" />
</div>
</div>
<h3>Transporte</h3>
<div class="rejilla">
<div class="field">
<label for="rutTransporte">RUT transportista</label>
<input id="rutTransporte" type="text" formControlName="rutTransporte" maxlength="20" />
</div>
<div class="field">
<label for="patente">Patente</label>
<input id="patente" type="text" formControlName="patente" maxlength="20" />
</div>
<div class="field">
<label for="rutChofer">RUT chofer</label>
<input id="rutChofer" type="text" formControlName="rutChofer" maxlength="20" />
</div>
<div class="field">
<label for="nombreChofer">Nombre chofer</label>
<input id="nombreChofer" type="text" formControlName="nombreChofer" maxlength="100" />
</div>
<div class="field">
<label for="fechaSalida">Fecha salida</label>
<input id="fechaSalida" type="date" formControlName="fechaSalida" />
</div>
<div class="field">
<label for="horaSalida">Hora salida</label>
<input id="horaSalida" type="time" formControlName="horaSalida" />
</div>
<div class="field">
<label for="fechaLlegada">Fecha llegada</label>
<input id="fechaLlegada" type="date" formControlName="fechaLlegada" />
</div>
</div>
<p class="muted nota">
RUT transportista, patente y datos del chofer se envían a la Devolución. Las fechas y la hora solo se guardan en la
planificación.
</p>
@if (abierta()) {
<div class="acciones">
<button class="btn" type="submit" [disabled]="form.invalid || ocupado()">Guardar cabecera</button>
</div>
}
</form>
<section class="card">
<div class="titulo-grilla">
<h2>Líneas</h2>
<span class="muted">
{{ aplicables().length }} marcada(s) pendiente(s) · {{ conDestino() }} con Devolución
@if (conError() > 0) {
· <span class="texto-error">{{ conError() }} con error</span>
}
</span>
</div>
<div class="scroll">
<table class="grid">
<thead>
<tr>
<th></th><th>Artículo</th><th class="num">Cantidad</th><th class="num">Stock</th>
<th>Entrada</th><th>Fecha entrada</th><th class="num">Abierta</th><th class="num">Asignada</th>
<th>Proveedor</th><th>Devolución</th>
</tr>
</thead>
<tbody>
@for (l of lineas(); track l.lineId) {
@if (l.tipoFila === 'F') {
<tr class="padre">
<td></td>
<td colspan="1"><code>{{ l.itemCode }}</code> {{ l.itemDescription }}</td>
<td class="num">{{ l.quantity }}</td>
<td class="num">{{ l.onHand }}</td>
<td colspan="6">
<span class="chip" [class.ok]="l.status === 'OK'" [class.falta]="l.status === 'SIN_STOCK'">
{{ l.status === 'SIN_STOCK' ? 'Sin stock' : 'OK' }}
</span>
</td>
</tr>
} @else {
<tr class="hijo" [class.deshabilitada]="!l.applied">
<td class="centro">
<input
type="checkbox"
[checked]="l.applied"
[disabled]="!puedeMarcar(l)"
[attr.aria-label]="'Devolver la línea de la Entrada ' + l.baseRef"
(change)="alternar(l, $any($event.target).checked)"
/>
</td>
<td></td>
<td></td>
<td></td>
<td>Nº {{ l.baseRef }}</td>
<td>{{ l.baseDocDate }}</td>
<td class="num">{{ l.openQty }}</td>
<td class="num">{{ l.allocatedQty }}</td>
<td>{{ l.cardCode }} <span class="muted">{{ l.cardName }}</span></td>
<td>
@if (l.targetEntry != null) {
<span class="chip ok">Devolución {{ l.targetEntry }}</span>
} @else if (l.errMsg) {
<span class="texto-error" [title]="l.errMsg">{{ l.errCode }}: {{ l.errMsg }}</span>
}
</td>
</tr>
}
}
</tbody>
</table>
</div>
</section>
@if (resultado(); as r) {
<section class="card">
<h2>Resultado de la creación</h2>
@if (r.errorGuardado) {
<div class="alert error" role="alert">
Las Devoluciones se crearon, pero no se pudo guardar el detalle en la planificación: {{ r.errorGuardado }}
</div>
}
<table class="grid">
<thead>
<tr><th>#</th><th>Proveedor</th><th class="num">Líneas</th><th>Resultado</th></tr>
</thead>
<tbody>
@for (d of r.devoluciones; track d.numero) {
<tr>
<td>{{ d.numero }}</td>
<td>{{ d.cardCode }} <span class="muted">{{ d.cardName }}</span></td>
<td class="num">{{ d.lineas }}</td>
<td>
@if (d.docEntry != null) {
<span class="chip ok">Devolución Nº {{ d.docNum }}</span>
} @else {
<span class="texto-error">{{ d.error }}</span>
}
</td>
</tr>
}
</tbody>
</table>
</section>
}
@if (conDestino() > 0) {
<section class="card">
<h2>Descargar PDF</h2>
<p class="muted nota">
Guía de despacho electrónica (DTE 52) de cada Devolución creada, desde el web service de BES.
</p>
<div class="barra-pdf">
<label class="opcion-pdf">
<input type="checkbox" [checked]="pdfUnir()" (change)="pdfUnir.set($any($event.target).checked)" [disabled]="pdfCargando()" />
Unir todos los PDF en uno solo
</label>
<button class="btn primary" type="button" (click)="descargarPdf()" [disabled]="pdfCargando()">
{{ pdfCargando() ? 'Consultando BES…' : 'Descargar PDF' }}
</button>
</div>
@if (pdfResultado(); as r) {
<table class="grid">
<thead>
<tr><th>Devolución</th><th>Folio consultado</th><th>Resultado</th></tr>
</thead>
<tbody>
@for (d of r.devoluciones; track d.docEntry) {
<tr>
<td>{{ d.docEntry }}</td>
<td>{{ d.folio ?? '—' }}</td>
<td>
@if (d.error) {
<span class="texto-error">{{ d.error }}</span>
} @else if (r.unificado) {
<span class="chip ok">Incluida en el PDF unificado</span>
} @else if (urlDe(d.folio); as url) {
<a [href]="url" [attr.download]="nombreDe(d.folio)">Descargar {{ nombreDe(d.folio) }}</a>
}
</td>
</tr>
}
</tbody>
</table>
@if (r.unificado && pdfDescargas()[0]; as u) {
<p class="nota">
<a class="btn primary" [href]="u.url" [attr.download]="u.nombre">Descargar {{ u.nombre }}</a>
</p>
}
@if (r.archivos.length === 0) {
<div class="alert error" role="alert">No se pudo descargar ningún PDF. Revise los mensajes de cada Devolución.</div>
} @else if (r.devoluciones.some((d) => d.error)) {
<div class="alert warn">Algunas Devoluciones no tienen PDF (ver detalle). Puede volver a intentarlo.</div>
}
}
</section>
}
<section class="pie">
<span></span>
@if (abierta()) {
<div class="acciones">
@if (conDestino() === 0) {
<a class="btn" routerLink="/planificaciones/nueva" [queryParams]="{ reemplaza: c.docEntry }">Rehacer FIFO</a>
}
<button class="btn" type="button" (click)="cerrar()" [disabled]="ocupado()">Cerrar planificación</button>
<button class="btn primary" type="button" (click)="crearDevoluciones()" [disabled]="!puedeCrear()">
{{ ocupado() ? 'Procesando…' : 'Crear devoluciones' }}
</button>
</div>
}
</section>
}
@@ -0,0 +1,234 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { PlanificacionDetallePage } from './detalle';
const linea = (extra: Record<string, unknown>) => ({
tipoFila: 'S', itemCode: 'A1', itemDescription: 'Libro', quantity: null, onHand: null, status: null,
baseType: 20, baseEntry: 15, baseLine: 0, baseRef: 101, baseDocDate: '2026-01-31', openQty: 10, allocatedQty: 4,
applied: true, cardCode: 'P1', cardName: 'Proveedor', targetType: null, targetEntry: null, errCode: '', errMsg: '',
...extra,
});
const plan = (status: 'O' | 'C', lineas: unknown[]) => ({
cabecera: {
docEntry: 5, docNum: 5, status, whsCode: '01', docDate: '2026-10-12', series: 15, printingLines: 11,
tipoDespacho: '1', indicadorTraslado: '7',
},
lineas,
});
const CATALOGOS = {
almacenes: [],
series: [{ series: 15, name: 'Primario', nextNumber: 33, esPorDefecto: true }],
tiposDespacho: [{ value: '1', description: 'Por cuenta del receptor' }],
indicadoresTraslado: [{ value: '7', description: 'Guía de devolución' }],
};
const tick = () => new Promise((r) => setTimeout(r));
describe('PlanificacionDetallePage', () => {
let http: HttpTestingController;
async function crear(p: object) {
await TestBed.configureTestingModule({
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: new Map([['docEntry', '5']]) } } },
],
}).compileComponents();
http = TestBed.inject(HttpTestingController);
const fixture = TestBed.createComponent(PlanificacionDetallePage);
fixture.detectChanges();
http.expectOne('/api/planificaciones/5').flush(p);
http.expectOne('/api/catalogos').flush(CATALOGOS);
await tick();
fixture.detectChanges();
return fixture;
}
it('una línea con Devolución ya creada tiene el checkbox bloqueado', async () => {
const fixture = await crear(
plan('O', [
linea({ lineId: 1 }),
linea({ lineId: 2, targetType: 21, targetEntry: 33 }),
]),
);
const cajas = (fixture.nativeElement as HTMLElement).querySelectorAll<HTMLInputElement>('tbody input[type=checkbox]');
expect(cajas).toHaveLength(2);
expect(cajas[0].disabled).toBe(false);
expect(cajas[1].disabled).toBe(true);
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Devolución 33');
});
it('desmarcar una línea llama a la API con applied=false', async () => {
const fixture = await crear(plan('O', [linea({ lineId: 1 })]));
const caja = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('tbody input[type=checkbox]')!;
caja.checked = false;
caja.dispatchEvent(new Event('change'));
const req = http.expectOne('/api/planificaciones/5/lineas/1');
expect(req.request.method).toBe('PATCH');
expect(req.request.body).toEqual({ applied: false });
req.flush(plan('O', [linea({ lineId: 1, applied: false })]));
await tick();
fixture.detectChanges();
expect(caja.checked).toBe(false);
});
it('crear devoluciones guarda la cabecera y luego pide crearlas', async () => {
vi.spyOn(globalThis, 'confirm').mockReturnValue(true);
const fixture = await crear(plan('O', [linea({ lineId: 1 })]));
const el: HTMLElement = fixture.nativeElement;
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Crear devoluciones'))!.click();
http.expectOne((r) => r.method === 'PATCH' && r.url === '/api/planificaciones/5/cabecera').flush(plan('O', [linea({ lineId: 1 })]));
await tick();
http.expectOne('/api/planificaciones/5/devoluciones').flush({
devoluciones: [{ numero: 1, cardCode: 'P1', cardName: 'Proveedor', lineas: 1, docEntry: 33, docNum: 33 }],
planificacion: plan('O', [linea({ lineId: 1, targetType: 21, targetEntry: 33 })]),
});
await tick();
fixture.detectChanges();
expect(el.textContent).toContain('Devolución Nº 33');
});
it('una planificación cerrada no permite cambios', async () => {
const fixture = await crear(plan('C', [linea({ lineId: 1, targetType: 21, targetEntry: 33 })]));
const el: HTMLElement = fixture.nativeElement;
const textos = [...el.querySelectorAll('button')].map((b) => b.textContent);
expect(textos.some((t) => t?.includes('Crear devoluciones'))).toBe(false);
expect(textos.some((t) => t?.includes('Cerrar planificación'))).toBe(false);
expect(el.querySelector<HTMLInputElement>('#docDate')!.disabled).toBe(true);
});
const botonRehacer = (fixture: Awaited<ReturnType<typeof crear>>) =>
[...(fixture.nativeElement as HTMLElement).querySelectorAll('a')].find((a) => a.textContent?.includes('Rehacer FIFO'));
it('abierta y sin Devoluciones: ofrece Rehacer FIFO', async () => {
const fixture = await crear(plan('O', [linea({ lineId: 1 })]));
expect(botonRehacer(fixture)).toBeTruthy();
});
it('con una Devolución ya creada no ofrece Rehacer FIFO', async () => {
const fixture = await crear(plan('O', [linea({ lineId: 1, targetType: 21, targetEntry: 33 })]));
expect(botonRehacer(fixture)).toBeFalsy();
});
it('cerrada no ofrece Rehacer FIFO', async () => {
const fixture = await crear(plan('C', [linea({ lineId: 1 })]));
expect(botonRehacer(fixture)).toBeFalsy();
});
describe('descarga de PDF (paso 4)', () => {
const conDevolucion = () => plan('C', [linea({ lineId: 1, targetType: 21, targetEntry: 33 })]);
const botonPdf = (el: HTMLElement) => [...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Descargar PDF'));
const pdfB64 = btoa('%PDF-1.4 prueba');
let clics: string[];
beforeEach(() => {
clics = [];
URL.createObjectURL = vi.fn(() => 'blob:prueba');
URL.revokeObjectURL = vi.fn();
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
clics.push(this.download);
});
});
it('sin Devoluciones creadas no se ofrece', async () => {
const fixture = await crear(plan('O', [linea({ lineId: 1 })]));
expect(botonPdf(fixture.nativeElement)).toBeUndefined();
});
it('con Devoluciones se ofrece, incluso con la planificación cerrada', async () => {
const fixture = await crear(conDevolucion());
expect(botonPdf(fixture.nativeElement)).toBeDefined();
});
it('un solo PDF: lo pide con unir=true y lo descarga directo', async () => {
const fixture = await crear(conDevolucion());
const el: HTMLElement = fixture.nativeElement;
botonPdf(el)!.click();
const req = http.expectOne('/api/planificaciones/5/pdf');
expect(req.request.body).toEqual({ unir: true });
req.flush({ devoluciones: [{ docEntry: 33, folio: 5001 }], archivos: [{ nombre: 'DTE_5001.pdf', base64: pdfB64, folios: [5001] }], unificado: false });
await tick();
fixture.detectChanges();
expect(clics).toEqual(['DTE_5001.pdf']);
expect(el.textContent).toContain('Descargar DTE_5001.pdf');
});
it('varios PDF sin unir: no descarga solo, deja un enlace por Devolución y muestra el error de la que falló', async () => {
const fixture = await crear(plan('C', [linea({ lineId: 1, targetEntry: 33 }), linea({ lineId: 2, targetEntry: 34 })]));
const el: HTMLElement = fixture.nativeElement;
el.querySelector<HTMLInputElement>('.opcion-pdf input')!.click();
botonPdf(el)!.click();
const req = http.expectOne('/api/planificaciones/5/pdf');
expect(req.request.body).toEqual({ unir: false });
req.flush({
devoluciones: [{ docEntry: 33, folio: 5001 }, { docEntry: 34, folio: 5002, error: 'Documento no emitido' }],
archivos: [{ nombre: 'DTE_5001.pdf', base64: pdfB64, folios: [5001] }],
unificado: false,
});
await tick();
fixture.detectChanges();
expect(el.textContent).toContain('Documento no emitido');
expect(el.textContent).toContain('Algunas Devoluciones no tienen PDF');
expect(el.querySelectorAll('table.grid a[download]')).toHaveLength(1);
});
it('unificado: un único enlace con todos los folios', async () => {
const fixture = await crear(plan('C', [linea({ lineId: 1, targetEntry: 33 }), linea({ lineId: 2, targetEntry: 34 })]));
const el: HTMLElement = fixture.nativeElement;
botonPdf(el)!.click();
http.expectOne('/api/planificaciones/5/pdf').flush({
devoluciones: [{ docEntry: 33, folio: 5001 }, { docEntry: 34, folio: 5002 }],
archivos: [{ nombre: 'DTE_Unificado_5.pdf', base64: pdfB64, folios: [5001, 5002] }],
unificado: true,
});
await tick();
fixture.detectChanges();
expect(clics).toEqual(['DTE_Unificado_5.pdf']);
expect(el.textContent).toContain('Incluida en el PDF unificado');
});
it('si el servicio no entrega ningún PDF lo dice', async () => {
const fixture = await crear(conDevolucion());
const el: HTMLElement = fixture.nativeElement;
botonPdf(el)!.click();
http.expectOne('/api/planificaciones/5/pdf').flush({ devoluciones: [{ docEntry: 33, folio: 5001, error: 'Usuario inválido' }], archivos: [], unificado: false });
await tick();
fixture.detectChanges();
expect(el.textContent).toContain('No se pudo descargar ningún PDF');
expect(clics).toEqual([]);
});
it('un error de la API (por ejemplo sin configuración DTE) se muestra', async () => {
const fixture = await crear(conDevolucion());
const el: HTMLElement = fixture.nativeElement;
botonPdf(el)!.click();
http.expectOne('/api/planificaciones/5/pdf').flush({ message: 'No se encontró configuración en la tabla @DTE_CONFC' }, { status: 400, statusText: 'Bad Request' });
await tick();
fixture.detectChanges();
expect(el.querySelector('.alert.error')?.textContent).toContain('@DTE_CONFC');
});
});
});
@@ -0,0 +1,224 @@
import { Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import type {
CatalogosDevolucion,
LineaPlanificacion,
Planificacion,
ResultadoCrearDevoluciones,
ResultadoPdf,
} from '@dp/shared';
import { consolidarDocumentos, extraerLineasAplicables, puedeCambiarSeleccion } from '@dp/shared';
import { mensajeDeError } from '../../../core/http-error';
import { PlanificacionesService } from '../../../core/planificaciones.service';
import { Stepper } from '../../../layout/stepper';
interface DescargaPdf {
nombre: string;
folios: number[];
url: string;
}
function base64ABytes(base64: string): Uint8Array<ArrayBuffer> {
const binario = atob(base64);
const bytes = new Uint8Array(binario.length);
for (let i = 0; i < binario.length; i++) bytes[i] = binario.charCodeAt(i);
return bytes;
}
function iniciarDescarga(d: DescargaPdf): void {
const a = document.createElement('a');
a.href = d.url;
a.download = d.nombre;
a.click();
}
@Component({
selector: 'app-planificacion-detalle',
imports: [ReactiveFormsModule, RouterLink, Stepper],
templateUrl: './detalle.html',
styleUrl: './detalle.css',
})
export class PlanificacionDetallePage implements OnInit, OnDestroy {
private readonly api = inject(PlanificacionesService);
private readonly route = inject(ActivatedRoute);
private docEntry = 0;
protected readonly plan = signal<Planificacion | null>(null);
protected readonly catalogos = signal<CatalogosDevolucion | null>(null);
protected readonly cargando = signal(true);
protected readonly ocupado = signal(false);
protected readonly error = signal<string | null>(null);
protected readonly aviso = signal<string | null>(null);
protected readonly advertencias = signal<string[]>([]);
protected readonly resultado = signal<ResultadoCrearDevoluciones | null>(null);
// Paso 4: descarga de PDF
protected readonly pdfUnir = signal(true);
protected readonly pdfCargando = signal(false);
protected readonly pdfResultado = signal<ResultadoPdf | null>(null);
protected readonly pdfDescargas = signal<DescargaPdf[]>([]);
protected readonly form = inject(FormBuilder).nonNullable.group({
docDate: ['', Validators.required],
series: [0, [Validators.required, Validators.min(1)]],
tipoDespacho: ['', Validators.required],
indicadorTraslado: ['', Validators.required],
printingLines: [11, [Validators.required, Validators.min(1)]],
rutTransporte: [''],
patente: [''],
rutChofer: [''],
nombreChofer: [''],
fechaSalida: [''],
horaSalida: [''],
fechaLlegada: [''],
});
protected readonly cabecera = computed(() => this.plan()?.cabecera ?? null);
protected readonly abierta = computed(() => this.cabecera()?.status === 'O');
protected readonly lineas = computed(() => this.plan()?.lineas ?? []);
protected readonly aplicables = computed(() => extraerLineasAplicables(this.lineas()));
protected readonly nDocumentos = computed(() =>
consolidarDocumentos(this.aplicables(), this.cabecera()?.printingLines ?? 0).length,
);
protected readonly conDestino = computed(() => this.lineas().filter((l) => l.tipoFila === 'S' && l.targetEntry != null).length);
protected readonly conError = computed(() => this.aplicables().filter((l) => l.errMsg).length);
/** 3 mientras falten Devoluciones por crear; 4 (PDF) cuando ya no queda nada pendiente. */
protected readonly paso = computed(() => (this.conDestino() > 0 && this.aplicables().length === 0 ? 4 : 3));
protected readonly puedeCrear = computed(
() => this.abierta() && this.aplicables().length > 0 && this.form.valid && !this.ocupado(),
);
ngOnDestroy(): void {
this.liberarDescargas();
}
async ngOnInit(): Promise<void> {
this.docEntry = Number(this.route.snapshot.paramMap.get('docEntry'));
this.advertencias.set((history.state as { advertencias?: string[] } | null)?.advertencias ?? []);
try {
const [plan, catalogos] = await Promise.all([this.api.obtener(this.docEntry), this.api.catalogos()]);
this.catalogos.set(catalogos);
this.mostrar(plan);
} catch (e) {
this.error.set(mensajeDeError(e));
} finally {
this.cargando.set(false);
}
}
/** Reemplaza el estado en pantalla con lo que devolvió la API. */
private mostrar(plan: Planificacion): void {
this.plan.set(plan);
const c = plan.cabecera;
this.form.reset({
docDate: c.docDate,
series: c.series,
tipoDespacho: c.tipoDespacho,
indicadorTraslado: c.indicadorTraslado,
printingLines: c.printingLines || 11,
rutTransporte: c.rutTransporte ?? '',
patente: c.patente ?? '',
rutChofer: c.rutChofer ?? '',
nombreChofer: c.nombreChofer ?? '',
fechaSalida: c.fechaSalida ?? '',
horaSalida: c.horaSalida ?? '',
fechaLlegada: c.fechaLlegada ?? '',
});
if (plan.cabecera.status === 'C') this.form.disable();
else this.form.enable();
}
protected puedeMarcar(l: LineaPlanificacion): boolean {
return this.abierta() && puedeCambiarSeleccion(l) && !this.ocupado();
}
protected async alternar(l: LineaPlanificacion, marcada: boolean): Promise<void> {
if (l.lineId == null) return;
await this.ejecutar(async () => this.mostrar(await this.api.cambiarSeleccion(this.docEntry, l.lineId!, marcada)));
}
protected async guardarCabecera(): Promise<void> {
if (this.form.invalid) return;
await this.ejecutar(async () => {
this.mostrar(await this.api.actualizarCabecera(this.docEntry, this.form.getRawValue()));
this.aviso.set('Cabecera guardada.');
});
}
protected async crearDevoluciones(): Promise<void> {
if (!this.puedeCrear()) return;
const lineas = this.aplicables().length;
const docs = this.nDocumentos();
if (!confirm(`Se crearán ${docs} Devolución(es) con ${lineas} línea(s) marcada(s) en SAP. ¿Desea continuar?`)) return;
await this.ejecutar(async () => {
// Se guarda primero la cabecera para que las Devoluciones salgan con lo que se ve en pantalla.
await this.api.actualizarCabecera(this.docEntry, this.form.getRawValue());
const r = await this.api.crearDevoluciones(this.docEntry);
this.resultado.set(r);
this.mostrar(r.planificacion);
});
}
/** Pide los PDF de las Devoluciones. Con un solo archivo lo descarga directo; con varios deja un enlace por archivo. */
protected async descargarPdf(): Promise<void> {
this.pdfCargando.set(true);
this.error.set(null);
this.liberarDescargas();
this.pdfResultado.set(null);
try {
const r = await this.api.pdf(this.docEntry, this.pdfUnir());
this.pdfResultado.set(r);
const descargas = r.archivos.map((a) => ({
nombre: a.nombre,
folios: a.folios,
url: URL.createObjectURL(new Blob([base64ABytes(a.base64)], { type: 'application/pdf' })),
}));
this.pdfDescargas.set(descargas);
if (descargas.length === 1) iniciarDescarga(descargas[0]);
} catch (e) {
this.error.set(mensajeDeError(e));
} finally {
this.pdfCargando.set(false);
}
}
protected urlDe(folio: number | undefined): string | null {
return this.pdfDescargas().find((d) => folio != null && d.folios.length === 1 && d.folios[0] === folio)?.url ?? null;
}
protected nombreDe(folio: number | undefined): string {
return this.pdfDescargas().find((d) => folio != null && d.folios.length === 1 && d.folios[0] === folio)?.nombre ?? '';
}
private liberarDescargas(): void {
for (const d of this.pdfDescargas()) URL.revokeObjectURL(d.url);
this.pdfDescargas.set([]);
}
protected async cerrar(): Promise<void> {
const pendientes = this.aplicables().length;
const aviso = pendientes > 0 ? ` Quedan ${pendientes} línea(s) marcada(s) sin Devolución.` : '';
if (!confirm(`Se cerrará la planificación y ya no se podrá modificar.${aviso} ¿Desea continuar?`)) return;
await this.ejecutar(async () => this.mostrar(await this.api.cerrar(this.docEntry)));
}
private async ejecutar(accion: () => Promise<void>): Promise<void> {
this.ocupado.set(true);
this.error.set(null);
this.aviso.set(null);
try {
await accion();
} catch (e) {
this.error.set(mensajeDeError(e));
} finally {
this.ocupado.set(false);
}
}
}
@@ -0,0 +1,87 @@
:host {
display: flex;
flex-direction: column;
gap: 16px;
}
.encabezado {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
p {
margin: 4px 0 0;
}
}
a.btn {
text-decoration: none;
}
.formulario {
display: flex;
flex-direction: column;
gap: 16px;
}
.fila {
display: flex;
gap: 16px;
flex-wrap: wrap;
.field {
min-width: 240px;
}
}
select,
textarea {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
font: inherit;
color: var(--text);
background: var(--surface);
&:focus {
outline: 2px solid var(--primary-soft);
border-color: var(--primary);
}
}
select {
height: 38px;
}
textarea {
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
resize: vertical;
}
.ayuda {
font-size: 12px;
}
.acciones,
.acciones-finales {
display: flex;
gap: 8px;
}
.acciones-finales {
justify-content: flex-end;
}
.num {
text-align: right;
}
.nota {
margin: 12px 0 0;
}
.alert ul {
margin: 4px 0 0;
padding-left: 20px;
}
@@ -0,0 +1,123 @@
<section class="encabezado">
<div>
<h1>{{ reemplaza() ? 'Rehacer planificación Nº ' + reemplazaNumero() : 'Nueva planificación' }}</h1>
<p class="muted">Pegue los artículos y cantidades desde Excel; se valida el stock del almacén antes de procesar.</p>
</div>
<a class="btn" routerLink="/planificaciones">Volver</a>
</section>
<app-stepper [actual]="paso()" />
@if (error(); as e) {
<div class="alert error" role="alert">{{ e }}</div>
}
@if (reemplaza()) {
<div class="alert warn">
Se creará una planificación nueva con este listado y la Nº {{ reemplazaNumero() }} se cerrará. Se conservan la serie, el
despacho, el traslado y los datos de transporte ya cargados. Edite el listado, o el almacén, antes de procesar.
</div>
}
@if (bloqueada() && abierta(); as a) {
<div class="alert warn">
Hay una planificación abierta (Nº {{ a.docNum }}). Ciérrela antes de crear otra.
<a [routerLink]="['/planificaciones', a.docEntry]">Ir a la planificación</a>
</div>
}
<section class="card formulario">
<div class="fila">
<div class="field">
<label for="almacen">Almacén</label>
<select id="almacen" [ngModel]="whsCode()" (ngModelChange)="cambiarAlmacen($event)" [disabled]="cargando() || bloqueada()">
<option value="" disabled>Seleccione…</option>
@for (a of almacenes(); track a.code) {
<option [value]="a.code">{{ a.code }} · {{ a.name }}</option>
}
</select>
</div>
<div class="field">
<label for="fecha">Fecha del documento</label>
<input id="fecha" type="date" [ngModel]="docDate()" (ngModelChange)="docDate.set($event)" [disabled]="bloqueada()" />
</div>
</div>
<div class="field">
<label for="texto">Artículos (código y cantidad, separados por tabulador)</label>
<textarea
id="texto"
rows="8"
spellcheck="false"
placeholder="Copie las columnas desde Excel y péguelas aquí.&#10;Código&#9;Cantidad&#10;A00001&#9;5"
[ngModel]="texto()"
(ngModelChange)="cambiarTexto($event)"
[disabled]="bloqueada()"
></textarea>
<span class="muted ayuda">
Con 2 columnas se toma la segunda como cantidad; con 3 o más, la tercera (la segunda se ignora). Una primera fila sin
cantidad numérica se toma como encabezado.
</span>
</div>
<div class="acciones">
<button class="btn" type="button" (click)="pegarDesdePortapapeles()" [disabled]="bloqueada() || validando() || procesando()">
Pegar desde el portapapeles
</button>
<button class="btn" type="button" (click)="validar()" [disabled]="!puedeValidar()">
{{ validando() ? 'Validando…' : 'Validar' }}
</button>
</div>
</section>
@if (resultado(); as r) {
@if (r.errores.length > 0) {
<div class="alert error" role="alert">
<strong>Corrija antes de procesar:</strong>
<ul>
@for (e of r.errores; track $index) {
<li>{{ e }}</li>
}
</ul>
</div>
}
@if (r.lineas.length > 0) {
<section class="card">
<h2>Artículos validados</h2>
<table class="grid">
<thead>
<tr>
<th>Código</th><th>Descripción</th><th class="num">A devolver</th><th class="num">Stock</th><th>Estado</th>
</tr>
</thead>
<tbody>
@for (l of r.lineas; track l.itemCode) {
<tr>
<td><code>{{ l.itemCode }}</code></td>
<td>{{ l.itemDescription }}</td>
<td class="num">{{ l.quantity }}</td>
<td class="num">{{ l.onHand }}</td>
<td>
<span class="chip" [class.ok]="l.status === 'OK'" [class.falta]="l.status === 'SIN_STOCK'">
{{ l.status === 'OK' ? 'OK' : 'Sin stock' }}
</span>
</td>
</tr>
}
</tbody>
</table>
@if (sinStock() > 0) {
<p class="muted nota">
{{ sinStock() }} artículo(s) sin stock suficiente quedarán fuera al procesar.
</p>
}
</section>
}
<section class="acciones-finales">
<button class="btn primary" type="button" (click)="procesar()" [disabled]="!puedeProcesar()">
{{ procesando() ? 'Procesando…' : reemplaza() ? 'Procesar y reemplazar' : 'Procesar (asignación FIFO)' }}
</button>
</section>
}
@@ -0,0 +1,222 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { NuevaPlanificacionPage } from './nueva';
const CATALOGOS = {
almacenes: [{ code: '01', name: 'Almacén general' }],
series: [],
tiposDespacho: [],
indicadoresTraslado: [],
};
const padre = (itemCode: string, status: 'OK' | 'SIN_STOCK') => ({
tipoFila: 'F', itemCode, itemDescription: 'Libro', quantity: 5, onHand: 10, status,
baseType: null, baseEntry: null, baseLine: null, baseRef: null, baseDocDate: null, openQty: null, allocatedQty: null,
applied: false, cardCode: '', cardName: '', targetType: null, targetEntry: null, errCode: '', errMsg: '',
});
const tick = () => new Promise((r) => setTimeout(r));
describe('NuevaPlanificacionPage', () => {
let http: HttpTestingController;
async function crear(abierta: object | null = null, reemplaza?: { docEntry: number; plan: object }) {
await TestBed.configureTestingModule({
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
...(reemplaza
? [{ provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({ reemplaza: String(reemplaza.docEntry) }) } } }]
: []),
],
}).compileComponents();
http = TestBed.inject(HttpTestingController);
const fixture = TestBed.createComponent(NuevaPlanificacionPage);
fixture.detectChanges();
http.expectOne('/api/catalogos').flush(CATALOGOS);
http.expectOne('/api/planificaciones/abierta').flush({ abierta });
if (reemplaza) http.expectOne(`/api/planificaciones/${reemplaza.docEntry}`).flush(reemplaza.plan);
await tick();
fixture.detectChanges();
return fixture;
}
async function escribir(fixture: Awaited<ReturnType<typeof crear>>, texto: string) {
const el: HTMLElement = fixture.nativeElement;
const almacen = el.querySelector<HTMLSelectElement>('#almacen')!;
almacen.value = '01';
almacen.dispatchEvent(new Event('change'));
const area = el.querySelector<HTMLTextAreaElement>('#texto')!;
area.value = texto;
area.dispatchEvent(new Event('input'));
await tick();
fixture.detectChanges();
return el;
}
it('valida lo pegado y habilita Procesar solo si no hay errores', async () => {
const fixture = await crear();
const el = await escribir(fixture, 'A1\t5');
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === 'Validar')!.click();
const req = http.expectOne('/api/planificaciones/pegar');
expect(req.request.body).toEqual({ whsCode: '01', texto: 'A1\t5' });
req.flush({ lineas: [padre('A1', 'OK')], errores: [] });
await tick();
fixture.detectChanges();
expect(el.textContent).toContain('A1');
const procesar = [...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Procesar'));
expect(procesar?.disabled).toBe(false);
});
it('el botón Pegar lee el portapapeles y valida', async () => {
const leer = vi.fn().mockResolvedValue('A1\t5');
Object.defineProperty(navigator, 'clipboard', { value: { readText: leer }, configurable: true });
const fixture = await crear();
const el = await escribir(fixture, '');
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Pegar'))!.click();
await tick();
const req = http.expectOne('/api/planificaciones/pegar');
expect(req.request.body).toEqual({ whsCode: '01', texto: 'A1\t5' });
req.flush({ lineas: [padre('A1', 'OK')], errores: [] });
await tick();
fixture.detectChanges();
expect(leer).toHaveBeenCalled();
expect(el.querySelector<HTMLTextAreaElement>('#texto')!.value).toBe('A1\t5');
expect(el.textContent).toContain('A1');
});
it('si el navegador niega el portapapeles muestra cómo pegar a mano', async () => {
Object.defineProperty(navigator, 'clipboard', { value: { readText: vi.fn().mockRejectedValue(new Error('denied')) }, configurable: true });
const fixture = await crear();
const el = await escribir(fixture, '');
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Pegar'))!.click();
await tick();
fixture.detectChanges();
expect(el.textContent).toContain('No se pudo leer el portapapeles');
http.expectNone('/api/planificaciones/pegar');
});
it('con errores de validación no deja procesar', async () => {
const fixture = await crear();
const el = await escribir(fixture, 'X9\t5');
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === 'Validar')!.click();
http.expectOne('/api/planificaciones/pegar').flush({ lineas: [], errores: ['X9: el artículo no existe.'] });
await tick();
fixture.detectChanges();
expect(el.textContent).toContain('X9: el artículo no existe.');
const procesar = [...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Procesar'));
expect(procesar?.disabled).toBe(true);
});
it('procesa y navega al detalle de la planificación creada', async () => {
const fixture = await crear();
const router = TestBed.inject(Router);
const navegar = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const el = await escribir(fixture, 'A1\t5');
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === 'Validar')!.click();
http.expectOne('/api/planificaciones/pegar').flush({ lineas: [padre('A1', 'OK')], errores: [] });
await tick();
fixture.detectChanges();
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Procesar'))!.click();
const req = http.expectOne('/api/planificaciones');
expect(req.request.body).toMatchObject({ whsCode: '01', texto: 'A1\t5' });
req.flush({ planificacion: { cabecera: { docEntry: 7 }, lineas: [] }, advertencias: ['aviso'] });
await tick();
expect(navegar).toHaveBeenCalledWith(['/planificaciones', 7], { state: { advertencias: ['aviso'] } });
});
it('si falla la consulta de la abierta, los almacenes igual se muestran', async () => {
await TestBed.configureTestingModule({
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
http = TestBed.inject(HttpTestingController);
const fixture = TestBed.createComponent(NuevaPlanificacionPage);
fixture.detectChanges();
http.expectOne('/api/catalogos').flush(CATALOGOS);
http.expectOne('/api/planificaciones/abierta').flush({ message: 'boom' }, { status: 500, statusText: 'Error' });
await tick();
fixture.detectChanges();
const el: HTMLElement = fixture.nativeElement;
expect(el.querySelectorAll('#almacen option')).toHaveLength(2);
expect(el.textContent).toContain('No se pudo consultar la planificación abierta');
});
it('con una planificación abierta bloquea el formulario', async () => {
const fixture = await crear({ docEntry: 3, docNum: 3, status: 'O', whsCode: '01', docDate: '2026-10-12' });
const el: HTMLElement = fixture.nativeElement;
expect(el.textContent).toContain('Hay una planificación abierta (Nº 3)');
expect(el.querySelector<HTMLSelectElement>('#almacen')!.disabled).toBe(true);
});
describe('rehacer una planificación (?reemplaza=)', () => {
const resumen = { docEntry: 3, docNum: 3, status: 'O', whsCode: '02', docDate: '2026-10-10' };
const planAbierta = (extra: Record<string, unknown> = {}) => ({
cabecera: { docEntry: 3, docNum: 3, status: 'O', whsCode: '02', docDate: '2026-10-10' },
lineas: [padre('A1', 'OK'), { ...padre('', 'OK'), tipoFila: 'S', ...extra }],
});
it('precarga almacén, fecha y listado, y no la bloquea la propia planificación abierta', async () => {
const fixture = await crear(resumen, { docEntry: 3, plan: planAbierta() });
const el: HTMLElement = fixture.nativeElement;
expect(el.textContent).toContain('Rehacer planificación Nº 3');
expect(el.querySelector<HTMLTextAreaElement>('#texto')!.value).toBe('A1\t5');
expect(el.querySelector<HTMLSelectElement>('#almacen')!.disabled).toBe(false);
expect(el.textContent).not.toContain('Ciérrela antes de crear otra');
});
it('procesar llama a /reprocesar y avisa que la anterior quedó cerrada', async () => {
const fixture = await crear(resumen, { docEntry: 3, plan: planAbierta() });
const navegar = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
const el: HTMLElement = fixture.nativeElement;
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === 'Validar')!.click();
http.expectOne('/api/planificaciones/pegar').flush({ lineas: [padre('A1', 'OK')], errores: [] });
await tick();
fixture.detectChanges();
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Procesar y reemplazar'))!.click();
const req = http.expectOne('/api/planificaciones/3/reprocesar');
expect(req.request.body).toMatchObject({ texto: 'A1\t5' });
req.flush({ planificacion: { cabecera: { docEntry: 4 }, lineas: [] }, advertencias: [], reemplazada: { docNum: 3 } });
await tick();
expect(navegar).toHaveBeenCalledWith(['/planificaciones', 4], {
state: { advertencias: ['Esta planificación reemplaza a la Nº 3, que quedó cerrada.'] },
});
});
it('si la anterior ya tiene una Devolución, no se puede rehacer', async () => {
const fixture = await crear(resumen, { docEntry: 3, plan: planAbierta({ targetEntry: 33, targetType: 21 }) });
const el: HTMLElement = fixture.nativeElement;
expect(el.textContent).toContain('ya no se puede rehacer');
expect(el.textContent).not.toContain('Rehacer planificación');
expect(el.querySelector<HTMLTextAreaElement>('#texto')!.value).toBe('');
});
it('si la anterior está cerrada, no se puede rehacer', async () => {
const plan = { ...planAbierta(), cabecera: { docEntry: 3, docNum: 3, status: 'C', whsCode: '02', docDate: '2026-10-10' } };
const fixture = await crear(null, { docEntry: 3, plan });
expect((fixture.nativeElement as HTMLElement).textContent).toContain('ya no se puede rehacer');
});
});
});
@@ -0,0 +1,178 @@
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import type { Almacen, Planificacion, ResultadoPegadoApi, ResumenPlanificacion } from '@dp/shared';
import { mensajeDeError } from '../../../core/http-error';
import { PlanificacionesService } from '../../../core/planificaciones.service';
import { Stepper } from '../../../layout/stepper';
/** Fecha de hoy en formato yyyy-MM-dd según la hora local (no UTC). */
function hoy(): string {
const d = new Date();
const dos = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${dos(d.getMonth() + 1)}-${dos(d.getDate())}`;
}
@Component({
selector: 'app-nueva-planificacion',
imports: [FormsModule, RouterLink, Stepper],
templateUrl: './nueva.html',
styleUrl: './nueva.css',
})
export class NuevaPlanificacionPage implements OnInit {
private readonly api = inject(PlanificacionesService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
protected readonly almacenes = signal<Almacen[]>([]);
protected readonly abierta = signal<ResumenPlanificacion | null>(null);
protected readonly cargando = signal(true);
/** docEntry de la planificación que se está rehaciendo (query param `reemplaza`), si aplica. */
protected readonly reemplaza = signal<number | null>(null);
protected readonly reemplazaNumero = signal<number | null>(null);
/** Hay otra planificación abierta distinta de la que se rehace: no se puede continuar. */
protected readonly bloqueada = computed(() => {
const a = this.abierta();
return !!a && a.docEntry !== this.reemplaza();
});
protected readonly whsCode = signal('');
protected readonly docDate = signal(hoy());
protected readonly texto = signal('');
protected readonly resultado = signal<ResultadoPegadoApi | null>(null);
protected readonly validando = signal(false);
protected readonly procesando = signal(false);
protected readonly error = signal<string | null>(null);
protected readonly paso = computed(() => (this.resultado() ? 2 : 1));
protected readonly puedeValidar = computed(
() => !!this.whsCode() && this.texto().trim().length > 0 && !this.validando() && !this.procesando() && !this.bloqueada(),
);
protected readonly conStock = computed(() => (this.resultado()?.lineas ?? []).filter((l) => l.status === 'OK').length);
protected readonly sinStock = computed(() => (this.resultado()?.lineas ?? []).filter((l) => l.status === 'SIN_STOCK').length);
protected readonly puedeProcesar = computed(() => {
const r = this.resultado();
return !!r && r.errores.length === 0 && this.conStock() > 0 && !this.procesando() && !this.bloqueada();
});
async ngOnInit(): Promise<void> {
const reemplaza = Number(this.route.snapshot.queryParamMap.get('reemplaza')) || null;
this.reemplaza.set(reemplaza);
// Cada carga va por separado: si falla una, la otra se muestra igual y el error dice cuál.
const [catalogos, abierta, anterior] = await Promise.allSettled([
this.api.catalogos(),
this.api.abierta(),
reemplaza ? this.api.obtener(reemplaza) : Promise.resolve(null),
]);
const errores: string[] = [];
if (catalogos.status === 'fulfilled') this.almacenes.set(catalogos.value.almacenes);
else errores.push(`No se pudieron cargar los almacenes: ${mensajeDeError(catalogos.reason)}`);
if (abierta.status === 'fulfilled') this.abierta.set(abierta.value);
else errores.push(`No se pudo consultar la planificación abierta: ${mensajeDeError(abierta.reason)}`);
if (anterior.status === 'fulfilled' && anterior.value) this.precargar(anterior.value);
else if (anterior.status === 'rejected') errores.push(`No se pudo leer la planificación a rehacer: ${mensajeDeError(anterior.reason)}`);
if (errores.length > 0) this.error.set(errores.join(' '));
this.cargando.set(false);
}
/** Deja listo el formulario con el almacén, la fecha y el listado de la planificación que se rehace. */
private precargar(plan: Planificacion): void {
if (plan.cabecera.status !== 'O' || plan.lineas.some((l) => l.targetEntry != null)) {
this.reemplaza.set(null);
this.error.set(`La planificación Nº ${plan.cabecera.docNum} ya no se puede rehacer (está cerrada o tiene Devoluciones creadas).`);
return;
}
this.reemplazaNumero.set(plan.cabecera.docNum ?? null);
this.whsCode.set(plan.cabecera.whsCode);
this.docDate.set(plan.cabecera.docDate || hoy());
this.texto.set(
plan.lineas
.filter((l) => l.tipoFila === 'F')
.map((l) => `${l.itemCode}\t${l.quantity}`)
.join('\n'),
);
}
/** Cualquier cambio en el almacén o en el texto invalida la validación anterior. */
protected cambiarAlmacen(codigo: string): void {
this.whsCode.set(codigo);
this.resultado.set(null);
}
protected cambiarTexto(texto: string): void {
this.texto.set(texto);
this.resultado.set(null);
}
/** Lee el portapapeles del navegador, lo deja en el área de texto y lo valida. */
async pegarDesdePortapapeles(): Promise<void> {
if (!this.whsCode()) {
this.error.set('Seleccione primero el almacén.');
return;
}
this.error.set(null);
let texto: string;
try {
texto = await navigator.clipboard.readText();
} catch {
this.error.set(
'No se pudo leer el portapapeles. Permita el acceso cuando el navegador lo pida, o pegue con Ctrl/Cmd+V dentro del cuadro de texto.',
);
return;
}
if (texto.trim().length === 0) {
this.error.set('El portapapeles está vacío. Copie primero las celdas desde Excel.');
return;
}
this.cambiarTexto(texto);
await this.validar();
}
async validar(): Promise<void> {
if (!this.puedeValidar()) return;
this.validando.set(true);
this.error.set(null);
try {
this.resultado.set(await this.api.pegar(this.whsCode(), this.texto()));
} catch (e) {
this.resultado.set(null);
this.error.set(mensajeDeError(e));
} finally {
this.validando.set(false);
}
}
async procesar(): Promise<void> {
if (!this.puedeProcesar()) return;
this.procesando.set(true);
this.error.set(null);
try {
const datos = { whsCode: this.whsCode(), docDate: this.docDate(), texto: this.texto() };
const anterior = this.reemplaza();
const { planificacion, advertencias, reemplazada } = anterior
? await this.api.reprocesar(anterior, datos)
: await this.api.procesar(datos);
const avisos = [...advertencias];
if (reemplazada?.errorCierre) {
avisos.push(
`La planificación Nº ${reemplazada.docNum} no se pudo cerrar (${reemplazada.errorCierre}). Ciérrela a mano para poder crear otras.`,
);
} else if (reemplazada) {
avisos.push(`Esta planificación reemplaza a la Nº ${reemplazada.docNum}, que quedó cerrada.`);
}
await this.router.navigate(['/planificaciones', planificacion.cabecera.docEntry], { state: { advertencias: avisos } });
} catch (e) {
this.error.set(mensajeDeError(e));
} finally {
this.procesando.set(false);
}
}
}
@@ -0,0 +1,86 @@
:host {
display: flex;
flex-direction: column;
gap: 16px;
}
.encabezado {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
p {
margin: 4px 0 0;
}
}
a.btn {
text-decoration: none;
}
.filtros {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px 16px;
.field {
min-width: 170px;
}
}
.limpiar {
margin-left: auto;
}
.derecha {
text-align: right;
}
table.recargando {
opacity: 0.55;
}
.paginacion {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-top: 16px;
}
.controles {
display: flex;
align-items: center;
gap: 6px;
}
.tamano {
display: flex;
align-items: center;
gap: 6px;
margin-right: 8px;
select {
height: 36px;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 6px;
font: inherit;
background: var(--surface);
}
}
.btn.numero {
min-width: 36px;
padding: 0 8px;
&.activo {
background: var(--primary-soft);
border-color: var(--primary);
color: var(--primary);
font-weight: 600;
}
}
@@ -0,0 +1,120 @@
<section class="encabezado">
<div>
<h1>Planificaciones</h1>
<p class="muted">Devoluciones a proveedores agrupadas por ejecución. Solo puede haber una abierta a la vez.</p>
</div>
@if (abierta()) {
<button class="btn primary" type="button" disabled title="Cierre la planificación abierta para crear otra">
Nueva planificación
</button>
} @else {
<a class="btn primary" routerLink="nueva">Nueva planificación</a>
}
</section>
@if (error(); as e) {
<div class="alert error" role="alert">{{ e }}</div>
}
@if (abierta(); as a) {
<div class="alert warn">
Hay una planificación abierta (Nº {{ a.docNum }}).
<a [routerLink]="[a.docEntry]">Continuar con ella</a> o ciérrela para crear otra.
</div>
}
<section class="card filtros" aria-label="Filtros">
<div class="field">
<label for="f-estado">Estado</label>
<select id="f-estado" [ngModel]="status()" (ngModelChange)="status.set($event); filtrar()">
<option value="">Todas</option>
<option value="O">Abiertas</option>
<option value="C">Cerradas</option>
</select>
</div>
<div class="field">
<label for="f-almacen">Almacén</label>
<select id="f-almacen" [ngModel]="whsCode()" (ngModelChange)="whsCode.set($event); filtrar()">
<option value="">Todos</option>
@for (a of almacenes(); track a.code) {
<option [value]="a.code">{{ a.code }} · {{ a.name }}</option>
}
</select>
</div>
<div class="field">
<label for="f-desde">Fecha desde</label>
<input id="f-desde" type="date" [ngModel]="desde()" [max]="hasta() || null" (ngModelChange)="desde.set($event); filtrar()" />
</div>
<div class="field">
<label for="f-hasta">Fecha hasta</label>
<input id="f-hasta" type="date" [ngModel]="hasta()" [min]="desde() || null" (ngModelChange)="hasta.set($event); filtrar()" />
</div>
<div class="limpiar">
<button class="btn" type="button" (click)="limpiar()" [disabled]="!hayFiltros()">Limpiar filtros</button>
</div>
</section>
<section class="card">
@if (cargando() && !datos()) {
<p class="muted">Cargando…</p>
} @else if (filas().length === 0) {
<p class="muted">
{{ hayFiltros() ? 'Ninguna planificación cumple los filtros.' : 'Todavía no hay planificaciones.' }}
</p>
} @else {
<table class="grid" [class.recargando]="cargando()">
<thead>
<tr><th>Nº</th><th>Fecha</th><th>Almacén</th><th>Estado</th><th></th></tr>
</thead>
<tbody>
@for (p of filas(); track p.docEntry) {
<tr>
<td>{{ p.docNum }}</td>
<td>{{ p.docDate }}</td>
<td>{{ p.whsCode }}</td>
<td>
<span class="chip" [class.ok]="p.status === 'C'" [class.neutral]="p.status === 'O'">
{{ p.status === 'O' ? 'Abierta' : 'Cerrada' }}
</span>
</td>
<td class="derecha"><a [routerLink]="[p.docEntry]">Ver</a></td>
</tr>
}
</tbody>
</table>
}
@if (rango(); as r) {
<nav class="paginacion" aria-label="Paginación">
<span class="muted resumen">Mostrando {{ r.primero }}–{{ r.ultimo }} de {{ r.total }}</span>
<div class="controles">
<label class="tamano muted" for="f-tamano">
Por página
<select id="f-tamano" [ngModel]="tamano()" (ngModelChange)="cambiarTamano($event)">
@for (t of tamanos; track t) {
<option [ngValue]="t">{{ t }}</option>
}
</select>
</label>
<button class="btn" type="button" (click)="irA(pagina() - 1)" [disabled]="pagina() <= 1 || cargando()">Atrás</button>
@for (n of numeros(); track n) {
<button
class="btn numero"
type="button"
[class.activo]="n === pagina()"
[attr.aria-current]="n === pagina() ? 'page' : null"
(click)="irA(n)"
[disabled]="cargando()"
>
{{ n }}
</button>
}
<button class="btn" type="button" (click)="irA(pagina() + 1)" [disabled]="pagina() >= totalPaginas() || cargando()">
Siguiente
</button>
</div>
</nav>
}
</section>
@@ -0,0 +1,174 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, TestRequest, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { PlanificacionesPage } from './planificaciones';
const tick = () => new Promise((r) => setTimeout(r));
const fila = (n: number, status: 'O' | 'C' = 'C') => ({ docEntry: n, docNum: n, status, whsCode: '01', docDate: '2026-10-12' });
const pagina = (pag: number, total: number, tamano = 20) => ({
items: Array.from({ length: Math.min(tamano, total - (pag - 1) * tamano) }, (_, i) => fila(total - (pag - 1) * tamano - i)),
total,
pagina: pag,
tamano,
totalPaginas: Math.max(Math.ceil(total / tamano), 1),
});
describe('PlanificacionesPage (listado)', () => {
let http: HttpTestingController;
async function crear(primera: object = pagina(1, 45)) {
await TestBed.configureTestingModule({
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
http = TestBed.inject(HttpTestingController);
const fixture = TestBed.createComponent(PlanificacionesPage);
fixture.detectChanges();
http.expectOne('/api/catalogos/almacenes').flush([{ code: '01', name: 'Almacén general' }, { code: '02', name: 'Bodega Secundaria' }]);
http.expectOne('/api/planificaciones/abierta').flush({ abierta: null });
peticion().flush(primera);
await tick();
fixture.detectChanges();
return fixture;
}
/** La petición pendiente al listado (se espera exactamente una). */
const peticion = (): TestRequest => http.expectOne((r) => r.url === '/api/planificaciones');
const boton = (el: HTMLElement, texto: string) =>
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === texto)!;
async function responder(fixture: Awaited<ReturnType<typeof crear>>, cuerpo: object): Promise<TestRequest> {
const req = peticion();
req.flush(cuerpo);
await tick();
fixture.detectChanges();
return req;
}
it('carga la primera página y muestra el rango', async () => {
const fixture = await crear();
const el: HTMLElement = fixture.nativeElement;
expect(el.querySelectorAll('tbody tr')).toHaveLength(20);
expect(el.textContent).toContain('Mostrando 1–20 de 45');
expect(boton(el, 'Atrás').disabled).toBe(true);
expect(boton(el, 'Siguiente').disabled).toBe(false);
});
it('el pedido inicial lleva pagina=1 y tamano=20 y no manda filtros vacíos', async () => {
await TestBed.configureTestingModule({ providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()] }).compileComponents();
http = TestBed.inject(HttpTestingController);
TestBed.createComponent(PlanificacionesPage).detectChanges();
http.expectOne('/api/catalogos/almacenes').flush([]);
http.expectOne('/api/planificaciones/abierta').flush({ abierta: null });
const { params } = peticion().request;
expect(params.get('pagina')).toBe('1');
expect(params.get('tamano')).toBe('20');
expect(params.has('status')).toBe(false);
expect(params.has('whsCode')).toBe(false);
});
it('Siguiente pide la página 2 y Atrás vuelve a la 1', async () => {
const fixture = await crear();
const el: HTMLElement = fixture.nativeElement;
boton(el, 'Siguiente').click();
const req = await responder(fixture, pagina(2, 45));
expect(req.request.params.get('pagina')).toBe('2');
expect(el.textContent).toContain('Mostrando 21–40 de 45');
expect(boton(el, 'Atrás').disabled).toBe(false);
boton(el, 'Atrás').click();
const atras = await responder(fixture, pagina(1, 45));
expect(atras.request.params.get('pagina')).toBe('1');
});
it('en la última página Siguiente queda deshabilitado', async () => {
const fixture = await crear();
const el: HTMLElement = fixture.nativeElement;
boton(el, 'Siguiente').click();
await responder(fixture, pagina(2, 45));
boton(el, 'Siguiente').click();
await responder(fixture, pagina(3, 45));
expect(el.textContent).toContain('Mostrando 41–45 de 45');
expect(boton(el, 'Siguiente').disabled).toBe(true);
expect(el.querySelectorAll('tbody tr')).toHaveLength(5);
});
it('al cambiar un filtro vuelve a la página 1 con ese filtro', async () => {
const fixture = await crear();
const el: HTMLElement = fixture.nativeElement;
boton(el, 'Siguiente').click();
await responder(fixture, pagina(2, 45));
const estado = el.querySelector<HTMLSelectElement>('#f-estado')!;
estado.value = 'C';
estado.dispatchEvent(new Event('change'));
const req = await responder(fixture, pagina(1, 7));
expect(req.request.params.get('status')).toBe('C');
expect(req.request.params.get('pagina')).toBe('1');
expect(el.textContent).toContain('Mostrando 1–7 de 7');
});
it('filtra por almacén y por rango de fechas', async () => {
const fixture = await crear();
const el: HTMLElement = fixture.nativeElement;
const almacen = el.querySelector<HTMLSelectElement>('#f-almacen')!;
almacen.value = '02';
almacen.dispatchEvent(new Event('change'));
await responder(fixture, pagina(1, 3));
const desde = el.querySelector<HTMLInputElement>('#f-desde')!;
desde.value = '2026-10-01';
desde.dispatchEvent(new Event('input'));
const req = await responder(fixture, pagina(1, 2));
expect(req.request.params.get('whsCode')).toBe('02');
expect(req.request.params.get('desde')).toBe('2026-10-01');
});
it('sin resultados con filtros lo dice, y Limpiar filtros los quita', async () => {
const fixture = await crear();
const el: HTMLElement = fixture.nativeElement;
const estado = el.querySelector<HTMLSelectElement>('#f-estado')!;
estado.value = 'O';
estado.dispatchEvent(new Event('change'));
await responder(fixture, pagina(1, 0));
expect(el.textContent).toContain('Ninguna planificación cumple los filtros');
expect(el.querySelector('.paginacion')).toBeNull();
boton(el, 'Limpiar filtros').click();
const req = await responder(fixture, pagina(1, 45));
expect(req.request.params.has('status')).toBe(false);
expect(el.textContent).toContain('Mostrando 1–20 de 45');
});
it('si la página pedida quedó fuera de rango, va a la última', async () => {
const fixture = await crear();
const el: HTMLElement = fixture.nativeElement;
boton(el, 'Siguiente').click();
// SAP responde vacío para una página que ya no existe (total 25 → 2 páginas).
const vacia = { items: [], total: 25, pagina: 3, tamano: 20, totalPaginas: 2 };
peticion().flush(vacia);
await tick();
const req = peticion();
expect(req.request.params.get('pagina')).toBe('2');
req.flush(pagina(2, 25));
await tick();
fixture.detectChanges();
expect(el.textContent).toContain('Mostrando 21–25 de 25');
});
});
@@ -0,0 +1,144 @@
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import type { Almacen, FiltrosPlanificaciones, PaginaPlanificaciones, ResumenPlanificacion } from '@dp/shared';
import { TAMANO_PAGINA_DEFECTO } from '@dp/shared';
import { mensajeDeError } from '../../core/http-error';
import { PlanificacionesService } from '../../core/planificaciones.service';
const TAMANOS = [10, 20, 50, 100];
@Component({
selector: 'app-planificaciones',
imports: [FormsModule, RouterLink],
templateUrl: './planificaciones.html',
styleUrl: './planificaciones.css',
})
export class PlanificacionesPage implements OnInit {
private readonly api = inject(PlanificacionesService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
protected readonly tamanos = TAMANOS;
protected readonly almacenes = signal<Almacen[]>([]);
protected readonly abierta = signal<ResumenPlanificacion | null>(null);
protected readonly datos = signal<PaginaPlanificaciones | null>(null);
protected readonly cargando = signal(true);
protected readonly error = signal<string | null>(null);
// Filtros y página actual (se reflejan en la URL).
protected readonly status = signal<'' | 'O' | 'C'>('');
protected readonly whsCode = signal('');
protected readonly desde = signal('');
protected readonly hasta = signal('');
protected readonly pagina = signal(1);
protected readonly tamano = signal(TAMANO_PAGINA_DEFECTO);
protected readonly filas = computed(() => this.datos()?.items ?? []);
protected readonly totalPaginas = computed(() => this.datos()?.totalPaginas ?? 1);
protected readonly hayFiltros = computed(() => !!(this.status() || this.whsCode() || this.desde() || this.hasta()));
protected readonly rango = computed(() => {
const d = this.datos();
if (!d || d.total === 0) return null;
const primero = (d.pagina - 1) * d.tamano + 1;
return { primero, ultimo: Math.min(primero + d.items.length - 1, d.total), total: d.total };
});
/** Hasta 5 números de página alrededor de la actual. */
protected readonly numeros = computed(() => {
const total = this.totalPaginas();
const inicio = Math.max(1, Math.min(this.pagina() - 2, total - 4));
return Array.from({ length: Math.min(5, total) }, (_, i) => inicio + i);
});
private peticion = 0;
async ngOnInit(): Promise<void> {
const q = this.route.snapshot.queryParamMap;
const estado = q.get('estado');
this.status.set(estado === 'O' || estado === 'C' ? estado : '');
this.whsCode.set(q.get('almacen') ?? '');
this.desde.set(q.get('desde') ?? '');
this.hasta.set(q.get('hasta') ?? '');
this.pagina.set(Math.max(Number(q.get('pagina')) || 1, 1));
this.tamano.set(TAMANOS.includes(Number(q.get('tamano'))) ? Number(q.get('tamano')) : TAMANO_PAGINA_DEFECTO);
// Los almacenes y la planificación abierta no condicionan el listado: si fallan, el listado igual se ve.
void this.api.almacenes().then((a) => this.almacenes.set(a)).catch(() => undefined);
void this.api.abierta().then((a) => this.abierta.set(a)).catch(() => undefined);
await this.cargar();
}
protected filtrar(): void {
this.pagina.set(1);
void this.cargar();
}
protected limpiar(): void {
this.status.set('');
this.whsCode.set('');
this.desde.set('');
this.hasta.set('');
this.filtrar();
}
protected irA(pagina: number): void {
if (pagina < 1 || pagina > this.totalPaginas() || pagina === this.pagina()) return;
this.pagina.set(pagina);
void this.cargar();
}
protected cambiarTamano(tamano: number): void {
this.tamano.set(Number(tamano));
this.filtrar();
}
private filtros(): FiltrosPlanificaciones {
return {
status: this.status() || undefined,
whsCode: this.whsCode() || undefined,
desde: this.desde() || undefined,
hasta: this.hasta() || undefined,
pagina: this.pagina(),
tamano: this.tamano(),
};
}
private async cargar(): Promise<void> {
const mia = ++this.peticion;
this.cargando.set(true);
this.error.set(null);
try {
const datos = await this.api.listar(this.filtros());
if (mia !== this.peticion) return; // llegó una respuesta más nueva: se descarta esta
// Si la página pedida quedó fuera de rango (por ejemplo desde una URL vieja), se va a la última.
if (datos.items.length === 0 && datos.total > 0 && datos.pagina > datos.totalPaginas) {
this.pagina.set(datos.totalPaginas);
await this.cargar();
return;
}
this.datos.set(datos);
this.sincronizarUrl();
} catch (e) {
if (mia === this.peticion) this.error.set(mensajeDeError(e));
} finally {
if (mia === this.peticion) this.cargando.set(false);
}
}
/** Deja los filtros y la página en la URL para que al refrescar o volver atrás se conserven. */
private sincronizarUrl(): void {
void this.router.navigate([], {
relativeTo: this.route,
replaceUrl: true,
queryParams: {
estado: this.status() || null,
almacen: this.whsCode() || null,
desde: this.desde() || null,
hasta: this.hasta() || null,
pagina: this.pagina() > 1 ? this.pagina() : null,
tamano: this.tamano() !== TAMANO_PAGINA_DEFECTO ? this.tamano() : null,
},
});
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Devolución a Proveedores</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));
+6
View File
@@ -0,0 +1,6 @@
{
"/api": {
"target": "http://localhost:3000",
"secure": false
}
}
+224
View File
@@ -0,0 +1,224 @@
:root {
--bg: #f4f6f8;
--surface: #ffffff;
--surface-alt: #f9fafb;
--border: #dde2e8;
--text: #1d2733;
--text-muted: #5b6876;
--primary: #0a6ed1;
--primary-hover: #0857a6;
--primary-soft: #e6f1fb;
--ok: #1e7e45;
--ok-soft: #e4f4ea;
--warn: #9a5b00;
--warn-soft: #fdf1dc;
--error: #b42318;
--error-soft: #fdecea;
--neutral-soft: #eef1f4;
--radius: 8px;
--shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 10%);
--font: 'Segoe UI', system-ui, -apple-system, Roboto, 'Helvetica Neue', Arial, sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
line-height: 1.45;
}
h1,
h2,
h3 {
margin: 0;
font-weight: 600;
}
h1 {
font-size: 20px;
}
h2 {
font-size: 16px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px;
}
.muted {
color: var(--text-muted);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 36px;
padding: 0 16px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--text);
font: inherit;
font-weight: 500;
cursor: pointer;
&:hover:not(:disabled) {
background: var(--surface-alt);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
&.primary {
background: var(--primary);
border-color: var(--primary);
color: #fff;
&:hover:not(:disabled) {
background: var(--primary-hover);
}
}
&.danger {
background: var(--error);
border-color: var(--error);
color: #fff;
}
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
label {
font-weight: 500;
color: var(--text-muted);
font-size: 13px;
}
input {
height: 38px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 6px;
font: inherit;
color: var(--text);
background: var(--surface);
&:focus {
outline: 2px solid var(--primary-soft);
border-color: var(--primary);
}
}
}
.alert {
padding: 10px 14px;
border-radius: 6px;
border: 1px solid transparent;
&.error {
background: var(--error-soft);
border-color: #f5c2bd;
color: var(--error);
}
&.warn {
background: var(--warn-soft);
border-color: #f3d29b;
color: var(--warn);
}
&.ok {
background: var(--ok-soft);
border-color: #b5e0c4;
color: var(--ok);
}
}
.chip {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
&.ok {
background: var(--ok-soft);
color: var(--ok);
}
&.falta,
&.diferente {
background: var(--error-soft);
color: var(--error);
}
&.sobrante {
background: var(--warn-soft);
color: var(--warn);
}
&.neutral {
background: var(--neutral-soft);
color: var(--text-muted);
}
}
table.grid {
width: 100%;
border-collapse: collapse;
th,
td {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
th {
background: var(--surface-alt);
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.02em;
}
code {
font-size: 13px;
}
}
.field select {
height: 38px;
padding: 0 10px;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
+14
View File
@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}
+31
View File
@@ -0,0 +1,31 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+14
View File
@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}