use backbone_orm::org_scope;
use rust_decimal::{Decimal, RoundingStrategy};
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
use crate::infrastructure::persistence::{
BinRepository, DeliveryNoteItemRepository, DeliveryNoteRepository, GlSettlementState, GlVoucher,
GlVoucherRepository, PickingBatchProjectionRepository, PurchaseReceiptItemRepository,
ScrapDoorRepository,
PurchaseReceiptRepository, QuantRepository, StockAdjustmentRepository, StockEntryItemRepository,
StockEntryRepository, StockItemRepository, StockLedgerEntryRepository, StockMoveLineRepository,
StockMoveRepository, StockPickingRepository, StockReconciliationItemRepository,
StockReconciliationRepository, ValuationOverlayRepository, WarehouseRepository,
};
use super::inventory_events::{InventoryEventSink, LoggingSink};
use super::inventory_gl::{AccountingPostEnvelope, GlPostSink};
pub(crate) fn legacy_company_echo() -> Uuid {
org_scope::current_org_scope()
.and_then(|s| s.legacy_company_id())
.unwrap_or(Uuid::nil())
}
pub(crate) async fn relay_ambient_scope(conn: &mut sqlx::PgConnection) -> Result<(), sqlx::Error> {
if let Some(scope) = org_scope::current_org_scope() {
org_scope::bind_org_scope_on(conn, &scope).await?;
}
Ok(())
}
pub(super) fn money(v: Decimal) -> Decimal {
v.round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero)
}
pub(super) fn rate6(v: Decimal) -> Decimal {
v.round_dp_with_strategy(6, RoundingStrategy::MidpointAwayFromZero)
}
#[derive(Debug, Clone)]
pub struct NewWarehouse {
pub code: String,
pub name: String,
pub warehouse_type: Option<String>,
pub parent_warehouse_id: Option<Uuid>,
pub is_group: bool,
}
#[derive(Debug, Clone)]
pub struct NewStockItem {
pub item_id: Uuid,
pub stock_uom: String,
pub valuation_method: Option<String>,
pub reorder_level: Decimal,
}
#[derive(Debug, Clone)]
pub struct ReceiptLine {
pub item_id: Uuid,
pub quantity: Decimal,
pub rate: Decimal,
pub is_landed_costs_line: bool,
}
#[derive(Debug, Clone)]
pub struct NewReceipt {
pub receipt_number: String,
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: String,
pub inventory_account_id: Uuid,
pub grir_account_id: Uuid,
pub lines: Vec<ReceiptLine>,
}
#[derive(Debug, Clone)]
pub struct DeliveryLine {
pub item_id: Uuid,
pub quantity: Decimal,
}
#[derive(Debug, Clone)]
pub struct NewDelivery {
pub delivery_number: String,
pub branch_id: Option<Uuid>,
pub customer_id: Uuid,
pub source_so_id: Option<Uuid>,
pub warehouse_id: Uuid,
pub posting_date: chrono::NaiveDate,
pub currency: String,
pub cogs_account_id: Uuid,
pub inventory_account_id: Uuid,
pub lines: Vec<DeliveryLine>,
}
#[derive(Debug, Clone)]
pub struct NewTransfer {
pub entry_number: String,
pub from_warehouse_id: Uuid,
pub to_warehouse_id: Uuid,
pub posting_date: chrono::NaiveDate,
pub lines: Vec<DeliveryLine>, }
#[derive(Debug, Clone)]
pub struct ReconLine {
pub item_id: Uuid,
pub counted_qty: Decimal,
pub counted_rate: Decimal, }
#[derive(Debug, Clone)]
pub struct NewReconciliation {
pub recon_number: String,
pub warehouse_id: Uuid,
pub posting_date: chrono::NaiveDate,
pub currency: String,
pub inventory_account_id: Uuid,
pub adjustment_account_id: Uuid,
pub lines: Vec<ReconLine>,
}
#[derive(Debug, Clone)]
pub struct SubmitOutcome {
pub voucher_id: Uuid,
pub posted: bool,
pub journal_id: Option<Uuid>,
pub post_id: Option<Uuid>,
pub gl_amount: Decimal,
}
#[derive(Debug, Clone)]
pub struct LcCostLine {
pub name: String,
pub account_id: Uuid,
pub split_method: String,
pub amount: Decimal,
}
#[derive(Debug, Clone)]
pub struct NewLandedCost {
pub lc_number: String,
pub branch_id: Option<Uuid>,
pub target_receipt_id: Uuid,
pub posting_date: chrono::NaiveDate,
pub currency: String,
pub notes: Option<String>,
pub lines: Vec<LcCostLine>,
}
#[derive(Debug)]
pub enum InventoryError {
EmptyDocument,
NegativeQuantity,
InsufficientStock { item_id: Uuid, warehouse_id: Uuid, available: Decimal, requested: Decimal },
DuplicateNumber(String),
NotFound(Uuid),
NotDraft(String),
SameWarehouse,
NotSubmitted(Uuid),
GlNotPosted(Uuid),
InsufficientStockToReverse { item_id: Uuid, warehouse_id: Uuid, available: Decimal, requested: Decimal },
GlRejected { code: String, message: String },
WrongMoveState { move_id: Uuid, action: &'static str, current: String },
MoveHasNoRule { move_id: Uuid },
SameLocation { move_id: Uuid, location_id: Uuid },
MoveLinesRequired { move_id: Uuid },
ViewLocationHoldsNoStock { location_id: Uuid },
LocationNotFound(Uuid),
CountReserved { quant_id: Uuid, reserved_qty: Decimal },
OutdatedCount { quant_id: Uuid, counted_qty: Decimal, on_hand_qty: Decimal },
CountedRateUnsupported,
AngloPostureUnconfigured,
LandedCostNoValuedTargets { receipt_id: Uuid },
LandedCostLineNeedsAccount { line_id: Uuid },
LandedCostRequiresCostMethod { cost_method: String },
LandedCostZeroSplitBasis { basis: String, target_receipt_id: Uuid },
LandedCostNotDraft { lc_id: Uuid, state: String },
LandedCostNoLines { lc_id: Uuid },
LandedCostNoValuationAccount { move_id: Uuid },
LandedCostAllocationMismatch { lc_id: Uuid, allocated: Decimal, declared: Decimal },
BatchTerminal { batch_id: Uuid, state: String },
PickingTerminalForBatch { picking_id: Uuid, state: String },
PickingAlreadyBatched { picking_id: Uuid, batch_id: Uuid },
NotABatchMember { batch_id: Uuid, picking_id: Uuid },
ScrapNotDraft { scrap_id: Uuid, state: String },
ScrapLocationUnavailable,
Db(sqlx::Error),
}
impl InventoryError {
pub fn code(&self) -> String {
match self {
InventoryError::EmptyDocument => "empty_document".into(),
InventoryError::NegativeQuantity => "negative_quantity".into(),
InventoryError::InsufficientStock { .. } => "insufficient_stock".into(),
InventoryError::DuplicateNumber(_) => "duplicate_number".into(),
InventoryError::NotFound(_) => "not_found".into(),
InventoryError::NotDraft(_) => "not_draft".into(),
InventoryError::NotSubmitted(_) => "not_submitted".into(),
InventoryError::GlNotPosted(_) => "gl_not_posted".into(),
InventoryError::InsufficientStockToReverse { .. } => "insufficient_stock_to_reverse".into(),
InventoryError::SameWarehouse => "same_warehouse".into(),
InventoryError::GlRejected { code, .. } => code.clone(),
InventoryError::WrongMoveState { .. } => "wrong_move_state".into(),
InventoryError::MoveHasNoRule { .. } => "move_has_no_rule".into(),
InventoryError::SameLocation { .. } => "same_location".into(),
InventoryError::MoveLinesRequired { .. } => "move_lines_required".into(),
InventoryError::ViewLocationHoldsNoStock { .. } => "quant_on_view_location".into(),
InventoryError::LocationNotFound(_) => "location_not_found".into(),
InventoryError::CountReserved { .. } => "quant_reserved".into(),
InventoryError::OutdatedCount { .. } => "outdated_count".into(),
InventoryError::CountedRateUnsupported => "counted_rate_unsupported".into(),
InventoryError::AngloPostureUnconfigured => "anglo_posture_unconfigured".into(),
InventoryError::LandedCostNoValuedTargets { .. } => "landed_cost_no_valued_targets".into(),
InventoryError::LandedCostLineNeedsAccount { .. } => "landed_cost_line_needs_account".into(),
InventoryError::LandedCostRequiresCostMethod { .. } => "landed_cost_requires_cost_method".into(),
InventoryError::LandedCostZeroSplitBasis { .. } => "landed_cost_zero_split_basis".into(),
InventoryError::LandedCostNotDraft { .. } => "landed_cost_not_draft".into(),
InventoryError::LandedCostNoLines { .. } => "landed_cost_no_lines".into(),
InventoryError::LandedCostNoValuationAccount { .. } => "landed_cost_no_valuation_account".into(),
InventoryError::LandedCostAllocationMismatch { .. } => "landed_cost_allocation_mismatch".into(),
InventoryError::BatchTerminal { .. } => "batch_terminal".into(),
InventoryError::PickingTerminalForBatch { .. } => "picking_terminal_for_batch".into(),
InventoryError::PickingAlreadyBatched { .. } => "picking_already_batched".into(),
InventoryError::NotABatchMember { .. } => "not_a_batch_member".into(),
InventoryError::ScrapNotDraft { .. } => "scrap_not_draft".into(),
InventoryError::ScrapLocationUnavailable { .. } => "scrap_location_unavailable".into(),
InventoryError::Db(_) => "internal_error".into(),
}
}
pub fn http_status(&self) -> u16 {
match self {
InventoryError::NotFound(_) => 404,
InventoryError::Db(_) => 500,
_ => 422,
}
}
}
impl std::fmt::Display for InventoryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
InventoryError::GlRejected { code, message } => write!(f, "{code}: {message}"),
other => write!(f, "{}", other.code()),
}
}
}
impl std::error::Error for InventoryError {}
impl From<sqlx::Error> for InventoryError {
fn from(e: sqlx::Error) -> Self { InventoryError::Db(e) }
}
pub(super) fn is_dup(e: &sqlx::Error) -> bool {
e.as_database_error().map(|d| d.is_unique_violation()).unwrap_or(false)
}
pub(in crate::application::service) struct DoorOwnedGlSink;
#[async_trait::async_trait]
impl super::inventory_gl::GlPostSink for DoorOwnedGlSink {
async fn post(
&self,
_e: &super::inventory_gl::AccountingPostEnvelope,
) -> Result<super::inventory_gl::GlPostAck, super::inventory_gl::GlPostRejected> {
Err(super::inventory_gl::GlPostRejected {
code: "gl_owned_by_voucher_door".into(),
message: "voucher doors post one door-owned GL envelope; their moves post none".into(),
})
}
}
#[derive(Clone)]
pub struct InventoryWriteService {
pub(super) db_pool: PgPool,
pub(super) sink: Arc<dyn InventoryEventSink>,
pub(super) warehouses: Arc<WarehouseRepository>,
pub(super) stock_items: Arc<StockItemRepository>,
pub(super) bins: Arc<BinRepository>,
pub(super) sles: Arc<StockLedgerEntryRepository>,
pub(super) receipts: Arc<PurchaseReceiptRepository>,
pub(super) receipt_items: Arc<PurchaseReceiptItemRepository>,
pub(super) deliveries: Arc<DeliveryNoteRepository>,
pub(super) delivery_items: Arc<DeliveryNoteItemRepository>,
pub(super) entries: Arc<StockEntryRepository>,
pub(super) entry_items: Arc<StockEntryItemRepository>,
pub(super) recons: Arc<StockReconciliationRepository>,
pub(super) recon_items: Arc<StockReconciliationItemRepository>,
pub(super) gl: Arc<GlVoucherRepository>,
pub(super) quants: Arc<QuantRepository>,
pub(super) moves: Arc<StockMoveRepository>,
pub(super) move_lines: Arc<StockMoveLineRepository>,
pub(super) pickings: Arc<StockPickingRepository>,
pub(super) adjustments: Arc<StockAdjustmentRepository>,
pub(super) valuation_overlay: Arc<ValuationOverlayRepository>,
pub(super) batches: Arc<PickingBatchProjectionRepository>,
pub(super) scraps: Arc<ScrapDoorRepository>,
}
impl InventoryWriteService {
pub fn new(db_pool: PgPool) -> Self {
Self::with_sink(db_pool, Arc::new(LoggingSink))
}
pub fn with_sink(db_pool: PgPool, sink: Arc<dyn InventoryEventSink>) -> Self {
Self {
warehouses: Arc::new(WarehouseRepository::new(db_pool.clone())),
stock_items: Arc::new(StockItemRepository::new(db_pool.clone())),
bins: Arc::new(BinRepository::new(db_pool.clone())),
sles: Arc::new(StockLedgerEntryRepository::new(db_pool.clone())),
receipts: Arc::new(PurchaseReceiptRepository::new(db_pool.clone())),
receipt_items: Arc::new(PurchaseReceiptItemRepository::new(db_pool.clone())),
deliveries: Arc::new(DeliveryNoteRepository::new(db_pool.clone())),
delivery_items: Arc::new(DeliveryNoteItemRepository::new(db_pool.clone())),
entries: Arc::new(StockEntryRepository::new(db_pool.clone())),
entry_items: Arc::new(StockEntryItemRepository::new(db_pool.clone())),
recons: Arc::new(StockReconciliationRepository::new(db_pool.clone())),
recon_items: Arc::new(StockReconciliationItemRepository::new(db_pool.clone())),
gl: Arc::new(GlVoucherRepository::new()),
quants: Arc::new(QuantRepository::new(db_pool.clone())),
moves: Arc::new(StockMoveRepository::new(db_pool.clone())),
move_lines: Arc::new(StockMoveLineRepository::new(db_pool.clone())),
pickings: Arc::new(StockPickingRepository::new()),
adjustments: Arc::new(StockAdjustmentRepository::new()),
valuation_overlay: Arc::new(ValuationOverlayRepository::new()),
batches: Arc::new(PickingBatchProjectionRepository::new()),
scraps: Arc::new(ScrapDoorRepository::new()),
db_pool,
sink,
}
}
pub(super) fn rpool(&self) -> PgPool {
crate::request_pool::current().unwrap_or_else(|| self.db_pool.clone())
}
pub(super) fn already_settled(gl: &GlSettlementState, id: Uuid) -> Option<SubmitOutcome> {
match gl.posting_state.as_str() {
"posted" => Some(SubmitOutcome {
voucher_id: id, posted: true,
journal_id: gl.journal_id, post_id: gl.accounting_post_id, gl_amount: Decimal::ZERO,
}),
"not_applicable" => Some(SubmitOutcome { voucher_id: id, posted: false, journal_id: None, post_id: None, gl_amount: Decimal::ZERO }),
_ => None,
}
}
pub(super) async fn emit_and_reconcile(
&self, voucher: GlVoucher, voucher_id: Uuid, env: &AccountingPostEnvelope, sink: &dyn GlPostSink, gl_amount: Decimal,
) -> Result<SubmitOutcome, InventoryError> {
debug_assert!(env.is_balanced());
match sink.post(env).await {
Ok(ack) => {
self.gl.mark_posted(&self.rpool(), voucher, voucher_id, ack.journal_id, ack.post_id).await?;
Ok(SubmitOutcome { voucher_id, posted: true, journal_id: Some(ack.journal_id), post_id: Some(ack.post_id), gl_amount })
}
Err(rej) => {
let _ = self.gl.mark_failed(&self.rpool(), voucher, voucher_id).await;
Err(InventoryError::GlRejected { code: rej.code, message: rej.message })
}
}
}
pub(super) async fn emit_reversal_and_reconcile(
&self, voucher: GlVoucher, voucher_id: Uuid, env: &AccountingPostEnvelope, sink: &dyn GlPostSink, gl_amount: Decimal,
) -> Result<SubmitOutcome, InventoryError> {
debug_assert!(env.is_balanced());
match sink.post(env).await {
Ok(ack) => {
self.gl.mark_reversal_posted(&self.rpool(), voucher, voucher_id, ack.journal_id, ack.post_id).await?;
Ok(SubmitOutcome { voucher_id, posted: true, journal_id: Some(ack.journal_id), post_id: Some(ack.post_id), gl_amount })
}
Err(rej) => Err(InventoryError::GlRejected { code: rej.code, message: rej.message }),
}
}
pub(in crate::application::service) async fn mint_line_move(
&self,
m: super::inventory_move_engine::NewStockMove,
) -> Result<Option<Uuid>, InventoryError> {
let origin = m.origin.clone().unwrap_or_default();
let existing = {
let mut tx = self.rpool().begin().await?;
relay_ambient_scope(&mut tx).await?;
let rows = self.moves.fetch_moves_by_origin(&mut tx, &origin).await?;
tx.commit().await?;
rows
};
if let Some(prior) = existing.iter().find(|mv| mv.name == m.name) {
return match prior.state.as_str() {
"done" => Ok(None),
"cancel" => Err(InventoryError::WrongMoveState {
move_id: prior.id, action: "mint", current: "cancel".into(),
}),
_ => Ok(Some(prior.id)),
};
}
Ok(Some(self.create_move(m).await?))
}
pub(in crate::application::service) async fn advance_move_to_assigned(
&self,
move_id: Uuid,
) -> Result<String, InventoryError> {
let mut state = self.move_state_of(move_id).await?;
if state == "draft" {
self.action_confirm(move_id).await?;
state = self.move_state_of(move_id).await?;
}
if state == "confirmed" || state == "partially_available" {
let outcome = self.action_assign(move_id).await?;
state = outcome.state;
}
Ok(state)
}
pub(in crate::application::service) async fn move_state_of(
&self,
move_id: Uuid,
) -> Result<String, InventoryError> {
let mut tx = self.rpool().begin().await?;
relay_ambient_scope(&mut tx).await?;
let mv = self.moves.fetch_move(&mut tx, move_id).await?
.ok_or(InventoryError::NotFound(move_id))?;
tx.commit().await?;
Ok(mv.state)
}
pub(in crate::application::service) async fn door_move_endpoints(
&self,
warehouse_id: Uuid,
partner_usage: &str,
) -> Result<(Uuid, Uuid), InventoryError> {
let mut tx = self.rpool().begin().await?;
relay_ambient_scope(&mut tx).await?;
let partner = self.pickings.ensure_partner_location(&mut tx, partner_usage).await?;
let stock = self.pickings.ensure_internal_location(&mut tx, warehouse_id).await?;
tx.commit().await?;
Ok((partner, stock))
}
}