From 3edd50625680d851771025471a32ca88cf94e652 Mon Sep 17 00:00:00 2001 From: Yohanna Lisnichuk Date: Fri, 14 Jun 2024 10:00:29 -0400 Subject: [PATCH 1/5] feat: initial R044 --- docs/cli/indicators/R/044.md | 60 +++++++++++++++++++++++++ docs/cli/init.md | 2 + docs/examples/R/044.jsonl | 1 + docs/examples/settings.ini | 1 + src/indicators/mod.rs | 3 ++ src/lib.rs | 4 ++ tests/fixtures/indicators/R044.expected | 1 + tests/fixtures/indicators/R044.jsonl | 1 + 8 files changed, 73 insertions(+) create mode 100644 docs/cli/indicators/R/044.md create mode 100644 docs/examples/R/044.jsonl create mode 100644 tests/fixtures/indicators/R044.expected create mode 100644 tests/fixtures/indicators/R044.jsonl diff --git a/docs/cli/indicators/R/044.md b/docs/cli/indicators/R/044.md new file mode 100644 index 0000000..6542b27 --- /dev/null +++ b/docs/cli/indicators/R/044.md @@ -0,0 +1,60 @@ +# TODO The title of the indicator (R044) + +TODO A one-sentence description of the indicator. + +## Methodology + +TODO + +:::{admonition} Example +:class: seealso + +TODO +::: + +:::{admonition} Why is this a red flag? +:class: hint + +TODO +::: + +Based on "TODO" in [*TODO*](TODO). + +## Output + +The indicator's value is TODO. + +## Configuration + +All configuration is optional. To override the default TODO: + +```ini +[R044] +TODO +``` + +## Exclusions + +A contracting process is excluded if: + +- TODO + +## Assumptions + +TODO + +## Demonstration + +*Input* + +:::{literalinclude} ../../../examples/R/044.jsonl +:language: json +::: + +*Output* + +```console +$ ocdscardinal indicators --settings docs/examples/settings.ini --no-meta docs/examples/R/044.jsonl +{} + +``` diff --git a/docs/cli/init.md b/docs/cli/init.md index e403214..5f0c03d 100644 --- a/docs/cli/init.md +++ b/docs/cli/init.md @@ -110,6 +110,8 @@ $ ocdscardinal init - ; minimum_submitted_bids = 2 ; minimum_contracting_processes = 2 +[R044] + [R048] ; digits = 2 ; threshold = 10 diff --git a/docs/examples/R/044.jsonl b/docs/examples/R/044.jsonl new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/docs/examples/R/044.jsonl @@ -0,0 +1 @@ +{} diff --git a/docs/examples/settings.ini b/docs/examples/settings.ini index ccd340b..4f997a1 100644 --- a/docs/examples/settings.ini +++ b/docs/examples/settings.ini @@ -6,5 +6,6 @@ [R035] [R036] [R038] +[R044] [R048] [R058] diff --git a/src/indicators/mod.rs b/src/indicators/mod.rs index f9b87a4..410b7ef 100644 --- a/src/indicators/mod.rs +++ b/src/indicators/mod.rs @@ -6,6 +6,7 @@ pub mod r030; pub mod r035; pub mod r036; pub mod r038; +pub mod r044; pub mod r048; pub mod r058; pub mod util; @@ -135,6 +136,7 @@ pub struct Settings { pub R035: Option, // count pub R036: Option, pub R038: Option, + pub R044: Option, pub R048: Option, pub R058: Option, // ratio } @@ -159,6 +161,7 @@ pub enum Indicator { R035, R036, R038, + R044, R048, R058, } diff --git a/src/lib.rs b/src/lib.rs index 8677a46..deea58e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,7 @@ use crate::indicators::r030::R030; use crate::indicators::r035::R035; use crate::indicators::r036::R036; use crate::indicators::r038::R038; +use crate::indicators::r044::R044; use crate::indicators::r048::R048; use crate::indicators::r058::R058; use crate::indicators::util::{SecondLowestBidRatio, Tenderers}; @@ -127,6 +128,8 @@ pub fn init(path: &PathBuf, force: &bool) -> std::io::Result { ; minimum_submitted_bids = 2 ; minimum_contracting_processes = 2 +[R044] + [R048] ; digits = 2 ; threshold = 10 @@ -236,6 +239,7 @@ impl Indicators { R035, R036, R038, + R044, R048, R058, ); diff --git a/tests/fixtures/indicators/R044.expected b/tests/fixtures/indicators/R044.expected new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/fixtures/indicators/R044.expected @@ -0,0 +1 @@ +{} diff --git a/tests/fixtures/indicators/R044.jsonl b/tests/fixtures/indicators/R044.jsonl new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/fixtures/indicators/R044.jsonl @@ -0,0 +1 @@ +{} From f580dbff311c4fcb734d05d042f79d5a5e5ba792 Mon Sep 17 00:00:00 2001 From: Yohanna Lisnichuk Date: Fri, 14 Jun 2024 15:16:40 -0400 Subject: [PATCH 2/5] feat: initial implementation of R044 with address --- src/indicators/r044.rs | 60 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/indicators/r044.rs diff --git a/src/indicators/r044.rs b/src/indicators/r044.rs new file mode 100644 index 0000000..31cb07e --- /dev/null +++ b/src/indicators/r044.rs @@ -0,0 +1,60 @@ +use serde_json::{Map, Value}; + +use crate::indicators::{set_result, Calculate, Indicators, Settings}; + +#[derive(Default)] +pub struct R044 {} + +impl R044 { + fn get_address(party: &Value) -> Option { + let mut full_address: String = String::new(); + if let Some(Value::Object(address)) = party.get("address") + && address.get("streetAddress").is_some() + { + for field in ["streetAddress", "locality", "region", "postalCode", "countryName"] { + if let Some(Value::String(value)) = address.get(field) { + full_address.push_str(value.trim().to_lowercase().as_str()); + full_address.push(' '); + } + } + Some(full_address) + } else { + None + } + } +} + +impl Calculate for R044 { + fn new(_settings: &mut Settings) -> Self { + Self::default() + } + + fn fold(&self, item: &mut Indicators, release: &Map, ocid: &str) { + if let Some(Value::Array(parties)) = release.get("parties") { + let tenderers: Vec<&Value> = parties + .iter() + .filter(|p| { + if let Some(Value::Array(roles)) = p.get("roles") { + roles.iter().any(|s| s == "tenderer") && p.get("id").is_some() + } else { + false + } + }) + .collect(); + for party_to_compare in tenderers { + if let Some(address_to_compare) = Self::get_address(party_to_compare) { + for party in parties { + if let Some(Value::String(party_id)) = party.get("id") + && party_id != party_to_compare.get("id").unwrap() + && let Some(address) = Self::get_address(party) + && address_to_compare == address + { + set_result!(item, OCID, ocid, R044, 1.0); + set_result!(item, Tenderer, party_id, R044, 1.0); + } + } + } + } + } + } +} From 6fe20ea346406e24cbabf25fcc3d355af7882ce8 Mon Sep 17 00:00:00 2001 From: Yohanna Lisnichuk Date: Fri, 14 Jun 2024 16:12:19 -0400 Subject: [PATCH 3/5] add tests --- tests/fixtures/indicators/R044.expected | 2 +- tests/fixtures/indicators/R044.jsonl | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/fixtures/indicators/R044.expected b/tests/fixtures/indicators/R044.expected index 0967ef4..7da3299 100644 --- a/tests/fixtures/indicators/R044.expected +++ b/tests/fixtures/indicators/R044.expected @@ -1 +1 @@ -{} +{"OCID":{"F1":{"R044":1.0},"F2":{"R044":1.0}},"Tenderer":{"GB-COH-09506234":{"R044":1.0},"GB-COH-09506232":{"R044":1.0}}} \ No newline at end of file diff --git a/tests/fixtures/indicators/R044.jsonl b/tests/fixtures/indicators/R044.jsonl index 0967ef4..4daea6f 100644 --- a/tests/fixtures/indicators/R044.jsonl +++ b/tests/fixtures/indicators/R044.jsonl @@ -1 +1,4 @@ -{} +{"ocid":"F1","parties":[{"address":{"streetAddress":"Street ABC","locality":"Atlantis City","region":"Moorland","postalCode":"1234","countryName":"Atlantis"},"roles":["tenderer"],"id":"GB-COH-09506232"},{"address":{"streetAddress":"Street ABC","locality":"Atlantis City","region":"Moorland","postalCode":"1234","countryName":"Atlantis"},"roles":["tenderer"],"id":"GB-COH-09506234"}]} +{"ocid":"F2","parties":[{"address":{"streetAddress":"Street ABC"},"roles":["tenderer"],"id":"GB-COH-09506232"},{"address":{"streetAddress":"Street ABC"},"roles":["tenderer"],"id":"GB-COH-09506234"}]} +{"ocid":"Same country only","parties":[{"address":{"countryName":"Atlantis"},"roles":["tenderer"],"id":"GB-COH-09506232"},{"address":{"countryName":"Atlantis"},"roles":["tenderer"],"id":"GB-COH-09506234"}]} +{"ocid":"No address","parties":[{"roles":["tenderer"],"id":"GB-COH-09506232"},{"roles":["tenderer"],"id":"GB-COH-09506234"}]} From 53628d02f53fc176ab6c1f74fde2a72b73f9b818 Mon Sep 17 00:00:00 2001 From: Yohanna Lisnichuk Date: Mon, 17 Jun 2024 13:36:33 -0400 Subject: [PATCH 4/5] feat: r044: refactor and add contact point logic --- src/indicators/r044.rs | 51 ++++++++++++++++++------- tests/fixtures/indicators/R044.expected | 2 +- tests/fixtures/indicators/R044.jsonl | 4 +- 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/indicators/r044.rs b/src/indicators/r044.rs index 31cb07e..ea5cde8 100644 --- a/src/indicators/r044.rs +++ b/src/indicators/r044.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use serde_json::{Map, Value}; use crate::indicators::{set_result, Calculate, Indicators, Settings}; @@ -22,6 +24,20 @@ impl R044 { None } } + fn compare_contact_point(party1: Option, party2: Option) -> bool { + if let Some(Value::Object(contact_point_1)) = party1 + && let Some(Value::Object(contact_point_2)) = party2 + { + for field in ["name", "email", "telephone", "faxNumber", "url"] { + if let Some(Value::String(field1)) = contact_point_1.get(field) + && let Some(Value::String(field2)) = contact_point_2.get(field) + { + return field1 == field2; + } + } + } + false + } } impl Calculate for R044 { @@ -31,26 +47,35 @@ impl Calculate for R044 { fn fold(&self, item: &mut Indicators, release: &Map, ocid: &str) { if let Some(Value::Array(parties)) = release.get("parties") { - let tenderers: Vec<&Value> = parties + let tenderers = parties .iter() - .filter(|p| { - if let Some(Value::Array(roles)) = p.get("roles") { - roles.iter().any(|s| s == "tenderer") && p.get("id").is_some() + .filter_map(|tenderer| { + if let Some(Value::Array(roles)) = tenderer.get("roles") + && roles.iter().any(|s| s == "tenderer") + && let Some(Value::String(id)) = tenderer.get("id") + { + Some((id.clone(), (Self::get_address(tenderer), tenderer.get("contactPoint")))) } else { - false + None } }) - .collect(); - for party_to_compare in tenderers { - if let Some(address_to_compare) = Self::get_address(party_to_compare) { - for party in parties { - if let Some(Value::String(party_id)) = party.get("id") - && party_id != party_to_compare.get("id").unwrap() - && let Some(address) = Self::get_address(party) + .collect::>(); + for (id_to_compare, details_to_compare) in &tenderers { + for (id, details) in &tenderers { + if id_to_compare != id { + let address_match = if let Some(address_to_compare) = details_to_compare.0.as_deref() + && let Some(address) = details.0.as_deref() && address_to_compare == address { + true + } else { + false + }; + let contact_point_match = + Self::compare_contact_point(details.1.cloned(), details_to_compare.1.cloned()); + if address_match || contact_point_match { set_result!(item, OCID, ocid, R044, 1.0); - set_result!(item, Tenderer, party_id, R044, 1.0); + set_result!(item, Tenderer, id_to_compare, R044, 1.0); } } } diff --git a/tests/fixtures/indicators/R044.expected b/tests/fixtures/indicators/R044.expected index 7da3299..c75971a 100644 --- a/tests/fixtures/indicators/R044.expected +++ b/tests/fixtures/indicators/R044.expected @@ -1 +1 @@ -{"OCID":{"F1":{"R044":1.0},"F2":{"R044":1.0}},"Tenderer":{"GB-COH-09506234":{"R044":1.0},"GB-COH-09506232":{"R044":1.0}}} \ No newline at end of file +{"OCID":{"F1":{"R044":1.0},"F2":{"R044":1.0},"F3":{"R044":1.0},"F4":{"R044":1.0}},"Tenderer":{"GB-COH-09506234":{"R044":1.0},"GB-COH-09506232":{"R044":1.0}}} \ No newline at end of file diff --git a/tests/fixtures/indicators/R044.jsonl b/tests/fixtures/indicators/R044.jsonl index 4daea6f..d64823e 100644 --- a/tests/fixtures/indicators/R044.jsonl +++ b/tests/fixtures/indicators/R044.jsonl @@ -1,4 +1,6 @@ {"ocid":"F1","parties":[{"address":{"streetAddress":"Street ABC","locality":"Atlantis City","region":"Moorland","postalCode":"1234","countryName":"Atlantis"},"roles":["tenderer"],"id":"GB-COH-09506232"},{"address":{"streetAddress":"Street ABC","locality":"Atlantis City","region":"Moorland","postalCode":"1234","countryName":"Atlantis"},"roles":["tenderer"],"id":"GB-COH-09506234"}]} {"ocid":"F2","parties":[{"address":{"streetAddress":"Street ABC"},"roles":["tenderer"],"id":"GB-COH-09506232"},{"address":{"streetAddress":"Street ABC"},"roles":["tenderer"],"id":"GB-COH-09506234"}]} +{"ocid":"F3","parties":[{"contactPoint":{"name":"Person","email":"person@example.com","telephone":"+123456","faxNumber":"+123456","url":"http://example.com"},"roles":["tenderer"],"id":"GB-COH-09506232"},{"contactPoint":{"name":"Person","email":"person@example.com","telephone":"+123456","faxNumber":"+123456","url":"http://example.com"},"roles":["tenderer"],"id":"GB-COH-09506234"}]} +{"ocid":"F4","parties":[{"contactPoint":{"name":"Person"},"roles":["tenderer"],"id":"GB-COH-09506232"},{"contactPoint":{"name":"Person"},"roles":["tenderer"],"id":"GB-COH-09506234"}]} {"ocid":"Same country only","parties":[{"address":{"countryName":"Atlantis"},"roles":["tenderer"],"id":"GB-COH-09506232"},{"address":{"countryName":"Atlantis"},"roles":["tenderer"],"id":"GB-COH-09506234"}]} -{"ocid":"No address","parties":[{"roles":["tenderer"],"id":"GB-COH-09506232"},{"roles":["tenderer"],"id":"GB-COH-09506234"}]} +{"ocid":"No address or contact point","parties":[{"roles":["tenderer"],"id":"GB-COH-09506232"},{"roles":["tenderer"],"id":"GB-COH-09506234"}]} From 1891189c7353d03df546e0a0c858b26c5ac4c218 Mon Sep 17 00:00:00 2001 From: Yohanna Lisnichuk Date: Mon, 17 Jun 2024 14:13:18 -0400 Subject: [PATCH 5/5] docs: Add docs for R044 --- docs/changelog.md | 1 + docs/cli/indicators/R/044.md | 34 ++++++++++------------------------ docs/cli/indicators/index.md | 3 +++ docs/examples/R/044.jsonl | 5 ++++- 4 files changed, 18 insertions(+), 25 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index ca22715..a5e4940 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -15,6 +15,7 @@ - [R018](cli/indicators/R/018): (*Single bid received*). - [R028](cli/indicators/R/028): (*Identical bid prices*). - [R030](cli/indicators/R/030): (*Late bid won*). + - [R044](cli/indicators/R/048): (*Business similarities between suppliers*). - [R048](cli/indicators/R/048): (*Heterogeneous supplier*). - [R058](cli/indicators/R/058): (*Heavily discounted bid*). - Add `no_price_comparison_procurement_methods` configuration. diff --git a/docs/cli/indicators/R/044.md b/docs/cli/indicators/R/044.md index 6542b27..bc1508b 100644 --- a/docs/cli/indicators/R/044.md +++ b/docs/cli/indicators/R/044.md @@ -1,47 +1,33 @@ -# TODO The title of the indicator (R044) +# Business similarities between suppliers (R044) -TODO A one-sentence description of the indicator. +Different tenderers bidding for the same contracting process have similar information. ## Methodology -TODO +A contracting process is flagged if different tenderers have either the exact same address or any contact point fields. +These tenderers are also flagged. :::{admonition} Example :class: seealso -TODO +BribeCorp and AnotherBribeCorp submit bids to the same procurement process. Both companies use "Collusion Street 1" as their street address in the Atlantis country. ::: :::{admonition} Why is this a red flag? :class: hint -TODO +Similarities between suppliers may indicate that the companies are connected. ::: -Based on "TODO" in [*TODO*](TODO). +Based on "Connections between bidders undermines competition" in [*Corruption in Public Procurement: Finding the Right Indicators*](https://www.researchgate.net/publication/303359108_Corruption_in_Public_Procurement_Finding_the_Right_Indicators), and "Similarity in Bids; Apparent Connections Between Bidders" in [*Guide to Combating Corruption & Fraud in Infrastructure Development Projects*](https://guide.iacrc.org/red-flag-similar-bids-apparent-connections-between-bidders/). ## Output -The indicator's value is TODO. +The indicator’s value is always 1.0. ## Configuration -All configuration is optional. To override the default TODO: - -```ini -[R044] -TODO -``` - -## Exclusions - -A contracting process is excluded if: - -- TODO - -## Assumptions - -TODO +The indicator is not configurable. ## Demonstration @@ -55,6 +41,6 @@ TODO ```console $ ocdscardinal indicators --settings docs/examples/settings.ini --no-meta docs/examples/R/044.jsonl -{} +{"OCID":{"F1":{"R044":1.0},"F2":{"R044":1.0},"F3":{"R044":1.0},"F4":{"R044":1.0}},"Tenderer":{"GB-COH-09506232":{"R044":1.0},"GB-COH-09506234":{"R044":1.0}}} ``` diff --git a/docs/cli/indicators/index.md b/docs/cli/indicators/index.md index 5412e15..8c671a5 100644 --- a/docs/cli/indicators/index.md +++ b/docs/cli/indicators/index.md @@ -320,6 +320,9 @@ R/index * - [R038](R/038) - [Excessive disqualified bids](R/038) - The ratio of disqualified bids to submitted bids is a high outlier per buyer, procuring entity or tenderer. +* - [R044](R/044) + - [Business similarities between suppliers](R/044) + - Different tenderers bidding for the same contracting process have similar information. * - [R048](R/048) - [Heterogeneous supplier](R/048) - The variety of items supplied by a tenderer is a high outlier. diff --git a/docs/examples/R/044.jsonl b/docs/examples/R/044.jsonl index 0967ef4..d3a1576 100644 --- a/docs/examples/R/044.jsonl +++ b/docs/examples/R/044.jsonl @@ -1 +1,4 @@ -{} +{"ocid":"F1","parties":[{"address":{"streetAddress":"Street ABC","locality":"Atlantis City","region":"Moorland","postalCode":"1234","countryName":"Atlantis"},"roles":["tenderer"],"id":"GB-COH-09506232"},{"address":{"streetAddress":"Street ABC","locality":"Atlantis City","region":"Moorland","postalCode":"1234","countryName":"Atlantis"},"roles":["tenderer"],"id":"GB-COH-09506234"}]} +{"ocid":"F2","parties":[{"address":{"streetAddress":"Street ABC"},"roles":["tenderer"],"id":"GB-COH-09506232"},{"address":{"streetAddress":"Street ABC"},"roles":["tenderer"],"id":"GB-COH-09506234"}]} +{"ocid":"F3","parties":[{"contactPoint":{"name":"Person","email":"person@example.com","telephone":"+123456","faxNumber":"+123456","url":"http://example.com"},"roles":["tenderer"],"id":"GB-COH-09506232"},{"contactPoint":{"name":"Person","email":"person@example.com","telephone":"+123456","faxNumber":"+123456","url":"http://example.com"},"roles":["tenderer"],"id":"GB-COH-09506234"}]} +{"ocid":"F4","parties":[{"contactPoint":{"name":"Person"},"roles":["tenderer"],"id":"GB-COH-09506232"},{"contactPoint":{"name":"Person"},"roles":["tenderer"],"id":"GB-COH-09506234"}]}