display what accouting would create

This commit is contained in:
Priec
2026-08-12 18:57:10 +02:00
parent 719bae0cd2
commit cdaa1b2b7d
11 changed files with 668 additions and 22 deletions

View File

@@ -76,6 +76,19 @@ pub(crate) struct ColumnType {
/// Groups several types behind one choice in the picker; empty when the
/// type stands on its own.
pub group: String,
/// What a compound type expands into, in the order the server creates the
/// columns. Empty for every other type.
pub generated_columns: Vec<GeneratedColumn>,
}
/// One column a compound type expands into, as the backend describes it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GeneratedColumn {
pub name: String,
pub data_type: String,
/// The column is kept in the currency and rounding declared on the
/// definition row.
pub inherits_currency: bool,
}
/// Every column type the backend accepts, as one screen's picker reads it.
@@ -184,6 +197,15 @@ impl ColumnCatalog {
.is_some_and(|column_type| column_type.compound)
}
/// The columns a compound type expands into, so a screen can show what
/// choosing it will really add. The names are the server's, not this
/// crate's: it reports them with the type, and they are fixed.
pub(crate) fn generated_columns(&self, field_type: &str) -> &[GeneratedColumn] {
self.find(field_type)
.map(|column_type| column_type.generated_columns.as_slice())
.unwrap_or_default()
}
fn is_creation_only(&self, field_type: &str) -> bool {
self.find(field_type)
.is_some_and(|column_type| column_type.creation_only)
@@ -455,6 +477,23 @@ impl ColumnDraft {
self.pending_carries_currency()
}
/// A compound column is named after its type, so there is nothing to type
/// in: the panel shows what it will generate instead of a name field.
pub(crate) fn pending_is_compound(&self) -> bool {
self.catalog.is_compound(&self.type_input)
}
/// What the pending choice would add to the table. Empty unless the choice
/// is a compound type.
pub(crate) fn pending_generated_columns(&self) -> &[GeneratedColumn] {
self.catalog.generated_columns(&self.type_input)
}
/// The name a compound definition row takes, which is its own type.
pub(crate) fn pending_compound_name(&self) -> String {
self.type_input.trim().to_ascii_lowercase()
}
/// The group the pending choice names, when it names one rather than a
/// type — which is what asks for a follow-up field.
fn pending_group(&self) -> Option<String> {
@@ -599,6 +638,39 @@ impl ColumnDraft {
Ok(self.added.remove(index))
}
/// Moves one column one place towards the front or the back of the list.
///
/// The order is the order the columns are declared in, which is the order
/// the table gets them in — including a compound column, whose generated
/// companions are created where its definition row sits. Moving the
/// definition row therefore moves the whole block it expands into.
///
/// A move off either end is not an error: the buttons for it are not
/// rendered, and a crafted post asking for one leaves the order alone.
pub(crate) fn move_column(&mut self, index: usize, offset: isize) -> Option<String> {
let target = index.checked_add_signed(offset)?;
if index >= self.added.len() || target >= self.added.len() {
return None;
}
self.added.swap(index, target);
Some(format!(
"Column `{}` moved {}.",
self.added[target].name,
if offset < 0 { "up" } else { "down" }
))
}
/// What an already-added column expands into — empty unless it is a
/// compound one. This is what the column list shows underneath it, so the
/// columns a definition row brings are visible while the table is still
/// being described.
pub(crate) fn generated_columns_of(&self, index: usize) -> &[GeneratedColumn] {
self.added
.get(index)
.map(|column| self.catalog.generated_columns(&column.data_type))
.unwrap_or_default()
}
/// Whether a column can be indexed or identify a row. A compound column
/// leaves no column of its own name behind, so it can do neither.
pub(crate) fn is_indexable(&self, index: usize) -> bool {
@@ -791,6 +863,15 @@ pub(crate) fn column_catalog(column_types: Vec<ProtoColumnType>) -> ColumnCatalo
creation_only: column_type.creation_only,
allows_quantity_ledger: column_type.allows_quantity_ledger,
group: column_type.group,
generated_columns: column_type
.generated_columns
.into_iter()
.map(|generated| GeneratedColumn {
name: generated.name,
data_type: generated.field_type,
inherits_currency: generated.inherits_currency,
})
.collect(),
})
.collect(),
)
@@ -954,6 +1035,15 @@ pub(crate) mod tests {
creation_only: false,
allows_quantity_ledger: false,
group: String::new(),
generated_columns: Vec::new(),
}
}
fn generated_column(name: &str, data_type: &str, inherits_currency: bool) -> GeneratedColumn {
GeneratedColumn {
name: name.to_string(),
data_type: data_type.to_string(),
inherits_currency,
}
}
@@ -987,9 +1077,23 @@ pub(crate) mod tests {
ColumnCatalog::new(vec![
ColumnType {
requires_currency: true,
// The companions the server reports with the type, in the
// order it creates them.
generated_columns: vec![
generated_column("name", "text", false),
generated_column("tax_point_date", "date", false),
generated_column("debit", "money", true),
generated_column("credit", "money", true),
],
..compound("accounting")
},
compound("accounting_transfer"),
ColumnType {
generated_columns: vec![
generated_column("source_period_id", "bigint", false),
generated_column("target_period_id", "bigint", false),
],
..compound("accounting_transfer")
},
ColumnType {
sql_type: "TIMESTAMPTZ(0)".to_string(),
..grouped("instant", "temporal")
@@ -1394,6 +1498,78 @@ pub(crate) mod tests {
assert!(columns[0].indexed);
}
/// The declared order is the order the table gets its columns in, so it is
/// the user's to change — and a move off either end changes nothing rather
/// than wrapping around or panicking.
#[test]
fn a_column_moves_one_place_and_stops_at_the_ends() {
let mut draft = draft();
for name in ["number", "issued_on", "total"] {
draft.name_input = name.to_string();
draft.type_input = "text".to_string();
draft.add_from_inputs().unwrap();
}
let names = |draft: &ColumnDraft| {
draft
.added
.iter()
.map(|column| column.name.clone())
.collect::<Vec<_>>()
};
assert_eq!(
draft.move_column(2, -1),
Some("Column `total` moved up.".to_string())
);
assert_eq!(names(&draft), ["number", "total", "issued_on"]);
draft.move_column(0, 1).unwrap();
assert_eq!(names(&draft), ["total", "number", "issued_on"]);
// Off either end, and past the end of the list entirely.
assert_eq!(draft.move_column(0, -1), None);
assert_eq!(draft.move_column(2, 1), None);
assert_eq!(draft.move_column(9, -1), None);
assert_eq!(names(&draft), ["total", "number", "issued_on"]);
}
/// The columns a definition row expands into are the server's answer,
/// carried by the catalog rather than written down here.
#[test]
fn a_compound_column_reports_what_it_expands_into() {
let mut draft = draft();
draft.type_input = "accounting".to_string();
assert!(draft.pending_is_compound());
assert_eq!(draft.pending_compound_name(), "accounting");
assert_eq!(
draft
.pending_generated_columns()
.iter()
.map(|generated| generated.name.as_str())
.collect::<Vec<_>>(),
["name", "tax_point_date", "debit", "credit"]
);
draft.add_from_inputs().unwrap();
assert_eq!(draft.generated_columns_of(0).len(), 4);
// DEBIT and CREDIT are the two kept in the declared currency.
assert_eq!(
draft
.generated_columns_of(0)
.iter()
.filter(|generated| generated.inherits_currency)
.count(),
2
);
// An ordinary column expands into nothing, and its name is its own.
draft.name_input = "number".to_string();
draft.type_input = "text".to_string();
assert!(!draft.pending_is_compound());
draft.add_from_inputs().unwrap();
assert!(draft.generated_columns_of(1).is_empty());
}
#[test]
fn indexed_columns_become_the_index_list() {
let mut draft = draft();