backbone-inventory 0.17.2

Inventory: stock ledger of record (SLE) + moving-average valuation; emits COGS/asset AccountingPost (Delivery/Receipt -> GL)
Documentation
//! Repository for StockReconciliation entities
//!
//! Originally generated by metaphor-schema; now **user-owned** — this exact path is declared under
//! `user_owned` in `metaphor.codegen.yaml`, so the generator skips it wholesale. The custom methods
//! below hold the hand-written StockReconciliation SQL
//! (4-layer rule: services orchestrate, repositories hold the SQL).
//!
//! Thin newtype over `backbone_orm::GenericCrudRepository<StockReconciliation, backbone_orm::SoftDelete>`.
//! All standard CRUD methods are available via `Deref`.

use anyhow::Result;
use rust_decimal::Decimal;
use sqlx::{PgPool, Row};
use uuid::Uuid;

use backbone_orm::org_scope;

use super::gl_voucher_repository::GlSettlementState;

use crate::domain::entity::StockReconciliation;

/// Table name for StockReconciliation entities
pub const TABLE_NAME: &str = "inventory.stock_reconciliations";

/// Repository for StockReconciliation entities.
///
/// All standard CRUD, soft-delete, pagination, and bulk methods are
/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
pub struct StockReconciliationRepository(
    backbone_orm::GenericCrudRepository<StockReconciliation, backbone_orm::SoftDelete>,
);

impl std::ops::Deref for StockReconciliationRepository {
    type Target = backbone_orm::GenericCrudRepository<StockReconciliation, backbone_orm::SoftDelete>;
    fn deref(&self) -> &Self::Target { &self.0 }
}

impl StockReconciliationRepository {
    /// Create a new repository instance.
    pub fn new(pool: PgPool) -> Self {
        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
    }
}

/// The exact row a Stock Reconciliation header insert writes.
///
/// Mirrors the raw column shape rather than the `StockReconciliation` entity: `net_difference`
/// starts as a literal 0 (it is only known once every counted line is applied), and `status` /
/// `posting_state` are literal casts.
pub struct NewReconciliationRow<'a> {
    pub id: Uuid,
    pub recon_number: &'a str,
    pub warehouse_id: Uuid,
    pub posting_date: chrono::NaiveDate,
    pub currency: &'a str,
    pub inventory_account_id: Uuid,
    pub adjustment_account_id: Uuid,
}

/// The repost path's header projection — rebuilds the SAME value-diff envelope from the stored
/// header, never re-touching the SLE/Bin (the physical movement already happened). Mirrors the
/// receipt/delivery repost headers (council 2026-07-29, parking-lot item: `repost_reconciliation`).
pub struct ReconRepostHeaderRow {
    pub recon_number: String,
    pub posting_date: chrono::NaiveDate,
    pub currency: String,
    pub net_difference: Decimal,
    pub inventory_account_id: Uuid,
    pub adjustment_account_id: Uuid,
    pub gl: GlSettlementState,
}

/// Hand-written StockReconciliation SQL. Lives here per the module's 4-layer rule.
impl StockReconciliationRepository {
    /// Insert the reconciliation header.
    ///
    /// Takes the CALLER'S connection so the header, its counted lines, and every bin/SLE write
    /// commit as one unit. The caller has already relayed the ambient org scope onto it — don't
    /// re-bind here.
    ///
    /// Returns the raw `sqlx::Error` deliberately: the caller inspects it for a unique violation to
    /// turn a duplicate recon number into `DuplicateNumber`.
    pub async fn insert_submitted(
        &self,
        conn: &mut sqlx::PgConnection,
        r: &NewReconciliationRow<'_>,
    ) -> Result<(), sqlx::Error> {
        sqlx::query(
            r#"INSERT INTO inventory.stock_reconciliations
                (id, recon_number, warehouse_id, posting_date, currency, net_difference,
                 inventory_account_id, adjustment_account_id, status, posting_state)
               VALUES ($1,$2,$3,$4,$5,0,$6,$7,'submitted'::doc_status,'pending'::gl_posting_state)"#,
        )
        .bind(r.id).bind(r.recon_number).bind(r.warehouse_id).bind(r.posting_date)
        .bind(r.currency)
        .bind(r.inventory_account_id).bind(r.adjustment_account_id)
        .execute(conn)
        .await?;
        Ok(())
    }

    /// Record the net value difference the count produced. Must ride the movement's transaction.
    pub async fn update_net_difference(
        &self,
        conn: &mut sqlx::PgConnection,
        id: Uuid,
        net_difference: Decimal,
    ) -> Result<(), sqlx::Error> {
        sqlx::query("UPDATE inventory.stock_reconciliations SET net_difference=$2 WHERE id=$1")
            .bind(id).bind(net_difference)
            .execute(conn)
            .await?;
        Ok(())
    }

    /// Mark a zero-net reconciliation as needing no GL post at all (the count matched the books).
    ///
    /// Runs `org_scope::execute_scoped` on the pool: the write rides the ambient org scope's
    /// request-dedicated connection so it passes the composing decorator's fence (ADR-0029).
    /// Undecorated (module tests) the UPDATE runs plain.
    pub async fn mark_not_applicable(
        &self,
        pool: &PgPool,
        id: Uuid,
    ) -> Result<(), sqlx::Error> {
        org_scope::execute_scoped(
            pool,
            sqlx::query("UPDATE inventory.stock_reconciliations SET posting_state='not_applicable'::gl_posting_state WHERE id=$1")
                .bind(id),
        )
        .await?;
        Ok(())
    }

    /// Read the repost path's header. Same ID-only scope fence as the receipt/delivery repost
    /// headers (`fetch_optional_row_scoped` rides the ambient org scope the composing service set
    /// per request). Used by `repost_reconciliation` to re-drive a stuck `pending`/`failed` post
    /// (or a `net==0` recon that crashed before `mark_not_applicable`).
    pub async fn fetch_repost_header(
        &self,
        pool: &PgPool,
        id: Uuid,
    ) -> Result<Option<ReconRepostHeaderRow>, sqlx::Error> {
        let row = org_scope::fetch_optional_row_scoped(
            pool,
            sqlx::query(
                r#"SELECT recon_number, posting_date, currency, net_difference,
                          inventory_account_id, adjustment_account_id,
                          posting_state::text AS ps, journal_id, accounting_post_id
                   FROM inventory.stock_reconciliations WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
            )
            .bind(id),
        )
        .await?;
        Ok(row.map(|h| ReconRepostHeaderRow {
            recon_number: h.get("recon_number"),
            posting_date: h.get("posting_date"), currency: h.get("currency"),
            net_difference: h.get("net_difference"),
            inventory_account_id: h.get("inventory_account_id"),
            adjustment_account_id: h.get("adjustment_account_id"),
            gl: GlSettlementState {
                posting_state: h.get("ps"), journal_id: h.get("journal_id"),
                accounting_post_id: h.get("accounting_post_id"),
            },
        }))
    }
}

backbone_core::impl_crud_repository!(StockReconciliationRepository, StockReconciliation, soft_delete);