backbone-inventory 0.17.2

Inventory: stock ledger of record (SLE) + moving-average valuation; emits COGS/asset AccountingPost (Delivery/Receipt -> GL)
Documentation
//! Repository for PurchaseReceipt 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 PurchaseReceipt SQL
//! (4-layer rule: services orchestrate, repositories hold the SQL).
//!
//! Thin newtype over `backbone_orm::GenericCrudRepository<PurchaseReceipt, 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::PurchaseReceipt;

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

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

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

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

/// The exact row a draft Purchase Receipt header insert writes.
///
/// Mirrors the raw column shape rather than the `PurchaseReceipt` entity: `status` and
/// `posting_state` are literal casts in the statement (`'draft'::doc_status`,
/// `'pending'::gl_posting_state`), `currency` arrives from the caller (no longer a hardcoded
/// 'IDR' — council 2026-07-29), and `total_value` arrives already rounded.
pub struct NewReceiptRow<'a> {
    pub id: Uuid,
    pub receipt_number: &'a str,
    pub branch_id: Option<Uuid>,
    pub supplier_id: Uuid,
    pub source_po_id: Option<Uuid>,
    pub warehouse_id: Uuid,
    pub posting_date: chrono::NaiveDate,
    pub currency: &'a str,
    pub total_value: Decimal,
    pub inventory_account_id: Uuid,
    pub grir_account_id: Uuid,
}

/// The submit path's header projection — everything needed to drive the movement + the asset post.
pub struct ReceiptSubmitHeaderRow {
    pub receipt_number: String,
    pub branch_id: Option<Uuid>,
    pub warehouse_id: Uuid,
    pub posting_date: chrono::NaiveDate,
    pub source_po_id: Option<Uuid>,
    pub currency: String,
    /// `status::text` — the submit path refuses anything but "draft".
    pub status: String,
    pub inventory_account_id: Uuid,
    pub grir_account_id: Uuid,
}

/// The repost path's header projection — rebuilds the SAME envelope from the stored header, never
/// re-touching the SLE/Bin (the physical movement already happened).
pub struct ReceiptRepostHeaderRow {
    pub branch_id: Option<Uuid>,
    pub receipt_number: String,
    pub posting_date: chrono::NaiveDate,
    pub currency: String,
    pub total_value: Decimal,
    pub inventory_account_id: Uuid,
    pub grir_account_id: Uuid,
    /// The warehouse whose stock location resolves the location valuation-account override —
    /// the repost must rebuild the SAME envelope the submit built, override included.
    pub warehouse_id: Uuid,
    pub gl: GlSettlementState,
}

/// The cancel path's header projection — everything needed to reverse the inflow and emit the
/// `posting_type='reversal'` post. Carries the original `accounting_post_id` (to reverse) and any
/// already-recorded `reversal_accounting_post_id` (the idempotency/recovery short-circuit).
pub struct ReceiptCancelHeaderRow {
    pub branch_id: Option<Uuid>,
    pub warehouse_id: Uuid,
    pub posting_date: chrono::NaiveDate,
    pub currency: String,
    pub receipt_number: String,
    /// The original posted value — the reversal GL leg re-posts this amount (Dr GR/IR · Cr Inventory).
    pub total_value: Decimal,
    /// `status::text` — a fresh cancel requires "submitted"; "cancelled" is the idempotent/recovery
    /// short-circuit.
    pub status: String,
    pub inventory_account_id: Uuid,
    pub grir_account_id: Uuid,
    pub gl: GlSettlementState,
    pub reversal_journal_id: Option<Uuid>,
    pub reversal_accounting_post_id: Option<Uuid>,
}

/// Hand-written PurchaseReceipt SQL. Lives here per the module's 4-layer rule.
impl PurchaseReceiptRepository {
    /// Insert the draft receipt header.
    ///
    /// Takes the CALLER'S connection so the header and its items 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 receipt number into `DuplicateNumber`.
    pub async fn insert_draft(
        &self,
        conn: &mut sqlx::PgConnection,
        r: &NewReceiptRow<'_>,
    ) -> Result<(), sqlx::Error> {
        sqlx::query(
            r#"INSERT INTO inventory.purchase_receipts
                (id, receipt_number, branch_id, supplier_id, source_po_id, warehouse_id,
                 posting_date, currency, total_value, inventory_account_id, grir_account_id,
                 status, posting_state)
               VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'draft'::doc_status,'pending'::gl_posting_state)"#,
        )
        .bind(r.id).bind(r.receipt_number).bind(r.branch_id).bind(r.supplier_id)
        .bind(r.source_po_id).bind(r.warehouse_id).bind(r.posting_date).bind(r.currency).bind(r.total_value)
        .bind(r.inventory_account_id).bind(r.grir_account_id)
        .execute(conn)
        .await?;
        Ok(())
    }

    /// Read the submit path's header. `Ok(None)` = no such live receipt in scope.
    ///
    /// ID-only: `fetch_optional_row_scoped` means it rides the ambient org scope's request-dedicated
    /// connection (ADR-0029), so the decorator's fence bounds what the read can see.
    pub async fn fetch_submit_header(
        &self,
        pool: &PgPool,
        id: Uuid,
    ) -> Result<Option<ReceiptSubmitHeaderRow>, sqlx::Error> {
        let row = org_scope::fetch_optional_row_scoped(
            pool,
            sqlx::query(
                r#"SELECT receipt_number, branch_id, warehouse_id, posting_date, source_po_id,
                          currency, status::text AS st, inventory_account_id, grir_account_id
                   FROM inventory.purchase_receipts WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
            )
            .bind(id),
        )
        .await?;
        Ok(row.map(|h| ReceiptSubmitHeaderRow {
            receipt_number: h.get("receipt_number"),
            branch_id: h.get("branch_id"), warehouse_id: h.get("warehouse_id"),
            posting_date: h.get("posting_date"), source_po_id: h.get("source_po_id"),
            currency: h.get("currency"),
            status: h.get("st"), inventory_account_id: h.get("inventory_account_id"),
            grir_account_id: h.get("grir_account_id"),
        }))
    }

    /// Read the repost path's header. Same ID-only scope fence as [`Self::fetch_submit_header`].
    pub async fn fetch_repost_header(
        &self,
        pool: &PgPool,
        id: Uuid,
    ) -> Result<Option<ReceiptRepostHeaderRow>, sqlx::Error> {
        let row = org_scope::fetch_optional_row_scoped(
            pool,
            sqlx::query(
                r#"SELECT branch_id, receipt_number, posting_date, currency, total_value,
                          inventory_account_id, grir_account_id, posting_state::text AS ps, journal_id, accounting_post_id,
                          warehouse_id
                   FROM inventory.purchase_receipts WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
            )
            .bind(id),
        )
        .await?;
        Ok(row.map(|h| ReceiptRepostHeaderRow {
            branch_id: h.get("branch_id"),
            receipt_number: h.get("receipt_number"), posting_date: h.get("posting_date"),
            currency: h.get("currency"),
            total_value: h.get("total_value"), inventory_account_id: h.get("inventory_account_id"),
            grir_account_id: h.get("grir_account_id"),
            warehouse_id: h.get("warehouse_id"),
            gl: GlSettlementState {
                posting_state: h.get("ps"), journal_id: h.get("journal_id"),
                accounting_post_id: h.get("accounting_post_id"),
            },
        }))
    }

    /// Flip the receipt draft→submitted. Must ride the movement's transaction, so the status change
    /// commits with the SLE + Bin writes it describes.
    pub async fn mark_submitted(
        &self,
        conn: &mut sqlx::PgConnection,
        id: Uuid,
    ) -> Result<(), sqlx::Error> {
        sqlx::query("UPDATE inventory.purchase_receipts SET status='submitted'::doc_status WHERE id=$1")
            .bind(id)
            .execute(conn)
            .await?;
        Ok(())
    }

    /// Flip the receipt submitted→cancelled. Must ride the cancellation's transaction, so the status
    /// change commits with the compensating SLE + Bin writes it describes. Guarded on `submitted` so a
    /// draft or an already-cancelled receipt is not touched here (the service gates those cases).
    pub async fn mark_cancelled(
        &self,
        conn: &mut sqlx::PgConnection,
        id: Uuid,
    ) -> Result<(), sqlx::Error> {
        sqlx::query("UPDATE inventory.purchase_receipts SET status='cancelled'::doc_status WHERE id=$1 AND status='submitted'::doc_status")
            .bind(id)
            .execute(conn)
            .await?;
        Ok(())
    }

    /// Read the cancel path's header. Same ID-only scope fence as [`Self::fetch_submit_header`].
    pub async fn fetch_cancel_header(
        &self,
        pool: &PgPool,
        id: Uuid,
    ) -> Result<Option<ReceiptCancelHeaderRow>, sqlx::Error> {
        let row = org_scope::fetch_optional_row_scoped(
            pool,
            sqlx::query(
                r#"SELECT branch_id, warehouse_id, posting_date, currency, receipt_number,
                          total_value, status::text AS st, inventory_account_id, grir_account_id,
                          posting_state::text AS ps, journal_id, accounting_post_id,
                          reversal_journal_id, reversal_accounting_post_id
                   FROM inventory.purchase_receipts WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
            )
            .bind(id),
        )
        .await?;
        Ok(row.map(|h| ReceiptCancelHeaderRow {
            branch_id: h.get("branch_id"),
            warehouse_id: h.get("warehouse_id"), posting_date: h.get("posting_date"),
            currency: h.get("currency"), receipt_number: h.get("receipt_number"),
            total_value: h.get("total_value"),
            status: h.get("st"), inventory_account_id: h.get("inventory_account_id"),
            grir_account_id: h.get("grir_account_id"),
            reversal_journal_id: h.get("reversal_journal_id"),
            reversal_accounting_post_id: h.get("reversal_accounting_post_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!(PurchaseReceiptRepository, PurchaseReceipt, soft_delete);