Add cash management schema and immediate variance alerts
This commit is contained in:
@@ -0,0 +1,511 @@
|
||||
-- =====================================================================
|
||||
-- Migration 0010 — Reporting and alerting (roadmap Step 11).
|
||||
--
|
||||
-- Owner-facing read model: Z-reports, daily P&L per service, employee
|
||||
-- scorecards, and a persistent alerts table fed by detector functions.
|
||||
--
|
||||
-- Threat-model rows addressed: 2, 3, 4, 6, 9, 10, 11, 12, 13, 14, 17,
|
||||
-- 18, 20, 22, 23, 24, 25.
|
||||
-- =====================================================================
|
||||
|
||||
-- =====================================================================
|
||||
-- Z-report: one row per closed shift, what the system says vs what the
|
||||
-- cashier declared vs what was found in the drawer.
|
||||
-- =====================================================================
|
||||
create or replace view app.v_z_report as
|
||||
with cm as (
|
||||
select sh.id as shift_id,
|
||||
coalesce(sum(amount) filter (where currency='USD'),0) as net_usd,
|
||||
coalesce(sum(amount) filter (where currency='LBP'),0) as net_lbp
|
||||
from app.shifts sh
|
||||
left join app.cash_movements m on m.shift_id = sh.id
|
||||
group by sh.id
|
||||
),
|
||||
txn as (
|
||||
select sh.id as shift_id,
|
||||
count(*) filter (where t.status='completed') as txn_count,
|
||||
count(*) filter (where t.status='voided') as void_count,
|
||||
coalesce(sum(t.gross_usd) filter (where t.status='completed'),0) as gross_usd,
|
||||
coalesce(sum(t.gross_lbp) filter (where t.status='completed'),0) as gross_lbp,
|
||||
coalesce(sum(t.fee_usd) filter (where t.status='completed'),0) as fee_usd,
|
||||
coalesce(sum(t.fee_lbp) filter (where t.status='completed'),0) as fee_lbp,
|
||||
coalesce(sum(t.commission_usd) filter (where t.status='completed'),0) as comm_usd,
|
||||
coalesce(sum(t.commission_lbp) filter (where t.status='completed'),0) as comm_lbp
|
||||
from app.shifts sh
|
||||
left join app.transactions t
|
||||
on t.shift_id = sh.id
|
||||
group by sh.id
|
||||
)
|
||||
select
|
||||
sh.id as shift_id,
|
||||
sh.shop_id,
|
||||
sh.till_id,
|
||||
sh.user_id as cashier_id,
|
||||
sh.opened_at,
|
||||
sh.closed_at,
|
||||
sh.status,
|
||||
sh.opening_usd,
|
||||
sh.opening_lbp,
|
||||
cm.net_usd as expected_close_usd, -- = sum(cash_movements USD)
|
||||
cm.net_lbp as expected_close_lbp,
|
||||
sh.declared_close_usd,
|
||||
sh.declared_close_lbp,
|
||||
sh.declared_close_usd - cm.net_usd as variance_usd,
|
||||
sh.declared_close_lbp - cm.net_lbp as variance_lbp,
|
||||
txn.txn_count,
|
||||
txn.void_count,
|
||||
txn.gross_usd,
|
||||
txn.gross_lbp,
|
||||
txn.fee_usd + txn.comm_usd as revenue_usd,
|
||||
txn.fee_lbp + txn.comm_lbp as revenue_lbp
|
||||
from app.shifts sh
|
||||
join cm on cm.shift_id = sh.id
|
||||
join txn on txn.shift_id = sh.id;
|
||||
|
||||
-- =====================================================================
|
||||
-- Daily P&L per shop / service.
|
||||
-- =====================================================================
|
||||
create or replace view app.v_daily_pnl as
|
||||
select
|
||||
t.shop_id,
|
||||
(t.occurred_at at time zone 'UTC')::date as day,
|
||||
t.service_code,
|
||||
count(*) filter (where t.status='completed') as txn_count,
|
||||
sum(t.gross_usd) filter (where t.status='completed') as gross_usd,
|
||||
sum(t.gross_lbp) filter (where t.status='completed') as gross_lbp,
|
||||
sum(t.fee_usd) filter (where t.status='completed') as fee_usd,
|
||||
sum(t.fee_lbp) filter (where t.status='completed') as fee_lbp,
|
||||
sum(t.commission_usd) filter (where t.status='completed') as comm_usd,
|
||||
sum(t.commission_lbp) filter (where t.status='completed') as comm_lbp,
|
||||
count(*) filter (where t.status='voided') as void_count
|
||||
from app.transactions t
|
||||
group by t.shop_id, (t.occurred_at at time zone 'UTC')::date, t.service_code;
|
||||
|
||||
-- =====================================================================
|
||||
-- Per-employee scorecard (last 30 days). Owner uses this to spot the
|
||||
-- cashier whose numbers always look just slightly off.
|
||||
-- =====================================================================
|
||||
create or replace view app.v_employee_scorecard_30d as
|
||||
with base as (
|
||||
select sh.user_id as cashier_id, sh.shop_id, sh.id as shift_id,
|
||||
(sh.declared_close_usd - z.expected_close_usd) as var_usd,
|
||||
(sh.declared_close_lbp - z.expected_close_lbp) as var_lbp
|
||||
from app.shifts sh
|
||||
join app.v_z_report z on z.shift_id = sh.id
|
||||
where sh.closed_at >= now() - interval '30 days'
|
||||
and sh.status = 'closed'
|
||||
),
|
||||
voids as (
|
||||
select t.shop_id, t.user_id as cashier_id,
|
||||
count(*) as voids_30d,
|
||||
count(*) filter (where t.voided_at - t.occurred_at > interval '10 minutes') as late_voids_30d
|
||||
from app.transactions t
|
||||
where t.status = 'voided'
|
||||
and t.voided_at >= now() - interval '30 days'
|
||||
group by t.shop_id, t.user_id
|
||||
),
|
||||
overrides as (
|
||||
select t.shop_id, t.user_id as cashier_id,
|
||||
count(*) as overrides_30d
|
||||
from app.price_overrides p
|
||||
join app.transactions t on t.id = p.txn_id
|
||||
where p.created_at >= now() - interval '30 days'
|
||||
group by t.shop_id, t.user_id
|
||||
)
|
||||
select
|
||||
b.cashier_id,
|
||||
b.shop_id,
|
||||
count(*) as shifts_30d,
|
||||
count(*) filter (where b.var_usd < 0) as short_shifts_usd,
|
||||
count(*) filter (where b.var_lbp < 0) as short_shifts_lbp,
|
||||
sum(b.var_usd) as total_var_usd,
|
||||
sum(b.var_lbp) as total_var_lbp,
|
||||
avg(b.var_usd) as avg_var_usd,
|
||||
avg(b.var_lbp) as avg_var_lbp,
|
||||
coalesce(v.voids_30d,0) as voids_30d,
|
||||
coalesce(v.late_voids_30d,0) as late_voids_30d,
|
||||
coalesce(o.overrides_30d,0) as overrides_30d
|
||||
from base b
|
||||
left join voids v on v.cashier_id = b.cashier_id and v.shop_id = b.shop_id
|
||||
left join overrides o on o.cashier_id = b.cashier_id and o.shop_id = b.shop_id
|
||||
group by b.cashier_id, b.shop_id, v.voids_30d, v.late_voids_30d, o.overrides_30d;
|
||||
|
||||
-- =====================================================================
|
||||
-- Detector views (raw signals used by the alert engine).
|
||||
-- =====================================================================
|
||||
-- Recon backlog (vector #24)
|
||||
create or replace view app.v_alert_recon_backlog as
|
||||
select s.shop_id,
|
||||
s.id as settlement_id,
|
||||
s.provider,
|
||||
s.period_start,
|
||||
s.period_end,
|
||||
count(e.id) as open_exceptions
|
||||
from app.settlements s
|
||||
join app.reconciliation_exceptions e on e.settlement_id = s.id and e.resolved_at is null
|
||||
where s.status = 'has_exceptions'
|
||||
group by s.shop_id, s.id, s.provider, s.period_start, s.period_end;
|
||||
|
||||
-- After-hours activity (vector #22)
|
||||
create or replace view app.v_alert_after_hours as
|
||||
select t.shop_id,
|
||||
t.id as txn_id,
|
||||
t.user_id as cashier_id,
|
||||
t.occurred_at,
|
||||
t.gross_usd, t.gross_lbp
|
||||
from app.transactions t
|
||||
where t.status = 'completed'
|
||||
and (extract(hour from (t.occurred_at at time zone 'Asia/Beirut')) < 7
|
||||
or extract(hour from (t.occurred_at at time zone 'Asia/Beirut')) >= 23);
|
||||
|
||||
-- Chronic short cashier (vector #2)
|
||||
create or replace view app.v_alert_chronic_shorts as
|
||||
select cashier_id, shop_id,
|
||||
short_shifts_usd, short_shifts_lbp,
|
||||
total_var_usd, total_var_lbp
|
||||
from app.v_employee_scorecard_30d
|
||||
where short_shifts_usd >= 5 or short_shifts_lbp >= 5
|
||||
or total_var_usd <= -50 or total_var_lbp <= -1000000;
|
||||
|
||||
-- Void spike (vector #10) — >5 voids/day per cashier or any cashier with
|
||||
-- voids_30d > 20.
|
||||
create or replace view app.v_alert_void_spikes as
|
||||
select t.shop_id, t.user_id as cashier_id,
|
||||
(t.occurred_at at time zone 'Asia/Beirut')::date as day,
|
||||
count(*) as void_count
|
||||
from app.transactions t
|
||||
where t.status = 'voided'
|
||||
and t.voided_at >= now() - interval '30 days'
|
||||
group by t.shop_id, t.user_id, (t.occurred_at at time zone 'Asia/Beirut')::date
|
||||
having count(*) >= 5;
|
||||
|
||||
-- Override spike (vector #12)
|
||||
create or replace view app.v_alert_override_spikes as
|
||||
select t.shop_id, t.user_id as cashier_id,
|
||||
(p.created_at at time zone 'Asia/Beirut')::date as day,
|
||||
count(*) as override_count
|
||||
from app.price_overrides p
|
||||
join app.transactions t on t.id = p.txn_id
|
||||
where p.created_at >= now() - interval '30 days'
|
||||
group by t.shop_id, t.user_id, (p.created_at at time zone 'Asia/Beirut')::date
|
||||
having count(*) >= 3;
|
||||
|
||||
-- Stock shrinkage (vector #13)
|
||||
create or replace view app.v_alert_stock_shrinkage as
|
||||
select s.shop_id, s.sku,
|
||||
sum(case when m.type in ('damaged_out','lost_out','adjustment_out')
|
||||
then -m.qty_delta else 0 end) as shrink_qty_30d,
|
||||
sum(case when m.type = 'sale_out' then -m.qty_delta else 0 end) as sales_qty_30d
|
||||
from app.stock_movements m
|
||||
join app.stock_on_hand s on s.shop_id = m.shop_id and s.sku = m.sku
|
||||
where m.created_at >= now() - interval '30 days'
|
||||
group by s.shop_id, s.sku
|
||||
having sum(case when m.type in ('damaged_out','lost_out','adjustment_out')
|
||||
then -m.qty_delta else 0 end) >= 5;
|
||||
|
||||
-- Voucher loss / damage spike (vector #14)
|
||||
create or replace view app.v_alert_voucher_writeoffs as
|
||||
select v.shop_id,
|
||||
v.sku,
|
||||
count(*) filter (where v.status in ('damaged','lost')) as bad_30d,
|
||||
count(*) as total_30d
|
||||
from app.voucher_inventory v
|
||||
where coalesce(v.sold_at, v.received_at) >= now() - interval '30 days'
|
||||
group by v.shop_id, v.sku
|
||||
having count(*) filter (where v.status in ('damaged','lost'))::numeric
|
||||
/ nullif(count(*),0)::numeric > 0.02; -- > 2 %
|
||||
|
||||
-- =====================================================================
|
||||
-- Persistent alerts table + detector engine
|
||||
-- =====================================================================
|
||||
do $$ begin
|
||||
create type app.alert_severity as enum ('info','warn','critical');
|
||||
exception when duplicate_object then null; end $$;
|
||||
|
||||
do $$ begin
|
||||
create type app.alert_kind as enum (
|
||||
'chronic_short',
|
||||
'void_spike',
|
||||
'override_spike',
|
||||
'voucher_writeoffs',
|
||||
'stock_shrinkage',
|
||||
'after_hours',
|
||||
'recon_backlog',
|
||||
'aml_structuring',
|
||||
'aml_burst',
|
||||
'shift_unclosed',
|
||||
'chain_break',
|
||||
'reference_gap'
|
||||
);
|
||||
exception when duplicate_object then null; end $$;
|
||||
|
||||
create table if not exists app.alerts (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
shop_id uuid not null references app.shops(id) on delete restrict,
|
||||
kind app.alert_kind not null,
|
||||
severity app.alert_severity not null default 'warn',
|
||||
subject_id uuid, -- cashier / txn / settlement / shift
|
||||
payload jsonb not null,
|
||||
created_at timestamptz not null default now(),
|
||||
acknowledged_at timestamptz,
|
||||
acknowledged_by uuid references auth.users(id),
|
||||
ack_note text,
|
||||
-- Avoid duplicate alerts for the same condition on the same day:
|
||||
dedupe_key text not null unique
|
||||
);
|
||||
create index if not exists idx_alerts_open on app.alerts(shop_id, kind)
|
||||
where acknowledged_at is null;
|
||||
|
||||
-- Append-only / controlled update.
|
||||
create or replace function app._alerts_guard()
|
||||
returns trigger language plpgsql as $$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
raise exception 'alerts cannot be deleted';
|
||||
end if;
|
||||
if current_setting('app.alerts_internal', true) is distinct from 'on' then
|
||||
raise exception 'alerts can only be modified via app.* functions';
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
drop trigger if exists trg_alerts_guard on app.alerts;
|
||||
create trigger trg_alerts_guard before update or delete on app.alerts
|
||||
for each row execute function app._alerts_guard();
|
||||
|
||||
create or replace function app._raise_alert(
|
||||
p_shop uuid, p_kind app.alert_kind, p_severity app.alert_severity,
|
||||
p_subject uuid, p_payload jsonb, p_dedupe text
|
||||
) returns uuid
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = app, public
|
||||
as $$
|
||||
declare aid uuid;
|
||||
begin
|
||||
insert into app.alerts(shop_id, kind, severity, subject_id, payload, dedupe_key)
|
||||
values (p_shop, p_kind, p_severity, p_subject, p_payload, p_dedupe)
|
||||
on conflict (dedupe_key) do nothing
|
||||
returning id into aid;
|
||||
return aid;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- The detector. Idempotent: each rule produces a deterministic
|
||||
-- `dedupe_key` so re-running it doesn't multiply alerts.
|
||||
create or replace function app.run_alert_detectors()
|
||||
returns int
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = app, public
|
||||
as $$
|
||||
declare n int := 0; r record;
|
||||
begin
|
||||
-- Chronic shorts (vector #2)
|
||||
for r in select * from app.v_alert_chronic_shorts loop
|
||||
if app._raise_alert(r.shop_id, 'chronic_short', 'critical',
|
||||
r.cashier_id,
|
||||
jsonb_build_object('short_usd_shifts', r.short_shifts_usd,
|
||||
'short_lbp_shifts', r.short_shifts_lbp,
|
||||
'total_var_usd', r.total_var_usd,
|
||||
'total_var_lbp', r.total_var_lbp),
|
||||
format('chronic_short:%s:%s:%s', r.shop_id, r.cashier_id, to_char(now(),'YYYYMMDD'))
|
||||
) is not null then n := n + 1; end if;
|
||||
end loop;
|
||||
|
||||
-- Void spikes (vector #10)
|
||||
for r in select * from app.v_alert_void_spikes loop
|
||||
if app._raise_alert(r.shop_id, 'void_spike', 'warn',
|
||||
r.cashier_id,
|
||||
jsonb_build_object('day', r.day, 'count', r.void_count),
|
||||
format('void_spike:%s:%s:%s', r.shop_id, r.cashier_id, r.day)
|
||||
) is not null then n := n + 1; end if;
|
||||
end loop;
|
||||
|
||||
-- Override spikes (vector #12)
|
||||
for r in select * from app.v_alert_override_spikes loop
|
||||
if app._raise_alert(r.shop_id, 'override_spike', 'warn',
|
||||
r.cashier_id,
|
||||
jsonb_build_object('day', r.day, 'count', r.override_count),
|
||||
format('override_spike:%s:%s:%s', r.shop_id, r.cashier_id, r.day)
|
||||
) is not null then n := n + 1; end if;
|
||||
end loop;
|
||||
|
||||
-- Voucher write-off rate (vector #14)
|
||||
for r in select * from app.v_alert_voucher_writeoffs loop
|
||||
if app._raise_alert(r.shop_id, 'voucher_writeoffs', 'critical',
|
||||
null,
|
||||
jsonb_build_object('sku', r.sku, 'bad_30d', r.bad_30d, 'total_30d', r.total_30d),
|
||||
format('voucher_writeoffs:%s:%s:%s', r.shop_id, r.sku, to_char(now(),'YYYYMMDD'))
|
||||
) is not null then n := n + 1; end if;
|
||||
end loop;
|
||||
|
||||
-- Stock shrinkage (vector #13)
|
||||
for r in select * from app.v_alert_stock_shrinkage loop
|
||||
if app._raise_alert(r.shop_id, 'stock_shrinkage', 'warn',
|
||||
null,
|
||||
jsonb_build_object('sku', r.sku, 'shrink_qty_30d', r.shrink_qty_30d, 'sales_qty_30d', r.sales_qty_30d),
|
||||
format('stock_shrinkage:%s:%s:%s', r.shop_id, r.sku, to_char(now(),'YYYYMMDD'))
|
||||
) is not null then n := n + 1; end if;
|
||||
end loop;
|
||||
|
||||
-- After-hours (vector #22) — bucket per cashier per day
|
||||
for r in
|
||||
select shop_id, cashier_id,
|
||||
(occurred_at at time zone 'Asia/Beirut')::date as day,
|
||||
count(*) as cnt,
|
||||
sum(coalesce(gross_usd,0)) as g_usd,
|
||||
sum(coalesce(gross_lbp,0)) as g_lbp
|
||||
from app.v_alert_after_hours
|
||||
where occurred_at >= now() - interval '7 days'
|
||||
group by shop_id, cashier_id, (occurred_at at time zone 'Asia/Beirut')::date
|
||||
loop
|
||||
if app._raise_alert(r.shop_id, 'after_hours', 'warn',
|
||||
r.cashier_id,
|
||||
jsonb_build_object('day', r.day, 'count', r.cnt,
|
||||
'gross_usd', r.g_usd, 'gross_lbp', r.g_lbp),
|
||||
format('after_hours:%s:%s:%s', r.shop_id, r.cashier_id, r.day)
|
||||
) is not null then n := n + 1; end if;
|
||||
end loop;
|
||||
|
||||
-- Recon backlog (vector #24)
|
||||
for r in select * from app.v_alert_recon_backlog loop
|
||||
if app._raise_alert(r.shop_id, 'recon_backlog', 'critical',
|
||||
r.settlement_id,
|
||||
jsonb_build_object('provider', r.provider,
|
||||
'period_start', r.period_start,
|
||||
'period_end', r.period_end,
|
||||
'open_exceptions', r.open_exceptions),
|
||||
format('recon_backlog:%s', r.settlement_id)
|
||||
) is not null then n := n + 1; end if;
|
||||
end loop;
|
||||
|
||||
-- AML signals (from 0006)
|
||||
for r in select * from app.v_aml_structuring_by_customer loop
|
||||
if app._raise_alert(r.shop_id, 'aml_structuring', 'critical',
|
||||
r.customer_id,
|
||||
jsonb_build_object('day', r.day, 'service', r.service_code,
|
||||
'cnt', r.cnt, 'sum_usd', r.sum_usd, 'sum_lbp', r.sum_lbp),
|
||||
format('aml_structuring:%s:%s:%s:%s', r.shop_id, r.customer_id, r.service_code, r.day)
|
||||
) is not null then n := n + 1; end if;
|
||||
end loop;
|
||||
for r in select * from app.v_aml_same_beneficiary_burst loop
|
||||
if app._raise_alert(r.shop_id, 'aml_burst', 'critical',
|
||||
null,
|
||||
jsonb_build_object('beneficiary_phone', r.beneficiary_phone,
|
||||
'window_hour', r.window_hour,
|
||||
'cashier_count', r.cashier_count, 'cnt', r.cnt),
|
||||
format('aml_burst:%s:%s:%s', r.shop_id, r.beneficiary_phone, r.window_hour)
|
||||
) is not null then n := n + 1; end if;
|
||||
end loop;
|
||||
|
||||
-- Shift left open > 18 hours (vector #4)
|
||||
for r in
|
||||
select id, shop_id, cashier_id, opened_at
|
||||
from app.shifts
|
||||
where status = 'open' and opened_at < now() - interval '18 hours'
|
||||
loop
|
||||
if app._raise_alert(r.shop_id, 'shift_unclosed', 'warn',
|
||||
r.cashier_id,
|
||||
jsonb_build_object('shift_id', r.id, 'opened_at', r.opened_at),
|
||||
format('shift_unclosed:%s', r.id)
|
||||
) is not null then n := n + 1; end if;
|
||||
end loop;
|
||||
|
||||
-- Reference number gaps (vector #20)
|
||||
for r in select * from app.v_reference_gaps loop
|
||||
if app._raise_alert(r.shop_id, 'reference_gap', 'critical',
|
||||
null,
|
||||
jsonb_build_object('expected', r.expected_ref, 'actual', r.actual_ref),
|
||||
format('reference_gap:%s:%s', r.shop_id, r.expected_ref)
|
||||
) is not null then n := n + 1; end if;
|
||||
end loop;
|
||||
|
||||
-- Hash chain break (vector #25) — verify per shop, raise if any row fails
|
||||
for r in
|
||||
select s.id as shop_id
|
||||
from app.shops s
|
||||
where exists (select 1 from app.verify_chain(s.id) v where v.ok = false)
|
||||
loop
|
||||
if app._raise_alert(r.shop_id, 'chain_break', 'critical',
|
||||
null,
|
||||
jsonb_build_object('detected_at', now()),
|
||||
format('chain_break:%s:%s', r.shop_id, to_char(now(),'YYYYMMDDHH24'))
|
||||
) is not null then n := n + 1; end if;
|
||||
end loop;
|
||||
|
||||
return n;
|
||||
end;
|
||||
$$;
|
||||
revoke all on function app.run_alert_detectors() from public;
|
||||
grant execute on function app.run_alert_detectors() to authenticated;
|
||||
|
||||
-- Acknowledge an alert (owner only, audited).
|
||||
create or replace function app.ack_alert(p_alert uuid, p_note text)
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = app, public
|
||||
as $$
|
||||
declare a app.alerts%rowtype;
|
||||
begin
|
||||
if p_note is null or length(btrim(p_note)) < 5 then
|
||||
raise exception 'ack note >= 5 chars required';
|
||||
end if;
|
||||
select * into a from app.alerts where id = p_alert;
|
||||
if a.id is null then raise exception 'alert not found'; end if;
|
||||
if not app.has_role_in_shop(a.shop_id, 'owner') then
|
||||
raise exception 'owner role required';
|
||||
end if;
|
||||
if a.acknowledged_at is not null then
|
||||
raise exception 'alert already acknowledged';
|
||||
end if;
|
||||
perform set_config('app.alerts_internal', 'on', true);
|
||||
update app.alerts
|
||||
set acknowledged_at = now(), acknowledged_by = auth.uid(), ack_note = p_note
|
||||
where id = p_alert;
|
||||
perform set_config('app.alerts_internal', 'off', true);
|
||||
perform app.log_auth_event('alert_ack', a.shop_id, null,
|
||||
jsonb_build_object('alert_id', p_alert, 'kind', a.kind));
|
||||
end;
|
||||
$$;
|
||||
revoke all on function app.ack_alert(uuid, text) from public;
|
||||
grant execute on function app.ack_alert(uuid, text) to authenticated;
|
||||
|
||||
-- =====================================================================
|
||||
-- Owner dashboard rollup
|
||||
-- =====================================================================
|
||||
create or replace view app.v_owner_dashboard as
|
||||
select
|
||||
s.id as shop_id,
|
||||
s.name as shop_name,
|
||||
(select count(*) from app.shifts where shop_id=s.id and status='open') as open_shifts,
|
||||
(select count(*) from app.alerts where shop_id=s.id and acknowledged_at is null) as open_alerts,
|
||||
(select count(*) from app.alerts where shop_id=s.id and acknowledged_at is null
|
||||
and severity='critical') as critical_alerts,
|
||||
(select count(*) from app.reconciliation_exceptions e
|
||||
join app.settlements st on st.id=e.settlement_id
|
||||
where st.shop_id=s.id and e.resolved_at is null) as open_recon_exceptions,
|
||||
(select coalesce(sum(gross_usd),0) from app.v_daily_pnl
|
||||
where shop_id=s.id and day = (now() at time zone 'Asia/Beirut')::date) as today_gross_usd,
|
||||
(select coalesce(sum(gross_lbp),0) from app.v_daily_pnl
|
||||
where shop_id=s.id and day = (now() at time zone 'Asia/Beirut')::date) as today_gross_lbp
|
||||
from app.shops s;
|
||||
|
||||
-- =====================================================================
|
||||
-- RLS
|
||||
-- =====================================================================
|
||||
alter table app.alerts enable row level security;
|
||||
alter table app.alerts force row level security;
|
||||
revoke insert, update, delete on app.alerts from authenticated;
|
||||
|
||||
drop policy if exists alerts_select on app.alerts;
|
||||
create policy alerts_select on app.alerts
|
||||
for select to authenticated
|
||||
using (
|
||||
app.has_any_role_in_shop(shop_id,
|
||||
array['owner','manager','auditor']::app.business_role[])
|
||||
);
|
||||
grant select on app.alerts to authenticated;
|
||||
|
||||
-- End migration 0010 ----------------------------------------------------
|
||||
Reference in New Issue
Block a user