Projects: classes as entities, and the sidebar fits short windows
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
A project is a class with facts (description, status, kind, party, dates, links, notes) in one project.set event behind the format feature projects. Four tools (books.add_project, update_project, projects, project); documents and decisions link to projects; B-37: on-spec costs are expensed as incurred, repayment is revenue, and the unrecovered cost is never below zero (Kani). Viewer: a Projects list and a project page with its figures, by month and by account. The sidebar measures its footer instead of reserving a fixed 172 px. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
71 files changed
+7446
-217
No files matched your search
@@ -185,6 +185,20 @@ pub struct OpenItemBriefOut {
|
||||
pub due: Option<String>,
|
||||
}
|
||||
|
||||
/// A project, in brief.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ProjectBriefOut {
|
||||
/// Its id (a class id).
|
||||
pub id: u32,
|
||||
/// Its name.
|
||||
pub name: String,
|
||||
/// active or paused.
|
||||
pub status: String,
|
||||
/// Its kind, if recorded.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<String>,
|
||||
}
|
||||
|
||||
/// A handbook section.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SectionOut {
|
||||
@@ -241,9 +255,12 @@ pub struct StartOut {
|
||||
pub conventions: Vec<ConventionBriefOut>,
|
||||
/// What is open, oldest first.
|
||||
pub open_items: Vec<OpenItemBriefOut>,
|
||||
/// The projects under way (active and paused), by name, at most 20
|
||||
/// (books.projects lists every one with its figures).
|
||||
pub projects: Vec<ProjectBriefOut>,
|
||||
/// Lists cut short to fit: what was left out, e.g. "conventions: 4
|
||||
/// more" (the full lists are in books.conventions and
|
||||
/// books.open_items).
|
||||
/// more" (the full lists are in books.conventions,
|
||||
/// books.open_items and books.projects).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub truncated: Vec<String>,
|
||||
/// The handbook's sections (books.handbook {section}).
|
||||
@@ -459,6 +476,9 @@ pub struct RecordDecisionParams {
|
||||
/// The rule or document it rests on, if any.
|
||||
#[serde(default)]
|
||||
pub citation: Option<String>,
|
||||
/// The projects it is about (ids or names), if any.
|
||||
#[serde(default)]
|
||||
pub projects: Vec<String>,
|
||||
}
|
||||
|
||||
/// A decision.
|
||||
@@ -486,6 +506,9 @@ pub struct DecisionOut {
|
||||
/// What it rests on.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub citation: Option<String>,
|
||||
/// The projects it is about.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub projects: Vec<ProjectRefOut>,
|
||||
/// The journal line.
|
||||
pub seq: u64,
|
||||
}
|
||||
@@ -496,6 +519,9 @@ pub struct DecisionsParams {
|
||||
/// Include decisions a later one replaced.
|
||||
#[serde(default)]
|
||||
pub include_superseded: bool,
|
||||
/// Only the decisions naming this project (id or name).
|
||||
#[serde(default)]
|
||||
pub project: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.decisions`.
|
||||
@@ -681,7 +707,7 @@ fn convention_out(c: &ledger_core::conventions::Convention) -> ConventionOut {
|
||||
}
|
||||
}
|
||||
|
||||
fn decision_out(d: &ledger_core::conventions::Decision) -> DecisionOut {
|
||||
pub(super) fn decision_out(ledger: &Ledger, d: &ledger_core::conventions::Decision) -> DecisionOut {
|
||||
DecisionOut {
|
||||
id: d.id,
|
||||
subject: d.new.subject.clone(),
|
||||
@@ -692,6 +718,12 @@ fn decision_out(d: &ledger_core::conventions::Decision) -> DecisionOut {
|
||||
supersedes: d.new.supersedes,
|
||||
superseded_by: d.superseded_by,
|
||||
citation: d.new.citation.clone(),
|
||||
projects: d
|
||||
.new
|
||||
.projects
|
||||
.iter()
|
||||
.map(|&p| super::projects::project_ref(ledger, p))
|
||||
.collect(),
|
||||
seq: d.seq,
|
||||
}
|
||||
}
|
||||
@@ -789,6 +821,28 @@ impl BooksServer {
|
||||
due: i.new.due.map(|d| d.to_string()),
|
||||
})
|
||||
.collect();
|
||||
// The projects under way, by name (rules.md B-37): at most 20.
|
||||
const START_PROJECTS: usize = 20;
|
||||
let mut projects: Vec<ProjectBriefOut> = ledger
|
||||
.list_classes()
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
let f = ledger.project_facts(c.id)?;
|
||||
matches!(
|
||||
f.status,
|
||||
ledger_core::ProjectStatus::Active | ledger_core::ProjectStatus::Paused
|
||||
)
|
||||
.then(|| ProjectBriefOut {
|
||||
id: c.id,
|
||||
name: c.name.clone(),
|
||||
status: f.status.key().to_string(),
|
||||
kind: f.kind.map(|k| k.key().to_string()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
projects.sort_by_key(|a| (a.name.to_lowercase(), a.id));
|
||||
let mut p_cut = projects.len().saturating_sub(START_PROJECTS);
|
||||
projects.truncate(START_PROJECTS);
|
||||
let journaled = cfg.journaled;
|
||||
let focus = focus.unwrap_or("daily");
|
||||
let step = |tool: &str, args: Option<serde_json::Value>, why: &str| NextStepOut {
|
||||
@@ -919,6 +973,7 @@ impl BooksServer {
|
||||
settings_summary: Vec::new(),
|
||||
conventions: Vec::new(),
|
||||
open_items: Vec::new(),
|
||||
projects: Vec::new(),
|
||||
truncated: Vec::new(),
|
||||
handbook_sections: handbook::SECTIONS
|
||||
.iter()
|
||||
@@ -942,6 +997,20 @@ impl BooksServer {
|
||||
out.settings_summary = std::mem::take(&mut settings_summary);
|
||||
out.conventions = std::mem::take(&mut conventions);
|
||||
out.open_items = std::mem::take(&mut open_items);
|
||||
out.projects = std::mem::take(&mut projects);
|
||||
let cut_note = |c: usize, i: usize, s: usize, p: usize| -> Vec<String> {
|
||||
[
|
||||
("conventions", c, "books.conventions"),
|
||||
("open_items", i, "books.open_items"),
|
||||
("settings_summary", s, "books.settings"),
|
||||
("projects", p, "books.projects"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(_, n, _)| *n > 0)
|
||||
.map(|(what, n, tool)| format!("{what}: {n} more ({tool})"))
|
||||
.collect()
|
||||
};
|
||||
out.truncated = cut_note(0, 0, 0, p_cut);
|
||||
if size(&out) > START_MAX {
|
||||
for c in &mut out.conventions {
|
||||
c.summary.clear();
|
||||
@@ -955,6 +1024,9 @@ impl BooksServer {
|
||||
} else if out.open_items.len() > 10 {
|
||||
out.open_items.pop();
|
||||
i_cut += 1;
|
||||
} else if out.projects.len() > 5 {
|
||||
out.projects.pop();
|
||||
p_cut += 1;
|
||||
} else if !out.settings_summary.is_empty() {
|
||||
out.settings_summary.pop();
|
||||
s_cut += 1;
|
||||
@@ -964,18 +1036,13 @@ impl BooksServer {
|
||||
} else if !out.open_items.is_empty() {
|
||||
out.open_items.pop();
|
||||
i_cut += 1;
|
||||
} else if !out.projects.is_empty() {
|
||||
out.projects.pop();
|
||||
p_cut += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
out.truncated = [
|
||||
("conventions", c_cut, "books.conventions"),
|
||||
("open_items", i_cut, "books.open_items"),
|
||||
("settings_summary", s_cut, "books.settings"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(_, n, _)| *n > 0)
|
||||
.map(|(what, n, tool)| format!("{what}: {n} more ({tool})"))
|
||||
.collect();
|
||||
out.truncated = cut_note(c_cut, i_cut, s_cut, p_cut);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1004,7 +1071,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.start",
|
||||
title = "Start here",
|
||||
description = "Call first in every session. The books in brief: company, today in its time zone, fiscal year, the journal's head, whether they need onboarding, where settings come from, the rules in force (receipts, parties, approvals), every setting with who set it, this company's conventions (how it keeps its books: posting templates, policies, close steps), open items, the handbook's sections, a format upgrade the books wait for (only the owner can agree to it: tell them), and next steps. Read-only; at most 8 KB.",
|
||||
description = "Call first in every session. The books in brief: company, today in its time zone, fiscal year, the journal's head, whether they need onboarding, where settings come from, the rules in force (receipts, parties, approvals), every setting with who set it, this company's conventions (how it keeps its books: posting templates, policies, close steps), open items, the projects under way, the handbook's sections, a format upgrade the books wait for (only the owner can agree to it: tell them), and next steps. Read-only; at most 8 KB.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
pub(super) async fn start(
|
||||
@@ -1337,7 +1404,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.record_decision",
|
||||
title = "Record a decision",
|
||||
description = "Record a decision the owners made about the books (a policy, a treatment, a cutover), with who decided, when, and the document behind it if one is filed. A decision that replaces an earlier one names it in supersedes. No signatures or memos needed: the record is the decision with its source.",
|
||||
description = "Record a decision the owners made about the books (a policy, a treatment, a cutover), with who decided, when, and the document behind it if one is filed. A decision that replaces an earlier one names it in supersedes. No signatures or memos needed: the record is the decision with its source. projects names the projects it is about.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -1349,6 +1416,13 @@ impl BooksServer {
|
||||
params: Parameters<RecordDecisionParams>,
|
||||
) -> Result<Json<IdOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
// The projects resolved before anything is written.
|
||||
let projects = p
|
||||
.projects
|
||||
.iter()
|
||||
.map(|r| resolve_class(&store, r.trim()))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let d = NewDecision {
|
||||
subject: p.subject,
|
||||
decision_md: p.decision_md,
|
||||
@@ -1357,8 +1431,8 @@ impl BooksServer {
|
||||
evidence_attachment: p.evidence_attachment,
|
||||
supersedes: p.supersedes,
|
||||
citation: p.citation,
|
||||
projects,
|
||||
};
|
||||
let mut store = self.lock()?;
|
||||
let id = store.record_decision(&d).map_err(bad)?;
|
||||
Ok(Json(IdOut { id }))
|
||||
}
|
||||
@@ -1366,7 +1440,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.decisions",
|
||||
title = "Decisions",
|
||||
description = "The owners' decisions about the books, oldest first, each with who decided, when, what it rests on, and what replaced it.",
|
||||
description = "The owners' decisions about the books, oldest first, each with who decided, when, what it rests on, the projects it is about, and what replaced it. project filters to the decisions naming one.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn decisions(
|
||||
@@ -1374,13 +1448,20 @@ impl BooksServer {
|
||||
params: Parameters<DecisionsParams>,
|
||||
) -> Result<Json<DecisionsOut>, String> {
|
||||
let store = self.lock()?;
|
||||
let project = params
|
||||
.0
|
||||
.project
|
||||
.as_deref()
|
||||
.map(|r| resolve_class(&store, r.trim()))
|
||||
.transpose()?;
|
||||
let ledger = store.ledger();
|
||||
Ok(Json(DecisionsOut {
|
||||
decisions: store
|
||||
.ledger()
|
||||
decisions: ledger
|
||||
.conventions()
|
||||
.decisions()
|
||||
.filter(|d| params.0.include_superseded || d.superseded_by.is_none())
|
||||
.map(decision_out)
|
||||
.filter(|d| project.is_none_or(|p| d.new.projects.contains(&p)))
|
||||
.map(|d| decision_out(ledger, d))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
//! Projects through the server: `books.add_project`,
|
||||
//! `books.update_project`, `books.projects` and `books.project` (rules.md
|
||||
//! B-37). A project is a class with its facts: `books.add_line`'s class
|
||||
//! tags a line with it, and every class tool keeps working.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: the agent records what each project is and links what belongs to
|
||||
//! it; anyone reads what it earned and cost from the same figures
|
||||
//! the viewer and bookspec use.
|
||||
//! SOURCE: docs/spec/rules.md B-37 (DECISION, owner, 2026-10-21; job
|
||||
//! costing; ASC 450-30-25-1 for on-spec work), B-30 (omitted
|
||||
//! dates), B-27 (journal feature `projects`).
|
||||
//! INVARIANTS:
|
||||
//! 1. Every field is checked before anything is written; a refusal
|
||||
//! journals nothing.
|
||||
//! 2. Figures come only from ledger_core::project_figures,
|
||||
//! project_to_date and project_entries (never re-summed here).
|
||||
//! 3. books.update_project with nothing new journals nothing.
|
||||
//! 4. A project is named by id or name (ignoring capitals); a number
|
||||
//! that is one project's id and another's name is refused.
|
||||
//! ```
|
||||
|
||||
use super::kept::DecisionOut;
|
||||
use super::*;
|
||||
use ledger_core::{ClassId, ProjectFacts, ProjectKind, ProjectStatus};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Result of `books.project`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ProjectDetailOut {
|
||||
/// Its facts.
|
||||
pub project: ProjectOut,
|
||||
/// The period asked.
|
||||
pub period: ProjectPeriodOut,
|
||||
/// Every tagged line through the period's end.
|
||||
pub to_date: ToDateOut,
|
||||
/// Every entry with a line tagged to it (drafts included), newest first.
|
||||
pub entries: Vec<ProjectEntryOut>,
|
||||
/// The files linked to it (its contract, spec, pitch), not withdrawn.
|
||||
pub documents: Vec<ReceiptOut>,
|
||||
/// The decisions naming it, oldest first, superseded ones included.
|
||||
pub decisions: Vec<DecisionOut>,
|
||||
}
|
||||
|
||||
/// A status key, or why not.
|
||||
fn parse_status(s: &str) -> Result<ProjectStatus, String> {
|
||||
ProjectStatus::parse(s.trim())
|
||||
.ok_or_else(|| format!("status {s:?}: expected active, paused, finished, or abandoned"))
|
||||
}
|
||||
|
||||
/// A kind key, or why not.
|
||||
fn parse_kind(s: &str) -> Result<ProjectKind, String> {
|
||||
ProjectKind::parse(s.trim())
|
||||
.ok_or_else(|| format!("kind {s:?}: expected internal, client, on_spec, or other"))
|
||||
}
|
||||
|
||||
/// A project's facts and party for a tool's output.
|
||||
pub(super) fn project_out(ledger: &Ledger, id: ClassId) -> ProjectOut {
|
||||
let name = ledger
|
||||
.get_class(id)
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_default();
|
||||
let f = ledger.project_facts(id).unwrap_or_default();
|
||||
ProjectOut {
|
||||
id,
|
||||
name,
|
||||
status: f.status.key().to_string(),
|
||||
kind: f.kind.map(|k| k.key().to_string()),
|
||||
description: f.description,
|
||||
party: f.party.map(|p| PartyRefOut {
|
||||
party_id: p,
|
||||
label: ledger
|
||||
.get_party(p)
|
||||
.map(|x| x.label().to_string())
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
started_on: f.started_on.map(|d| d.to_string()),
|
||||
ended_on: f.ended_on.map(|d| d.to_string()),
|
||||
links: f.links,
|
||||
notes: f.notes,
|
||||
}
|
||||
}
|
||||
|
||||
/// A project named by id and name.
|
||||
pub(super) fn project_ref(ledger: &Ledger, id: ClassId) -> ProjectRefOut {
|
||||
ProjectRefOut {
|
||||
id,
|
||||
name: ledger
|
||||
.get_class(id)
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Figures to date for a tool's output.
|
||||
fn to_date_out(t: &ledger_core::ProjectToDate) -> ToDateOut {
|
||||
ToDateOut {
|
||||
through: t.through.to_string(),
|
||||
revenue: money(t.revenue),
|
||||
expenses: money(t.expenses),
|
||||
net: money(t.net()),
|
||||
unrecovered_cost: t.unrecovered_cost.map(money),
|
||||
}
|
||||
}
|
||||
|
||||
/// An optional date from a tool: "" clears it.
|
||||
fn optional_date(what: &str, s: &str) -> Result<Option<NaiveDate>, String> {
|
||||
match optional_text(s) {
|
||||
None => Ok(None),
|
||||
Some(t) => parse_date(&t).map(Some).map_err(|e| format!("{what}: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The projects ordered by name, ignoring capitals (then id).
|
||||
fn by_name(ledger: &Ledger) -> Vec<ClassId> {
|
||||
let mut ids: Vec<(String, ClassId)> = ledger
|
||||
.list_classes()
|
||||
.into_iter()
|
||||
.map(|c| (c.name.to_lowercase(), c.id))
|
||||
.collect();
|
||||
ids.sort();
|
||||
ids.into_iter().map(|(_, id)| id).collect()
|
||||
}
|
||||
|
||||
/// The note `books.add_line` and `books.record_payout` give for a line tagged to a finished or
|
||||
/// abandoned project (rules.md B-37 item 5).
|
||||
pub(super) fn late_tag_note(ledger: &Ledger, id: ClassId) -> Option<String> {
|
||||
let f = ledger.project_facts(id)?;
|
||||
if !f.status.is_over() {
|
||||
return None;
|
||||
}
|
||||
let name = &ledger.get_class(id)?.name;
|
||||
let ended = f
|
||||
.ended_on
|
||||
.map(|d| format!(" (ended {d})"))
|
||||
.unwrap_or_default();
|
||||
Some(format!(
|
||||
"project {name} is {}{ended}: tag a late cost or invoice to it only if it belongs to it",
|
||||
f.status.key()
|
||||
))
|
||||
}
|
||||
|
||||
#[tool_router(router = projects_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.add_project",
|
||||
title = "Add a project",
|
||||
description = "Add a project: a class (books.add_line's class tags a line with it) with its facts. status: active (default), paused, finished, abandoned. kind: internal (the company's own product), client (work for a customer), on_spec (work done hoping to be paid: its costs are expenses, a repayment is income when received), other. party: who it is for or with (id or name). started_on, ended_on (finished or abandoned only), links (web addresses), description, notes. Names are unique. A name alone journals class.added; facts add project.set. Returns the project.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn add_project(
|
||||
&self,
|
||||
params: Parameters<AddProjectParams>,
|
||||
) -> Result<Json<ProjectOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let facts = ProjectFacts {
|
||||
description: p.description.as_deref().and_then(optional_text),
|
||||
status: p
|
||||
.status
|
||||
.as_deref()
|
||||
.map(parse_status)
|
||||
.transpose()?
|
||||
.unwrap_or_default(),
|
||||
kind: match p.kind.as_deref().and_then(optional_text) {
|
||||
Some(k) => Some(parse_kind(&k)?),
|
||||
None => None,
|
||||
},
|
||||
party: match p.party.as_deref().and_then(optional_text) {
|
||||
Some(r) => Some(resolve_party(&store, &r)?),
|
||||
None => None,
|
||||
},
|
||||
started_on: optional_date("started_on", p.started_on.as_deref().unwrap_or(""))?,
|
||||
ended_on: optional_date("ended_on", p.ended_on.as_deref().unwrap_or(""))?,
|
||||
links: p.links,
|
||||
notes: p.notes.as_deref().and_then(optional_text),
|
||||
};
|
||||
let id = store
|
||||
.add_project(&p.name, &facts)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(project_out(store.ledger(), id)))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.update_project",
|
||||
title = "Update a project",
|
||||
description = "Change a project's facts (see books.add_project): give only the fields to change; \"\" clears description, kind, party, started_on, ended_on or notes, and links replaces the list ([] clears it). Every field is checked before anything is written, then the whole record is journaled as project.set; nothing new journals nothing. A rename changes the label every report prints, nothing else; a name another project has is refused. Returns the project.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
idempotent_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn update_project(
|
||||
&self,
|
||||
params: Parameters<UpdateProjectParams>,
|
||||
) -> Result<Json<ProjectOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let id = resolve_class(&store, &p.project)?;
|
||||
if p.name.is_none()
|
||||
&& p.description.is_none()
|
||||
&& p.status.is_none()
|
||||
&& p.kind.is_none()
|
||||
&& p.party.is_none()
|
||||
&& p.started_on.is_none()
|
||||
&& p.ended_on.is_none()
|
||||
&& p.links.is_none()
|
||||
&& p.notes.is_none()
|
||||
{
|
||||
return Err(
|
||||
"give at least one field to change: name, description, status, kind, party, started_on, ended_on, links, notes"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let ledger = store.ledger();
|
||||
let mut f = ledger.project_facts(id).expect("resolved");
|
||||
let name = p
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| ledger.get_class(id).expect("resolved").name.clone());
|
||||
if let Some(v) = &p.description {
|
||||
f.description = optional_text(v);
|
||||
}
|
||||
if let Some(v) = &p.status {
|
||||
f.status = parse_status(v)?;
|
||||
}
|
||||
if let Some(v) = &p.kind {
|
||||
f.kind = optional_text(v).map(|k| parse_kind(&k)).transpose()?;
|
||||
}
|
||||
if let Some(v) = &p.party {
|
||||
f.party = optional_text(v)
|
||||
.map(|r| resolve_party(&store, &r))
|
||||
.transpose()?;
|
||||
}
|
||||
if let Some(v) = &p.started_on {
|
||||
f.started_on = optional_date("started_on", v)?;
|
||||
}
|
||||
if let Some(v) = &p.ended_on {
|
||||
f.ended_on = optional_date("ended_on", v)?;
|
||||
}
|
||||
if let Some(v) = p.links {
|
||||
f.links = v;
|
||||
}
|
||||
if let Some(v) = &p.notes {
|
||||
f.notes = optional_text(v);
|
||||
}
|
||||
store
|
||||
.set_project(id, &name, &f)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(project_out(store.ledger(), id)))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.projects",
|
||||
title = "Projects",
|
||||
description = "Every project (every class, with its facts), by name: status, kind, party, dates, and its figures to date (every posted line tagged to it through today in the company's time zone, closes left out): revenue, expenses, net, and for an on_spec project its unrecovered cost. status filters to one status.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn projects(
|
||||
&self,
|
||||
params: Parameters<ProjectsParams>,
|
||||
) -> Result<Json<ProjectsOut>, String> {
|
||||
let only = params.0.status.as_deref().map(parse_status).transpose()?;
|
||||
let through = today();
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let mut projects = Vec::new();
|
||||
for id in by_name(ledger) {
|
||||
let f = ledger.project_facts(id).expect("listed");
|
||||
if only.is_some_and(|s| s != f.status) {
|
||||
continue;
|
||||
}
|
||||
let t = ledger_core::project_to_date(ledger, id, through).map_err(|e| e.to_string())?;
|
||||
projects.push(ProjectRowOut {
|
||||
project: project_out(ledger, id),
|
||||
to_date: to_date_out(&t),
|
||||
});
|
||||
}
|
||||
Ok(Json(ProjectsOut {
|
||||
through: through.to_string(),
|
||||
projects,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.project",
|
||||
title = "Get a project",
|
||||
description = described(
|
||||
"One project, by id or name: its facts, party and decisions, the documents linked to it, and every entry with a line tagged to it (date, description, project amount, receipts). For the period: revenue, expenses, net, by account and by month. To date (every tagged line through end): the same totals and, for an on_spec project, its unrecovered cost, the 'if repaid' figure, never a receivable. Only revenue and expense lines count.",
|
||||
ledger_core::calendar::PeriodKind::Statement
|
||||
),
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn project(
|
||||
&self,
|
||||
params: Parameters<ProjectParams>,
|
||||
) -> Result<Json<ProjectDetailOut>, String> {
|
||||
let p = params.0;
|
||||
let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?;
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let id = resolve_class(&store, &p.project)?;
|
||||
let fig =
|
||||
ledger_core::project_figures(ledger, id, start, end).map_err(|e| e.to_string())?;
|
||||
let account = |l: &ledger_core::StatementLine, t: &str| ProjectAccountOut {
|
||||
code: l.code.clone(),
|
||||
name: l.name.clone(),
|
||||
account_type: t.to_string(),
|
||||
amount: money(l.amount),
|
||||
};
|
||||
let by_account = fig
|
||||
.period
|
||||
.revenue
|
||||
.iter()
|
||||
.map(|l| account(l, "revenue"))
|
||||
.chain(fig.period.expenses.iter().map(|l| account(l, "expense")))
|
||||
.collect();
|
||||
let by_month = fig
|
||||
.months
|
||||
.iter()
|
||||
.map(|m| ProjectMonthOut {
|
||||
month: m.start.format("%Y-%m").to_string(),
|
||||
start: m.start.to_string(),
|
||||
end: m.end.to_string(),
|
||||
revenue: money(m.revenue),
|
||||
expenses: money(m.expenses),
|
||||
net: money(m.net()),
|
||||
})
|
||||
.collect();
|
||||
let mut entries = Vec::new();
|
||||
for (eid, amount) in ledger_core::project_entries(ledger, id) {
|
||||
let e = ledger.get_entry(eid).expect("listed");
|
||||
let receipts = store
|
||||
.attachments_for(ledger_store::Subject::Entry(eid))
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|a| a.id)
|
||||
.collect();
|
||||
entries.push(ProjectEntryOut {
|
||||
entry_id: eid,
|
||||
date: e.date().to_string(),
|
||||
description: e.description().to_string(),
|
||||
status: match e.status() {
|
||||
ledger_core::EntryStatus::Draft => "draft",
|
||||
ledger_core::EntryStatus::Posted => "posted",
|
||||
}
|
||||
.to_string(),
|
||||
project_amount: money(amount),
|
||||
total: ReceiptPolicy::entry_debits(e).ok().map(money),
|
||||
reversal_of: e.reversal_of(),
|
||||
receipts,
|
||||
});
|
||||
}
|
||||
let documents = store
|
||||
.attachments_for(ledger_store::Subject::Project(u64::from(id)))
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|a| receipt_out(&store, a))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let decisions = ledger
|
||||
.conventions()
|
||||
.decisions()
|
||||
.filter(|d| d.new.projects.contains(&id))
|
||||
.map(|d| super::kept::decision_out(ledger, d))
|
||||
.collect();
|
||||
Ok(Json(ProjectDetailOut {
|
||||
project: project_out(ledger, id),
|
||||
period: ProjectPeriodOut {
|
||||
start: start.to_string(),
|
||||
end: end.to_string(),
|
||||
revenue: money(fig.period.total_revenue),
|
||||
expenses: money(fig.period.total_expenses),
|
||||
net: money(fig.period.net_income()),
|
||||
by_account,
|
||||
by_month,
|
||||
},
|
||||
to_date: to_date_out(&fig.to_date),
|
||||
entries,
|
||||
documents,
|
||||
decisions,
|
||||
}))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user