feat(F-176): completed feature
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 "{search}"</p>
|
<p className="text-center text-gray-400 py-4">Sin resultados para "{search}"</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
3
work/artifacts/F-176/architect.md
Normal file
3
work/artifacts/F-176/architect.md
Normal 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.
|
||||||
3
work/artifacts/F-176/documenter.md
Normal file
3
work/artifacts/F-176/documenter.md
Normal 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”.
|
||||||
19
work/artifacts/F-176/implementer.md
Normal file
19
work/artifacts/F-176/implementer.md
Normal 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.
|
||||||
1
work/artifacts/F-176/leader-close.json
Normal file
1
work/artifacts/F-176/leader-close.json
Normal 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":[]}
|
||||||
1
work/artifacts/F-176/qa.json
Normal file
1
work/artifacts/F-176/qa.json
Normal 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":[]}
|
||||||
1
work/artifacts/F-176/reviewer.json
Normal file
1
work/artifacts/F-176/reviewer.json
Normal 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":[]}
|
||||||
1
work/artifacts/F-176/security.json
Normal file
1
work/artifacts/F-176/security.json
Normal 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":[]}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user