parcels_challenge 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 493fe29181c73db866981d412582161bbb11314081a73bfd1c33e23786ea0ef9
4
+ data.tar.gz: e74595e138bc4235b8ed9eca5b1dd785278be6fcc84f5689e1ca8e28c151985e
5
+ SHA512:
6
+ metadata.gz: dd09ab8024cd5ae6dd70656f84e11dec9393b1f41858e624895d6ced520d7a7d6d23d128ba2346e741b02de3e2e50a1bdbe05231e8889bd77778c17632446dfd
7
+ data.tar.gz: 91a0502416e5833b7289955442b7de3264a91e2cbbac8fe19bb5c262d63ff516b58b9cbbe2af4c69d1b6fe313c24e7b466bf773f7174e4634d1378a99ba692a1
data/.gitignore ADDED
@@ -0,0 +1,58 @@
1
+ *.gem
2
+ *.rbc
3
+ /.config
4
+ /coverage/
5
+ /InstalledFiles
6
+ /pkg/
7
+ /spec/reports/
8
+ /spec/examples.txt
9
+ /test/tmp/
10
+ /test/version_tmp/
11
+ /tmp/
12
+
13
+ # Used by dotenv library to load environment variables.
14
+ # .env
15
+
16
+ # Ignore Byebug command history file.
17
+ .byebug_history
18
+
19
+ ## Specific to RubyMotion:
20
+ .dat*
21
+ .repl_history
22
+ build/
23
+ *.bridgesupport
24
+ build-iPhoneOS/
25
+ build-iPhoneSimulator/
26
+
27
+ ## Specific to RubyMotion (use of CocoaPods):
28
+ #
29
+ # We recommend against adding the Pods directory to your .gitignore. However
30
+ # you should judge for yourself, the pros and cons are mentioned at:
31
+ # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
32
+ #
33
+ # vendor/Pods/
34
+
35
+ ## Documentation cache and generated files:
36
+ /.yardoc/
37
+ /_yardoc/
38
+ /doc/
39
+ /rdoc/
40
+
41
+ ## Environment normalization:
42
+ /.bundle/
43
+ /vendor/bundle
44
+ /lib/bundler/man/
45
+
46
+ # for a library or gem, you might want to ignore these files since the code is
47
+ # intended to run in multiple environments; otherwise, check them in:
48
+ # Gemfile.lock
49
+ # .ruby-version
50
+ # .ruby-gemset
51
+
52
+ # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
53
+ .rvmrc
54
+
55
+ # Used by RuboCop. Remote config files pulled in from inherit_from directive.
56
+ # .rubocop-https?--*
57
+
58
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ source 'https://rubygems.org'
2
+
3
+ git_source(:github) {|repo_name| "https://github.com/sbaus42/#{repo_name}" }
4
+
5
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,42 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ parcels_challenge (0.1.0)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ coderay (1.1.3)
10
+ diff-lcs (1.5.0)
11
+ method_source (1.0.0)
12
+ pry (0.14.2)
13
+ coderay (~> 1.1)
14
+ method_source (~> 1.0)
15
+ rake (13.1.0)
16
+ rspec (3.12.0)
17
+ rspec-core (~> 3.12.0)
18
+ rspec-expectations (~> 3.12.0)
19
+ rspec-mocks (~> 3.12.0)
20
+ rspec-core (3.12.2)
21
+ rspec-support (~> 3.12.0)
22
+ rspec-expectations (3.12.3)
23
+ diff-lcs (>= 1.2.0, < 2.0)
24
+ rspec-support (~> 3.12.0)
25
+ rspec-mocks (3.12.6)
26
+ diff-lcs (>= 1.2.0, < 2.0)
27
+ rspec-support (~> 3.12.0)
28
+ rspec-support (3.12.1)
29
+
30
+ PLATFORMS
31
+ ruby
32
+ x86_64-linux
33
+
34
+ DEPENDENCIES
35
+ bundler (~> 2.5.1)
36
+ parcels_challenge!
37
+ pry (~> 0.14.2)
38
+ rake (~> 13.1.0)
39
+ rspec (~> 3.12.0)
40
+
41
+ BUNDLED WITH
42
+ 2.5.1
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Santiago Baus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # parcels
2
+ Like the [band](https://www.youtube.com/watch?v=hznxLHB0oy8) but not really
3
+
4
+ ## Assignment
5
+
6
+ Given a CSV file that contains a list of parcels and their respective weight and client's name. You'll need to write a program that will efficiently group these parcels into shipments. The conditions are:
7
+
8
+ - A shipment has a maximum weight of 2311 kg
9
+ - One client's parcels may not be in multiple shipments
10
+ - A shipment may contain parcels from multiple clients
11
+ - A single parcel may not be split between shipments
12
+ - The code must be written in Ruby
13
+ - Units of weight don't matter, since there's no need for conversion or split, but for the sake of the exercise, all weight units can be assumed to be in kilograms.
14
+
15
+ ## Dev Comments
16
+
17
+ This assigment holds as part of its challenge a very complex problem that is fairly well known in the world of combinatronics. I decided to go for a greedy algorithm that simply sorts the clients into a group whenever the maximum weight is reached.
18
+
19
+ My main focus has been testing and decent packaging of the assignment code. This challenge will be exported as a gem and can be required in existing projects or run independently to generate the output csv.
20
+
21
+ ## Installation
22
+ Add the following line to your application's Gemfile:
23
+ ```bash
24
+ gem 'parcels_challenge'
25
+ ```
26
+ And then run:
27
+ ```bash
28
+ bundle install
29
+ ```
30
+ Now in your project, you'll be able to require 'parcels'. The `ShipmentGrouper` is the class you'll want to use.
31
+
32
+ Alternatively, you can install it globally and it will come with an executable.
33
+ ```bash
34
+ gem install parcels_challenge
35
+ ```
36
+
37
+ ## Usage
38
+ After installing, you can run in terminal:
39
+ ```bash
40
+ parcels_challenge [path_to_csv_file]
41
+ ```
42
+ Ensure the CSV conforms to the expected format and file will generated with the default name `shipment_assignment.csv`
43
+
44
+ To test from an Interactive Ruby session, try the following:
45
+ ```Ruby
46
+ 3.2.2 :001 > require 'parcels'
47
+ => true
48
+
49
+ 3.2.2 :003 > Parcels::ShipmentGrouper.new('spec/fixtures/good_input.csv')
50
+ =>
51
+ #<Parcels::ShipmentGrouper:0x00007f2791aad808
52
+ @file_path="spec/fixtures/good_input.csv",
53
+ @output_file_path="shipment_assignment.csv",
54
+ @shipment_max_weight=2311,
55
+ @validate_csv=false>
56
+ 3.2.2 :004 > grouper = _
57
+ =>
58
+ #<Parcels::ShipmentGrouper:0x00007f2791aad808
59
+ ...
60
+ 3.2.2 :005 > file_location = grouper.perform!
61
+ Output file generated at: /home/santi/Personal/parcels/shipment_assignment.csv
62
+ => "shipment_assignment.csv"
63
+ 3.2.2 :006 >
64
+
65
+ ```
66
+
67
+ All the above assuming you're in the root of the repository's directory. If you have a file you'll like to test, make sure to pass the relative path as an argument.
68
+
69
+ ## License
70
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/license/mit/)
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ require 'parcels_challenge'
3
+
4
+ # TODO: Improve me with 'optparse'
5
+
6
+ if ARGV.empty?
7
+ puts 'I will need a csv file path, relative path will do just fine.'
8
+ else
9
+ ParcelsChallenge.group_shipments(ARGV[0])
10
+ end
@@ -0,0 +1,124 @@
1
+ require 'pathname'
2
+ require 'csv'
3
+
4
+ module ParcelsChallenge
5
+ class ShipmentGrouper
6
+ def initialize(
7
+ file_path,
8
+ output_file_path = 'shipment_assignment.csv',
9
+ shipment_max_weight = 2311,
10
+ validate_csv = false
11
+ )
12
+ @shipment_max_weight = shipment_max_weight
13
+ @validate_csv = validate_csv
14
+ @output_file_path = output_file_path
15
+ @file_path = check_file(file_path)
16
+ end
17
+
18
+ def perform!
19
+ """
20
+ The perform method is where the magic happens. I've mentally
21
+ divided this method into the grouping of the clients, assignment
22
+ of the clients into shipments base on previous groupings and
23
+ generating the output file.
24
+ I tried to use CSV's #foreach method as much as possible since
25
+ it doesn't load the full file into memory. This should be performant
26
+ in terms of memory but as files get larger and more shipments are
27
+ possible, so will the amount of client_shipment_assignments grow.
28
+ """
29
+ grouped_sum = {}
30
+
31
+ CSV.foreach(file_path, headers: true) do |parcel|
32
+ Integer(parcel['weight']) rescue raise 'Weight is not an Integer'
33
+ if grouped_sum[parcel['client_name']].nil?
34
+ grouped_sum[parcel['client_name']] = parcel['weight'].to_i
35
+ else
36
+ grouped_sum[parcel['client_name']] += parcel['weight'].to_i
37
+ end
38
+ end
39
+
40
+ client_shipment_assignment = assign_client_to_shipment(grouped_sum)
41
+
42
+ CSV.open(output_file_path, 'wb') do |csv|
43
+ csv << %i[parcel_ref client_name weight shipment_ref]
44
+ CSV.foreach(file_path, headers: true) do |parcel|
45
+ csv << [
46
+ parcel['parcel_ref'],
47
+ parcel['client_name'],
48
+ parcel['weight'],
49
+ client_shipment_assignment[parcel['client_name']]
50
+ ]
51
+ end
52
+ end
53
+
54
+ puts "Output file generated at: #{Pathname.pwd.to_s}/#{output_file_path}"
55
+
56
+ output_file_path
57
+ end
58
+
59
+ private
60
+
61
+ attr_reader :file_path, :shipment_max_weight, :validate_csv, :output_file_path
62
+
63
+ def assign_client_to_shipment(client_grouped_sum)
64
+ """
65
+ Assigning clients to shipments is the truly complex part of this project in
66
+ terms of optimization techniques for the algorithm.
67
+ Grouping values into shipments base on an upper bound is a form of the
68
+ subset sum problem. This is an NP-Complete problem and I decided in this case
69
+ to go for a greedy approach.
70
+ I extracted this method and tested it separately in case another developer
71
+ is interested in applying a better heuristic.
72
+ """
73
+ raise 'Argument is expected to be a hash' unless client_grouped_sum.is_a?(Hash)
74
+ client_grouped_sum.each do |key, value|
75
+ String(key) rescue raise 'Key is not a string'
76
+ Integer(value) rescue raise 'Value is not an integer'
77
+ end
78
+
79
+ client_shipment_assignment = Hash.new(0)
80
+ current_shipment = 1
81
+ current_shipment_value = 0
82
+
83
+ client_grouped_sum.each do |name, value|
84
+ if current_shipment_value + value > shipment_max_weight
85
+ current_shipment += 1
86
+ current_shipment_value = 0
87
+ end
88
+
89
+ client_shipment_assignment[name] = "shipment #{current_shipment}"
90
+ current_shipment_value += value
91
+ end
92
+
93
+ client_shipment_assignment
94
+ end
95
+
96
+ def check_file(file_path)
97
+ """
98
+ This is a very generic check where we ensure the file provided
99
+ exists and the headers those we expect.
100
+ It also includes an optional validation for the CSV but didn't
101
+ go deeper into that validation since it can get ugly.
102
+ """
103
+ pn = Pathname.new(file_path)
104
+ raise 'File does not exist' unless pn.exist?
105
+
106
+ unless CSV.foreach(file_path).first == ['parcel_ref', 'client_name', 'weight']
107
+ raise 'Invalid csv headers'
108
+ end
109
+
110
+ # This validation is optional because otherwise it would be one more pass
111
+ # through the file
112
+ if validate_csv
113
+ CSV.foreach(file_path).each do |row|
114
+ raise 'Invalid number of columns' unless row.length == 3
115
+ if row[2] != 'weight'
116
+ Integer(row[2]) rescue raise 'Weight is not an integer (it should be!)'
117
+ end
118
+ end
119
+ end
120
+
121
+ file_path
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,3 @@
1
+ module ParcelsChallenge
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,8 @@
1
+ require 'parcels_challenge/version'
2
+ require 'parcels_challenge/shipment_grouper'
3
+
4
+ module ParcelsChallenge
5
+ def self.group_shipments(file_path)
6
+ ShipmentGrouper.new(file_path).perform!
7
+ end
8
+ end
@@ -0,0 +1,27 @@
1
+ lib = File.expand_path("../lib", __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require "parcels_challenge/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "parcels_challenge"
7
+ spec.version = ParcelsChallenge::VERSION
8
+ spec.authors = ["Santiago Baus"]
9
+ spec.email = ["sbaus87@gmail.com"]
10
+
11
+ spec.summary = %q{Given a CSV file that contains a list of parcels and their respective weight and client's name, group such parcels into shipments}
12
+ spec.homepage = "https://github.com/sbaus42/parcels"
13
+ spec.license = "MIT"
14
+
15
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
16
+ f.match(%r{^(test|spec|features)/})
17
+ end
18
+
19
+ spec.bindir = "bin"
20
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
21
+ spec.require_paths = ["lib"]
22
+
23
+ spec.add_development_dependency "bundler", "~> 2.5.1"
24
+ spec.add_development_dependency "rake", "~> 13.1.0"
25
+ spec.add_development_dependency "rspec", "~> 3.12.0"
26
+ spec.add_development_dependency "pry", "~> 0.14.2"
27
+ end
metadata ADDED
@@ -0,0 +1,112 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: parcels_challenge
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Santiago Baus
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-12-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 2.5.1
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 2.5.1
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 13.1.0
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 13.1.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 3.12.0
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 3.12.0
55
+ - !ruby/object:Gem::Dependency
56
+ name: pry
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 0.14.2
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 0.14.2
69
+ description:
70
+ email:
71
+ - sbaus87@gmail.com
72
+ executables:
73
+ - parcels_challenge
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - ".gitignore"
78
+ - ".rspec"
79
+ - Gemfile
80
+ - Gemfile.lock
81
+ - LICENSE
82
+ - README.md
83
+ - bin/parcels_challenge
84
+ - lib/parcels_challenge.rb
85
+ - lib/parcels_challenge/shipment_grouper.rb
86
+ - lib/parcels_challenge/version.rb
87
+ - parcels_challenge.gemspec
88
+ homepage: https://github.com/sbaus42/parcels
89
+ licenses:
90
+ - MIT
91
+ metadata: {}
92
+ post_install_message:
93
+ rdoc_options: []
94
+ require_paths:
95
+ - lib
96
+ required_ruby_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ required_rubygems_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '0'
106
+ requirements: []
107
+ rubygems_version: 3.4.10
108
+ signing_key:
109
+ specification_version: 4
110
+ summary: Given a CSV file that contains a list of parcels and their respective weight
111
+ and client's name, group such parcels into shipments
112
+ test_files: []