Some checks failed
BotServer CI / build (push) Failing after 1m34s
Split 20+ files over 1000 lines into focused subdirectories for better maintainability and code organization. All changes maintain backward compatibility through re-export wrappers. Major splits: - attendance/llm_assist.rs (2074→7 modules) - basic/keywords/face_api.rs → face_api/ (7 modules) - basic/keywords/file_operations.rs → file_ops/ (8 modules) - basic/keywords/hear_talk.rs → hearing/ (6 modules) - channels/wechat.rs → wechat/ (10 modules) - channels/youtube.rs → youtube/ (5 modules) - contacts/mod.rs → contacts_api/ (6 modules) - core/bootstrap/mod.rs → bootstrap/ (5 modules) - core/shared/admin.rs → admin_*.rs (5 modules) - designer/canvas.rs → canvas_api/ (6 modules) - designer/mod.rs → designer_api/ (6 modules) - docs/handlers.rs → handlers_api/ (11 modules) - drive/mod.rs → drive_handlers.rs, drive_types.rs - learn/mod.rs → types.rs - main.rs → main_module/ (7 modules) - meet/webinar.rs → webinar_api/ (8 modules) - paper/mod.rs → (10 modules) - security/auth.rs → auth_api/ (7 modules) - security/passkey.rs → (4 modules) - sources/mod.rs → sources_api/ (5 modules) - tasks/mod.rs → task_api/ (5 modules) Stats: 38,040 deletions, 1,315 additions across 318 files Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1035 lines
33 KiB
Rust
1035 lines
33 KiB
Rust
use std::str::FromStr;
|
|
|
|
use axum::{
|
|
extract::{Path, Query, State},
|
|
http::StatusCode,
|
|
routing::{get, put},
|
|
Json, Router,
|
|
};
|
|
|
|
use bigdecimal::BigDecimal;
|
|
use chrono::{DateTime, Utc};
|
|
use diesel::prelude::*;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use std::sync::Arc;
|
|
use uuid::Uuid;
|
|
|
|
use crate::core::bot::get_default_bot;
|
|
use crate::core::shared::schema::{
|
|
inventory_movements, price_list_items, price_lists, product_categories, product_variants,
|
|
products, services,
|
|
};
|
|
use crate::core::shared::state::AppState;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)]
|
|
#[diesel(table_name = products)]
|
|
pub struct Product {
|
|
pub id: Uuid,
|
|
pub org_id: Uuid,
|
|
pub bot_id: Uuid,
|
|
pub sku: Option<String>,
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub category: Option<String>,
|
|
pub product_type: String,
|
|
pub price: BigDecimal,
|
|
pub cost: Option<BigDecimal>,
|
|
pub currency: String,
|
|
pub tax_rate: BigDecimal,
|
|
pub unit: String,
|
|
pub stock_quantity: i32,
|
|
pub low_stock_threshold: i32,
|
|
pub is_active: bool,
|
|
pub images: serde_json::Value,
|
|
pub attributes: serde_json::Value,
|
|
pub weight: Option<BigDecimal>,
|
|
pub dimensions: Option<serde_json::Value>,
|
|
pub barcode: Option<String>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
// // Tax and fiscal identification
|
|
// pub tax_code: Option<String>,
|
|
// pub tax_class: Option<String>,
|
|
// pub fiscal_code: Option<String>,
|
|
// pub origin_code: Option<i32>,
|
|
// pub global_trade_number: Option<String>,
|
|
// pub tax_unit_code: Option<String>,
|
|
// // Detailed dimensions (shipping)
|
|
// pub net_weight: Option<BigDecimal>,
|
|
// pub gross_weight: Option<BigDecimal>,
|
|
// pub width: Option<BigDecimal>,
|
|
// pub height: Option<BigDecimal>,
|
|
// pub length: Option<BigDecimal>,
|
|
// pub package_count: Option<i32>,
|
|
// // Tax rates by type
|
|
// pub sales_tax_code: Option<String>,
|
|
// pub sales_tax_rate: Option<BigDecimal>,
|
|
// pub excise_tax_code: Option<String>,
|
|
// pub excise_tax_rate: Option<BigDecimal>,
|
|
// pub vat_code: Option<String>,
|
|
// pub vat_rate: Option<BigDecimal>,
|
|
// pub service_tax_code: Option<String>,
|
|
// pub service_tax_rate: Option<BigDecimal>,
|
|
// // Marketplace and e-commerce
|
|
// pub brand: Option<String>,
|
|
// pub model: Option<String>,
|
|
// pub color: Option<String>,
|
|
// pub size: Option<String>,
|
|
// pub material: Option<String>,
|
|
// pub gender: Option<String>,
|
|
// // Advanced inventory control
|
|
// pub warehouse_location: Option<String>,
|
|
// pub batch_number: Option<String>,
|
|
// pub expiration_date: Option<chrono::NaiveDate>,
|
|
// pub manufacture_date: Option<chrono::NaiveDate>,
|
|
// pub min_stock: Option<i32>,
|
|
// pub max_stock: Option<i32>,
|
|
// pub reorder_point: Option<i32>,
|
|
// // Detailed pricing
|
|
// pub sale_price: Option<BigDecimal>,
|
|
// pub sale_start: Option<DateTime<Utc>>,
|
|
// pub sale_end: Option<DateTime<Utc>>,
|
|
// pub shipping_cost: Option<BigDecimal>,
|
|
// pub profit_margin: Option<BigDecimal>,
|
|
// Payment gateway integration
|
|
// pub external_id: Option<String>,
|
|
// pub external_category_id: Option<String>,
|
|
// pub external_metadata: Option<serde_json::Value>,
|
|
// // SEO and search
|
|
// pub slug: Option<String>,
|
|
// pub meta_title: Option<String>,
|
|
// pub meta_description: Option<String>,
|
|
// pub tags: Option<Vec<Option<String>>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)]
|
|
#[diesel(table_name = services)]
|
|
pub struct Service {
|
|
pub id: Uuid,
|
|
pub org_id: Uuid,
|
|
pub bot_id: Uuid,
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub category: Option<String>,
|
|
pub service_type: String,
|
|
pub hourly_rate: Option<BigDecimal>,
|
|
pub fixed_price: Option<BigDecimal>,
|
|
pub currency: String,
|
|
pub duration_minutes: Option<i32>,
|
|
pub is_active: bool,
|
|
pub attributes: serde_json::Value,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)]
|
|
#[diesel(table_name = product_categories)]
|
|
pub struct ProductCategory {
|
|
pub id: Uuid,
|
|
pub org_id: Uuid,
|
|
pub bot_id: Uuid,
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub parent_id: Option<Uuid>,
|
|
pub slug: Option<String>,
|
|
pub image_url: Option<String>,
|
|
pub sort_order: i32,
|
|
pub is_active: bool,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)]
|
|
#[diesel(table_name = price_lists)]
|
|
pub struct PriceList {
|
|
pub id: Uuid,
|
|
pub org_id: Uuid,
|
|
pub bot_id: Uuid,
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub currency: String,
|
|
pub is_default: bool,
|
|
pub valid_from: Option<chrono::NaiveDate>,
|
|
pub valid_until: Option<chrono::NaiveDate>,
|
|
pub customer_group: Option<String>,
|
|
pub discount_percent: BigDecimal,
|
|
pub is_active: bool,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)]
|
|
#[diesel(table_name = price_list_items)]
|
|
pub struct PriceListItem {
|
|
pub id: Uuid,
|
|
pub price_list_id: Uuid,
|
|
pub product_id: Option<Uuid>,
|
|
pub service_id: Option<Uuid>,
|
|
pub price: BigDecimal,
|
|
pub min_quantity: i32,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)]
|
|
#[diesel(table_name = inventory_movements)]
|
|
pub struct InventoryMovement {
|
|
pub id: Uuid,
|
|
pub org_id: Uuid,
|
|
pub bot_id: Uuid,
|
|
pub product_id: Uuid,
|
|
pub movement_type: String,
|
|
pub quantity: i32,
|
|
pub reference_type: Option<String>,
|
|
pub reference_id: Option<Uuid>,
|
|
pub notes: Option<String>,
|
|
pub created_by: Option<Uuid>,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)]
|
|
#[diesel(table_name = product_variants)]
|
|
pub struct ProductVariant {
|
|
pub id: Uuid,
|
|
pub product_id: Uuid,
|
|
pub sku: Option<String>,
|
|
pub name: String,
|
|
pub price_adjustment: BigDecimal,
|
|
pub stock_quantity: i32,
|
|
pub attributes: serde_json::Value,
|
|
pub is_active: bool,
|
|
pub created_at: DateTime<Utc>,
|
|
// Extended variant fields
|
|
pub global_trade_number: Option<String>,
|
|
pub net_weight: Option<BigDecimal>,
|
|
pub gross_weight: Option<BigDecimal>,
|
|
pub width: Option<BigDecimal>,
|
|
pub height: Option<BigDecimal>,
|
|
pub length: Option<BigDecimal>,
|
|
pub color: Option<String>,
|
|
pub size: Option<String>,
|
|
pub images: Option<serde_json::Value>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateProductRequest {
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub sku: Option<String>,
|
|
pub category: Option<String>,
|
|
pub product_type: Option<String>,
|
|
pub price: f64,
|
|
pub cost: Option<f64>,
|
|
pub currency: Option<String>,
|
|
pub tax_rate: Option<f64>,
|
|
pub unit: Option<String>,
|
|
pub stock_quantity: Option<i32>,
|
|
pub low_stock_threshold: Option<i32>,
|
|
pub images: Option<Vec<String>>,
|
|
pub barcode: Option<String>,
|
|
pub weight: Option<f64>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpdateProductRequest {
|
|
pub name: Option<String>,
|
|
pub description: Option<String>,
|
|
pub sku: Option<String>,
|
|
pub category: Option<String>,
|
|
pub price: Option<f64>,
|
|
pub cost: Option<f64>,
|
|
pub tax_rate: Option<f64>,
|
|
pub unit: Option<String>,
|
|
pub stock_quantity: Option<i32>,
|
|
pub low_stock_threshold: Option<i32>,
|
|
pub is_active: Option<bool>,
|
|
pub barcode: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateServiceRequest {
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub category: Option<String>,
|
|
pub service_type: Option<String>,
|
|
pub hourly_rate: Option<f64>,
|
|
pub fixed_price: Option<f64>,
|
|
pub currency: Option<String>,
|
|
pub duration_minutes: Option<i32>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpdateServiceRequest {
|
|
pub name: Option<String>,
|
|
pub description: Option<String>,
|
|
pub category: Option<String>,
|
|
pub hourly_rate: Option<f64>,
|
|
pub fixed_price: Option<f64>,
|
|
pub duration_minutes: Option<i32>,
|
|
pub is_active: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateCategoryRequest {
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub parent_id: Option<Uuid>,
|
|
pub slug: Option<String>,
|
|
pub image_url: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreatePriceListRequest {
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub currency: Option<String>,
|
|
pub discount_percent: Option<f64>,
|
|
pub customer_group: Option<String>,
|
|
pub valid_from: Option<String>,
|
|
pub valid_until: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct AdjustStockRequest {
|
|
pub quantity: i32,
|
|
pub movement_type: String,
|
|
pub notes: Option<String>,
|
|
pub reference_type: Option<String>,
|
|
pub reference_id: Option<Uuid>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ListQuery {
|
|
pub search: Option<String>,
|
|
pub category: Option<String>,
|
|
pub is_active: Option<bool>,
|
|
pub low_stock: Option<bool>,
|
|
pub limit: Option<i64>,
|
|
pub offset: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ProductStats {
|
|
pub total_products: i64,
|
|
pub active_products: i64,
|
|
pub total_services: i64,
|
|
pub active_services: i64,
|
|
pub low_stock_count: i64,
|
|
pub total_stock_value: f64,
|
|
pub categories_count: i64,
|
|
pub price_lists_count: i64,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ProductWithVariants {
|
|
pub product: Product,
|
|
pub variants: Vec<ProductVariant>,
|
|
}
|
|
|
|
fn get_bot_context(state: &AppState) -> (Uuid, Uuid) {
|
|
let Ok(mut conn) = state.conn.get() else {
|
|
return (Uuid::nil(), Uuid::nil());
|
|
};
|
|
let (bot_id, _bot_name) = get_default_bot(&mut conn);
|
|
let org_id = Uuid::nil();
|
|
(org_id, bot_id)
|
|
}
|
|
|
|
fn bd(val: f64) -> BigDecimal {
|
|
BigDecimal::from_str(&val.to_string()).unwrap_or_else(|_| BigDecimal::from(0))
|
|
}
|
|
|
|
fn bd_to_f64(val: &BigDecimal) -> f64 {
|
|
val.to_string().parse::<f64>().unwrap_or(0.0)
|
|
}
|
|
|
|
pub async fn create_product(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(req): Json<CreateProductRequest>,
|
|
) -> Result<Json<Product>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let (org_id, bot_id) = get_bot_context(&state);
|
|
let id = Uuid::new_v4();
|
|
let now = Utc::now();
|
|
|
|
let product = Product {
|
|
id,
|
|
org_id,
|
|
bot_id,
|
|
sku: req.sku,
|
|
name: req.name,
|
|
description: req.description,
|
|
category: req.category,
|
|
product_type: req.product_type.unwrap_or_else(|| "physical".to_string()),
|
|
price: bd(req.price),
|
|
cost: req.cost.map(bd),
|
|
currency: req.currency.unwrap_or_else(|| "USD".to_string()),
|
|
tax_rate: bd(req.tax_rate.unwrap_or(0.0)),
|
|
unit: req.unit.unwrap_or_else(|| "unit".to_string()),
|
|
stock_quantity: req.stock_quantity.unwrap_or(0),
|
|
low_stock_threshold: req.low_stock_threshold.unwrap_or(10),
|
|
is_active: true,
|
|
images: serde_json::json!(req.images.unwrap_or_default()),
|
|
attributes: serde_json::json!({}),
|
|
weight: req.weight.map(bd),
|
|
dimensions: None,
|
|
barcode: req.barcode,
|
|
created_at: now,
|
|
updated_at: now,
|
|
|
|
};
|
|
|
|
diesel::insert_into(products::table)
|
|
.values(&product)
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?;
|
|
|
|
Ok(Json(product))
|
|
}
|
|
|
|
pub async fn list_products(
|
|
State(state): State<Arc<AppState>>,
|
|
Query(query): Query<ListQuery>,
|
|
) -> Result<Json<Vec<Product>>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let (org_id, bot_id) = get_bot_context(&state);
|
|
let limit = query.limit.unwrap_or(50);
|
|
let offset = query.offset.unwrap_or(0);
|
|
|
|
let mut q = products::table
|
|
.filter(products::org_id.eq(org_id))
|
|
.filter(products::bot_id.eq(bot_id))
|
|
.into_boxed();
|
|
|
|
if let Some(is_active) = query.is_active {
|
|
q = q.filter(products::is_active.eq(is_active));
|
|
}
|
|
|
|
if let Some(category) = query.category {
|
|
q = q.filter(products::category.eq(category));
|
|
}
|
|
|
|
if let Some(true) = query.low_stock {
|
|
q = q.filter(products::stock_quantity.le(products::low_stock_threshold));
|
|
}
|
|
|
|
if let Some(search) = query.search {
|
|
let pattern = format!("%{search}%");
|
|
q = q.filter(
|
|
products::name
|
|
.ilike(pattern.clone())
|
|
.or(products::sku.ilike(pattern.clone()))
|
|
.or(products::description.ilike(pattern)),
|
|
);
|
|
}
|
|
|
|
let prods: Vec<Product> = q
|
|
.order(products::name.asc())
|
|
.limit(limit)
|
|
.offset(offset)
|
|
.load(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
|
|
|
|
Ok(Json(prods))
|
|
}
|
|
|
|
pub async fn get_product(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<ProductWithVariants>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let product: Product = products::table
|
|
.filter(products::id.eq(id))
|
|
.first(&mut conn)
|
|
.map_err(|_| (StatusCode::NOT_FOUND, "Product not found".to_string()))?;
|
|
|
|
let variants: Vec<ProductVariant> = product_variants::table
|
|
.filter(product_variants::product_id.eq(id))
|
|
.order(product_variants::name.asc())
|
|
.load(&mut conn)
|
|
.unwrap_or_default();
|
|
|
|
Ok(Json(ProductWithVariants { product, variants }))
|
|
}
|
|
|
|
pub async fn update_product(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<Uuid>,
|
|
Json(req): Json<UpdateProductRequest>,
|
|
) -> Result<Json<Product>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let now = Utc::now();
|
|
|
|
diesel::update(products::table.filter(products::id.eq(id)))
|
|
.set(products::updated_at.eq(now))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
|
|
if let Some(name) = req.name {
|
|
diesel::update(products::table.filter(products::id.eq(id)))
|
|
.set(products::name.eq(name))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(description) = req.description {
|
|
diesel::update(products::table.filter(products::id.eq(id)))
|
|
.set(products::description.eq(description))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(price) = req.price {
|
|
diesel::update(products::table.filter(products::id.eq(id)))
|
|
.set(products::price.eq(bd(price)))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(stock_quantity) = req.stock_quantity {
|
|
diesel::update(products::table.filter(products::id.eq(id)))
|
|
.set(products::stock_quantity.eq(stock_quantity))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(is_active) = req.is_active {
|
|
diesel::update(products::table.filter(products::id.eq(id)))
|
|
.set(products::is_active.eq(is_active))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(category) = req.category {
|
|
diesel::update(products::table.filter(products::id.eq(id)))
|
|
.set(products::category.eq(category))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
let product: Product = products::table
|
|
.filter(products::id.eq(id))
|
|
.first(&mut conn)
|
|
.map_err(|_| (StatusCode::NOT_FOUND, "Product not found".to_string()))?;
|
|
|
|
Ok(Json(product))
|
|
}
|
|
|
|
pub async fn delete_product(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<StatusCode, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
diesel::delete(products::table.filter(products::id.eq(id)))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete error: {e}")))?;
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
pub async fn adjust_stock(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<Uuid>,
|
|
Json(req): Json<AdjustStockRequest>,
|
|
) -> Result<Json<Product>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let (org_id, bot_id) = get_bot_context(&state);
|
|
let now = Utc::now();
|
|
|
|
let product: Product = products::table
|
|
.filter(products::id.eq(id))
|
|
.first(&mut conn)
|
|
.map_err(|_| (StatusCode::NOT_FOUND, "Product not found".to_string()))?;
|
|
|
|
let new_quantity = match req.movement_type.as_str() {
|
|
"in" | "purchase" | "return" | "adjustment_add" => product.stock_quantity + req.quantity,
|
|
"out" | "sale" | "adjustment_remove" | "damage" => product.stock_quantity - req.quantity,
|
|
"set" => req.quantity,
|
|
_ => product.stock_quantity + req.quantity,
|
|
};
|
|
|
|
diesel::update(products::table.filter(products::id.eq(id)))
|
|
.set((
|
|
products::stock_quantity.eq(new_quantity),
|
|
products::updated_at.eq(now),
|
|
))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
|
|
let movement = InventoryMovement {
|
|
id: Uuid::new_v4(),
|
|
org_id,
|
|
bot_id,
|
|
product_id: id,
|
|
movement_type: req.movement_type,
|
|
quantity: req.quantity,
|
|
reference_type: req.reference_type,
|
|
reference_id: req.reference_id,
|
|
notes: req.notes,
|
|
created_by: None,
|
|
created_at: now,
|
|
};
|
|
|
|
diesel::insert_into(inventory_movements::table)
|
|
.values(&movement)
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?;
|
|
|
|
let updated: Product = products::table
|
|
.filter(products::id.eq(id))
|
|
.first(&mut conn)
|
|
.map_err(|_| (StatusCode::NOT_FOUND, "Product not found".to_string()))?;
|
|
|
|
Ok(Json(updated))
|
|
}
|
|
|
|
pub async fn create_service(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(req): Json<CreateServiceRequest>,
|
|
) -> Result<Json<Service>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let (org_id, bot_id) = get_bot_context(&state);
|
|
let id = Uuid::new_v4();
|
|
let now = Utc::now();
|
|
|
|
let service = Service {
|
|
id,
|
|
org_id,
|
|
bot_id,
|
|
name: req.name,
|
|
description: req.description,
|
|
category: req.category,
|
|
service_type: req.service_type.unwrap_or_else(|| "hourly".to_string()),
|
|
hourly_rate: req.hourly_rate.map(bd),
|
|
fixed_price: req.fixed_price.map(bd),
|
|
currency: req.currency.unwrap_or_else(|| "USD".to_string()),
|
|
duration_minutes: req.duration_minutes,
|
|
is_active: true,
|
|
attributes: serde_json::json!({}),
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
|
|
diesel::insert_into(services::table)
|
|
.values(&service)
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?;
|
|
|
|
Ok(Json(service))
|
|
}
|
|
|
|
pub async fn list_services(
|
|
State(state): State<Arc<AppState>>,
|
|
Query(query): Query<ListQuery>,
|
|
) -> Result<Json<Vec<Service>>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let (org_id, bot_id) = get_bot_context(&state);
|
|
let limit = query.limit.unwrap_or(50);
|
|
let offset = query.offset.unwrap_or(0);
|
|
|
|
let mut q = services::table
|
|
.filter(services::org_id.eq(org_id))
|
|
.filter(services::bot_id.eq(bot_id))
|
|
.into_boxed();
|
|
|
|
if let Some(is_active) = query.is_active {
|
|
q = q.filter(services::is_active.eq(is_active));
|
|
}
|
|
|
|
if let Some(category) = query.category {
|
|
q = q.filter(services::category.eq(category));
|
|
}
|
|
|
|
if let Some(search) = query.search {
|
|
let pattern = format!("%{search}%");
|
|
q = q.filter(
|
|
services::name
|
|
.ilike(pattern.clone())
|
|
.or(services::description.ilike(pattern)),
|
|
);
|
|
}
|
|
|
|
let svcs: Vec<Service> = q
|
|
.order(services::name.asc())
|
|
.limit(limit)
|
|
.offset(offset)
|
|
.load(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
|
|
|
|
Ok(Json(svcs))
|
|
}
|
|
|
|
pub async fn get_service(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<Service>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let service: Service = services::table
|
|
.filter(services::id.eq(id))
|
|
.first(&mut conn)
|
|
.map_err(|_| (StatusCode::NOT_FOUND, "Service not found".to_string()))?;
|
|
|
|
Ok(Json(service))
|
|
}
|
|
|
|
pub async fn update_service(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<Uuid>,
|
|
Json(req): Json<UpdateServiceRequest>,
|
|
) -> Result<Json<Service>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let now = Utc::now();
|
|
|
|
diesel::update(services::table.filter(services::id.eq(id)))
|
|
.set(services::updated_at.eq(now))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
|
|
if let Some(name) = req.name {
|
|
diesel::update(services::table.filter(services::id.eq(id)))
|
|
.set(services::name.eq(name))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(description) = req.description {
|
|
diesel::update(services::table.filter(services::id.eq(id)))
|
|
.set(services::description.eq(description))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(hourly_rate) = req.hourly_rate {
|
|
diesel::update(services::table.filter(services::id.eq(id)))
|
|
.set(services::hourly_rate.eq(Some(bd(hourly_rate))))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(is_active) = req.is_active {
|
|
diesel::update(services::table.filter(services::id.eq(id)))
|
|
.set(services::is_active.eq(is_active))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
let service: Service = services::table
|
|
.filter(services::id.eq(id))
|
|
.first(&mut conn)
|
|
.map_err(|_| (StatusCode::NOT_FOUND, "Service not found".to_string()))?;
|
|
|
|
Ok(Json(service))
|
|
}
|
|
|
|
pub async fn delete_service(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<StatusCode, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
diesel::delete(services::table.filter(services::id.eq(id)))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete error: {e}")))?;
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
pub async fn list_categories(
|
|
State(state): State<Arc<AppState>>,
|
|
) -> Result<Json<Vec<ProductCategory>>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let (org_id, bot_id) = get_bot_context(&state);
|
|
|
|
let cats: Vec<ProductCategory> = product_categories::table
|
|
.filter(product_categories::org_id.eq(org_id))
|
|
.filter(product_categories::bot_id.eq(bot_id))
|
|
.filter(product_categories::is_active.eq(true))
|
|
.order(product_categories::sort_order.asc())
|
|
.load(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
|
|
|
|
Ok(Json(cats))
|
|
}
|
|
|
|
pub async fn create_category(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(req): Json<CreateCategoryRequest>,
|
|
) -> Result<Json<ProductCategory>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let (org_id, bot_id) = get_bot_context(&state);
|
|
let id = Uuid::new_v4();
|
|
let now = Utc::now();
|
|
|
|
let max_order: Option<i32> = product_categories::table
|
|
.filter(product_categories::org_id.eq(org_id))
|
|
.filter(product_categories::bot_id.eq(bot_id))
|
|
.select(diesel::dsl::max(product_categories::sort_order))
|
|
.first(&mut conn)
|
|
.unwrap_or(None);
|
|
|
|
let category = ProductCategory {
|
|
id,
|
|
org_id,
|
|
bot_id,
|
|
name: req.name,
|
|
description: req.description,
|
|
parent_id: req.parent_id,
|
|
slug: req.slug,
|
|
image_url: req.image_url,
|
|
sort_order: max_order.unwrap_or(0) + 1,
|
|
is_active: true,
|
|
created_at: now,
|
|
};
|
|
|
|
diesel::insert_into(product_categories::table)
|
|
.values(&category)
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?;
|
|
|
|
Ok(Json(category))
|
|
}
|
|
|
|
pub async fn list_price_lists(
|
|
State(state): State<Arc<AppState>>,
|
|
) -> Result<Json<Vec<PriceList>>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let (org_id, bot_id) = get_bot_context(&state);
|
|
|
|
let lists: Vec<PriceList> = price_lists::table
|
|
.filter(price_lists::org_id.eq(org_id))
|
|
.filter(price_lists::bot_id.eq(bot_id))
|
|
.filter(price_lists::is_active.eq(true))
|
|
.order(price_lists::name.asc())
|
|
.load(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
|
|
|
|
Ok(Json(lists))
|
|
}
|
|
|
|
pub async fn create_price_list(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(req): Json<CreatePriceListRequest>,
|
|
) -> Result<Json<PriceList>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let (org_id, bot_id) = get_bot_context(&state);
|
|
let id = Uuid::new_v4();
|
|
let now = Utc::now();
|
|
|
|
let valid_from = req
|
|
.valid_from
|
|
.and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok());
|
|
|
|
let valid_until = req
|
|
.valid_until
|
|
.and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok());
|
|
|
|
let price_list = PriceList {
|
|
id,
|
|
org_id,
|
|
bot_id,
|
|
name: req.name,
|
|
description: req.description,
|
|
currency: req.currency.unwrap_or_else(|| "USD".to_string()),
|
|
is_default: false,
|
|
valid_from,
|
|
valid_until,
|
|
customer_group: req.customer_group,
|
|
discount_percent: bd(req.discount_percent.unwrap_or(0.0)),
|
|
is_active: true,
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
|
|
diesel::insert_into(price_lists::table)
|
|
.values(&price_list)
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?;
|
|
|
|
Ok(Json(price_list))
|
|
}
|
|
|
|
pub async fn list_inventory_movements(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(product_id): Path<Uuid>,
|
|
) -> Result<Json<Vec<InventoryMovement>>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let movements: Vec<InventoryMovement> = inventory_movements::table
|
|
.filter(inventory_movements::product_id.eq(product_id))
|
|
.order(inventory_movements::created_at.desc())
|
|
.limit(100)
|
|
.load(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
|
|
|
|
Ok(Json(movements))
|
|
}
|
|
|
|
pub async fn get_product_stats(
|
|
State(state): State<Arc<AppState>>,
|
|
) -> Result<Json<ProductStats>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let (org_id, bot_id) = get_bot_context(&state);
|
|
|
|
let total_products: i64 = products::table
|
|
.filter(products::org_id.eq(org_id))
|
|
.filter(products::bot_id.eq(bot_id))
|
|
.count()
|
|
.get_result(&mut conn)
|
|
.unwrap_or(0);
|
|
|
|
let active_products: i64 = products::table
|
|
.filter(products::org_id.eq(org_id))
|
|
.filter(products::bot_id.eq(bot_id))
|
|
.filter(products::is_active.eq(true))
|
|
.count()
|
|
.get_result(&mut conn)
|
|
.unwrap_or(0);
|
|
|
|
let total_services: i64 = services::table
|
|
.filter(services::org_id.eq(org_id))
|
|
.filter(services::bot_id.eq(bot_id))
|
|
.count()
|
|
.get_result(&mut conn)
|
|
.unwrap_or(0);
|
|
|
|
let active_services: i64 = services::table
|
|
.filter(services::org_id.eq(org_id))
|
|
.filter(services::bot_id.eq(bot_id))
|
|
.filter(services::is_active.eq(true))
|
|
.count()
|
|
.get_result(&mut conn)
|
|
.unwrap_or(0);
|
|
|
|
let low_stock_count: i64 = products::table
|
|
.filter(products::org_id.eq(org_id))
|
|
.filter(products::bot_id.eq(bot_id))
|
|
.filter(products::is_active.eq(true))
|
|
.filter(products::stock_quantity.le(products::low_stock_threshold))
|
|
.count()
|
|
.get_result(&mut conn)
|
|
.unwrap_or(0);
|
|
|
|
let categories_count: i64 = product_categories::table
|
|
.filter(product_categories::org_id.eq(org_id))
|
|
.filter(product_categories::bot_id.eq(bot_id))
|
|
.filter(product_categories::is_active.eq(true))
|
|
.count()
|
|
.get_result(&mut conn)
|
|
.unwrap_or(0);
|
|
|
|
let price_lists_count: i64 = price_lists::table
|
|
.filter(price_lists::org_id.eq(org_id))
|
|
.filter(price_lists::bot_id.eq(bot_id))
|
|
.filter(price_lists::is_active.eq(true))
|
|
.count()
|
|
.get_result(&mut conn)
|
|
.unwrap_or(0);
|
|
|
|
let all_products: Vec<Product> = products::table
|
|
.filter(products::org_id.eq(org_id))
|
|
.filter(products::bot_id.eq(bot_id))
|
|
.filter(products::is_active.eq(true))
|
|
.load(&mut conn)
|
|
.unwrap_or_default();
|
|
|
|
let total_stock_value: f64 = all_products
|
|
.iter()
|
|
.map(|p| bd_to_f64(&p.price) * p.stock_quantity as f64)
|
|
.sum();
|
|
|
|
let stats = ProductStats {
|
|
total_products,
|
|
active_products,
|
|
total_services,
|
|
active_services,
|
|
low_stock_count,
|
|
total_stock_value,
|
|
categories_count,
|
|
price_lists_count,
|
|
};
|
|
|
|
Ok(Json(stats))
|
|
}
|
|
|
|
pub async fn list_low_stock(
|
|
State(state): State<Arc<AppState>>,
|
|
) -> Result<Json<Vec<Product>>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let (org_id, bot_id) = get_bot_context(&state);
|
|
|
|
let prods: Vec<Product> = products::table
|
|
.filter(products::org_id.eq(org_id))
|
|
.filter(products::bot_id.eq(bot_id))
|
|
.filter(products::is_active.eq(true))
|
|
.filter(products::stock_quantity.le(products::low_stock_threshold))
|
|
.order(products::stock_quantity.asc())
|
|
.load(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
|
|
|
|
Ok(Json(prods))
|
|
}
|
|
|
|
pub fn configure_products_api_routes() -> Router<Arc<AppState>> {
|
|
Router::new()
|
|
.route("/api/products/items", get(list_products).post(create_product))
|
|
.route("/api/products/items/:id", get(get_product).put(update_product).delete(delete_product))
|
|
.route("/api/products/items/:id/stock", put(adjust_stock))
|
|
.route("/api/products/items/:id/movements", get(list_inventory_movements))
|
|
.route("/api/products/services", get(list_services).post(create_service))
|
|
.route("/api/products/services/:id", get(get_service).put(update_service).delete(delete_service))
|
|
.route("/api/products/categories", get(list_categories).post(create_category))
|
|
.route("/api/products/price-lists", get(list_price_lists).post(create_price_list))
|
|
.route("/api/products/stats", get(get_product_stats))
|
|
.route("/api/products/low-stock", get(list_low_stock))
|
|
}
|