feat(F-176): completed feature

This commit is contained in:
chattie
2026-08-22 18:49:33 +02:00
parent 8e23602fbb
commit 4081462cc4
12 changed files with 170 additions and 25 deletions

View File

@@ -6988,6 +6988,38 @@
"close": true "close": true
}, },
"completed_at": "2026-08-22T16:35:10Z" "completed_at": "2026-08-22T16:35:10Z"
},
{
"id": "F-176",
"type": "fix",
"title": "Fix POS existing product search",
"description": "POS returns no results for existing barcode 590123412345",
"priority": "high",
"risk": "med",
"status": "done",
"created_at": "2026-08-22",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"completed_at": "2026-08-22T16:49:33Z"
},
{
"id": "F-177",
"type": "feature",
"title": "Add stale order notifications to admin header",
"description": "Create admin header notification module for pending and shipped orders unchanged for more than 24 hours",
"priority": "high",
"risk": "med",
"status": "pending",
"created_at": "2026-08-22",
"gates": {
"reviewer": false,
"security": false,
"qa": false
}
} }
] ]
} }

View File

@@ -50,6 +50,7 @@ export default function RegisterPage() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]); const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState('');
const [selectedItem, setSelectedItem] = useState<CartItem | null>(null); const [selectedItem, setSelectedItem] = useState<CartItem | null>(null);
const [showDiscountPanel, setShowDiscountPanel] = useState(false); const [showDiscountPanel, setShowDiscountPanel] = useState(false);
const [customer, setCustomer] = useState<Customer | null>(null); const [customer, setCustomer] = useState<Customer | null>(null);
@@ -112,13 +113,16 @@ export default function RegisterPage() {
}; };
const doSearch = useCallback(async (q: string) => { const doSearch = useCallback(async (q: string) => {
if (q.trim().length < 2) { setSearchResults([]); return; } if (q.trim().length < 2) { setSearchResults([]); setSearchError(''); return; }
setSearching(true); setSearching(true);
setSearchError('');
try { try {
const res = await posApi.searchProducts(q, config?.session?.storeId) as { items: SearchResult[] }; const res = await posApi.searchProducts(q, config?.session?.storeId) as { items: SearchResult[] };
setSearchResults(res.items ?? []); setSearchResults(res.items ?? []);
} catch { setSearchResults([]); } } catch (err) {
finally { setSearching(false); } setSearchResults([]);
setSearchError(err instanceof Error ? err.message : 'No se pudo buscar productos');
} finally { setSearching(false); }
}, [config]); }, [config]);
useEffect(() => { useEffect(() => {
@@ -324,7 +328,8 @@ export default function RegisterPage() {
)} )}
{searching && <p className="text-center text-gray-400 py-4">Buscando</p>} {searching && <p className="text-center text-gray-400 py-4">Buscando</p>}
{search && searchResults.length === 0 && !searching && ( {searchError && !searching && <p className="text-center text-red-600 py-4">{searchError}</p>}
{search && searchResults.length === 0 && !searching && !searchError && (
<p className="text-center text-gray-400 py-4">Sin resultados para &quot;{search}&quot;</p> <p className="text-center text-gray-400 py-4">Sin resultados para &quot;{search}&quot;</p>
)} )}
</div> </div>

View File

@@ -454,17 +454,29 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
if (!q || q.trim().length < 2) if (!q || q.trim().length < 2)
throw new AppError(400, 'INVALID_QUERY', 'Query must be at least 2 characters'); throw new AppError(400, 'INVALID_QUERY', 'Query must be at least 2 characters');
const result = await pool.query( const result = await pool.query(
`SELECT v.id AS variant_id, v.product_id, v.name, v.sku, v.ean, `SELECT v.id AS variant_id, v.product_id, p.name, v.sku, v.ean,
COALESCE(s.quantity, 0) AS stock, pp.price_cents, c.name AS category, b.name AS brand COALESCE(s.quantity, 0) AS stock,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS price_cents,
c.name AS category, b.name AS brand
FROM catalog_product_variants v FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN LATERAL (SELECT quantity FROM inventory_stock WHERE variant_id = v.id AND store_id = $3 LIMIT 1) s ON true LEFT JOIN LATERAL (
SELECT COALESCE(SUM(available), 0)::int AS quantity
FROM inventory_stock
WHERE variant_id = v.id AND ($3::uuid IS NULL OR store_id = $3)
) s ON true
LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR' LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR'
LEFT JOIN catalog_product_categories pc ON pc.variant_id = v.id AND pc.is_primary = true LEFT JOIN LATERAL (
LEFT JOIN categories_categories c ON c.id = pc.category_id SELECT category.name
FROM catalog_product_categories pc
JOIN categories_categories category ON category.id = pc.category_id
WHERE pc.product_id = p.id
ORDER BY category.name
LIMIT 1
) c ON true
LEFT JOIN brands_brands b ON b.id = p.brand_id LEFT JOIN brands_brands b ON b.id = p.brand_id
WHERE (v.name ILIKE $1 OR v.sku ILIKE $1 OR v.ean = $1) AND v.active = true AND p.active = true WHERE (p.name ILIKE $1 OR v.sku ILIKE $1 OR v.ean ILIKE $1) AND p.state = 'active'
ORDER BY v.name LIMIT $2`, ORDER BY p.name LIMIT $2`,
[`%${q.trim()}%`, limit, storeId ?? null], [`%${q.trim()}%`, limit, storeId ?? null],
); );
return reply.send({ return reply.send({
@@ -498,12 +510,17 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>); requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const { ean } = request.params; const { ean } = request.params;
const result = await pool.query( const result = await pool.query(
`SELECT v.id AS variant_id, v.product_id, v.name, v.sku, v.ean, COALESCE(s.quantity, 0) AS stock, pp.price_cents `SELECT v.id AS variant_id, v.product_id, p.name, v.sku, v.ean,
COALESCE(s.quantity, 0) AS stock,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS price_cents
FROM catalog_product_variants v FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN LATERAL (SELECT quantity FROM inventory_stock WHERE variant_id = v.id LIMIT 1) s ON true LEFT JOIN LATERAL (
SELECT COALESCE(SUM(available), 0)::int AS quantity
FROM inventory_stock WHERE variant_id = v.id
) s ON true
LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR' LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR'
WHERE v.ean = $1 AND v.active = true AND p.active = true LIMIT 1`, WHERE v.ean = $1 AND p.state = 'active' LIMIT 1`,
[ean], [ean],
); );
if (!result.rows[0]) throw new AppError(404, 'NOT_FOUND', 'Product not found'); if (!result.rows[0]) throw new AppError(404, 'NOT_FOUND', 'Product not found');
@@ -535,12 +552,17 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>); requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const { sku } = request.params; const { sku } = request.params;
const result = await pool.query( const result = await pool.query(
`SELECT v.id AS variant_id, v.product_id, v.name, v.sku, v.ean, COALESCE(s.quantity, 0) AS stock, pp.price_cents `SELECT v.id AS variant_id, v.product_id, p.name, v.sku, v.ean,
COALESCE(s.quantity, 0) AS stock,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS price_cents
FROM catalog_product_variants v FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN LATERAL (SELECT quantity FROM inventory_stock WHERE variant_id = v.id LIMIT 1) s ON true LEFT JOIN LATERAL (
SELECT COALESCE(SUM(available), 0)::int AS quantity
FROM inventory_stock WHERE variant_id = v.id
) s ON true
LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR' LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR'
WHERE v.sku = $1 AND v.active = true AND p.active = true LIMIT 1`, WHERE v.sku = $1 AND p.state = 'active' LIMIT 1`,
[sku], [sku],
); );
if (!result.rows[0]) throw new AppError(404, 'NOT_FOUND', 'Product not found'); if (!result.rows[0]) throw new AppError(404, 'NOT_FOUND', 'Product not found');

View File

@@ -0,0 +1,3 @@
# F-176 — Design
Use `p.name`, `p.state='active'`, `v.sku/ean`, `inventory_stock.available`, `pricing_variant_prices.net_unit_amount_cents`, and categories linked by `product_id`. Search EAN with `ILIKE` so scanner input and partial manual barcode searches both work. Keep all input parameterized.

View File

@@ -0,0 +1,3 @@
# POS product search
The POS search field accepts product names, SKU, full EAN and partial EAN text. Lookup failures now display an explicit API error instead of the misleading “Sin resultados”.

View File

@@ -0,0 +1,19 @@
# F-176 — Implementer
## Cause
POS lookup SQL was written for an obsolete schema and referenced nonexistent columns: `v.name`, `v.active`, `p.active`, `inventory_stock.quantity`, `pricing_variant_prices.price_cents`, plus category links by variant. PostgreSQL returned 500; the UI swallowed it and rendered “Sin resultados”.
## Changes
- Search uses product name/state and current variant SKU/EAN.
- Inventory uses summed `available`, scoped to the active store.
- Price uses offer or current net amount.
- Category lookup uses `catalog_product_categories.product_id`.
- Manual partial EAN search uses parameterized `ILIKE`; exact EAN/SKU endpoints were repaired too.
- UI now exposes API search errors rather than reporting false empty results.
## Evidence
- Project TypeScript PASS.
- POS Next build PASS.
- `git diff --check` PASS.
- Authenticated runtime search `q=590123412345`: HTTP 200, 12 existing products.
- Exact EAN `59012341234501`: HTTP 200, Almendras Crudas Ecologicas, stock 25, price 895.

View File

@@ -0,0 +1 @@
{"feature_id":"F-176","agent":"leader","stage":"close","verdict":"APPROVED","checks":[{"item":"reviewer/security/qa approved","ok":true},{"item":"runtime partial and exact barcode acceptance","ok":true},{"item":"verify.sh green","ok":true}],"issues":[]}

View File

@@ -0,0 +1 @@
{"feature_id":"F-176","agent":"qa","stage":"qa_gate","verdict":"APPROVED","checks":[{"item":"partial barcode 590123412345 returns HTTP 200 and 12 products","ok":true},{"item":"exact EAN 59012341234501 returns correct product","ok":true},{"item":"stock and price returned","ok":true},{"item":"POS build and TypeScript","ok":true},{"item":"verify.sh","ok":true}],"issues":[]}

View File

@@ -0,0 +1 @@
{"feature_id":"F-176","agent":"reviewer","stage":"review_gate","verdict":"APPROVED","checks":[{"item":"all lookup fields match current database schema","ok":true},{"item":"search UI no longer hides server errors","ok":true},{"item":"TypeScript and POS build","ok":true},{"item":"authenticated runtime search","ok":true}],"issues":[]}

View File

@@ -0,0 +1 @@
{"feature_id":"F-176","agent":"security","stage":"security_gate","verdict":"APPROVED","checks":[{"item":"role authorization retained","ok":true},{"item":"query and limits remain parameterized","ok":true},{"item":"store scope supplied by active cash session","ok":true}],"issues":[]}

View File

@@ -1,3 +1,3 @@
# F-174 — Fix missing almagro.ttf # F-176 — Fix POS existing product search
CSS solicita `/fonts/almagro.ttf`, pero archivo está como `Almagro.ttf`. Normalizar asset a minúsculas para filesystem case-sensitive. POS search currently executes stale SQL against nonexistent variant fields (`v.name`, `v.active`, `p.active`, `s.quantity`, `pp.price_cents`, category-by-variant), then hides HTTP 500 as an empty result. Align search/EAN/SKU queries with current catalog, pricing, inventory and category schemas. Barcode text must support exact and partial EAN lookup.

View File

@@ -1,11 +1,68 @@
{ {
"feature_id": null, "feature_id": "F-176",
"stage": "idle", "stage": "close",
"agent": "leader", "agent": "leader",
"action": "Sin ejecución activa", "action": "Close POS existing product search fix",
"state": "waiting", "state": "running",
"next_agent": "leader", "next_agent": "leader",
"waiting_for": "Seleccionar una feature pending y actualizar este estado", "waiting_for": "Seleccionar una feature pending y actualizar este estado",
"updated_at": "2026-08-22T16:39:53Z", "updated_at": "2026-08-22T16:49:23Z",
"timeline": [] "timeline": [
{
"ts": "2026-08-22T16:45:39Z",
"agent": "leader",
"stage": "intake",
"state": "running",
"message": "Diagnose POS product search for existing barcode"
},
{
"ts": "2026-08-22T16:46:57Z",
"agent": "architect",
"stage": "design",
"state": "running",
"message": "Align POS lookup SQL with current catalog schema"
},
{
"ts": "2026-08-22T16:47:05Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Repair POS product lookup queries and visible errors"
},
{
"ts": "2026-08-22T16:48:42Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "running",
"message": "Review current-schema POS search repair"
},
{
"ts": "2026-08-22T16:48:51Z",
"agent": "security",
"stage": "security_gate",
"state": "running",
"message": "Audit POS search authorization and SQL parameters"
},
{
"ts": "2026-08-22T16:49:02Z",
"agent": "qa",
"stage": "qa_gate",
"state": "running",
"message": "Validate partial barcode and exact EAN searches"
},
{
"ts": "2026-08-22T16:49:12Z",
"agent": "documenter",
"stage": "document",
"state": "running",
"message": "Document repaired POS lookup behavior"
},
{
"ts": "2026-08-22T16:49:23Z",
"agent": "leader",
"stage": "close",
"state": "running",
"message": "Close POS existing product search fix"
}
]
} }