From 1782bf5c623ea62166a8ffacbf31157d095a0590 Mon Sep 17 00:00:00 2001 From: Victoria Mihell-Hale Date: Tue, 19 Dec 2023 19:02:13 +0000 Subject: [PATCH] [Glos] Fetch jobs & job updates from Confirm using GraphQL --- perllib/Integrations/Confirm.pm | 225 +++++++++++ perllib/Open311/Endpoint.pm | 2 +- .../Open311/Endpoint/Integration/Confirm.pm | 224 ++++++++++- t/open311/endpoint/confirm.t | 370 ++++++++++++++++++ t/open311/endpoint/confirm_jobs.yml | 27 ++ 5 files changed, 835 insertions(+), 13 deletions(-) create mode 100644 t/open311/endpoint/confirm_jobs.yml diff --git a/perllib/Integrations/Confirm.pm b/perllib/Integrations/Confirm.pm index de33fe160..1d91cfc45 100644 --- a/perllib/Integrations/Confirm.pm +++ b/perllib/Integrations/Confirm.pm @@ -289,6 +289,231 @@ sub perform_request { return $response; } +sub perform_request_graphql { + my ($self, %args) = @_; + + my $uri = URI->new( $self->config->{graphql_url} ); + my $request = HTTP::Request->new( + 'POST', + $uri, + ); + $request->header( + Authorization => 'Basic ' + . $self->config->{graphql_key} + ); + $request->content_type('application/json; charset=UTF-8'); + + my $query; + if ( $args{type} eq 'job_types' ) { + $query = $self->job_types_graphql_query(); + } elsif ( $args{type} eq 'jobs' ) { + $query = $self->jobs_graphql_query(%args); + } elsif ( $args{type} eq 'job_status_logs' ) { + $query = $self->job_status_logs_graphql_query(%args); + } + + my $body = { + query => $query, + }; + + $request->content(encode_json($body)); + + my $response = $self->ua->request($request); + + my $content = decode_json($response->content); + + return $content; +} + +# GraphQL queries. +# Confirm docs: https://help.dudesolutions.com/Content/PDF/Confirm/v21.10/confirm-v21-10-web-api-specification.pdf +# +# We use GraphQL to fetch 'job' objects from Confirm. +# You can read about GraphQL at https://graphql.org/learn/ +# BUT +# it appears that Confirm's implementation lacks some features, notably +# variable support (hence the use of string interpolation below). It also +# tends to ignore faulty filter definitions in favour of fetching everything. + +sub job_status_logs_graphql_query { + my ( $self, %args ) = @_; + + my @job_type_codes + = keys %{ $self->config->{job_service_whitelist} // () }; + + my @status_codes + = keys %{ $self->config->{job_reverse_status_mapping} // () }; + + my ( + $start_date, + $end_date, + $job_type_codes_str, + $status_codes_str, + ) = ( + $args{start_date}, + $args{end_date}, + join( ',', @job_type_codes ), + join( ',', @status_codes ), + ); + + return <config->{job_service_whitelist} // () }; + + my @status_codes + = keys %{ $self->config->{job_reverse_status_mapping} // () }; + + my ( + $start_date, + $end_date, + $job_type_codes_str, + $status_codes_str, + ) = ( + $args{start_date}, + $args{end_date}, + join( ',', @job_type_codes ), + join( ',', @status_codes ), + ); + + return <<"GRAPHQL" +{ + jobs ( + filter: { + entryDate: { + greaterThanEquals: "$start_date" + lessThanEquals: "$end_date" + } + } + ){ + jobType( + filter: { + code: { + inList: [ $job_type_codes_str ] + } + } + ){ + code + name + } + + statusLogs ( + filter: { + statusCode: { + inList: [ $status_codes_str ] + } + } + ) { + loggedDate + statusCode + } + + entryDate + description + geometry + jobNumber + + priority { + code + name + } + } +} +GRAPHQL +} + +sub job_types_graphql_query { + return <<'GRAPHQL' +{ + jobTypes{ + code + name + } +} +GRAPHQL +} + +sub GetJobStatusLogs { + my ( $self, %args ) = @_; + + my $content + = $self->perform_request_graphql( type => 'job_status_logs', %args ); + + return $content->{data}{jobStatusLogs} // []; +} + +sub GetJobs { + my ($self, %args) = @_; + + my $content = $self->perform_request_graphql( type => 'jobs', %args ); + + return [] unless $content->{data}{jobs}; + + my @jobs; + + # Extra filtering. + # I don't know how to filter out jobs with certain priorities in the + # graphql (possibly a limitation of Confirm's implementation), so let's + # do it here. + for my $job ( @{$content->{data}{jobs}} ) { + next + if $self->config->{job_priority_blacklist} + && $self->config->{job_priority_blacklist}{ $job->{priority}{code} }; + + push @jobs, $job; + } + + return \@jobs; +} + +sub GetJobLookups { + my $self = shift; + + my $lookups = $self->memcache->get('GetJobLookups'); + unless ($lookups) { + $lookups = $self->perform_request_graphql(type => 'job_types'); + + $self->memcache->set('GetJobLookups', $lookups, 1800); + } + + return $lookups->{data}{jobTypes} // []; +} + sub GetEnquiries { my $self = shift; diff --git a/perllib/Open311/Endpoint.pm b/perllib/Open311/Endpoint.pm index ee0f01b03..1fec84f3d 100644 --- a/perllib/Open311/Endpoint.pm +++ b/perllib/Open311/Endpoint.pm @@ -366,7 +366,7 @@ sub GET_Service_List { keywords => (join ',' => @{ $service->keywords } ), metadata => $self->format_boolean( $service->has_attributes ), @{$service->groups} ? (groups => $service->groups) : (group => $service->group), - map { $_ => $service->$_ } + map { $_ => $service->$_ } qw/ service_name service_code description type /, } } $self->services($args); diff --git a/perllib/Open311/Endpoint/Integration/Confirm.pm b/perllib/Open311/Endpoint/Integration/Confirm.pm index 1161a5558..309bd74b0 100644 --- a/perllib/Open311/Endpoint/Integration/Confirm.pm +++ b/perllib/Open311/Endpoint/Integration/Confirm.pm @@ -67,6 +67,34 @@ has service_whitelist => ( } ); +=head2 handle_jobs + +Whether cobrand fetches jobs from Confirm alongside enquiries. This is +based on whether the cobrand has provided a GraphQL URL in its config. + +=cut + +has handle_jobs => ( + is => 'lazy', + default => sub { + return $_[0]->get_integration->config->{graphql_url} ? 1 : 0; + } +); + +=head2 job_service_whitelist + +Controls the mapping of Confirm job service/subject codes to Open311 services +(as opposed to service_whitelist, which handles enquiry services) + +=cut + +has job_service_whitelist => ( + is => 'ro', + default => sub { + return {}; + } +); + =head2 wrapped_services Some Confirm installations are configured in a manner that encodes metadata @@ -223,6 +251,19 @@ has reverse_status_mapping => ( default => sub { {} } ); +=head2 job_reverse_status_mapping + +Maps Confirm job status codes to Open311 service request status codes. +Used for service request updates which are generated by job updates in +Confirm, and for job service requests initially fetched from Confirm. + +=cut + +has job_reverse_status_mapping => ( + is => 'ro', + default => sub { {} } +); + =head2 request_ignore_statuses A list of Confirm enquiry status codes that means those Confirm enquiries @@ -548,6 +589,43 @@ sub get_service_request_updates { ); } } + + if ( $self->handle_jobs ) { + my $status_logs = $integ->GetJobStatusLogs( + start_date => $args->{start_date}, + end_date => $args->{end_date}, + ); + + for my $log ( @{$status_logs} ) { + my $status + = $self->job_reverse_status_mapping->{ $log->{statusCode} }; + + if (!$status) { + # This shouldn't happen given that we filter by status code + # in graphql. But just in case, default to open. + $self->logger->warn( + "Missing reverse job status mapping for statusCode $log->{statusCode} (jobNumber $log->{jobNumber})" + ); + $status = "open"; + } + + my $dt + = $self->date_parser->parse_datetime( $log->{loggedDate} ) + ->truncate( to => 'second' ); + $dt->set_time_zone( $integ->server_timezone ); + + push @updates, + Open311::Endpoint::Service::Request::Update::mySociety->new( + status => $status, + update_id => 'JOB_' . $log->{key}, + service_request_id => 'JOB_' . $log->{jobNumber}, + updated_datetime => $dt, + external_status_code => $log->{statusCode}, + description => '', + ); + } + } + return @updates; } @@ -577,6 +655,9 @@ sub services { my @services = $self->_services; @services = $self->_wrap_services(@services) if defined $self->wrapped_services; + + push @services, $self->job_services; + return @services; } @@ -664,6 +745,51 @@ sub _services { return @services; } +sub job_services { + my $self = shift; + + return () unless $self->handle_jobs; + + my $integ = $self->get_integration; + my $possible_services = $integ->GetJobLookups; + + $possible_services = { + map { $_->{code} => $_ } @$possible_services + }; + + my @services; + my %service_codes; + + my $service_whitelist = $self->job_service_whitelist; + + for my $code (keys %{ $service_whitelist }) { + if (!$possible_services->{$code}) { + $self->logger->error("Job type $code doesn't exist in Confirm."); + next; + } + + my $name; + $name = $service_whitelist->{$code} + if $service_whitelist->{$code} ne 1; + $name ||= $possible_services->{$code}{name}; + + $service_codes{$code} = { + service_name => $name, + service_code => $code, + description => $name, + keywords => [ qw/inactive/ ], + }; + } + + for my $code (sort keys %service_codes) { + my %service = %{ $service_codes{$code} }; + my $o311_service = $self->service_class->new(%service); + push @services, $o311_service; + } + + return @services; +} + sub get_service_request { my ($self, $id) = @_; @@ -677,6 +803,15 @@ sub get_service_requests { my ($self, $args) = @_; my $integ = $self->get_integration; + my @requests; + my @services = $self->services; + my %services = map { + $_->{service_code} => $_ + } @services; + my %private_services = map { $_ => 1 } @{$self->private_services}; + + # Enquiries + my $updated_enquiries = $integ->GetEnquiryStatusChanges( $args->{start_date}, $args->{end_date} @@ -685,19 +820,7 @@ sub get_service_requests { $_->{EnquiryNumber} } @$updated_enquiries; - # no sense doing all the other calls if we don't have - # anything to fetch - return () unless @enquiry_ids; - - my @services = $self->services; - my %services = map { - $_->{service_code} => $_ - } @services; - my %private_services = map { $_ => 1 } @{$self->private_services}; - my @enquiries = $integ->GetEnquiries(@enquiry_ids); - - my @requests; for my $enquiry ( @enquiries ) { my $code = $enquiry->{ServiceCode} . "_" . $enquiry->{SubjectCode}; my $service = $services{$code}; @@ -759,6 +882,83 @@ sub get_service_requests { push @requests, $request; } + # Jobs + + if ($self->handle_jobs) { + my $jobs = $integ->GetJobs( + start_date => $args->{start_date}, + end_date => $args->{end_date}, + ); + + for my $job (@$jobs) { + my $job_id = $job->{jobNumber}; + + unless ( $job->{geometry} ) { + $self->logger->warn("geometry data missing for job $job_id"); + next; + } + + # Of form e.g. 'POINT (-2.07951462 51.88413492)' + my ($geo) = $job->{geometry} =~ s/POINT \((.+)\)/$1/r; + my ( $lon, $lat ) = split / /, $geo; + + unless ( $lon && $lat ) { + $self->logger->warn("no lat/lon for job $job_id"); + next; + } + + my $service = $services{ $job->{jobType}{code} }; + unless ($service) { + # Should not happen given that we filter by job type in graphql + $self->logger->warn( "no service for job type code " + . $job->{jobType}{code} + . " for job $job_id" ); + next; + } + + my $last_status_log = $job->{statusLogs}[-1]; + + my $status = $self->job_reverse_status_mapping + ->{ $last_status_log->{statusCode} }; + unless ($status) { + # This shouldn't happen given that we filter by status code + # in graphql. But just in case, default to open. + $self->logger->warn( "no reverse mapping for job status code " + . $last_status_log->{statusCode} + . " for job $job_id" ); + $status = 'open'; + } + + my $createdtime + = $self->date_parser->parse_datetime( $job->{entryDate} ) + ->truncate( to => 'second' ); + $createdtime->set_time_zone( $integ->server_timezone ); + next + if $self->cutoff_enquiry_date + && $createdtime < $self->cutoff_enquiry_date; + + my $updatedtime = $self->date_parser->parse_datetime( $last_status_log->{loggedDate} ) + ->truncate( to => 'second' ); + $updatedtime->set_time_zone( $integ->server_timezone ); + + my %args = ( + service => $service, + service_request_id => 'JOB_' . $job_id, + description => $job->{description}, + requested_datetime => $createdtime, + updated_datetime => $updatedtime, + # NOTE These are NOT EPSG:27700 easting/northing, unlike + # enquiries above + latlong => [ $lat, $lon ], + status => $status, + ); + + my $request = $self->new_request( %args ); + + push @requests, $request; + } + } + return @requests; } diff --git a/t/open311/endpoint/confirm.t b/t/open311/endpoint/confirm.t index 310c71f21..9241af179 100644 --- a/t/open311/endpoint/confirm.t +++ b/t/open311/endpoint/confirm.t @@ -10,6 +10,12 @@ use Moo; extends 'Integrations::Confirm'; sub _build_config_file { path(__FILE__)->sibling("confirm_customer_ref.yml")->stringify } +package Integrations::Confirm::DummyJobs; +use Path::Tiny; +use Moo; +extends 'Integrations::Confirm'; +sub _build_config_file { path(__FILE__)->sibling("confirm_jobs.yml")->stringify } + package Open311::Endpoint::Integration::UK::Dummy; use Path::Tiny; use Moo; @@ -71,6 +77,18 @@ around BUILDARGS => sub { }; has integration_class => (is => 'ro', default => 'Integrations::Confirm::DummyCustomerRef'); +package Open311::Endpoint::Integration::UK::DummyJobs; +use Path::Tiny; +use Moo; +extends 'Open311::Endpoint::Integration::Confirm'; +around BUILDARGS => sub { + my ($orig, $class, %args) = @_; + $args{jurisdiction_id} = 'confirm_dummy_jobs'; + $args{config_file} = path(__FILE__)->sibling("confirm_jobs.yml")->stringify; + return $class->$orig(%args); +}; +has integration_class => (is => 'ro', default => 'Integrations::Confirm::DummyJobs'); + package main; use strict; @@ -165,6 +183,149 @@ $open311->mock(perform_request => sub { return {}; }); +$open311->mock( perform_request_graphql => sub { + my ( $self, %args ) = @_; + + if ( $args{type} eq 'job_types' ) { + return { + data => { + jobTypes => [ + { code => 'TYPE_1', name => 'Type 1' }, + { code => 'TYPE_2', name => 'Type 2' }, + ], + }, + }; + } elsif ( $args{type} eq 'jobs' ) { + return { + data => { + jobs => [ + # Pass filters + { + description => 'An open job', + entryDate => '2022-12-01T00:00:00', + feature => { site => { centralSite => { name => 'Abc St.' } } }, + geometry => + 'POINT (-2.26317120000001 51.8458834999995)', + jobNumber => 'open_standard', + jobType => { + code => 'TYPE_1', + name => 'Type 1', + }, + priority => { + code => 'ASAP', + name => 'ASAP', + }, + statusLogs => [ + { + loggedDate => '2023-12-01T00:00:00', + statusCode => 'OPEN', + }, + ], + }, + { + description => 'A completed job', + entryDate => '2022-12-01T00:00:00', + feature => { site => { centralSite => { name => 'Abc St.' } } }, + geometry => + 'POINT (-2.26317120000001 51.8458834999995)', + jobNumber => 'closed_standard', + jobType => { + code => 'TYPE_2', + name => 'Type 2', + }, + priority => { + code => 'ASAP', + name => 'ASAP', + }, + statusLogs => [ + { + loggedDate => '2023-12-01T00:00:00', + statusCode => 'OPEN', + }, + { + loggedDate => '2024-01-01T00:00:00', + statusCode => 'FIXED', + }, + ], + }, + + # Filtered out + { + description => 'A job with unhandled type', + entryDate => '2022-12-01T00:00:00', + feature => { site => { centralSite => { name => 'Abc St.' } } }, + geometry => + 'POINT (-2.26317120000001 51.8458834999995)', + jobNumber => 'unhandled_type', + jobType => { + code => 'UNHANDLED', + name => 'Unhandled', + }, + priority => { + code => 'ASAP', + name => 'ASAP', + }, + statusLogs => [ + { + loggedDate => '2023-12-01T00:00:00', + statusCode => 'OPEN', + }, + { + loggedDate => '2023-12-01T01:00:00', + statusCode => 'SHUT', + }, + ], + }, + { + description => 'A job with EOFY priority', + entryDate => '2022-12-01T00:00:00', + feature => { site => { centralSite => { name => 'Abc St.' } } }, + geometry => + 'POINT (-2.26317120000001 51.8458834999995)', + jobNumber => 'eofy_priority', + jobType => { + code => 'TYPE_1', + name => 'Type 1', + }, + priority => { + code => 'EOFY', + name => 'End Of Financial Year', + }, + statusLogs => [], + }, + ], + }, + }; + } elsif ( $args{type} eq 'job_status_logs' ) { + return { + data => { + jobStatusLogs => [ + { + jobNumber => 'open_standard', + key => 'open_standardx1', + loggedDate => '2023-12-01T00:00:00', + statusCode => 'OPEN', + }, + { + jobNumber => 'open_standard', + key => 'open_standardx2', + loggedDate => '2023-12-01T01:00:00', + statusCode => 'SHUT', + }, + { + jobNumber => 'open_standard', + key => 'open_standardx3', + loggedDate => '2023-12-01T02:00:00', + statusCode => 'NOT_IN_CONFIG', + }, + ], + }, + }; + } + + return {}; +}); + my $endpoint = Open311::Endpoint::Integration::UK::Dummy->new; my $endpoint2 = Open311::Endpoint::Integration::UK::DummyOmitLogged->new; @@ -722,4 +883,213 @@ subtest "StatusLogNotes shouldn't appear in updates" => sub { lacks_string $res->content, 'Secret status log notes'; }; +$endpoint = Open311::Endpoint::Integration::UK::DummyJobs->new; + +subtest "GET Service List - include ones for jobs" => sub { + local $ENV{TEST_LOGGER} = 'warn'; + + my $res; + stderr_like { + $res = $endpoint->run_test_request( + GET => '/services.xml?jurisdiction_id=confirm_dummy_jobs' ); + } + qr/Job type NOT doesn't exist in Confirm./, + 'warning about nonexistent job type'; + + ok $res->is_success, 'xml success'; + + my $expected = { + services => { + service => [ + { description => 'Flooding', + groups => { group => 'Flooding & Drainage' }, + keywords => undef, + metadata => 'true', + service_code => 'ABC_DEF', + service_name => 'Flooding', + type => 'realtime' + }, + { description => 'Type 1', + group => undef, + keywords => 'inactive', + metadata => 'true', + service_code => 'TYPE_1', + service_name => 'Type 1', + type => 'realtime', + }, + { description => 'Type 2', + group => undef, + keywords => 'inactive', + metadata => 'true', + service_code => 'TYPE_2', + service_name => 'Type 2', + type => 'realtime', + }, + ] + } + }; + + my $content = $endpoint->xml->parse_string($res->content); + is_deeply $content, $expected, 'correct data fetched'; +}; + +subtest 'GET jobs alongside enquiries' => sub { + local $ENV{TEST_LOGGER} = 'warn'; + + my @expected_warnings = ( + '.*Job type NOT doesn\'t exist in Confirm.', + '.*no easting/northing for Enquiry 2004', + '.*no easting/northing for Enquiry 2005', + '.*no service for job type code UNHANDLED for job unhandled_type', + ); + + my $regex = join '\n', @expected_warnings; + $regex = qr/$regex/; + + my $res; + stderr_like { + $res = $endpoint->run_test_request( + GET => '/requests.xml?jurisdiction_id=confirm_dummy_jobs&start_date=2018-04-17T00:00:00Z&end_date=2023-12-01T23:59:59Z', + ); + } $regex, 'Various warnings'; + + ok $res->is_success, 'valid request' or diag $res->content; + + my $expected = { + service_requests => { + request => [ + # Enquiries + { address => undef, + address_id => undef, + description => 'this is a report from confirm', + lat => '100', + long => '100', + media_url => undef, + requested_datetime => '2018-04-17T13:34:56+01:00', + service_code => 'ABC_DEF', + service_name => 'Flooding', + service_request_id => '2003', + status => 'in_progress', + updated_datetime => '2018-04-17T13:34:56+01:00', + zipcode => undef, + }, + + # Jobs + { address => undef, + address_id => undef, + description => 'An open job', + lat => '51.8458834999995', + long => '-2.26317120000001', + media_url => undef, + requested_datetime => '2022-12-01T00:00:00+00:00', + service_code => 'TYPE_1', + service_name => 'Type 1', + service_request_id => 'JOB_open_standard', + status => 'open', + updated_datetime => '2023-12-01T00:00:00+00:00', + zipcode => undef + }, + { address => undef, + address_id => undef, + description => 'A completed job', + lat => '51.8458834999995', + long => '-2.26317120000001', + media_url => undef, + requested_datetime => '2022-12-01T00:00:00+00:00', + service_code => 'TYPE_2', + service_name => 'Type 2', + service_request_id => 'JOB_closed_standard', + status => 'fixed', + updated_datetime => '2024-01-01T00:00:00+00:00', + zipcode => undef + } + ], + }, + }; + + my $content = $endpoint->xml->parse_string($res->content); + is_deeply $content, $expected, 'correct data fetched'; +}; + +subtest 'GET updates - including for jobs' => sub { + local $ENV{TEST_LOGGER} = 'warn'; + + my @expected_warnings = ( + '.*Missing reverse job status mapping for statusCode NOT_IN_CONFIG \(jobNumber open_standard\)', + ); + + my $regex = join '\n', @expected_warnings; + $regex = qr/$regex/; + + my $res; + stderr_like { + $res = $endpoint->run_test_request( + GET => '/servicerequestupdates.xml?jurisdiction_id=confirm_dummy_jobs&start_date=2018-04-17T00:00:00Z&end_date=2023-12-01T23:59:59Z', + ); + } $regex, 'Expected warnings'; + + ok $res->is_success, 'valid request' or diag $res->content; + + my $expected = { + service_request_updates => { + request_update => [ + # Enquiries + { description => undef, + external_status_code => 'INP', + media_url => undef, + service_request_id => '2001', + status => 'in_progress', + update_id => '2001_3', + updated_datetime => '2018-03-01T12:00:00+00:00', + }, + { description => undef, + external_status_code => 'INP', + media_url => undef, + service_request_id => '2002', + status => 'in_progress', + update_id => '2002_1', + updated_datetime => '2018-03-01T13:00:00+00:00', + }, + { description => undef, + external_status_code => 'DUP', + media_url => undef, + service_request_id => '2002', + status => 'duplicate', + update_id => '2002_2', + updated_datetime => '2018-03-01T13:30:00+00:00', + }, + + # Jobs + { description => undef, + external_status_code => 'OPEN', + media_url => undef, + service_request_id => 'JOB_open_standard', + status => 'open', + update_id => 'JOB_open_standardx1', + updated_datetime => '2023-12-01T00:00:00+00:00', + }, + { description => undef, + external_status_code => 'SHUT', + media_url => undef, + service_request_id => 'JOB_open_standard', + status => 'closed', + update_id => 'JOB_open_standardx2', + updated_datetime => '2023-12-01T01:00:00+00:00', + }, + { description => undef, + external_status_code => 'NOT_IN_CONFIG', + media_url => undef, + service_request_id => 'JOB_open_standard', + status => 'open', + update_id => 'JOB_open_standardx3', + updated_datetime => '2023-12-01T02:00:00+00:00', + }, + ], + }, + }; + + my $content = $endpoint->xml->parse_string($res->content); + is_deeply $content, $expected, 'correct data fetched'; +}; + done_testing; diff --git a/t/open311/endpoint/confirm_jobs.yml b/t/open311/endpoint/confirm_jobs.yml new file mode 100644 index 000000000..1ede60e3b --- /dev/null +++ b/t/open311/endpoint/confirm_jobs.yml @@ -0,0 +1,27 @@ +endpoint_url: "http://example.org/endpoint" +username: "username" +password: "pw" +tenant_id: "123" +server_timezone: Europe/London +default_site_code: 999999 +service_whitelist: + Flooding & Drainage: + ABC_DEF: Flooding +reverse_status_mapping: + DUP: duplicate + INP: in_progress +request_ignore_statuses: [ 'FOR' ] +cutoff_enquiry_date: 2018-04-12T12:00:00 + +graphql_url: "http://example.org/graphql" +graphql_key: "key" +job_service_whitelist: + TYPE_1: 1 + TYPE_2: "Type 2" + NOT: "Doesn't exist in Confirm" +job_reverse_status_mapping: + OPEN: open + SHUT: closed + FIXED: fixed +job_priority_blacklist: + EOFY: End Of Financial Year