feat: add reviewed household records and unit validation

This commit is contained in:
LucaPisl
2026-10-01 01:07:42 +03:00
parent 04c8281532
commit 2131974765
8 changed files with 3296 additions and 0 deletions

2
src/tools/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod records;
pub mod validation;

186
src/tools/records.rs Normal file
View File

@@ -0,0 +1,186 @@
use crate::{
catalog::{ValidatedCall, entities},
client::{ApiClient, ApiRequest, HttpMethod, RequestBody},
error::{AppError, Result},
model::{ToolOutput, Verification, WriteReport, WriteStatus},
};
use serde_json::{Value, json};
use std::collections::BTreeMap;
pub async fn get_record(client: &ApiClient, entity: &str, id: &Value) -> Result<Value> {
let mut r = ApiRequest::get("/objects/{entity}/{id}");
r.params = BTreeMap::from([
("entity".into(), entity.into()),
(
"id".into(),
id.as_str()
.map(str::to_owned)
.unwrap_or_else(|| id.to_string()),
),
]);
client.request(r).await?.json()
}
pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput> {
let entity = call.args["entity"].as_str().ok_or_else(AppError::input)?;
if call.op.name == "records_describe" {
return Ok(ToolOutput::data(
json!({"entity":entity,"definition":entities()[entity],"notes":"Create using required fields; updates contain only changed fields. IDs are explicit. Product units require valid conversions; stock-unit changes require a valid Grocy conversion and use its database cascade."}),
));
}
if call.op.mutates {
if call.op.method != HttpMethod::Post {
get_record(client, entity, &call.args["id"]).await?;
}
if entity == "products" {
for (key, target) in [
("location_id", "locations"),
("qu_id_stock", "quantity_units"),
("qu_id_purchase", "quantity_units"),
("qu_id_consume", "quantity_units"),
("qu_id_price", "quantity_units"),
] {
if let Some(id) = call.args["fields"].get(key) {
if id.as_u64().is_some_and(|n| n > 0) {
get_record(client, target, id).await?;
}
}
}
if call.op.method == HttpMethod::Put && call.args["fields"].get("qu_id_stock").is_some()
{
let current = get_record(client, "products", &call.args["id"]).await?;
let old = super::validation::record_id(&current["qu_id_stock"])?;
let new = super::validation::record_id(&call.args["fields"]["qu_id_stock"])?;
if old != new {
let product_id = super::validation::record_id(&call.args["id"])?;
let conversions =
super::validation::load_conversions(client, product_id).await?;
super::validation::convert_quantity(
rust_decimal::Decimal::ONE,
old,
new,
&conversions,
)?;
}
}
}
if call.op.name == "userfields_update" {
let mut defs = ApiRequest::get("/objects/{entity}");
defs.params.insert("entity".into(), "userfields".into());
let rows = client.request(defs).await?.json()?;
for key in call.args["fields"]
.as_object()
.ok_or_else(AppError::input)?
.keys()
{
if !rows
.as_array()
.ok_or_else(AppError::input)?
.iter()
.any(|r| {
r["entity"].as_str() == Some(entity) && r["name"].as_str() == Some(key)
})
{
return Err(AppError::input());
}
}
}
}
let mut request = ApiRequest::get(call.op.route);
request.method = call.op.method;
for (key, param) in &call.op.path_params {
let v = &call.args[key];
request.params.insert(
param.clone(),
v.as_str()
.map(str::to_owned)
.unwrap_or_else(|| v.to_string()),
);
}
if let Some(fields) = call.args.get("fields") {
request.body = RequestBody::Json(fields.clone());
}
if call.op.name == "records_list" {
request
.query
.insert("limit".into(), (call.page.limit + 1).to_string());
request
.query
.insert("offset".into(), call.page.offset.to_string());
if entities()[entity]["writable"] == true
|| ["stock", "stock_log", "chores_log", "battery_charge_cycles"].contains(&entity)
{
request.query.insert("order".into(), "id".into());
}
}
let receipt = client.request(request).await?.json()?;
if call.op.name == "records_list" {
let mut rows = receipt
.as_array()
.ok_or_else(|| {
AppError::new("INVALID_RESPONSE", "Expected a list of household records.")
})?
.clone();
let more = rows.len() > call.page.limit;
rows.truncate(call.page.limit);
return Ok(ToolOutput::data(
json!({"items":rows,"has_more":more,"next_offset":if more{Some(call.page.offset+call.page.limit)}else{None}}),
));
}
if call.op.name == "records_lookup" {
let name = call.args["name"].as_str().ok_or_else(AppError::input)?;
let rows = receipt
.as_array()
.ok_or_else(AppError::input)?
.iter()
.filter(|r| r["name"].as_str() == Some(name))
.cloned()
.collect::<Vec<_>>();
return Ok(ToolOutput::data(
json!({"candidates":rows,"ambiguous":rows.len()>1}),
));
}
if !call.op.mutates {
return Ok(ToolOutput::data(receipt));
}
let target = json!({"entity":entity,"id":call.args.get("id").or_else(||receipt.get("created_object_id")),"operation":call.op.name});
let (data, verification) = if call.op.method == HttpMethod::Delete {
let check = get_record(client, entity, &call.args["id"]).await;
match check {
Err(e) if e.code == "NOT_FOUND" => (json!({"deleted":true}), Verification::Confirmed),
_ => (Value::Null, Verification::Incomplete),
}
} else if let Some(id) = call
.args
.get("id")
.or_else(|| receipt.get("created_object_id"))
{
let result = if call.op.name == "userfields_update" {
let mut r = ApiRequest::get("/userfields/{entity}/{id}");
r.params = BTreeMap::from([
("entity".into(), entity.into()),
("id".into(), id.to_string()),
]);
match client.request(r).await {
Ok(r) => r.json(),
Err(e) => Err(e),
}
} else {
get_record(client, entity, id).await
};
match result {
Ok(v) => (v, Verification::Confirmed),
Err(_) => (Value::Null, Verification::Incomplete),
}
} else {
(Value::Null, Verification::Unavailable)
};
Ok(ToolOutput {
data,
write: Some(WriteReport {
status: WriteStatus::Completed,
verification,
target,
receipt,
}),
attachment: None,
})
}

99
src/tools/validation.rs Normal file
View File

@@ -0,0 +1,99 @@
use crate::{
error::{AppError, Result},
model::RecordId,
};
use rust_decimal::Decimal;
use serde_json::{Value, json};
pub enum RecordSelector {
Id(RecordId),
ExactName(String),
}
pub struct Conversion {
pub from: RecordId,
pub to: RecordId,
pub factor: Decimal,
}
pub fn record_id(value: &Value) -> Result<RecordId> {
let id = value
.as_u64()
.or_else(|| value.as_str().and_then(|s| s.parse().ok()))
.ok_or_else(AppError::input)?;
if id == 0 {
return Err(AppError::input());
}
Ok(RecordId(id))
}
pub fn resolve_record(rows: &[Value], selector: &RecordSelector) -> Result<RecordId> {
let matches = rows
.iter()
.filter(|row| match selector {
RecordSelector::Id(id) => record_id(&row["id"]).is_ok_and(|n| n == *id),
RecordSelector::ExactName(name) => row["name"].as_str() == Some(name),
})
.collect::<Vec<_>>();
match matches.as_slice() {
[row] => record_id(&row["id"]),
[] => Err(AppError::new(
"NOT_FOUND",
"No exact record matches. List the relevant records first.",
)),
_ => Err(AppError {
code: "AMBIGUOUS_RECORD",
message: "Multiple records have this name. Choose a candidate ID explicitly.",
recovery: Some(
json!({"candidates":matches.iter().map(|r|json!({"id":r["id"],"name":r["name"]})).collect::<Vec<_>>()}),
),
}),
}
}
pub fn convert_quantity(
amount: Decimal,
from: RecordId,
to: RecordId,
conversions: &[Conversion],
) -> Result<Decimal> {
if amount < Decimal::ZERO {
return Err(AppError::input());
}
if from == to {
return Ok(amount);
}
let matches = conversions
.iter()
.filter(|c| c.from == from && c.to == to && c.factor > Decimal::ZERO)
.collect::<Vec<_>>();
match matches.as_slice() {
[c] => amount.checked_mul(c.factor).ok_or_else(AppError::input),
_ => Err(AppError::new(
"UNIT_CONVERSION_REQUIRED",
"Choose a valid unambiguous Grocy conversion to the product's stock unit.",
)),
}
}
pub async fn load_conversions(
client: &crate::client::ApiClient,
product_id: RecordId,
) -> Result<Vec<Conversion>> {
let mut r = crate::client::ApiRequest::get("/objects/{entity}");
r.params
.insert("entity".into(), "quantity_unit_conversions_resolved".into());
let data = client.request(r).await?.json()?;
let mut output = Vec::new();
for row in data.as_array().ok_or_else(AppError::input)? {
if record_id(&row["product_id"]).is_ok_and(|id| id == product_id) {
let factor = row["factor"]
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| row["factor"].to_string())
.parse::<Decimal>()
.map_err(|_| AppError::input())?;
output.push(Conversion {
from: record_id(&row["from_qu_id"])?,
to: record_id(&row["to_qu_id"])?,
factor,
});
}
}
Ok(output)
}