bisac 0.8

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.
data/lib/bisac/poa.rb ADDED
@@ -0,0 +1,212 @@
1
+ module Bisac
2
+
3
+ # Represents a single BISAC purchase order acknowledgement
4
+ #
5
+ # = Generating
6
+ #
7
+ # It's currently not possible to generate a POA with this class. All it needs
8
+ # is a to_s method though, much like Bisac::PO. Patches welcome.
9
+ #
10
+ # = Reading
11
+ #
12
+ # Each POA file can contain multiple POA's, so use pasrse_file() to iterate
13
+ # over them all.
14
+ #
15
+ # Bisac::POA.parse_file("filename.bsc") do |msg|
16
+ # puts msg.source_san
17
+ # puts msg.source_name
18
+ # puts msg.items.size
19
+ # ...
20
+ # end
21
+ class POA
22
+
23
+ # file header attributes
24
+ attr_accessor :source_san, :source_suffix, :source_name
25
+ attr_accessor :date, :filename, :format_version
26
+ attr_accessor :destination_san, :destination_suffix
27
+ attr_accessor :ack_type
28
+
29
+ # poa header attributes
30
+ attr_accessor :supplier_poa_number, :po_number
31
+ attr_accessor :customer_san, :customer_suffix
32
+ attr_accessor :supplier_san, :supplier_suffix
33
+ attr_accessor :poa_date, :currency, :po_date
34
+ attr_accessor :po_cancel_date, :po_type
35
+ attr_accessor :items
36
+
37
+ # creates a new Bisac::POA object
38
+ def initialize
39
+ @items = []
40
+
41
+ # default values
42
+ @cancellation_date = "000000"
43
+ @do_not_ship_before = "000000"
44
+ @backorder = true
45
+ end
46
+
47
+ # return all POs from a BISAC file
48
+ def self.parse_file(input, &block)
49
+ raise ArgumentError, 'no file provided' if input.nil?
50
+ raise ArgumentError, 'Invalid file' unless File.file?(input)
51
+ self.parse_string(File.read(input)) do |msg|
52
+ yield msg
53
+ end
54
+ end
55
+
56
+ # return all POs from a BISAC string
57
+ def self.parse_string(input, &block)
58
+ raise ArgumentError, 'no data provided' if input.nil?
59
+ data = []
60
+ input.split("\n").each do |l|
61
+ data << l
62
+
63
+ # yield each message found in the string. A line starting with
64
+ # 90 is the footer to a PO
65
+ if data.last[0,2] == "90"
66
+ msg = Bisac::POA.new
67
+ msg.build_message(data)
68
+ yield msg
69
+ data = []
70
+ end
71
+ end
72
+
73
+ # if we've got to the end of the file, and haven't hit a footer line yet, the file
74
+ # is probably malformed. Call build_message anyway, and let it detect any errors
75
+ if data.size > 0
76
+ msg = Bisac::POA.new
77
+ msg.build_message(data)
78
+ yield msg
79
+ end
80
+ end
81
+
82
+ def total_qty
83
+ @items.collect { |i| i.qty }.inject { |sum, x| sum ? sum+x : x}
84
+ end
85
+
86
+ def to_s
87
+ lines = []
88
+
89
+ # file header
90
+ line = " " * 80
91
+ line[0,2] = "00" # line type
92
+ line[2,5] = "00001" # line counter
93
+ line[7,7] = pad_trunc(@source_san, 7)
94
+ line[14,5] = pad_trunc(@source_suffix, 5)
95
+ line[19,13] = pad_trunc(@source_name, 13)
96
+ line[32,6] = pad_trunc(@date, 6)
97
+ line[38,22] = pad_trunc(@filename, 22)
98
+ line[60,3] = pad_trunc(@format_version, 3)
99
+ line[63,7] = pad_trunc(@destination_san, 7)
100
+ line[70,5] = pad_trunc(@destination_suffix, 5)
101
+ lines << line
102
+
103
+ # po header
104
+ lines << ""
105
+ lines.last << "10"
106
+ lines.last << "00002" # line counter
107
+ lines.last << " "
108
+ lines.last << @po_number.to_s.ljust(11, " ")
109
+ lines.last << " " # TODO
110
+ lines.last << pad_trunc(@source_san, 7)
111
+ lines.last << pad_trunc("",5) # TODO
112
+ lines.last << pad_trunc(@destination_san, 7)
113
+ lines.last << pad_trunc("",5) # TODO
114
+ lines.last << pad_trunc(@date, 6)
115
+ lines.last << pad_trunc(@cancellation_date,6)
116
+ lines.last << yes_no(@backorder)
117
+ lines.last << pad_trunc(@do_not_exceed_action,1)
118
+ lines.last << pad_trunc(@do_not_exceed_amount,7)
119
+ lines.last << pad_trunc(@invoice_copies,2)
120
+ lines.last << yes_no(@special_instructions)
121
+ lines.last << pad_trunc("",5) # TODO
122
+ lines.last << pad_trunc(@do_not_ship_before,6)
123
+
124
+ sequence = 3
125
+ @items.each_with_index do |item, idx|
126
+ item.line_item_number = idx + 1
127
+ item.sequence_number = sequence
128
+ lines += item.to_s.split("\n")
129
+ sequence += 3
130
+ end
131
+
132
+ # PO control
133
+ line = " " * 80
134
+ line[0,2] = "50"
135
+ line[2,5] = (lines.size + 1).to_s.rjust(5,"0") # line counter
136
+ line[8,12] = @po_number.to_s.ljust(13, " ")
137
+ line[20,5] = "00001" # number of POs in file
138
+ line[25,10] = @items.size.to_s.rjust(10,"0")
139
+ line[35,10] = total_qty.to_s.rjust(10,"0")
140
+ lines << line
141
+
142
+ # file trailer
143
+ line = " " * 80
144
+ line[0,2] = "90"
145
+ line[2,5] = (lines.size+1).to_s.rjust(5,"0") # line counter
146
+ line[7,20] = @items.size.to_s.rjust(13,"0")
147
+ line[20,5] = "00001" # total '10' (PO) records
148
+ line[25,10] = total_qty.to_s.rjust(10,"0")
149
+ line[35,5] = "00001" # number of '00'-'09' records
150
+ line[40,5] = "00001" # number of '10'-'19' records
151
+ line[55,5] = (@items.size * 3).to_s.rjust(5,"0") # number of '40'-'49' records
152
+ line[60,5] = "00000" # number of '50'-'59' records
153
+ line[45,5] = "00000" # number of '60'-'69' records
154
+ lines << line
155
+
156
+ lines.join("\n")
157
+ end
158
+
159
+ # Populate the current object with the message contained in data
160
+ #
161
+ # An array of lines making a complete, single POA message
162
+ #
163
+ def build_message(data)
164
+ raise Bisac::InvalidFileError, 'File appears to be too short' unless data.size >= 3
165
+ raise Bisac::InvalidFileError, 'Missing header information' unless data[0][0,2].eql?("02")
166
+ raise Bisac::InvalidFileError, 'Missing footer information' unless data[-1][0,2].eql?("91")
167
+
168
+ data.each do |line|
169
+ # ensure each line is at least 80 chars long
170
+ line = line.ljust(80)
171
+
172
+ case line[0,2]
173
+ when "02" # file header
174
+ self.source_san = line[7,7].strip
175
+ self.source_suffix = line[14,5].strip
176
+ self.source_name = line[19,13].strip
177
+ self.date = line[32,6].strip
178
+ self.filename = line[38,22].strip
179
+ self.format_version = line[60,3].strip
180
+ self.destination_san = line[63,7].strip
181
+ self.destination_suffix = line[70,5].strip
182
+ self.ack_type = line[75,1].strip
183
+ when "11" # poa header
184
+ self.supplier_poa_number = line[7,13].strip
185
+ self.po_number = line[20,13].strip
186
+ self.customer_san = line[33,7].strip
187
+ self.customer_suffix = line[40,5].strip
188
+ self.supplier_san = line[45,7].strip
189
+ self.supplier_suffix = line[52,5].strip
190
+ self.poa_date = line[57,6].strip
191
+ self.currency = line[63,3].strip
192
+ self.po_date = line[66,6].strip
193
+ self.po_cancel_date = line[72,6].strip
194
+ self.po_type = line[78,2].strip
195
+ when "40" # line item
196
+ item = Bisac::POALineItem.load_from_string(line)
197
+ self.items << item
198
+ when "41"
199
+ when "42"
200
+ when "59" # poa footer
201
+ # check the built objects match the file
202
+ when "91" # file footer
203
+ # check the built objects match the file
204
+ end
205
+
206
+ end
207
+
208
+ self
209
+ end
210
+
211
+ end
212
+ end
@@ -0,0 +1,81 @@
1
+ require 'bigdecimal'
2
+
3
+ module Bisac
4
+
5
+ # represents a single line on the purchase order ack
6
+ class POALineItem
7
+
8
+ attr_accessor :sequence_number, :supplier_poa_number
9
+ attr_accessor :line_item_number, :order_qty, :unit_price, :nett_price
10
+ attr_accessor :list_nett_indicator, :special_price, :discount
11
+ attr_accessor :shippable_qty, :status, :warehouse_status
12
+ attr_reader :isbn
13
+
14
+ # returns a new Bisac::POLineItem object using the data passed in as a string
15
+ # refer to the bisac spec for the expected format of the string
16
+ def self.load_from_string(data)
17
+ data = data.to_s.ljust(93)
18
+ raise ArgumentError, "POA Line Items must start with '40'" unless data[0,2] == "40"
19
+
20
+ item = POALineItem.new
21
+
22
+ item.sequence_number = data[2,5].to_i
23
+ item.supplier_poa_number = data[7,13].strip
24
+ item.line_item_number = data[20,10].strip
25
+
26
+ # prefer the 13 digit ISBN if it exists (it's a non-standard, Pacstream extension)
27
+ # fallback to the ISBN10
28
+ item.isbn = data[80,13].strip
29
+ item.isbn = data[30,10].strip if item.isbn.nil?
30
+ item.order_qty = data[40,5].to_i
31
+ item.unit_price = BigDecimal.new(data[45,8]) / 100
32
+ item.nett_price = BigDecimal.new(data[53,9]) / 100
33
+ item.list_nett_indicator = data[62,1]
34
+ item.special_price = data[63,1]
35
+ item.discount = BigDecimal.new(data[63,1]) / 100
36
+ item.shippable_qty = data[69,5].to_i
37
+ item.status = data[74,2].to_i
38
+ item.warehouse_status = data[76,2].to_i
39
+
40
+ return item
41
+ end
42
+
43
+ def isbn=(val)
44
+ if val == ""
45
+ @isbn = nil
46
+ elsif RBook::ISBN.valid_isbn10?(val)
47
+ @isbn = RBook::ISBN.convert_to_isbn13(val)
48
+ else
49
+ @isbn = val
50
+ end
51
+ end
52
+
53
+ # is the isbn for this product valid?
54
+ def isbn?
55
+ RBook::ISBN.valid_isbn13?(@isbn || "")
56
+ end
57
+
58
+ def isbn10
59
+ if isbn?
60
+ RBook::ISBN.convert_to_isbn10(@isbn)
61
+ else
62
+ @isbn
63
+ end
64
+ end
65
+
66
+ def status_text
67
+ case self.status
68
+ when 1 then "Title Shipped As Ordered"
69
+ when 3 then "Cancelled: Future Publication"
70
+ when 6 then "Cancelled: Out of Stock"
71
+ when 7 then "Backordered: Out of Stock"
72
+ when 9 then "Partial Ship: Cancel Rest"
73
+ when 10 then "Partial Ship: Backorder Rest"
74
+ when 20 then "Cancelled: Not Carried"
75
+ when 28 then "Cancelled: Out of Print"
76
+ else
77
+ "UNKNOWN"
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,174 @@
1
+ module Bisac
2
+
3
+ # Class to represent a single product line in a Bisac File. See
4
+ # Bisac::Message for basic usage instructions.
5
+ class Product
6
+
7
+ attr_reader :isbn, :title, :author, :price, :pubdate, :publisher
8
+ attr_reader :imprint, :volumes, :edition, :binding, :volume, :status
9
+
10
+ # Creates a new product object with the requested ISBN. Must be a 10 digit ISBN.
11
+ def initialize(isbn)
12
+ raise ArgumentError, 'isbn must 10 chars or less' if isbn.to_s.length > 10
13
+
14
+ @isbn = isbn.to_s
15
+ @title = ""
16
+ @author = ""
17
+ @price = ""
18
+ @pubdate = ""
19
+ @publisher = ""
20
+ @imprint = ""
21
+ @volumes = ""
22
+ @edition = ""
23
+ @binding = ""
24
+ @volume = ""
25
+ @status = ""
26
+ end
27
+
28
+ # sets the products author. Maximum of 30 chars.
29
+ def author=(author)
30
+ @author = author.to_s
31
+ end
32
+
33
+ # sets the products binding. Maximum of 2 chars.
34
+ def binding=(binding)
35
+ @binding = binding.to_s
36
+ end
37
+
38
+ # sets the products edition number. Maximum of 3 chars.
39
+ def edition=(edition)
40
+ @edition = edition.to_s
41
+ end
42
+
43
+ # takes a single line from a BISAC file and attempts to convert
44
+ # it to a Product object
45
+ def self.from_string(s)
46
+ s = s.to_s
47
+ return nil if s.length < 259
48
+ product = self.new(s[0,10])
49
+ product.title = s[15,30].strip
50
+ product.author = s[46,30].strip
51
+ product.price = s[79,7].strip if s[79,7].strip.match(/\A\d{0,7}\Z/)
52
+ product.pubdate = s[87,6].strip if s[87,6].strip.match(/\A\d{6}\Z/)
53
+ product.publisher = s[94,10].strip
54
+ product.imprint = s[105,14].strip
55
+ product.volumes = s[120,3].strip
56
+ product.edition = s[124,2].strip
57
+ product.binding = s[127,2].strip
58
+ product.volume = s[130,3].strip
59
+ product.status = s[153,3].strip
60
+ return product
61
+ end
62
+
63
+ # Sets the products imprint. Maximum of 14 chars.
64
+ def imprint=(imprint)
65
+ @imprint = imprint.to_s
66
+ end
67
+
68
+ # Sets the price imprint. Maximum of 7 chars. Must by a whole
69
+ # number (represent price in cents).
70
+ def price=(price)
71
+ unless price.to_s.match(/\A\d{0,7}\Z/)
72
+ raise ArgumentError, 'price should be a whole number with no more than 7 digits. (price in cents)'
73
+ end
74
+ @price = price.to_s
75
+ end
76
+
77
+ # Sets the products pubdate. Must be in the form YYMMDD
78
+ def pubdate=(pubdate)
79
+ unless pubdate.to_s.match(/\A\d{6}\Z/)
80
+ raise ArgumentError, 'pubdate should be a date in the form YYMMDD.'
81
+ end
82
+ @pubdate = pubdate.to_s
83
+ end
84
+
85
+ # sets the products publisher. Maximum of 10 chars.
86
+ def publisher=(publisher)
87
+ @publisher = publisher.to_s
88
+ end
89
+
90
+ # sets the products status code. Maximum of 3 chars.
91
+ def status=(status)
92
+ @status = status.to_s
93
+ end
94
+
95
+ # sets the products title. Maximum of 30 chars.
96
+ def title=(title)
97
+ @title = title.to_s
98
+ end
99
+
100
+ # Returns the product as a single line ready for inserting into a BISAC file.
101
+ # Doesn't have a \n on the end
102
+ def to_s
103
+ content = ""
104
+ content << @isbn[0,10].ljust(10) # 10 digit isbn
105
+ content << "1"
106
+ content << "N"
107
+ content << "N"
108
+ content << "B"
109
+ content << "N"
110
+ content << @title[0,30].ljust(30)
111
+ content << "N"
112
+ content << @author[0,30].ljust(30)
113
+ content << "N"
114
+ content << "A" # author role
115
+ content << "N"
116
+ content << @price[0,7].rjust(7,"0") # current price
117
+ content << "N"
118
+ content << @pubdate[0,6].ljust(6) # published date
119
+ content << "N"
120
+ content << @publisher[0,10].ljust(10) # publisher
121
+ content << "N"
122
+ content << @imprint[0,14].ljust(14) #imprint
123
+ content << "N"
124
+ content << @volumes[0,3].rjust(3,"0") # volumes included in this isbn
125
+ content << "N"
126
+ content << @edition[0,2].rjust(2,"0") # edition
127
+ content << "N"
128
+ content << @binding[0,2].rjust(2,"0") # binding
129
+ content << "N"
130
+ content << @volume[0,3].rjust(3,"0") # volume number
131
+ content << "N"
132
+ content << "0000000" # new price
133
+ content << "N"
134
+ content << "000000" # new price effective date
135
+ content << "N"
136
+ content << " " # audience type
137
+ content << "N"
138
+ content << @status[0,3].rjust(3) # status
139
+ content << "N"
140
+ content << " " # available date. only use for status' like NYP
141
+ content << "N"
142
+ content << " " # alternate isbn
143
+ content << "N"
144
+ content << "999999" # out of print date. only use for status == OP
145
+ content << "N"
146
+ content << " " # geographic restrictions
147
+ content << "N"
148
+ content << " " # library of congress catalogue number
149
+ content << "N"
150
+ content << "".ljust(40) # series title
151
+ content << "N"
152
+ content << "0" # price code for current price
153
+ content << "N"
154
+ content << "0" # price code for new price
155
+ content << "N"
156
+ content << "0000000" # freight pass through price
157
+ content << "N"
158
+ content << "000000" # new freight pass through price
159
+ content << "00000" # last changed date
160
+
161
+ return content
162
+ end
163
+
164
+ # sets the products volume number. Maximum of 3 chars.
165
+ def volume=(volume)
166
+ @volume = volume.to_s
167
+ end
168
+
169
+ # sets the number of volumes in the set this product belongs to. Maximum of 3 chars.
170
+ def volumes=(volumes)
171
+ @volumes = volumes.to_s
172
+ end
173
+ end
174
+ end