panels-origin/db/core/changesets/043-invoices.sql
Alberto Martinez 90bcd48f95 Facturas, nómina, dispersión e IMSS (#28)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Resumen

Implementa las observaciones de facturas, nómina e IMSS sobre el catálogo de proveedores y la nómina semanal que ya existían.

## Qué queda disponible

- Proveedores: régimen fiscal, código postal, estatus activo/inactivo y filtro por nombre o RFC.
- Facturas recibidas y emitidas: Excel, UUID, revisión si el RFC no está en el catálogo, clasificación a obra/concepto y gasto `source = invoice` solo para la recibida clasificada.
- Nómina: bono, descuento con observación, horas y día festivo dentro del armado semanal. La semana pagada no acepta cambios.
- Préstamos: edición (libre sin pagos; con pagos solo nota y cuota) y estado de cuenta con saldo = entregado + comisión − pagos.
- Finiquitos y liquidaciones: conceptos, neto descontando descuentos vigentes y saldo de préstamo, y gasto al pagar.
- Dispersión: corte semanal, corrección antes de definitiva y comprobante.
- Historial de alta, baja y reingreso en la ficha, más movimientos IMSS.
- Reporte por periodo en Excel y PDF.
- Jornal IMSS distinto del jornal real y consulta de cuotas exportable a Excel.

## Verificación

`deno check` de la API pasó. En este entorno no hay Postgres, así que `api/pendientes_test.ts` queda listo para correr cuando la base tenga las migraciones 042–050 y el permiso IAM 007.

<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a href="https://cursor.com/agents/bc-5e23dc3b-921e-4091-9650-7091d3a52bbf?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-5e23dc3b-921e-4091-9650-7091d3a52bbf&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>
2026-09-25 03:26:30 +00:00

427 lines
18 KiB
PL/PgSQL

--liquibase formatted sql
-- PANELS · core · facturas recibidas y emitidas
--changeset panel:core-043a-expense-source-invoice endDelimiter:; splitStatements:true
ALTER TABLE core.expense_entries DROP CONSTRAINT IF EXISTS expense_entries_source_check;
ALTER TABLE core.expense_entries
ADD CONSTRAINT expense_entries_source_check
CHECK (source IN ('manual', 'payroll', 'destajo', 'invoice', 'settlement'));
--changeset panel:core-043b-invoices endDelimiter:; splitStatements:true
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='invoices'
CREATE TABLE core.invoices (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id INTEGER NOT NULL,
direction TEXT NOT NULL CHECK (direction IN ('recibida', 'emitida')),
supplier_id BIGINT REFERENCES core.suppliers(id) ON DELETE SET NULL,
supplier_name TEXT NOT NULL DEFAULT '',
supplier_rfc TEXT NOT NULL DEFAULT '',
uuid TEXT NOT NULL DEFAULT '',
serie TEXT NOT NULL DEFAULT '',
folio TEXT NOT NULL DEFAULT '',
invoice_date DATE NOT NULL,
subtotal NUMERIC(14,2) NOT NULL DEFAULT 0,
iva_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
total NUMERIC(14,2) NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'MXN',
status TEXT NOT NULL DEFAULT 'registrada' CHECK (status IN ('borrador', 'registrada', 'clasificada', 'anulada')),
review_note TEXT NOT NULL DEFAULT '',
company_id BIGINT REFERENCES core.companies(id),
expense_id BIGINT REFERENCES core.expense_entries(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_core_invoices_uuid
ON core.invoices (tenant_id, uuid)
WHERE uuid <> '';
CREATE INDEX idx_core_invoices_tenant_date ON core.invoices (tenant_id, invoice_date DESC);
ALTER TABLE core.invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON core.invoices
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
GRANT SELECT, INSERT, UPDATE, DELETE ON core.invoices TO panels_core_app;
CREATE TABLE core.invoice_lines (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
invoice_id BIGINT NOT NULL REFERENCES core.invoices(id) ON DELETE CASCADE,
description TEXT NOT NULL DEFAULT '',
quantity NUMERIC(14,4) NOT NULL DEFAULT 0,
unit TEXT NOT NULL DEFAULT '',
amount NUMERIC(14,2) NOT NULL DEFAULT 0,
project_id BIGINT REFERENCES core.projects(id) ON DELETE SET NULL,
budget_item_id BIGINT REFERENCES core.budget_items(id) ON DELETE SET NULL,
project_code TEXT NOT NULL DEFAULT '',
item_code TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_core_invoice_lines_invoice ON core.invoice_lines (invoice_id);
GRANT SELECT, INSERT, UPDATE, DELETE ON core.invoice_lines TO panels_core_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app;
--changeset panel:core-043c-fn-invoice-list splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_invoice_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_direction text := coalesce(nullif(btrim(payload->>'direction'), ''), 'recibida');
v_supplier_id bigint := NULLIF(payload->>'supplier_id', '')::bigint;
v_rfc text := upper(coalesce(nullif(btrim(payload->>'rfc'), ''), ''));
v_uuid text := upper(coalesce(nullif(btrim(payload->>'uuid'), ''), ''));
v_folio text := coalesce(nullif(btrim(payload->>'folio'), ''), '');
v_q text := lower(coalesce(nullif(btrim(payload->>'q'), ''), ''));
v_min numeric := NULLIF(payload->>'amount_min', '')::numeric;
v_max numeric := NULLIF(payload->>'amount_max', '')::numeric;
v_from date := NULLIF(payload->>'date_from', '')::date;
v_to date := NULLIF(payload->>'date_to', '')::date;
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_classified text := nullif(btrim(payload->>'classified'), '');
v_rows jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.invoice_date DESC, x.id DESC), '[]'::jsonb)
INTO v_rows
FROM (
SELECT i.*,
EXISTS (
SELECT 1 FROM invoice_lines l
WHERE l.invoice_id = i.id AND l.budget_item_id IS NOT NULL
) AS has_concept
FROM invoices i
WHERE i.tenant_id = v_tid
AND i.direction = v_direction
AND i.status <> 'anulada'
AND (v_supplier_id IS NULL OR i.supplier_id = v_supplier_id)
AND (v_rfc = '' OR i.supplier_rfc = v_rfc)
AND (v_uuid = '' OR i.uuid = v_uuid)
AND (v_folio = '' OR i.folio ILIKE '%' || v_folio || '%')
AND (v_min IS NULL OR i.total >= v_min)
AND (v_max IS NULL OR i.total <= v_max)
AND (v_from IS NULL OR i.invoice_date >= v_from)
AND (v_to IS NULL OR i.invoice_date <= v_to)
AND (
v_q = ''
OR lower(i.supplier_name) LIKE '%' || v_q || '%'
OR lower(i.supplier_rfc) LIKE '%' || v_q || '%'
OR lower(i.uuid) LIKE '%' || v_q || '%'
OR lower(i.folio) LIKE '%' || v_q || '%'
)
AND (
v_project_id IS NULL
OR EXISTS (SELECT 1 FROM invoice_lines l WHERE l.invoice_id = i.id AND l.project_id = v_project_id)
)
AND (
v_classified IS NULL
OR (v_classified = 'si' AND i.status = 'clasificada')
OR (v_classified = 'no' AND i.status <> 'clasificada')
)
) x;
RETURN core.rpc_ok(
jsonb_build_object('invoices', v_rows, 'count', jsonb_array_length(v_rows)),
format('Facturas %s: %s registro(s)', v_direction, jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_invoice_list', 'tenant_id', v_tid)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_invoice_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-043d-fn-invoice-get splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_invoice_get(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_id bigint := NULLIF(payload->>'id', '')::bigint;
v_inv jsonb;
v_lines jsonb;
BEGIN
SELECT to_jsonb(i) INTO v_inv FROM invoices i WHERE i.id = v_id;
IF v_inv IS NULL THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_invoice_get: factura id=%s no encontrada', v_id),
jsonb_build_object('fn', 'fn_invoice_get', 'id', v_id));
END IF;
SELECT COALESCE(jsonb_agg(to_jsonb(l) ORDER BY l.id), '[]'::jsonb)
INTO v_lines FROM invoice_lines l WHERE l.invoice_id = v_id;
RETURN core.rpc_ok(
jsonb_build_object('invoice', v_inv, 'lines', v_lines),
format('Factura id=%s cargada', v_id),
jsonb_build_object('fn', 'fn_invoice_get', 'id', v_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_invoice_get', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-043e-fn-invoice-import splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_invoice_import(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_company_id bigint := NULLIF(payload->>'company_id', '')::bigint;
v_row jsonb;
v_key text;
v_groups jsonb := '{}'::jsonb;
v_group jsonb;
v_invoice_id bigint;
v_created integer := 0;
v_review integer := 0;
v_skipped integer := 0;
v_direction text;
v_rfc text;
v_uuid text;
v_serie text;
v_folio text;
v_date date;
v_name text;
v_supplier_id bigint;
v_supplier_name text;
v_status text;
v_note text;
v_subtotal numeric;
v_iva numeric;
v_total numeric;
v_project_id bigint;
v_item_id bigint;
v_line jsonb;
v_all_classified boolean;
v_expense_id bigint;
BEGIN
IF v_company_id IS NULL OR NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN
RETURN core.rpc_err('VALIDATION', 'fn_invoice_import: company_id es obligatorio',
jsonb_build_object('fn', 'fn_invoice_import', 'field', 'company_id'));
END IF;
FOR v_row IN SELECT value FROM jsonb_array_elements(COALESCE(payload->'rows', '[]'::jsonb))
LOOP
v_direction := lower(coalesce(nullif(btrim(v_row->>'direction'), ''), 'recibida'));
IF v_direction IN ('emitida', 'emitidas', 'ingreso') THEN
v_direction := 'emitida';
ELSE
v_direction := 'recibida';
END IF;
v_rfc := upper(regexp_replace(coalesce(v_row->>'rfc', ''), '\s+', '', 'g'));
v_uuid := upper(regexp_replace(coalesce(v_row->>'uuid', ''), '\s+', '', 'g'));
v_serie := coalesce(btrim(v_row->>'serie'), '');
v_folio := coalesce(btrim(v_row->>'folio'), '');
v_date := NULLIF(btrim(v_row->>'invoice_date'), '')::date;
IF v_date IS NULL THEN
v_skipped := v_skipped + 1;
CONTINUE;
END IF;
v_key := v_direction || '|' || CASE
WHEN v_uuid <> '' THEN 'u:' || v_uuid
ELSE 'k:' || v_rfc || '|' || v_folio || '|' || v_date::text
END;
v_groups := jsonb_set(
v_groups,
ARRAY[v_key],
COALESCE(v_groups->v_key, '[]'::jsonb) || jsonb_build_array(v_row || jsonb_build_object('direction', v_direction, 'rfc', v_rfc, 'uuid', v_uuid)),
true
);
END LOOP;
FOR v_key, v_group IN SELECT key, value FROM jsonb_each(v_groups)
LOOP
v_row := v_group->0;
v_direction := v_row->>'direction';
v_rfc := v_row->>'rfc';
v_uuid := v_row->>'uuid';
v_serie := coalesce(v_row->>'serie', '');
v_folio := coalesce(v_row->>'folio', '');
v_date := (v_row->>'invoice_date')::date;
v_name := coalesce(nullif(btrim(v_row->>'supplier_name'), ''), '');
v_supplier_id := NULL;
v_supplier_name := v_name;
v_note := '';
v_status := 'registrada';
IF v_uuid <> '' AND EXISTS (
SELECT 1 FROM invoices WHERE tenant_id = v_tid AND uuid = v_uuid
) THEN
v_skipped := v_skipped + 1;
CONTINUE;
END IF;
IF v_rfc <> '' THEN
SELECT id, name INTO v_supplier_id, v_supplier_name
FROM suppliers
WHERE tenant_id = v_tid AND rfc = v_rfc AND status = 'activo'
LIMIT 1;
IF v_supplier_id IS NULL THEN
v_status := 'borrador';
v_note := 'RFC sin proveedor en el catálogo';
v_review := v_review + 1;
IF v_supplier_name = '' THEN
v_supplier_name := v_rfc;
END IF;
END IF;
END IF;
v_subtotal := 0;
v_iva := 0;
v_total := 0;
FOR v_line IN SELECT value FROM jsonb_array_elements(v_group)
LOOP
v_subtotal := v_subtotal + COALESCE((v_line->>'amount')::numeric, 0);
v_iva := v_iva + COALESCE((v_line->>'iva')::numeric, 0);
v_total := v_total + COALESCE(NULLIF(v_line->>'total', '')::numeric, COALESCE((v_line->>'amount')::numeric, 0) + COALESCE((v_line->>'iva')::numeric, 0));
END LOOP;
INSERT INTO invoices (
tenant_id, direction, supplier_id, supplier_name, supplier_rfc, uuid, serie, folio,
invoice_date, subtotal, iva_amount, total, status, review_note, company_id
) VALUES (
v_tid, v_direction, v_supplier_id, coalesce(v_supplier_name, ''), v_rfc, v_uuid, v_serie, v_folio,
v_date, core.round_money(v_subtotal), core.round_money(v_iva), core.round_money(v_total),
v_status, v_note, v_company_id
) RETURNING id INTO v_invoice_id;
v_all_classified := v_direction = 'recibida' AND v_status = 'registrada';
FOR v_line IN SELECT value FROM jsonb_array_elements(v_group)
LOOP
v_project_id := NULL;
v_item_id := NULL;
IF coalesce(btrim(v_line->>'project_code'), '') <> '' THEN
SELECT id INTO v_project_id FROM projects
WHERE tenant_id = v_tid AND lower(code) = lower(btrim(v_line->>'project_code'))
LIMIT 1;
END IF;
IF v_project_id IS NOT NULL AND coalesce(btrim(v_line->>'item_code'), '') <> '' THEN
SELECT id INTO v_item_id FROM budget_items
WHERE project_id = v_project_id AND lower(code) = lower(btrim(v_line->>'item_code'))
LIMIT 1;
END IF;
IF v_project_id IS NULL OR v_item_id IS NULL THEN
v_all_classified := false;
END IF;
INSERT INTO invoice_lines (
invoice_id, description, quantity, unit, amount, project_id, budget_item_id, project_code, item_code
) VALUES (
v_invoice_id,
coalesce(nullif(btrim(v_line->>'description'), ''), 'Partida'),
COALESCE((v_line->>'quantity')::numeric, 0),
coalesce(btrim(v_line->>'unit'), ''),
COALESCE((v_line->>'amount')::numeric, 0),
v_project_id, v_item_id,
coalesce(btrim(v_line->>'project_code'), ''),
coalesce(btrim(v_line->>'item_code'), '')
);
END LOOP;
IF v_all_classified AND v_status = 'registrada' THEN
v_item_id := (SELECT budget_item_id FROM invoice_lines WHERE invoice_id = v_invoice_id AND budget_item_id IS NOT NULL ORDER BY id LIMIT 1);
v_project_id := (SELECT project_id FROM invoice_lines WHERE invoice_id = v_invoice_id AND project_id IS NOT NULL ORDER BY id LIMIT 1);
INSERT INTO expense_entries (
tenant_id, company_id, project_id, expense_date, source, source_ref_id, description,
supplier_id, supplier_name, supplier_rfc, budget_item_id,
subtotal, iva_amount, total, status, notes
) VALUES (
v_tid, v_company_id, v_project_id, v_date, 'invoice', v_invoice_id,
format('Factura %s %s', NULLIF(v_serie, ''), COALESCE(NULLIF(v_folio, ''), v_uuid)),
v_supplier_id, coalesce(v_supplier_name, ''), v_rfc, v_item_id,
core.round_money(v_subtotal), core.round_money(v_iva), core.round_money(v_total),
'confirmed', 'Generado al importar factura clasificada'
) RETURNING id INTO v_expense_id;
UPDATE invoices SET status = 'clasificada', expense_id = v_expense_id WHERE id = v_invoice_id;
END IF;
v_created := v_created + 1;
END LOOP;
RETURN core.rpc_ok(
jsonb_build_object('created', v_created, 'review', v_review, 'skipped', v_skipped),
format('Importación de facturas: %s creadas, %s en revisión, %s omitidas', v_created, v_review, v_skipped),
jsonb_build_object('fn', 'fn_invoice_import', 'created', v_created)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_invoice_import', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-043f-fn-invoice-classify splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_invoice_classify(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_id bigint := NULLIF(payload->>'id', '')::bigint;
v_line_id bigint := NULLIF(payload->>'line_id', '')::bigint;
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_item_id bigint := NULLIF(payload->>'budget_item_id', '')::bigint;
v_inv invoices%ROWTYPE;
v_expense_id bigint;
v_subtotal numeric;
v_iva numeric;
BEGIN
SELECT * INTO v_inv FROM invoices WHERE id = v_id AND tenant_id = v_tid;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_invoice_classify: factura id=%s no encontrada', v_id),
jsonb_build_object('fn', 'fn_invoice_classify', 'id', v_id));
END IF;
IF v_inv.direction <> 'recibida' THEN
RETURN core.rpc_err('VALIDATION', 'fn_invoice_classify: solo se clasifican facturas recibidas',
jsonb_build_object('fn', 'fn_invoice_classify', 'id', v_id));
END IF;
IF v_inv.status = 'anulada' THEN
RETURN core.rpc_err('VALIDATION', 'fn_invoice_classify: la factura está anulada',
jsonb_build_object('fn', 'fn_invoice_classify', 'id', v_id));
END IF;
IF v_line_id IS NOT NULL THEN
UPDATE invoice_lines
SET project_id = COALESCE(v_project_id, project_id),
budget_item_id = COALESCE(v_item_id, budget_item_id)
WHERE id = v_line_id AND invoice_id = v_id;
ELSE
UPDATE invoice_lines
SET project_id = COALESCE(v_project_id, project_id),
budget_item_id = COALESCE(v_item_id, budget_item_id)
WHERE invoice_id = v_id;
END IF;
IF EXISTS (
SELECT 1 FROM invoice_lines WHERE invoice_id = v_id AND (project_id IS NULL OR budget_item_id IS NULL)
) OR v_inv.supplier_id IS NULL THEN
RETURN core.rpc_ok(
jsonb_build_object('id', v_id, 'status', v_inv.status),
'Factura actualizada; falta proveedor, obra o concepto para generar el gasto',
jsonb_build_object('fn', 'fn_invoice_classify', 'id', v_id)
);
END IF;
SELECT project_id, budget_item_id INTO v_project_id, v_item_id
FROM invoice_lines WHERE invoice_id = v_id ORDER BY id LIMIT 1;
v_subtotal := v_inv.subtotal;
v_iva := v_inv.iva_amount;
IF v_inv.expense_id IS NULL THEN
INSERT INTO expense_entries (
tenant_id, company_id, project_id, expense_date, source, source_ref_id, description,
supplier_id, supplier_name, supplier_rfc, budget_item_id,
subtotal, iva_amount, total, status, notes
) VALUES (
v_tid, v_inv.company_id, v_project_id, v_inv.invoice_date, 'invoice', v_id,
format('Factura %s', COALESCE(NULLIF(v_inv.folio, ''), NULLIF(v_inv.uuid, ''), v_id::text)),
v_inv.supplier_id, v_inv.supplier_name, v_inv.supplier_rfc, v_item_id,
v_subtotal, v_iva, v_inv.total, 'confirmed', 'Clasificación de factura recibida'
) RETURNING id INTO v_expense_id;
UPDATE invoices SET status = 'clasificada', expense_id = v_expense_id WHERE id = v_id;
ELSE
UPDATE expense_entries
SET project_id = v_project_id, budget_item_id = v_item_id
WHERE id = v_inv.expense_id AND status <> 'void';
v_expense_id := v_inv.expense_id;
UPDATE invoices SET status = 'clasificada' WHERE id = v_id;
END IF;
RETURN core.rpc_ok(
jsonb_build_object('id', v_id, 'status', 'clasificada', 'expense_id', v_expense_id),
format('Factura id=%s clasificada; gasto id=%s', v_id, v_expense_id),
jsonb_build_object('fn', 'fn_invoice_classify', 'id', v_id, 'expense_id', v_expense_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_invoice_classify', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-043g-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core.fn_invoice_list(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_invoice_get(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_invoice_import(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_invoice_classify(jsonb) TO panels_core_app;