diff --git a/client b/client index 1920964..40c6db6 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit 19209641b6ad8a4a9abd0917f8d799bd8e7dd03d +Subproject commit 40c6db688dce79f9ad19b976be71a9505ba8cd5f diff --git a/common/build.rs b/common/build.rs index 453e5c9..5fd113e 100644 --- a/common/build.rs +++ b/common/build.rs @@ -215,6 +215,7 @@ fn main() -> Result<(), Box> { ) .compile_protos( &[ + "proto/accounting.proto", "proto/common.proto", "proto/document_data.proto", "proto/ecb.proto", @@ -242,6 +243,7 @@ fn main() -> Result<(), Box> { // (tonic_prost_build) no longer does. println!("cargo:rerun-if-changed=build.rs"); for proto in [ + "proto/accounting.proto", "proto/common.proto", "proto/document_data.proto", "proto/ecb.proto", diff --git a/common/proto/accounting.proto b/common/proto/accounting.proto new file mode 100644 index 0000000..82e0a46 --- /dev/null +++ b/common/proto/accounting.proto @@ -0,0 +1,84 @@ +syntax = "proto3"; +package komp_ac.accounting; + +// Mutable informational journals. A journal may contain only debits, only +// credits, or any non-zero balance. Balance is reported but never enforced. +service Accounting { + // Create an empty journal and return its generated journal_id. + rpc CreateJournal(CreateJournalRequest) returns (Journal); + + // Return journal lines and credits-minus-debits informational balance. + rpc GetJournal(GetJournalRequest) returns (Journal); + + // Append one manual debit or credit line to an existing journal. + rpc AddJournalLine(AddJournalLineRequest) returns (Journal); + + // Soft-delete one line while retaining it for audit display. + rpc SoftDeleteJournalLine(SoftDeleteJournalLineRequest) returns (Journal); +} + +// Tables with an active posting script accept journal_id as a reserved key in +// PostTableDataRequest.data. It selects the journal receiving generated lines +// and is removed before the source row is validated or stored. + +enum JournalSide { + JOURNAL_SIDE_UNSPECIFIED = 0; + JOURNAL_SIDE_DEBIT = 1; + JOURNAL_SIDE_CREDIT = 2; +} + +message CreateJournalRequest { + string profile_name = 1; + string currency = 2; + string description = 3; +} + +message GetJournalRequest { + string profile_name = 1; + int64 journal_id = 2; + bool include_deleted = 3; +} + +message AddJournalLineRequest { + string profile_name = 1; + int64 journal_id = 2; + JournalSide side = 3; + string account_code = 4; + string amount = 5; + string description = 6; +} + +message SoftDeleteJournalLineRequest { + string profile_name = 1; + int64 journal_id = 2; + int64 journal_line_id = 3; +} + +message JournalLine { + int64 id = 1; + int32 line_number = 2; + JournalSide side = 3; + string account_code = 4; + string amount = 5; + string description = 6; + bool deleted = 7; + string created_at = 8; + string deleted_at = 9; + string source_table_name = 10; + int64 source_record_id = 11; + int64 source_row_revision = 12; + int64 posting_script_id = 13; +} + +message Journal { + int64 id = 1; + string profile_name = 2; + string currency = 3; + string description = 4; + string created_at = 5; + repeated JournalLine lines = 6; + string total_debit = 7; + string total_credit = 8; + // Informational difference calculated as credits minus debits. + string balance = 9; +} diff --git a/common/proto/tables_data.proto b/common/proto/tables_data.proto index 4145d6f..c406780 100644 --- a/common/proto/tables_data.proto +++ b/common/proto/tables_data.proto @@ -117,10 +117,10 @@ message PostTableDataRequest { // comparison, e.g., decimals are compared numerically). // // Notes: - // - Unknown/invalid column names are rejected - // - Some application-specific validations may apply (e.g., max length for - // certain fields like "telefon") - map data = 3; +// - Unknown/invalid column names are rejected +// - Some application-specific validations may apply (e.g., max length for +// certain fields like "telefon") +map data = 3; } // Insert response. diff --git a/common/src/lib.rs b/common/src/lib.rs index 4a6aa34..5c99249 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -5,6 +5,9 @@ pub mod grpc_error; pub mod proto { pub mod komp_ac { + pub mod accounting { + include!("proto/komp_ac.accounting.rs"); + } pub mod adresar { include!("proto/komp_ac.adresar.rs"); } diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index d379a7f..3e26fae 100644 Binary files a/common/src/proto/descriptor.bin and b/common/src/proto/descriptor.bin differ diff --git a/common/src/proto/komp_ac.accounting.rs b/common/src/proto/komp_ac.accounting.rs new file mode 100644 index 0000000..7839d8c --- /dev/null +++ b/common/src/proto/komp_ac.accounting.rs @@ -0,0 +1,648 @@ +// This file is @generated by prost-build. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CreateJournalRequest { + #[prost(string, tag = "1")] + pub profile_name: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub currency: ::prost::alloc::string::String, + #[prost(string, tag = "3")] + pub description: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GetJournalRequest { + #[prost(string, tag = "1")] + pub profile_name: ::prost::alloc::string::String, + #[prost(int64, tag = "2")] + pub journal_id: i64, + #[prost(bool, tag = "3")] + pub include_deleted: bool, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct AddJournalLineRequest { + #[prost(string, tag = "1")] + pub profile_name: ::prost::alloc::string::String, + #[prost(int64, tag = "2")] + pub journal_id: i64, + #[prost(enumeration = "JournalSide", tag = "3")] + pub side: i32, + #[prost(string, tag = "4")] + pub account_code: ::prost::alloc::string::String, + #[prost(string, tag = "5")] + pub amount: ::prost::alloc::string::String, + #[prost(string, tag = "6")] + pub description: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SoftDeleteJournalLineRequest { + #[prost(string, tag = "1")] + pub profile_name: ::prost::alloc::string::String, + #[prost(int64, tag = "2")] + pub journal_id: i64, + #[prost(int64, tag = "3")] + pub journal_line_id: i64, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct JournalLine { + #[prost(int64, tag = "1")] + pub id: i64, + #[prost(int32, tag = "2")] + pub line_number: i32, + #[prost(enumeration = "JournalSide", tag = "3")] + pub side: i32, + #[prost(string, tag = "4")] + pub account_code: ::prost::alloc::string::String, + #[prost(string, tag = "5")] + pub amount: ::prost::alloc::string::String, + #[prost(string, tag = "6")] + pub description: ::prost::alloc::string::String, + #[prost(bool, tag = "7")] + pub deleted: bool, + #[prost(string, tag = "8")] + pub created_at: ::prost::alloc::string::String, + #[prost(string, tag = "9")] + pub deleted_at: ::prost::alloc::string::String, + #[prost(string, tag = "10")] + pub source_table_name: ::prost::alloc::string::String, + #[prost(int64, tag = "11")] + pub source_record_id: i64, + #[prost(int64, tag = "12")] + pub source_row_revision: i64, + #[prost(int64, tag = "13")] + pub posting_script_id: i64, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Journal { + #[prost(int64, tag = "1")] + pub id: i64, + #[prost(string, tag = "2")] + pub profile_name: ::prost::alloc::string::String, + #[prost(string, tag = "3")] + pub currency: ::prost::alloc::string::String, + #[prost(string, tag = "4")] + pub description: ::prost::alloc::string::String, + #[prost(string, tag = "5")] + pub created_at: ::prost::alloc::string::String, + #[prost(message, repeated, tag = "6")] + pub lines: ::prost::alloc::vec::Vec, + #[prost(string, tag = "7")] + pub total_debit: ::prost::alloc::string::String, + #[prost(string, tag = "8")] + pub total_credit: ::prost::alloc::string::String, + /// Informational difference calculated as credits minus debits. + #[prost(string, tag = "9")] + pub balance: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum JournalSide { + Unspecified = 0, + Debit = 1, + Credit = 2, +} +impl JournalSide { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "JOURNAL_SIDE_UNSPECIFIED", + Self::Debit => "JOURNAL_SIDE_DEBIT", + Self::Credit => "JOURNAL_SIDE_CREDIT", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "JOURNAL_SIDE_UNSPECIFIED" => Some(Self::Unspecified), + "JOURNAL_SIDE_DEBIT" => Some(Self::Debit), + "JOURNAL_SIDE_CREDIT" => Some(Self::Credit), + _ => None, + } + } +} +/// Generated client implementations. +pub mod accounting_client { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + use tonic::codegen::http::Uri; + /// Mutable informational journals. A journal may contain only debits, only + /// credits, or any non-zero balance. Balance is reported but never enforced. + #[derive(Debug, Clone)] + pub struct AccountingClient { + inner: tonic::client::Grpc, + } + impl AccountingClient { + /// Attempt to create a new client by connecting to a given endpoint. + pub async fn connect(dst: D) -> Result + where + D: TryInto, + D::Error: Into, + { + let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; + Ok(Self::new(conn)) + } + } + impl AccountingClient + where + T: tonic::client::GrpcService, + T::Error: Into, + T::ResponseBody: Body + std::marker::Send + 'static, + ::Error: Into + std::marker::Send, + { + pub fn new(inner: T) -> Self { + let inner = tonic::client::Grpc::new(inner); + Self { inner } + } + pub fn with_origin(inner: T, origin: Uri) -> Self { + let inner = tonic::client::Grpc::with_origin(inner, origin); + Self { inner } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> AccountingClient> + where + F: tonic::service::Interceptor, + T::ResponseBody: Default, + T: tonic::codegen::Service< + http::Request, + Response = http::Response< + >::ResponseBody, + >, + >, + , + >>::Error: Into + std::marker::Send + std::marker::Sync, + { + AccountingClient::new(InterceptedService::new(inner, interceptor)) + } + /// Compress requests with the given encoding. + /// + /// This requires the server to support it otherwise it might respond with an + /// error. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.send_compressed(encoding); + self + } + /// Enable decompressing responses. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.accept_compressed(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_decoding_message_size(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_encoding_message_size(limit); + self + } + /// Create an empty journal and return its generated journal_id. + pub async fn create_journal( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result, tonic::Status> { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/komp_ac.accounting.Accounting/CreateJournal", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new("komp_ac.accounting.Accounting", "CreateJournal"), + ); + self.inner.unary(req, path, codec).await + } + /// Return journal lines and credits-minus-debits informational balance. + pub async fn get_journal( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result, tonic::Status> { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/komp_ac.accounting.Accounting/GetJournal", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("komp_ac.accounting.Accounting", "GetJournal")); + self.inner.unary(req, path, codec).await + } + /// Append one manual debit or credit line to an existing journal. + pub async fn add_journal_line( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result, tonic::Status> { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/komp_ac.accounting.Accounting/AddJournalLine", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new("komp_ac.accounting.Accounting", "AddJournalLine"), + ); + self.inner.unary(req, path, codec).await + } + /// Soft-delete one line while retaining it for audit display. + pub async fn soft_delete_journal_line( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result, tonic::Status> { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/komp_ac.accounting.Accounting/SoftDeleteJournalLine", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "komp_ac.accounting.Accounting", + "SoftDeleteJournalLine", + ), + ); + self.inner.unary(req, path, codec).await + } + } +} +/// Generated server implementations. +pub mod accounting_server { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + /// Generated trait containing gRPC methods that should be implemented for use with AccountingServer. + #[async_trait] + pub trait Accounting: std::marker::Send + std::marker::Sync + 'static { + /// Create an empty journal and return its generated journal_id. + async fn create_journal( + &self, + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; + /// Return journal lines and credits-minus-debits informational balance. + async fn get_journal( + &self, + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; + /// Append one manual debit or credit line to an existing journal. + async fn add_journal_line( + &self, + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; + /// Soft-delete one line while retaining it for audit display. + async fn soft_delete_journal_line( + &self, + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; + } + /// Mutable informational journals. A journal may contain only debits, only + /// credits, or any non-zero balance. Balance is reported but never enforced. + #[derive(Debug)] + pub struct AccountingServer { + inner: Arc, + accept_compression_encodings: EnabledCompressionEncodings, + send_compression_encodings: EnabledCompressionEncodings, + max_decoding_message_size: Option, + max_encoding_message_size: Option, + } + impl AccountingServer { + pub fn new(inner: T) -> Self { + Self::from_arc(Arc::new(inner)) + } + pub fn from_arc(inner: Arc) -> Self { + Self { + inner, + accept_compression_encodings: Default::default(), + send_compression_encodings: Default::default(), + max_decoding_message_size: None, + max_encoding_message_size: None, + } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> InterceptedService + where + F: tonic::service::Interceptor, + { + InterceptedService::new(Self::new(inner), interceptor) + } + /// Enable decompressing requests with the given encoding. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.accept_compression_encodings.enable(encoding); + self + } + /// Compress responses with the given encoding, if the client supports it. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.send_compression_encodings.enable(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.max_decoding_message_size = Some(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.max_encoding_message_size = Some(limit); + self + } + } + impl tonic::codegen::Service> for AccountingServer + where + T: Accounting, + B: Body + std::marker::Send + 'static, + B::Error: Into + std::marker::Send + 'static, + { + type Response = http::Response; + type Error = std::convert::Infallible; + type Future = BoxFuture; + fn poll_ready( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + fn call(&mut self, req: http::Request) -> Self::Future { + match req.uri().path() { + "/komp_ac.accounting.Accounting/CreateJournal" => { + #[allow(non_camel_case_types)] + struct CreateJournalSvc(pub Arc); + impl< + T: Accounting, + > tonic::server::UnaryService + for CreateJournalSvc { + type Response = super::Journal; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::create_journal(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = CreateJournalSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/komp_ac.accounting.Accounting/GetJournal" => { + #[allow(non_camel_case_types)] + struct GetJournalSvc(pub Arc); + impl< + T: Accounting, + > tonic::server::UnaryService + for GetJournalSvc { + type Response = super::Journal; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_journal(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetJournalSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/komp_ac.accounting.Accounting/AddJournalLine" => { + #[allow(non_camel_case_types)] + struct AddJournalLineSvc(pub Arc); + impl< + T: Accounting, + > tonic::server::UnaryService + for AddJournalLineSvc { + type Response = super::Journal; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::add_journal_line(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = AddJournalLineSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/komp_ac.accounting.Accounting/SoftDeleteJournalLine" => { + #[allow(non_camel_case_types)] + struct SoftDeleteJournalLineSvc(pub Arc); + impl< + T: Accounting, + > tonic::server::UnaryService + for SoftDeleteJournalLineSvc { + type Response = super::Journal; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::soft_delete_journal_line(&inner, request) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = SoftDeleteJournalLineSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + _ => { + Box::pin(async move { + let mut response = http::Response::new( + tonic::body::Body::default(), + ); + let headers = response.headers_mut(); + headers + .insert( + tonic::Status::GRPC_STATUS, + (tonic::Code::Unimplemented as i32).into(), + ); + headers + .insert( + http::header::CONTENT_TYPE, + tonic::metadata::GRPC_CONTENT_TYPE, + ); + Ok(response) + }) + } + } + } + } + impl Clone for AccountingServer { + fn clone(&self) -> Self { + let inner = self.inner.clone(); + Self { + inner, + accept_compression_encodings: self.accept_compression_encodings, + send_compression_encodings: self.send_compression_encodings, + max_decoding_message_size: self.max_decoding_message_size, + max_encoding_message_size: self.max_encoding_message_size, + } + } + } + /// Generated gRPC service name + pub const SERVICE_NAME: &str = "komp_ac.accounting.Accounting"; + impl tonic::server::NamedService for AccountingServer { + const NAME: &'static str = SERVICE_NAME; + } +} diff --git a/tui-pages b/tui-pages index 7584476..380d29b 160000 --- a/tui-pages +++ b/tui-pages @@ -1 +1 @@ -Subproject commit 7584476d25c0575160e57681fc4efc23faaa2ad8 +Subproject commit 380d29b432f98a497156a808f28a8099fc7265e5