bisac 0.8

Sign up to get free protection for your applications and to get access to all the features.
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