modulo de ventas
This commit is contained in:
@@ -38,6 +38,8 @@ async fn main() -> std::io::Result<()> {
|
||||
.configure(modules::auth::routes::configure)
|
||||
.configure(modules::users::routes::configure)
|
||||
.configure(modules::catalog::routes::configure)
|
||||
.configure(modules::sales::routes::configure)
|
||||
|
||||
|
||||
})
|
||||
.bind((bind_host.as_str(), bind_port))?
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod auth;
|
||||
pub mod users;
|
||||
pub mod catalog;
|
||||
pub mod catalog;
|
||||
pub mod sales;
|
||||
87
src/modules/sales/errors.rs
Normal file
87
src/modules/sales/errors.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use actix_web::{http::StatusCode, HttpResponse, ResponseError};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ErrorBody {
|
||||
error: String,
|
||||
code: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SalesError {
|
||||
#[error("Datos inválidos")]
|
||||
BadRequest,
|
||||
|
||||
#[error("Datos inválidos")]
|
||||
BadRequestDetail(String),
|
||||
|
||||
#[error("No encontrado")]
|
||||
NotFound,
|
||||
|
||||
#[error("Sin stock suficiente")]
|
||||
OutOfStock,
|
||||
|
||||
#[error("No autorizado")]
|
||||
Forbidden,
|
||||
|
||||
#[error("Ocurrió un error inesperado")]
|
||||
Internal,
|
||||
}
|
||||
|
||||
impl SalesError {
|
||||
fn code(&self) -> &'static str {
|
||||
match self {
|
||||
SalesError::BadRequest | SalesError::BadRequestDetail(_) => "bad_request",
|
||||
SalesError::NotFound => "not_found",
|
||||
SalesError::OutOfStock => "out_of_stock",
|
||||
SalesError::Forbidden => "forbidden",
|
||||
SalesError::Internal => "internal_error",
|
||||
}
|
||||
}
|
||||
|
||||
fn detail(&self) -> Option<String> {
|
||||
match self {
|
||||
SalesError::BadRequest => Some("Revisa los campos requeridos e inténtalo nuevamente.".to_string()),
|
||||
SalesError::BadRequestDetail(s) => Some(s.clone()),
|
||||
SalesError::NotFound => Some("No existe en tu empresa.".to_string()),
|
||||
SalesError::OutOfStock => Some("No hay inventario suficiente para completar la venta.".to_string()),
|
||||
SalesError::Forbidden => Some("No tienes permisos para esta acción.".to_string()),
|
||||
SalesError::Internal => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn status(&self) -> StatusCode {
|
||||
match self {
|
||||
SalesError::BadRequest | SalesError::BadRequestDetail(_) => StatusCode::BAD_REQUEST,
|
||||
SalesError::NotFound => StatusCode::NOT_FOUND,
|
||||
SalesError::OutOfStock => StatusCode::CONFLICT,
|
||||
SalesError::Forbidden => StatusCode::FORBIDDEN,
|
||||
SalesError::Internal => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponseError for SalesError {
|
||||
fn status_code(&self) -> StatusCode { self.status() }
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
let body = ErrorBody {
|
||||
error: self.to_string(),
|
||||
code: self.code(),
|
||||
detail: self.detail(),
|
||||
};
|
||||
HttpResponse::build(self.status_code()).json(body)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::middleware::errors::ApiAuthError> for SalesError {
|
||||
fn from(e: crate::middleware::errors::ApiAuthError) -> Self {
|
||||
match e {
|
||||
crate::middleware::errors::ApiAuthError::Forbidden => SalesError::Forbidden,
|
||||
_ => SalesError::Internal,
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/modules/sales/handlers.rs
Normal file
44
src/modules/sales/handlers.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use actix_web::{web, HttpResponse};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::middleware::{authz::require_roles, jwt::AuthenticatedUser};
|
||||
|
||||
use super::{errors::SalesError, models::SaleCreateReq, service};
|
||||
|
||||
pub async fn list(
|
||||
pool: web::Data<PgPool>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<HttpResponse, SalesError> {
|
||||
require_roles(&user, &["ADMIN", "CASHIER"])?;
|
||||
Ok(HttpResponse::Ok().json(service::list_sales(&pool, user.company_id).await?))
|
||||
}
|
||||
|
||||
pub async fn get_by_id(
|
||||
pool: web::Data<PgPool>,
|
||||
user: AuthenticatedUser,
|
||||
path: web::Path<i64>,
|
||||
) -> Result<HttpResponse, SalesError> {
|
||||
require_roles(&user, &["ADMIN", "CASHIER"])?;
|
||||
Ok(HttpResponse::Ok().json(service::get_sale(&pool, user.company_id, path.into_inner()).await?))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
pool: web::Data<PgPool>,
|
||||
user: AuthenticatedUser,
|
||||
body: web::Json<SaleCreateReq>,
|
||||
) -> Result<HttpResponse, SalesError> {
|
||||
require_roles(&user, &["ADMIN", "CASHIER"])?;
|
||||
Ok(HttpResponse::Created().json(
|
||||
service::create_sale(&pool, user.company_id, user.user_id, body.into_inner()).await?
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn cancel(
|
||||
pool: web::Data<PgPool>,
|
||||
user: AuthenticatedUser,
|
||||
path: web::Path<i64>,
|
||||
) -> Result<HttpResponse, SalesError> {
|
||||
require_roles(&user, &["ADMIN"])?;
|
||||
service::cancel_sale(&pool, user.company_id, user.user_id, path.into_inner()).await?;
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
5
src/modules/sales/mod.rs
Normal file
5
src/modules/sales/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod routes;
|
||||
mod handlers;
|
||||
mod service;
|
||||
mod models;
|
||||
mod errors;
|
||||
77
src/modules/sales/models.rs
Normal file
77
src/modules/sales/models.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SaleCreateReq {
|
||||
pub warehouse_id: Option<i64>, // si null => default
|
||||
pub items: Vec<SaleItemReq>,
|
||||
pub payments: Vec<SalePaymentReq>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SaleItemReq {
|
||||
pub product_id: i64,
|
||||
pub quantity: i64, // (si luego quieres 14,3 lo pasamos a Decimal)
|
||||
pub unit_price_cents: Option<i64>, // si null => toma precio activo
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SalePaymentReq {
|
||||
pub method_code: String, // "CASH" | "CARD" | "TRANSFER"
|
||||
pub amount_cents: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SaleListDto {
|
||||
pub id: i64,
|
||||
pub invoice_number: Option<String>,
|
||||
pub status_code: String,
|
||||
pub sold_at: Option<DateTime<Utc>>,
|
||||
pub total_cents: i64,
|
||||
pub paid_cents: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SaleDto {
|
||||
pub id: i64,
|
||||
pub invoice_number: Option<String>,
|
||||
pub status_code: String,
|
||||
pub cashier_user_id: Option<i64>,
|
||||
pub sold_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
|
||||
pub subtotal_cents: i64,
|
||||
pub tax_cents: i64,
|
||||
pub total_cents: i64,
|
||||
pub paid_cents: i64,
|
||||
pub balance_cents: i64,
|
||||
pub change_cents: i64,
|
||||
|
||||
pub items: Vec<SaleItemDto>,
|
||||
pub taxes: Vec<SaleTaxDto>,
|
||||
pub payments: Vec<SalePaymentDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SaleItemDto {
|
||||
pub product_id: i64,
|
||||
pub quantity: i64,
|
||||
pub unit_price_cents: i64,
|
||||
pub line_total_cents: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SaleTaxDto {
|
||||
pub tax_id: i64,
|
||||
pub rate_bp: i32, // 16% => 1600
|
||||
pub base_cents: i64,
|
||||
pub tax_cents: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SalePaymentDto {
|
||||
pub method_code: String,
|
||||
pub amount_cents: i64,
|
||||
}
|
||||
12
src/modules/sales/routes.rs
Normal file
12
src/modules/sales/routes.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use actix_web::web;
|
||||
use super::handlers;
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/sales")
|
||||
.route("", web::post().to(handlers::create))
|
||||
.route("", web::get().to(handlers::list))
|
||||
.route("/{id}", web::get().to(handlers::get_by_id))
|
||||
.route("/{id}/cancel", web::put().to(handlers::cancel)),
|
||||
);
|
||||
}
|
||||
701
src/modules/sales/service.rs
Normal file
701
src/modules/sales/service.rs
Normal file
@@ -0,0 +1,701 @@
|
||||
use chrono::Utc;
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
|
||||
use super::{errors::SalesError, models::*};
|
||||
|
||||
fn map_db_error(e: sqlx::Error) -> SalesError {
|
||||
if let sqlx::Error::Database(db_err) = &e {
|
||||
let code = db_err.code().map(|c| c.to_string()).unwrap_or_default();
|
||||
let msg = db_err.message().to_string();
|
||||
let constraint = db_err.constraint().unwrap_or("").to_string();
|
||||
|
||||
tracing::error!(
|
||||
pg_code = %code,
|
||||
pg_constraint = %constraint,
|
||||
pg_message = %msg,
|
||||
"DB error in sales module"
|
||||
);
|
||||
|
||||
// Unique
|
||||
if code == "23505" {
|
||||
return SalesError::BadRequestDetail("Duplicado (unique)".to_string());
|
||||
}
|
||||
// FK
|
||||
if code == "23503" {
|
||||
return SalesError::BadRequestDetail("Referencia inválida (FK)".to_string());
|
||||
}
|
||||
// NOT NULL
|
||||
if code == "23502" {
|
||||
return SalesError::BadRequestDetail("Falta un campo obligatorio (NOT NULL)".to_string());
|
||||
}
|
||||
// CHECK
|
||||
if code == "23514" {
|
||||
return SalesError::BadRequestDetail("Datos inválidos: violación de restricción (CHECK)".to_string());
|
||||
}
|
||||
|
||||
// En desarrollo: si cae aquí, te dejo el mensaje real (para que lo veas en Postman)
|
||||
return SalesError::BadRequestDetail(format!("DB {}: {}", code, msg));
|
||||
}
|
||||
|
||||
tracing::error!(error = %e, "Non-DB error in sales module");
|
||||
SalesError::Internal
|
||||
}
|
||||
|
||||
|
||||
|
||||
async fn get_id_by_code(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
table: &str,
|
||||
code: &str,
|
||||
) -> Result<i64, SalesError> {
|
||||
let q = format!("SELECT id::bigint FROM {} WHERE code = $1 LIMIT 1", table);
|
||||
let id: Option<i64> = sqlx::query_scalar(&q)
|
||||
.bind(code)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
id.ok_or_else(|| SalesError::BadRequestDetail(format!("No existe code='{}' en {}", code, table)))
|
||||
}
|
||||
|
||||
async fn get_default_warehouse(tx: &mut Transaction<'_, Postgres>, company_id: i64) -> Result<i64, SalesError> {
|
||||
let wh: Option<i64> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT id::bigint
|
||||
FROM pos.warehouse
|
||||
WHERE company_id=$1 AND is_default=TRUE
|
||||
ORDER BY id LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
if let Some(id) = wh { return Ok(id); }
|
||||
|
||||
let any: Option<i64> = sqlx::query_scalar(
|
||||
r#"SELECT id::bigint FROM pos.warehouse WHERE company_id=$1 ORDER BY id LIMIT 1"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
if let Some(id) = any {
|
||||
sqlx::query(r#"UPDATE pos.warehouse SET is_default=TRUE WHERE id=$1"#)
|
||||
.bind(id)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
return Ok(id);
|
||||
}
|
||||
|
||||
let new_id: i64 = sqlx::query_scalar(
|
||||
r#"INSERT INTO pos.warehouse(company_id,name,is_default) VALUES ($1,'Default',TRUE) RETURNING id::bigint"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
Ok(new_id)
|
||||
}
|
||||
|
||||
async fn ensure_product_ok(tx: &mut Transaction<'_, Postgres>, company_id: i64, product_id: i64) -> Result<(), SalesError> {
|
||||
let ok: Option<i64> = sqlx::query_scalar(
|
||||
r#"SELECT id::bigint FROM pos.product WHERE company_id=$1 AND id=$2 AND is_active=TRUE LIMIT 1"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.bind(product_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
if ok.is_some() { Ok(()) } else { Err(SalesError::BadRequestDetail("Producto inválido o inactivo".to_string())) }
|
||||
}
|
||||
|
||||
async fn get_active_price_cents(tx: &mut Transaction<'_, Postgres>, product_id: i64) -> Result<i64, SalesError> {
|
||||
let p: Option<i64> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COALESCE(ROUND(unit_price * 100)::bigint, 0)
|
||||
FROM pos.product_price
|
||||
WHERE product_id=$1 AND valid_to IS NULL
|
||||
ORDER BY valid_from DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(product_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
Ok(p.unwrap_or(0))
|
||||
}
|
||||
|
||||
async fn stock_decrease(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
warehouse_id: i64,
|
||||
product_id: i64,
|
||||
qty: i64,
|
||||
) -> Result<(), SalesError> {
|
||||
// UPDATE con guardia qty_on_hand >= qty
|
||||
let updated: Option<i64> = sqlx::query_scalar(
|
||||
r#"
|
||||
UPDATE pos.inventory_stock
|
||||
SET qty_on_hand = qty_on_hand - ($3::numeric)
|
||||
WHERE warehouse_id=$1 AND product_id=$2
|
||||
AND qty_on_hand >= ($3::numeric)
|
||||
RETURNING 1::bigint
|
||||
"#,
|
||||
)
|
||||
.bind(warehouse_id)
|
||||
.bind(product_id)
|
||||
.bind(qty)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
if updated.is_none() { return Err(SalesError::OutOfStock); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stock_increase(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
warehouse_id: i64,
|
||||
product_id: i64,
|
||||
qty: i64,
|
||||
) -> Result<(), SalesError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO pos.inventory_stock(warehouse_id, product_id, qty_on_hand)
|
||||
VALUES ($1, $2, ($3::numeric))
|
||||
ON CONFLICT (warehouse_id, product_id)
|
||||
DO UPDATE SET qty_on_hand = pos.inventory_stock.qty_on_hand + EXCLUDED.qty_on_hand
|
||||
"#,
|
||||
)
|
||||
.bind(warehouse_id)
|
||||
.bind(product_id)
|
||||
.bind(qty)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_sale(
|
||||
pool: &PgPool,
|
||||
company_id: i64,
|
||||
cashier_user_id: i64,
|
||||
req: SaleCreateReq,
|
||||
) -> Result<SaleDto, SalesError> {
|
||||
if req.items.is_empty() {
|
||||
return Err(SalesError::BadRequestDetail("La venta debe tener items".to_string()));
|
||||
}
|
||||
if req.payments.is_empty() {
|
||||
return Err(SalesError::BadRequestDetail("La venta debe tener pagos".to_string()));
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await.map_err(map_db_error)?;
|
||||
|
||||
let warehouse_id = match req.warehouse_id {
|
||||
Some(w) => w,
|
||||
None => get_default_warehouse(&mut tx, company_id).await?,
|
||||
};
|
||||
|
||||
// status ids
|
||||
let status_draft_id = get_id_by_code(&mut tx, "pos.sale_status", "DRAFT").await?;
|
||||
let status_completed_id = get_id_by_code(&mut tx, "pos.sale_status", "COMPLETED").await?;
|
||||
let out_sale_type_id = get_id_by_code(&mut tx, "pos.inventory_movement_type", "OUT_SALE").await?;
|
||||
|
||||
// 1) crear sale en DRAFT
|
||||
let sale_id: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO pos.sale(company_id, status_id, cashier_user_id)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id::bigint
|
||||
"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.bind(status_draft_id)
|
||||
.bind(cashier_user_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
// 2) asignar invoice_number basado en id (garantiza unique)
|
||||
let invoice_number = format!("FACT-{}-{:06}", company_id, sale_id);
|
||||
sqlx::query(
|
||||
r#"UPDATE pos.sale SET invoice_number=$2 WHERE id=$1"#,
|
||||
)
|
||||
.bind(sale_id)
|
||||
.bind(&invoice_number)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
// 3) insertar items + descontar stock + kardex OUT_SALE
|
||||
let mut subtotal_cents: i64 = 0;
|
||||
|
||||
for it in &req.items {
|
||||
if it.quantity <= 0 {
|
||||
let _ = tx.rollback().await;
|
||||
return Err(SalesError::BadRequestDetail("quantity debe ser > 0".to_string()));
|
||||
}
|
||||
|
||||
ensure_product_ok(&mut tx, company_id, it.product_id).await?;
|
||||
|
||||
let unit_price_cents = match it.unit_price_cents {
|
||||
Some(v) => v,
|
||||
None => get_active_price_cents(&mut tx, it.product_id).await?,
|
||||
};
|
||||
|
||||
let line_total_cents = unit_price_cents
|
||||
.checked_mul(it.quantity)
|
||||
.ok_or(SalesError::Internal)?;
|
||||
subtotal_cents = subtotal_cents.checked_add(line_total_cents).ok_or(SalesError::Internal)?;
|
||||
|
||||
// stock
|
||||
stock_decrease(&mut tx, warehouse_id, it.product_id, it.quantity).await?;
|
||||
|
||||
// sale_item (quantity NUMERIC(14,3), unit_price NUMERIC(14,2))
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO pos.sale_item(sale_id, product_id, quantity, unit_price)
|
||||
VALUES ($1, $2, ($3::numeric), ($4::numeric / 100))
|
||||
"#,
|
||||
)
|
||||
.bind(sale_id)
|
||||
.bind(it.product_id)
|
||||
.bind(it.quantity)
|
||||
.bind(unit_price_cents)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
// inventory_movement (reference_table + reference_id)
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO pos.inventory_movement(
|
||||
company_id, warehouse_id, product_id, movement_type_id, qty,
|
||||
reference_table, reference_id, created_by
|
||||
)
|
||||
VALUES ($1,$2,$3,$4,($5::numeric),'sale',$6,$7)
|
||||
"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.bind(warehouse_id)
|
||||
.bind(it.product_id)
|
||||
.bind(out_sale_type_id)
|
||||
.bind(it.quantity)
|
||||
.bind(sale_id)
|
||||
.bind(cashier_user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
}
|
||||
|
||||
// 4) impuestos: snapshot con tasas activas de la empresa (tax_rate activo)
|
||||
// base_amount = subtotal, tax_amount = base * rate_pct/100
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO pos.sale_tax(sale_id, tax_id, rate_pct, base_amount, tax_amount)
|
||||
SELECT
|
||||
$1 AS sale_id,
|
||||
tr.tax_id,
|
||||
tr.rate_pct,
|
||||
(($2::numeric)/100) AS base_amount,
|
||||
((($2::numeric)/100) * (tr.rate_pct/100)) AS tax_amount
|
||||
FROM pos.tax_rate tr
|
||||
WHERE tr.company_id = $3 AND tr.valid_to IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(sale_id)
|
||||
.bind(subtotal_cents)
|
||||
.bind(company_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
// tax_total_cents y total_cents
|
||||
let tax_cents: i64 = sqlx::query_scalar(
|
||||
r#"SELECT COALESCE(ROUND(SUM(tax_amount)*100)::bigint, 0) FROM pos.sale_tax WHERE sale_id=$1"#,
|
||||
)
|
||||
.bind(sale_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
let total_cents = subtotal_cents.checked_add(tax_cents).ok_or(SalesError::Internal)?;
|
||||
|
||||
// 5) pagos (payment_method_id)
|
||||
let mut paid_cents: i64 = 0;
|
||||
for p in &req.payments {
|
||||
let code = p.method_code.trim().to_uppercase();
|
||||
if code.is_empty() || p.amount_cents <= 0 {
|
||||
let _ = tx.rollback().await;
|
||||
return Err(SalesError::BadRequestDetail("Pago inválido".to_string()));
|
||||
}
|
||||
|
||||
let pm_id = get_id_by_code(&mut tx, "pos.payment_method", &code).await?;
|
||||
paid_cents = paid_cents.checked_add(p.amount_cents).ok_or(SalesError::Internal)?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO pos.sale_payment(sale_id, payment_method_id, amount)
|
||||
VALUES ($1, $2, (($3::numeric)/100))
|
||||
"#,
|
||||
)
|
||||
.bind(sale_id)
|
||||
.bind(pm_id)
|
||||
.bind(p.amount_cents)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
}
|
||||
|
||||
// 6) si pagó completo => COMPLETED + sold_at
|
||||
if paid_cents >= total_cents {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE pos.sale
|
||||
SET status_id = $2,
|
||||
sold_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND company_id = $3
|
||||
"#,
|
||||
)
|
||||
.bind(sale_id)
|
||||
.bind(status_completed_id)
|
||||
.bind(company_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
} else {
|
||||
sqlx::query(r#"UPDATE pos.sale SET updated_at=now() WHERE id=$1"#)
|
||||
.bind(sale_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(map_db_error)?;
|
||||
get_sale(pool, company_id, sale_id).await
|
||||
}
|
||||
|
||||
pub async fn list_sales(pool: &PgPool, company_id: i64) -> Result<Vec<SaleListDto>, SalesError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: i64,
|
||||
invoice_number: Option<String>,
|
||||
status_code: String,
|
||||
sold_at: Option<chrono::DateTime<Utc>>,
|
||||
total_cents: i64,
|
||||
paid_cents: i64,
|
||||
created_at: chrono::DateTime<Utc>,
|
||||
}
|
||||
|
||||
let rows: Vec<Row> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
s.id::bigint AS id,
|
||||
s.invoice_number,
|
||||
st.code AS status_code,
|
||||
s.sold_at,
|
||||
COALESCE(ROUND((
|
||||
(SELECT COALESCE(SUM(si.quantity * si.unit_price),0) FROM pos.sale_item si WHERE si.sale_id=s.id)
|
||||
+
|
||||
(SELECT COALESCE(SUM(tx.tax_amount),0) FROM pos.sale_tax tx WHERE tx.sale_id=s.id)
|
||||
) * 100)::bigint, 0) AS total_cents,
|
||||
COALESCE(ROUND((
|
||||
SELECT COALESCE(SUM(sp.amount),0) FROM pos.sale_payment sp WHERE sp.sale_id=s.id
|
||||
) * 100)::bigint, 0) AS paid_cents,
|
||||
s.created_at
|
||||
FROM pos.sale s
|
||||
JOIN pos.sale_status st ON st.id = s.status_id
|
||||
WHERE s.company_id = $1
|
||||
ORDER BY s.id DESC
|
||||
LIMIT 200
|
||||
"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| SaleListDto {
|
||||
id: r.id,
|
||||
invoice_number: r.invoice_number,
|
||||
status_code: r.status_code,
|
||||
sold_at: r.sold_at,
|
||||
total_cents: r.total_cents,
|
||||
paid_cents: r.paid_cents,
|
||||
created_at: r.created_at,
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub async fn get_sale(pool: &PgPool, company_id: i64, sale_id: i64) -> Result<SaleDto, SalesError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct H {
|
||||
id: i64,
|
||||
invoice_number: Option<String>,
|
||||
status_code: String,
|
||||
cashier_user_id: Option<i64>,
|
||||
sold_at: Option<chrono::DateTime<Utc>>,
|
||||
created_at: chrono::DateTime<Utc>,
|
||||
updated_at: chrono::DateTime<Utc>,
|
||||
}
|
||||
|
||||
let h: Option<H> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
s.id::bigint AS id,
|
||||
s.invoice_number,
|
||||
st.code AS status_code,
|
||||
s.cashier_user_id::bigint AS cashier_user_id,
|
||||
s.sold_at,
|
||||
s.created_at,
|
||||
s.updated_at
|
||||
FROM pos.sale s
|
||||
JOIN pos.sale_status st ON st.id = s.status_id
|
||||
WHERE s.company_id=$1 AND s.id=$2
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.bind(sale_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
let h = h.ok_or(SalesError::NotFound)?;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct I { product_id: i64, quantity_i64: i64, unit_price_cents: i64 }
|
||||
let item_rows: Vec<I> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
product_id::bigint AS product_id,
|
||||
ROUND(quantity)::bigint AS quantity_i64,
|
||||
COALESCE(ROUND(unit_price*100)::bigint,0) AS unit_price_cents
|
||||
FROM pos.sale_item
|
||||
WHERE sale_id=$1
|
||||
ORDER BY id
|
||||
"#,
|
||||
)
|
||||
.bind(sale_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
let mut subtotal_cents: i64 = 0;
|
||||
let items: Vec<SaleItemDto> = item_rows.into_iter().map(|r| {
|
||||
let q = r.quantity_i64;
|
||||
let line = r.unit_price_cents * q;
|
||||
subtotal_cents += line;
|
||||
SaleItemDto {
|
||||
product_id: r.product_id,
|
||||
quantity: q,
|
||||
unit_price_cents: r.unit_price_cents,
|
||||
line_total_cents: line,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct T { tax_id: i64, rate_bp: i32, base_cents: i64, tax_cents: i64 }
|
||||
let tax_rows: Vec<T> = sqlx::query_as::<_, T>(
|
||||
r#"
|
||||
SELECT
|
||||
tax_id::bigint AS tax_id,
|
||||
ROUND(rate_pct*100)::int AS rate_bp,
|
||||
COALESCE(ROUND(base_amount*100)::bigint,0) AS base_cents,
|
||||
COALESCE(ROUND(tax_amount*100)::bigint,0) AS tax_cents
|
||||
FROM pos.sale_tax
|
||||
WHERE sale_id=$1
|
||||
ORDER BY tax_id
|
||||
"#,
|
||||
)
|
||||
.bind(sale_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
let taxes: Vec<SaleTaxDto> = tax_rows
|
||||
.into_iter()
|
||||
.map(|r| SaleTaxDto {
|
||||
tax_id: r.tax_id,
|
||||
rate_bp: r.rate_bp,
|
||||
base_cents: r.base_cents,
|
||||
tax_cents: r.tax_cents,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tax_cents: i64 = taxes.iter().map(|t| t.tax_cents).sum();
|
||||
let total_cents = subtotal_cents + tax_cents;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct PayRow {
|
||||
method_code: String,
|
||||
amount_cents: i64,
|
||||
}
|
||||
|
||||
let pay_rows: Vec<PayRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
pm.code AS method_code,
|
||||
COALESCE(ROUND(sp.amount*100)::bigint,0) AS amount_cents
|
||||
FROM pos.sale_payment sp
|
||||
JOIN pos.payment_method pm ON pm.id = sp.payment_method_id
|
||||
WHERE sp.sale_id=$1
|
||||
ORDER BY sp.id
|
||||
"#,
|
||||
)
|
||||
.bind(sale_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
let payments: Vec<SalePaymentDto> = pay_rows
|
||||
.into_iter()
|
||||
.map(|r| SalePaymentDto {
|
||||
method_code: r.method_code,
|
||||
amount_cents: r.amount_cents,
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
let paid_cents: i64 = payments.iter().map(|p| p.amount_cents).sum();
|
||||
|
||||
// balance = lo que falta por pagar (nunca negativo)
|
||||
let balance_cents = (total_cents - paid_cents).max(0);
|
||||
|
||||
// change = vuelto (nunca negativo)
|
||||
let change_cents = (paid_cents - total_cents).max(0);
|
||||
|
||||
Ok(SaleDto {
|
||||
id: h.id,
|
||||
invoice_number: h.invoice_number,
|
||||
status_code: h.status_code,
|
||||
cashier_user_id: h.cashier_user_id,
|
||||
sold_at: h.sold_at,
|
||||
created_at: h.created_at,
|
||||
updated_at: h.updated_at,
|
||||
subtotal_cents,
|
||||
tax_cents,
|
||||
total_cents,
|
||||
paid_cents,
|
||||
balance_cents,
|
||||
change_cents,
|
||||
items,
|
||||
taxes,
|
||||
payments,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn cancel_sale(
|
||||
pool: &PgPool,
|
||||
company_id: i64,
|
||||
admin_user_id: i64,
|
||||
sale_id: i64,
|
||||
) -> Result<(), SalesError> {
|
||||
let mut tx = pool.begin().await.map_err(map_db_error)?;
|
||||
|
||||
let status_cancelled_id = get_id_by_code(&mut tx, "pos.sale_status", "CANCELLED").await?;
|
||||
let in_void_type_id = get_id_by_code(&mut tx, "pos.inventory_movement_type", "IN_VOID").await?;
|
||||
|
||||
// validar sale existe y no esté cancelada
|
||||
let status_code: Option<String> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT st.code
|
||||
FROM pos.sale s
|
||||
JOIN pos.sale_status st ON st.id = s.status_id
|
||||
WHERE s.company_id=$1 AND s.id=$2
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.bind(sale_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
let status_code = status_code.ok_or(SalesError::NotFound)?;
|
||||
if status_code == "CANCELLED" {
|
||||
let _ = tx.rollback().await;
|
||||
return Err(SalesError::BadRequestDetail("La venta ya está cancelada".to_string()));
|
||||
}
|
||||
|
||||
// warehouse usado: lo tomamos del movimiento OUT_SALE (reference_table='sale')
|
||||
let warehouse_id: Option<i64> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT warehouse_id::bigint AS warehouse_id
|
||||
FROM pos.inventory_movement
|
||||
WHERE company_id=$1 AND reference_table='sale' AND reference_id=$2
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.bind(sale_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
let warehouse_id = warehouse_id.ok_or_else(|| SalesError::BadRequestDetail(
|
||||
"No hay movimientos OUT_SALE asociados; no puedo determinar warehouse".to_string()
|
||||
))?;
|
||||
|
||||
// items (reponer stock) desde sale_item
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct I { product_id: i64, qty_i64: i64 }
|
||||
|
||||
let items: Vec<I> = sqlx::query_as(
|
||||
r#"SELECT product_id::bigint AS product_id, ROUND(quantity)::bigint AS qty_i64
|
||||
FROM pos.sale_item WHERE sale_id=$1"#,
|
||||
)
|
||||
.bind(sale_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
for it in items {
|
||||
let qty = it.qty_i64;
|
||||
stock_increase(&mut tx, warehouse_id, it.product_id, qty).await?;
|
||||
|
||||
// kardex IN_VOID
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO pos.inventory_movement(
|
||||
company_id, warehouse_id, product_id, movement_type_id, qty,
|
||||
reference_table, reference_id, notes, created_by
|
||||
)
|
||||
VALUES ($1,$2,$3,$4,($5::numeric),'sale',$6,'Cancelación de venta',$7)
|
||||
"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.bind(warehouse_id)
|
||||
.bind(it.product_id)
|
||||
.bind(in_void_type_id)
|
||||
.bind(qty)
|
||||
.bind(sale_id)
|
||||
.bind(admin_user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
}
|
||||
|
||||
// actualizar sale a CANCELLED
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE pos.sale
|
||||
SET status_id=$3,
|
||||
updated_at=now()
|
||||
WHERE company_id=$1 AND id=$2
|
||||
"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.bind(sale_id)
|
||||
.bind(status_cancelled_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
tx.commit().await.map_err(map_db_error)?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user