appydave-tools 0.84.0 → 0.86.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/CLAUDE.md +1 -1
- data/CONTEXT.md +133 -23
- data/bin/configuration.rb +20 -0
- data/bin/query_apps.rb +74 -0
- data/config/examples/locations.example.json +48 -0
- data/config/examples/settings.example.json +9 -0
- data/config/random-queries.yml +24 -45
- data/context.globs.json +24 -0
- data/docs/guides/tools/dam/dam-usage.md +261 -471
- data/docs/planning/multi-user-support.md +108 -0
- data/docs/planning/query-apps-design.md +344 -0
- data/docs/planning/query-skills-plan.md +354 -0
- data/docs/specs/jump-add-display-fix.md +249 -0
- data/exe/query_apps +7 -0
- data/lib/appydave/tools/app_context/app_finder.rb +176 -0
- data/lib/appydave/tools/app_context/globs_loader.rb +116 -0
- data/lib/appydave/tools/app_context/options.rb +28 -0
- data/lib/appydave/tools/bank_reconciliation/_doc.md +36 -0
- data/lib/appydave/tools/bank_reconciliation/clean/clean_transactions.rb +159 -0
- data/lib/appydave/tools/bank_reconciliation/clean/mapper.rb +133 -0
- data/lib/appydave/tools/bank_reconciliation/clean/read_transactions.rb +258 -0
- data/lib/appydave/tools/bank_reconciliation/models/transaction.rb +158 -0
- data/lib/appydave/tools/brain_context/options.rb +19 -2
- data/lib/appydave/tools/configuration/example_installer.rb +72 -0
- data/lib/appydave/tools/configuration/models/bank_reconciliation_config.rb +152 -0
- data/lib/appydave/tools/configuration/models/settings_config.rb +12 -0
- data/lib/appydave/tools/jump/formatters/table_formatter.rb +16 -5
- data/lib/appydave/tools/version.rb +1 -1
- data/lib/appydave/tools/zsh_history/config.rb +2 -2
- data/lib/appydave/tools/zsh_history/filter.rb +10 -4
- data/lib/appydave/tools.rb +12 -0
- data/package.json +1 -1
- metadata +36 -2
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Appydave
|
|
6
|
+
module Tools
|
|
7
|
+
module AppContext
|
|
8
|
+
# Queries locations.json to find app files via context.globs.json patterns.
|
|
9
|
+
#
|
|
10
|
+
# Follows the same find/find_meta pattern as BrainQuery and OmiQuery.
|
|
11
|
+
class AppQuery
|
|
12
|
+
def initialize(options, jump_config: nil)
|
|
13
|
+
@options = options
|
|
14
|
+
@jump_config = jump_config
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Return absolute file paths matching the query
|
|
18
|
+
def find
|
|
19
|
+
return [] unless @options.query?
|
|
20
|
+
|
|
21
|
+
apps = resolve_apps
|
|
22
|
+
return [] if apps.empty?
|
|
23
|
+
|
|
24
|
+
apps.flat_map { |app| expand_app(app) }.uniq.sort
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Return structured metadata about the query results
|
|
28
|
+
def find_meta
|
|
29
|
+
return [] unless @options.query?
|
|
30
|
+
|
|
31
|
+
apps = resolve_apps
|
|
32
|
+
return [] if apps.empty?
|
|
33
|
+
|
|
34
|
+
apps.map { |app| build_meta(app) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# List all available glob names for a specific app
|
|
38
|
+
def list_globs(app_name)
|
|
39
|
+
location = find_location(app_name)
|
|
40
|
+
return [] unless location
|
|
41
|
+
|
|
42
|
+
loader = build_loader(location)
|
|
43
|
+
return [] unless loader.available?
|
|
44
|
+
|
|
45
|
+
loader.available_names
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# List all apps that have context.globs.json
|
|
49
|
+
def list_apps
|
|
50
|
+
jump_config.locations
|
|
51
|
+
.select { |loc| File.exist?(File.join(expand_path(loc.path), 'context.globs.json')) }
|
|
52
|
+
.map do |loc|
|
|
53
|
+
loader = build_loader(loc)
|
|
54
|
+
{
|
|
55
|
+
'key' => loc.key,
|
|
56
|
+
'path' => expand_path(loc.path),
|
|
57
|
+
'pattern' => loader.pattern,
|
|
58
|
+
'glob_count' => loader.globs.size
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def jump_config
|
|
66
|
+
@jump_config ||= Jump::Config.new
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Resolve app names to Location objects
|
|
70
|
+
def resolve_apps
|
|
71
|
+
if @options.pattern_filter
|
|
72
|
+
resolve_by_pattern(@options.pattern_filter)
|
|
73
|
+
else
|
|
74
|
+
@options.app_names.flat_map { |name| resolve_app(name) }.compact.uniq(&:key)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# 4-tier app resolution
|
|
79
|
+
def resolve_app(name)
|
|
80
|
+
name_down = name.downcase
|
|
81
|
+
|
|
82
|
+
# Tier 1: exact key match
|
|
83
|
+
loc = jump_config.find(name_down)
|
|
84
|
+
return [loc] if loc
|
|
85
|
+
|
|
86
|
+
# Tier 2: jump alias match
|
|
87
|
+
loc = jump_config.locations.find { |l| l.jump&.downcase == name_down }
|
|
88
|
+
return [loc] if loc
|
|
89
|
+
|
|
90
|
+
# Tier 3: substring match on key
|
|
91
|
+
matches = jump_config.locations.select { |l| l.key.downcase.include?(name_down) }
|
|
92
|
+
return matches unless matches.empty?
|
|
93
|
+
|
|
94
|
+
# Tier 4: substring match on description
|
|
95
|
+
jump_config.locations.select { |l| l.description&.downcase&.include?(name_down) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def resolve_by_pattern(pattern)
|
|
99
|
+
jump_config.locations.select do |loc|
|
|
100
|
+
loader = build_loader(loc)
|
|
101
|
+
loader.available? && loader.pattern&.downcase == pattern.downcase
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def expand_app(location)
|
|
106
|
+
loader = build_loader(location)
|
|
107
|
+
return [] unless loader.available?
|
|
108
|
+
|
|
109
|
+
glob_names = @options.glob_names
|
|
110
|
+
return [] if glob_names.empty?
|
|
111
|
+
|
|
112
|
+
loader.expand(glob_names)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def build_meta(location)
|
|
116
|
+
loader = build_loader(location)
|
|
117
|
+
glob_names = @options.glob_names
|
|
118
|
+
file_count = loader.available? && glob_names.any? ? loader.expand(glob_names).size : 0
|
|
119
|
+
|
|
120
|
+
resolved_from = glob_names.map { |n| describe_resolution(loader, n) }.join(', ')
|
|
121
|
+
|
|
122
|
+
{
|
|
123
|
+
'app' => location.key,
|
|
124
|
+
'path' => expand_path(location.path),
|
|
125
|
+
'pattern' => loader.pattern,
|
|
126
|
+
'matched_globs' => resolve_glob_names(loader, glob_names),
|
|
127
|
+
'resolved_from' => resolved_from,
|
|
128
|
+
'file_count' => file_count
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def build_loader(location)
|
|
133
|
+
GlobsLoader.new(expand_path(location.path))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def expand_path(path)
|
|
137
|
+
File.expand_path(path)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def find_location(app_name)
|
|
141
|
+
results = resolve_app(app_name)
|
|
142
|
+
results&.first
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Describe how a glob name was resolved (for meta output)
|
|
146
|
+
def describe_resolution(loader, name)
|
|
147
|
+
name = name.strip.downcase
|
|
148
|
+
return "#{name} (glob)" if loader.globs.key?(name)
|
|
149
|
+
return "#{name} (alias)" if loader.aliases.key?(name)
|
|
150
|
+
return "#{name} (composite)" if loader.composites.key?(name)
|
|
151
|
+
|
|
152
|
+
"#{name} (fuzzy)"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Resolve glob names to their constituent direct glob names
|
|
156
|
+
def resolve_glob_names(loader, names)
|
|
157
|
+
return [] unless loader.available?
|
|
158
|
+
|
|
159
|
+
names.flat_map do |name|
|
|
160
|
+
name = name.strip.downcase
|
|
161
|
+
if loader.globs.key?(name)
|
|
162
|
+
[name]
|
|
163
|
+
elsif loader.aliases.key?(name)
|
|
164
|
+
loader.aliases[name]
|
|
165
|
+
elsif loader.composites.key?(name)
|
|
166
|
+
members = loader.composites[name]
|
|
167
|
+
members == ['*'] ? loader.globs.keys : members
|
|
168
|
+
else # rubocop:disable Lint/DuplicateBranch
|
|
169
|
+
[name]
|
|
170
|
+
end
|
|
171
|
+
end.uniq
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Appydave
|
|
6
|
+
module Tools
|
|
7
|
+
module AppContext
|
|
8
|
+
# Loads and resolves named glob patterns from a context.globs.json file.
|
|
9
|
+
#
|
|
10
|
+
# Resolution is 3-tier:
|
|
11
|
+
# 1. Direct glob name — "services" → globs["services"]
|
|
12
|
+
# 2. Alias match — "backend" → aliases["backend"] → ["services", "routes"]
|
|
13
|
+
# 3. Composite match — "understand" → composites["understand"] → ["context", "docs", ...]
|
|
14
|
+
class GlobsLoader
|
|
15
|
+
attr_reader :project_path, :data
|
|
16
|
+
|
|
17
|
+
def initialize(project_path)
|
|
18
|
+
@project_path = project_path
|
|
19
|
+
@data = load_globs_file
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def available?
|
|
23
|
+
!data.nil?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def globs
|
|
27
|
+
data&.fetch('globs', {}) || {}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def aliases
|
|
31
|
+
data&.fetch('aliases', {}) || {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def composites
|
|
35
|
+
data&.fetch('composites', {}) || {}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def pattern
|
|
39
|
+
data&.fetch('pattern', nil)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def project_name
|
|
43
|
+
data&.fetch('project', nil)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# List all available glob names (direct + aliases + composites)
|
|
47
|
+
def available_names
|
|
48
|
+
names = globs.keys.map { |k| { name: k, type: 'glob' } }
|
|
49
|
+
names += aliases.keys.map { |k| { name: k, type: 'alias' } }
|
|
50
|
+
names += composites.keys.map { |k| { name: k, type: 'composite' } }
|
|
51
|
+
names
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Resolve a single name through the 3-tier hierarchy.
|
|
55
|
+
# Returns an array of raw glob patterns (strings).
|
|
56
|
+
def resolve(name)
|
|
57
|
+
name = name.strip.downcase
|
|
58
|
+
|
|
59
|
+
# Tier 1: direct glob name
|
|
60
|
+
return globs[name] if globs.key?(name)
|
|
61
|
+
|
|
62
|
+
# Tier 2: alias → list of glob names
|
|
63
|
+
return aliases[name].flat_map { |glob_name| globs[glob_name] || [] } if aliases.key?(name)
|
|
64
|
+
|
|
65
|
+
# Tier 3: composite → list of glob names (or "*" for all)
|
|
66
|
+
if composites.key?(name)
|
|
67
|
+
members = composites[name]
|
|
68
|
+
return globs.values.flatten if members == ['*']
|
|
69
|
+
|
|
70
|
+
return members.flat_map { |glob_name| resolve_single_glob(glob_name) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Tier 4: substring fallback
|
|
74
|
+
match = find_substring_match(name)
|
|
75
|
+
return resolve(match) if match
|
|
76
|
+
|
|
77
|
+
[]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Resolve multiple names, expand globs against the filesystem, return absolute paths.
|
|
81
|
+
def expand(names)
|
|
82
|
+
patterns = names.flat_map { |name| resolve(name) }.uniq
|
|
83
|
+
|
|
84
|
+
patterns.flat_map { |pat| Dir.glob(File.join(project_path, pat)) }
|
|
85
|
+
.select { |f| File.file?(f) }
|
|
86
|
+
.uniq
|
|
87
|
+
.sort
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def globs_file_path
|
|
93
|
+
File.join(project_path, 'context.globs.json')
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def load_globs_file
|
|
97
|
+
path = globs_file_path
|
|
98
|
+
return nil unless File.exist?(path)
|
|
99
|
+
|
|
100
|
+
JSON.parse(File.read(path))
|
|
101
|
+
rescue JSON::ParserError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def resolve_single_glob(glob_name)
|
|
106
|
+
globs[glob_name] || []
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def find_substring_match(name)
|
|
110
|
+
all_names = globs.keys + aliases.keys + composites.keys
|
|
111
|
+
all_names.find { |n| n.include?(name) }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Appydave
|
|
4
|
+
module Tools
|
|
5
|
+
module AppContext
|
|
6
|
+
# Options struct for app context query tool
|
|
7
|
+
class Options
|
|
8
|
+
attr_accessor :app_names, :glob_names, :pattern_filter,
|
|
9
|
+
:meta, :list, :list_apps,
|
|
10
|
+
:debug_level
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@app_names = []
|
|
14
|
+
@glob_names = []
|
|
15
|
+
@pattern_filter = nil
|
|
16
|
+
@meta = false
|
|
17
|
+
@list = false
|
|
18
|
+
@list_apps = false
|
|
19
|
+
@debug_level = 'none'
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def query?
|
|
23
|
+
app_names.any? || !pattern_filter.nil?
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Bank reconciliation
|
|
2
|
+
|
|
3
|
+
[ChatGPT conversation](https://chatgpt.com/c/5d382562-95e5-4243-9b74-c3807d363486)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
## Code structure
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
├─ lib
|
|
10
|
+
│ ├─ appydave
|
|
11
|
+
│ │ └─ tools
|
|
12
|
+
│ │ ├─ bank_reconciliation
|
|
13
|
+
│ │ │ ├─ clean
|
|
14
|
+
│ │ │ │ ├─ read_transactions.rb
|
|
15
|
+
│ │ │ │ ├─ transaction_cleaner.rb
|
|
16
|
+
│ │ │ ├─ models
|
|
17
|
+
│ │ │ │ ├─ raw_transaction.rb
|
|
18
|
+
│ │ │ │ └─ reconciled_transaction.rb
|
|
19
|
+
│ │ └─ configuration
|
|
20
|
+
│ │ └─ models
|
|
21
|
+
│ │ └─ bank_reconciliation_config.rb
|
|
22
|
+
└─ spec
|
|
23
|
+
├─ appydave
|
|
24
|
+
│ ├─ tools
|
|
25
|
+
│ │ ├─ bank_reconciliation
|
|
26
|
+
│ │ │ ├─ clean
|
|
27
|
+
│ │ │ │ ├─ read_transactions_spec.rb
|
|
28
|
+
│ │ │ ├─ models
|
|
29
|
+
│ │ │ │ └─ raw_transaction_spec.rb
|
|
30
|
+
│ │ └─ configuration
|
|
31
|
+
│ │ └─ models
|
|
32
|
+
│ │ └─ bank_reconciliation_config_spec.rb
|
|
33
|
+
└─ fixtures
|
|
34
|
+
└─ bank-reconciliation
|
|
35
|
+
└─ bank-west.csv
|
|
36
|
+
```
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'clipboard'
|
|
4
|
+
|
|
5
|
+
module Appydave
|
|
6
|
+
module Tools
|
|
7
|
+
module BankReconciliation
|
|
8
|
+
module Clean
|
|
9
|
+
# Clean transactions
|
|
10
|
+
class CleanTransactions
|
|
11
|
+
include KLog::Logging
|
|
12
|
+
include Appydave::Tools::Configuration::Configurable
|
|
13
|
+
include Appydave::Tools::Debuggable
|
|
14
|
+
|
|
15
|
+
attr_reader :transaction_folder
|
|
16
|
+
attr_reader :output_folder
|
|
17
|
+
attr_reader :transactions
|
|
18
|
+
|
|
19
|
+
# (config_file)
|
|
20
|
+
def initialize(transaction_folder: nil, output_folder: nil, debug: false)
|
|
21
|
+
@debug = debug
|
|
22
|
+
# needs to use config.bank_reconciliation.transaction_folder
|
|
23
|
+
transaction_folder ||= '/Volumes/Expansion/Sync/bank-reconciliation/original-transactions'
|
|
24
|
+
output_folder ||= File.join(transaction_folder, 'clean')
|
|
25
|
+
|
|
26
|
+
@transaction_folder = transaction_folder
|
|
27
|
+
@output_folder = output_folder
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def clean_transactions(input_globs, output_file)
|
|
31
|
+
log_info("Starting transaction cleaning with input patterns: #{input_globs}")
|
|
32
|
+
|
|
33
|
+
raw_transactions = grab_raw_transactions(input_globs)
|
|
34
|
+
log_info("Total raw transactions collected: #{raw_transactions.size}")
|
|
35
|
+
|
|
36
|
+
transactions, duplicates_count = deduplicate_across_files(raw_transactions)
|
|
37
|
+
log_info("Duplicates found and removed: #{duplicates_count}")
|
|
38
|
+
|
|
39
|
+
transactions = Mapper.new.map(transactions)
|
|
40
|
+
log_info('Transactions mapped to chart of accounts and bank accounts')
|
|
41
|
+
|
|
42
|
+
log_kv 'Deduped consolidated transactions', duplicates_count if duplicates_count.positive?
|
|
43
|
+
|
|
44
|
+
save_to_csv(transactions, output_file)
|
|
45
|
+
|
|
46
|
+
csv_to_clipboard(output_file)
|
|
47
|
+
|
|
48
|
+
@transactions = transactions
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def grab_raw_transactions(input_globs)
|
|
54
|
+
original_dir = Dir.pwd
|
|
55
|
+
transactions = []
|
|
56
|
+
|
|
57
|
+
begin
|
|
58
|
+
Dir.chdir(transaction_folder)
|
|
59
|
+
|
|
60
|
+
input_globs.each do |glob|
|
|
61
|
+
Dir.glob(glob).each do |file|
|
|
62
|
+
log_kv 'Reading transactions from', file
|
|
63
|
+
raw_transactions = ReadTransactions.new(file).read
|
|
64
|
+
deduped_transactions, duplicates_count = deduplicate_within_file(raw_transactions)
|
|
65
|
+
|
|
66
|
+
if duplicates_count.positive?
|
|
67
|
+
log_kv 'Duplicates count within file', duplicates_count
|
|
68
|
+
log_kv 'File', file
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
transactions += deduped_transactions
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
ensure
|
|
75
|
+
Dir.chdir(original_dir)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
transactions
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def deduplicate_within_file(transactions)
|
|
82
|
+
unique_transactions = transactions.uniq do |transaction|
|
|
83
|
+
[
|
|
84
|
+
transaction.bsb_number,
|
|
85
|
+
transaction.account_number,
|
|
86
|
+
transaction.transaction_date,
|
|
87
|
+
transaction.narration,
|
|
88
|
+
transaction.cheque_number,
|
|
89
|
+
transaction.debit,
|
|
90
|
+
transaction.credit,
|
|
91
|
+
transaction.balance,
|
|
92
|
+
transaction.transaction_type
|
|
93
|
+
]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
duplicates = transactions.size - unique_transactions.size
|
|
97
|
+
|
|
98
|
+
[unique_transactions, duplicates]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def deduplicate_across_files(transactions)
|
|
102
|
+
grouped_transactions = transactions.group_by do |transaction|
|
|
103
|
+
[
|
|
104
|
+
transaction.bsb_number,
|
|
105
|
+
transaction.account_number,
|
|
106
|
+
transaction.transaction_date,
|
|
107
|
+
transaction.narration,
|
|
108
|
+
transaction.cheque_number,
|
|
109
|
+
transaction.debit,
|
|
110
|
+
transaction.credit,
|
|
111
|
+
transaction.balance,
|
|
112
|
+
transaction.transaction_type
|
|
113
|
+
]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
unique_transactions = []
|
|
117
|
+
duplicates_count = 0
|
|
118
|
+
|
|
119
|
+
grouped_transactions.each_value do |dupes|
|
|
120
|
+
unique_transaction = dupes.first
|
|
121
|
+
unique_transaction.source_files = dupes.flat_map(&:source_files).uniq
|
|
122
|
+
duplicates_count += dupes.size - 1
|
|
123
|
+
unique_transactions << unique_transaction
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
[unique_transactions, duplicates_count]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def full_output_file(output_file)
|
|
130
|
+
File.join(output_folder, output_file)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def save_to_csv(transactions, output_file)
|
|
134
|
+
FileUtils.mkdir_p(output_folder)
|
|
135
|
+
output_file = full_output_file(output_file)
|
|
136
|
+
# puts "Output file: #{output_file}"
|
|
137
|
+
|
|
138
|
+
# lets sort the transactions by the by the transaction date which is formated as 2023-07-10
|
|
139
|
+
transactions.sort_by!(&:transaction_date)
|
|
140
|
+
|
|
141
|
+
CSV.open(output_file, 'w') do |csv|
|
|
142
|
+
csv << Appydave::Tools::BankReconciliation::Models::Transaction.csv_headers
|
|
143
|
+
transactions.each do |transaction|
|
|
144
|
+
csv << transaction.to_csv_row
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
log_kv('Transaction Output File', full_output_file(output_file))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def csv_to_clipboard(output_file)
|
|
152
|
+
Clipboard.copy(File.read(full_output_file(output_file)))
|
|
153
|
+
log_info('Transactions copied to clipboard')
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Appydave
|
|
4
|
+
module Tools
|
|
5
|
+
module BankReconciliation
|
|
6
|
+
module Clean
|
|
7
|
+
# Map transactions to chart of accounts and bank accounts
|
|
8
|
+
class Mapper
|
|
9
|
+
include Appydave::Tools::Configuration::Configurable
|
|
10
|
+
|
|
11
|
+
def map(transactions)
|
|
12
|
+
# tp transactions, :coa_match_type, :coa_code, :narration, :debit, :credit
|
|
13
|
+
transactions.map do |original_transaction|
|
|
14
|
+
transaction = original_transaction.dup
|
|
15
|
+
|
|
16
|
+
transaction = map_chart_of_account(transaction)
|
|
17
|
+
clean_amount(transaction)
|
|
18
|
+
map_bank_account(transaction)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def map_chart_of_account(transaction)
|
|
25
|
+
equality_match(transaction) ||
|
|
26
|
+
trigram_match(transaction, 0.9, '90%') ||
|
|
27
|
+
trigram_match(transaction, 0.8, '80%') ||
|
|
28
|
+
trigram_match(transaction, 0.7, '70%') ||
|
|
29
|
+
trigram_match(transaction, 0.6, '60%') ||
|
|
30
|
+
trigram_match(transaction, 0.5, '50%') ||
|
|
31
|
+
start_with_match(transaction) ||
|
|
32
|
+
includes(transaction)
|
|
33
|
+
transaction
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def map_bank_account(transaction)
|
|
37
|
+
bank_account = config.bank_reconciliation.get_bank_account(transaction.account_number, transaction.bsb_number)
|
|
38
|
+
|
|
39
|
+
if bank_account
|
|
40
|
+
transaction.account_name = bank_account.name
|
|
41
|
+
transaction.platform = bank_account.platform
|
|
42
|
+
else
|
|
43
|
+
puts "Bank account not found for BSB#: #{transaction.bsb_number} | Account#: #{transaction.account_number}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
transaction
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def equality_match(transaction)
|
|
50
|
+
coa = config.bank_reconciliation.chart_of_accounts.find do |chart_of_account|
|
|
51
|
+
chart_of_account.narration.to_s.delete(' ').downcase == transaction.narration.delete(' ').downcase
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
return nil unless coa
|
|
55
|
+
|
|
56
|
+
transaction.coa_match_type = 'equality'
|
|
57
|
+
transaction.coa_code = coa.code
|
|
58
|
+
transaction
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def start_with_match(transaction)
|
|
62
|
+
coa = config.bank_reconciliation.chart_of_accounts.find do |chart_of_account|
|
|
63
|
+
transaction.narration.to_s.delete(' ').downcase.start_with?(chart_of_account.narration.to_s.downcase)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
return nil unless coa
|
|
67
|
+
|
|
68
|
+
transaction.coa_match_type = 'starts_with'
|
|
69
|
+
transaction.coa_code = coa.code
|
|
70
|
+
transaction
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def includes(transaction)
|
|
74
|
+
coa = config.bank_reconciliation.chart_of_accounts.find do |chart_of_account|
|
|
75
|
+
transaction.narration.to_s.delete(' ').downcase.include?(chart_of_account.narration.delete(' ').to_s.downcase)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
return nil unless coa
|
|
79
|
+
|
|
80
|
+
transaction.coa_match_type = 'includes'
|
|
81
|
+
transaction.coa_code = coa.code
|
|
82
|
+
transaction
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def trigram_match(transaction, score_threshold, match_type)
|
|
86
|
+
scored_transactions = config.bank_reconciliation.chart_of_accounts.map do |coa|
|
|
87
|
+
{
|
|
88
|
+
coa: coa,
|
|
89
|
+
score: compare(coa.narration, transaction.narration)
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
scored_transactions.sort_by! { |t| t[:score] }.reverse!
|
|
94
|
+
|
|
95
|
+
best = scored_transactions.first
|
|
96
|
+
|
|
97
|
+
return nil unless best
|
|
98
|
+
return nil if best[:score] < score_threshold
|
|
99
|
+
|
|
100
|
+
coa = best[:coa]
|
|
101
|
+
|
|
102
|
+
transaction.coa_match_type = match_type
|
|
103
|
+
transaction.coa_code = coa.code
|
|
104
|
+
transaction
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def compare(text1, text2)
|
|
108
|
+
text1_trigs = trigramify(text1)
|
|
109
|
+
text2_trigs = trigramify(text2)
|
|
110
|
+
|
|
111
|
+
all_cnt = (text1_trigs | text2_trigs).size
|
|
112
|
+
same_cnt = (text1_trigs & text2_trigs).size
|
|
113
|
+
|
|
114
|
+
same_cnt.to_f / all_cnt
|
|
115
|
+
# rescue StandardError => e
|
|
116
|
+
# puts "Error comparing text: #{e.message}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def trigramify(text)
|
|
120
|
+
trigs = []
|
|
121
|
+
text.chars.each_cons(3) { |v| trigs << v.join }
|
|
122
|
+
trigs
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def clean_amount(transaction)
|
|
126
|
+
transaction.debit = transaction.clean_amount(transaction.debit)
|
|
127
|
+
transaction.credit = transaction.clean_amount(transaction.credit)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|