Overhaul desktop views for receiving, for efficiency

still could use even more i'm sure, but this takes advantage of buefy
to add dialogs etc. from the "view receiving batch row" page.  this
batch no longer allows direct edit of rows but that's hopefully for
the better.
This commit is contained in:
Lance Edgar 2021-12-13 17:53:14 -06:00
parent 2f676774e9
commit 340a177a29
14 changed files with 1014 additions and 157 deletions

View file

@ -848,7 +848,10 @@ class Form(object):
return ''
# TODO: fair bit of duplication here, should merge with deform.mako
label = HTML.tag('label', self.get_label(field_name), for_=field_name)
label = kwargs.get('label')
if not label:
label = self.get_label(field_name)
label = HTML.tag('label', label, for_=field_name)
field = self.render_field_value(field_name) or ''
field_div = HTML.tag('div', class_='field', c=[field])
contents = [label, field_div]

View file

@ -145,14 +145,14 @@
</div><!-- card -->
<div class="buttons">
<once-button tag="a" href="${form.cancel_url}"
text="Cancel">
</once-button>
<b-button type="is-primary"
native-type="submit"
:disabled="formSubmitting">
{{ formButtonText }}
</b-button>
<once-button tag="a" href="${form.cancel_url}"
text="Cancel">
</once-button>
</div>
</div><!-- app-wrapper -->

View file

@ -6,7 +6,7 @@
let FormPosterMixin = {
methods: {
submitForm(action, params, success) {
submitForm(action, params, success, failure) {
let csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}
@ -21,18 +21,24 @@
} else {
this.$buefy.toast.open({
message: "Failed to send feedback: " + response.data.error,
message: "Submit failed: " + response.data.error,
type: 'is-danger',
duration: 4000, // 4 seconds
})
if (failure) {
failure(response)
}
}
}, response => {
this.$buefy.toast.open({
message: "Failed to submit form! (unknown server error)",
message: "Submit failed! (unknown server error)",
type: 'is-danger',
duration: 4000, // 4 seconds
})
if (failure) {
failure(response)
}
})
},
},

View file

@ -203,7 +203,7 @@
% for action in grid.main_actions + grid.more_actions:
<a v-if="props.row._action_url_${action.key}"
:href="props.row._action_url_${action.key}"
class="grid-action${' has-text-danger' if action.key == 'delete' else ''}"
class="grid-action${' has-text-danger' if action.key == 'delete' else ''} ${action.link_class or ''}"
% if action.click_handler:
@click.prevent="${action.click_handler}"
% endif

View file

@ -1,8 +1,8 @@
## -*- coding: utf-8 -*-
## -*- coding: utf-8; -*-
<%inherit file="/master/edit.mako" />
<%def name="context_menu_items()">
<li>${h.link_to("Back to {}".format(model_title), index_url)}</li>
<li>${h.link_to("Back to {}".format(parent_model_title), parent_url)}</li>
% if master.rows_viewable and request.has_perm('{}.view'.format(row_permission_prefix)):
<li>${h.link_to("View this {}".format(row_model_title), row_action_url('view', instance))}</li>
% endif

View file

@ -12,7 +12,7 @@
% if master.rows_editable and instance_editable and request.has_perm('{}.edit'.format(permission_prefix)):
<li>${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}</li>
% endif
% if master.rows_deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)):
% if instance_deletable and master.has_perm('delete_row'):
<li>${h.link_to("Delete this {}".format(model_title), action_url('delete', instance))}</li>
% endif
% if rows_creatable and request.has_perm('{}.create'.format(permission_prefix)):

View file

@ -32,6 +32,7 @@
let ThisPage = {
template: '#this-page-template',
mixins: [FormPosterMixin],
computed: {},
methods: {},
}

View file

@ -284,7 +284,19 @@
</%def>
<%def name="object_helpers()">
${parent.object_helpers()}
${self.render_status_breakdown()}
% if use_buefy and master.handler.has_purchase_order(batch) and master.handler.has_invoice_file(batch):
<div class="object-helper">
<h3>PO vs. Invoice</h3>
<div class="object-helper-content">
${po_vs_invoice_breakdown_grid.render_buefy_table_element(data_prop='poVsInvoiceBreakdownData', empty_labels=True)|n}
</div>
</div>
% endif
${self.render_execute_helper()}
% if master.has_perm('auto_receive') and master.can_auto_receive(batch):
<div class="object-helper">
@ -292,7 +304,9 @@
<div class="object-helper-content">
% if use_buefy:
<b-button type="is-primary"
@click="autoReceiveShowDialog = true">
@click="autoReceiveShowDialog = true"
icon-pack="fas"
icon-left="check">
Auto-Receive All Items
</b-button>
% else:
@ -334,7 +348,7 @@
:disabled="autoReceiveSubmitting"
@click="autoReceiveSubmitting = true"
icon-pack="fas"
icon-left="arrow-circle-right">
icon-left="check">
{{ autoReceiveSubmitting ? "Working, please wait..." : "Auto-Receive All Items" }}
</b-button>
${h.end_form()}
@ -352,6 +366,10 @@
ThisPageData.autoReceiveShowDialog = false
ThisPageData.autoReceiveSubmitting = false
% if po_vs_invoice_breakdown_grid is not Undefined:
ThisPageData.poVsInvoiceBreakdownData = ${json.dumps(po_vs_invoice_breakdown_grid.get_buefy_data()['data'])|n}
% endif
</script>
</%def>

View file

@ -5,9 +5,37 @@
${parent.extra_styles()}
<style type="text/css">
% if use_buefy:
nav.panel {
margin: 0.5rem;
}
.header-fields {
margin-top: 1rem;
}
.header-fields .field.is-horizontal {
margin-left: 3rem;
}
.header-fields .field.is-horizontal .field-label .label {
white-space: nowrap;
}
.quantity-form-fields {
margin: 2rem auto;
padding-left: 2rem;
}
.quantity-form-fields .field.is-horizontal .field-label .label {
text-align: left;
width: 8rem;
}
.remove-credit .field.is-horizontal .field-label .label {
white-space: nowrap;
}
% endif
</style>
</%def>
@ -30,9 +58,20 @@
<%def name="page_content()">
% if use_buefy:
<b-field grouped>
${form.render_field_readonly('sequence')}
${form.render_field_readonly('status_code')}
<b-field grouped class="header-fields">
<b-field label="Sequence" horizontal>
{{ rowData.sequence }}
</b-field>
<b-field label="Status" horizontal>
{{ rowData.status }}
</b-field>
<b-field label="Calculated Total" horizontal>
{{ rowData.invoice_total_calculated }}
</b-field>
</b-field>
<div style="display: flex;">
@ -42,18 +81,23 @@
<div class="panel-block">
<div style="display: flex;">
<div>
% if not row.product:
${form.render_field_readonly('item_entry')}
% endif
% if row.product:
${form.render_field_readonly('upc')}
${form.render_field_readonly('product')}
% else:
${form.render_field_readonly('item_entry')}
${form.render_field_readonly('upc')}
${form.render_field_readonly('brand_name')}
${form.render_field_readonly('description')}
${form.render_field_readonly('size')}
% endif
${form.render_field_readonly('vendor_code')}
${form.render_field_readonly('case_quantity')}
${form.render_field_readonly('catalog_unit_cost')}
</div>
% if image_url:
<div class="is-pulled-right">
${h.image(image_url, "Product Image")}
${h.image(image_url, "Product Image", width=150, height=150)}
</div>
% endif
</div>
@ -64,88 +108,351 @@
<p class="panel-heading">Quantities</p>
<div class="panel-block">
<div>
${form.render_field_readonly('ordered')}
${form.render_field_readonly('shipped')}
${form.render_field_readonly('received')}
${form.render_field_readonly('damaged')}
${form.render_field_readonly('expired')}
${form.render_field_readonly('mispick')}
<div class="quantity-form-fields">
<b-field label="Ordered" horizontal>
{{ rowData.ordered }}
</b-field>
<hr />
<b-field label="Shipped" horizontal>
{{ rowData.shipped }}
</b-field>
<hr />
<b-field label="Received" horizontal
v-if="rowData.received">
{{ rowData.received }}
</b-field>
<b-field label="Damaged" horizontal
v-if="rowData.damaged">
{{ rowData.damaged }}
</b-field>
<b-field label="Expired" horizontal
v-if="rowData.expired">
{{ rowData.expired }}
</b-field>
<b-field label="Mispick" horizontal
v-if="rowData.mispick">
{{ rowData.mispick }}
</b-field>
<b-field label="Missing" horizontal
v-if="rowData.missing">
{{ rowData.missing }}
</b-field>
<div class="buttons">
<once-button type="is-primary"
tag="a" href="${url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}"
icon-left="download"
text="Receive Product">
</once-button>
<once-button type="is-primary"
tag="a" href="${url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}"
icon-left="thumbs-down"
text="Declare Credit">
</once-button>
</div>
% if master.has_perm('edit_row') and master.row_editable(row):
<div class="buttons">
<b-button type="is-primary"
@click="accountForProductInit()"
icon-pack="fas"
icon-left="check">
Account for Product
</b-button>
<b-button type="is-warning"
@click="declareCreditInit()"
:disabled="!rowData.received"
icon-pack="fas"
icon-left="thumbs-down">
Declare Credit
</b-button>
</div>
% endif
</div>
</div>
</nav>
</div>
<b-modal has-modal-card
:active.sync="accountForProductShowDialog">
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Account for Product</p>
</header>
<section class="modal-card-body">
<p class="block">
This is for declaring that you have encountered some
amount of the product.&nbsp; Ideally you will just
"receive" it normally, but you can indicate a "credit"
state if there is something amiss.
</p>
<b-field grouped>
<b-field label="Case Qty.">
<span class="control">
{{ rowData.case_quantity }}
</span>
</b-field>
<span class="control">
&nbsp;
</span>
<b-field label="Product State"
:type="accountForProductMode ? null : 'is-danger'">
<b-select v-model="accountForProductMode">
<option v-for="mode in possibleReceivingModes"
:key="mode"
:value="mode">
{{ mode }}
</option>
</b-select>
</b-field>
<b-field label="Expiration Date"
v-show="accountForProductMode == 'expired'"
:type="accountForProductExpiration ? null : 'is-danger'">
<tailbone-datepicker v-model="accountForProductExpiration">
</tailbone-datepicker>
</b-field>
</b-field>
<div class="level">
<div class="level-left">
<div class="level-item">
<b-input v-model="accountForProductQuantity"
type="number" step="0.0001"
ref="accountForProductQuantityInput">
</b-input>
</div>
<div class="level-item">
<b-field>
<b-radio-button v-model="accountForProductUOM"
@click.native="accountForProductUOMClicked('units')"
native-value="units">
Units
</b-radio-button>
<b-radio-button v-model="accountForProductUOM"
@click.native="accountForProductUOMClicked('cases')"
native-value="cases">
Cases
</b-radio-button>
</b-field>
</div>
<div class="level-item"
v-if="accountForProductUOM == 'cases' && accountForProductQuantity">
= {{ accountForProductTotalUnits }}
</div>
</div>
</div>
</section>
<footer class="modal-card-foot">
<b-button @click="accountForProductShowDialog = false">
Cancel
</b-button>
<b-button type="is-primary"
@click="accountForProductSubmit()"
:disabled="accountForProductSubmitDisabled"
icon-pack="fas"
icon-left="check">
{{ accountForProductSubmitting ? "Working, please wait..." : "Account for Product" }}
</b-button>
</footer>
</div>
</b-modal>
<b-modal has-modal-card
:active.sync="declareCreditShowDialog">
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Declare Credit</p>
</header>
<section class="modal-card-body">
<p class="block">
This is for <span class="is-italic">converting</span>
some amount you <span class="is-italic">already
received</span>, and now declaring there is something
wrong with it.
</p>
<b-field grouped>
<b-field label="Received">
<span class="control">
{{ rowData.received }}
</span>
</b-field>
<span class="control">
&nbsp;
</span>
<b-field label="Credit Type"
:type="declareCreditType ? null : 'is-danger'">
<b-select v-model="declareCreditType">
<option v-for="typ in possibleCreditTypes"
:key="typ"
:value="typ">
{{ typ }}
</option>
</b-select>
</b-field>
<b-field label="Expiration Date"
v-show="declareCreditType == 'expired'"
:type="declareCreditExpiration ? null : 'is-danger'">
<tailbone-datepicker v-model="declareCreditExpiration">
</tailbone-datepicker>
</b-field>
</b-field>
<div class="level">
<div class="level-left">
<div class="level-item">
<b-input v-model="declareCreditQuantity"
type="number" step="0.0001"
ref="declareCreditQuantityInput">
</b-input>
</div>
<div class="level-item">
<b-field>
<b-radio-button v-model="declareCreditUOM"
@click.native="declareCreditUOMClicked('units')"
native-value="units">
Units
</b-radio-button>
<b-radio-button v-model="declareCreditUOM"
@click.native="declareCreditUOMClicked('cases')"
native-value="cases">
Cases
</b-radio-button>
</b-field>
</div>
<div class="level-item"
v-if="declareCreditUOM == 'cases' && declareCreditQuantity">
= {{ declareCreditTotalUnits }}
</div>
</div>
</div>
</section>
<footer class="modal-card-foot">
<b-button @click="declareCreditShowDialog = false">
Cancel
</b-button>
<b-button type="is-warning"
@click="declareCreditSubmit()"
:disabled="declareCreditSubmitDisabled"
icon-pack="fas"
icon-left="thumbs-down">
{{ declareCreditSubmitting ? "Working, please wait..." : "Declare this Credit" }}
</b-button>
</footer>
</div>
</b-modal>
<nav class="panel" >
<p class="panel-heading">Credits</p>
<div class="panel-block">
<div>
${form.render_field_value('credits')}
</div>
</div>
</nav>
<b-modal has-modal-card
:active.sync="removeCreditShowDialog">
<div class="modal-card remove-credit">
<header class="modal-card-head">
<p class="modal-card-title">Un-Declare Credit</p>
</header>
<section class="modal-card-body">
<p class="block">
If you un-declare this credit, the quantity below will
be added back to the
<span class="has-text-weight-bold">Received</span> tally.
</p>
<b-field label="Credit Type" horizontal>
{{ removeCreditRow.credit_type }}
</b-field>
<b-field label="Quantity" horizontal>
{{ removeCreditRow.shorted }}
</b-field>
</section>
<footer class="modal-card-foot">
<b-button @click="removeCreditShowDialog = false">
Cancel
</b-button>
<b-button type="is-danger"
@click="removeCreditSubmit()"
:disabled="removeCreditSubmitting"
icon-pack="fas"
icon-left="trash">
{{ removeCreditSubmitting ? "Working, please wait..." : "Un-Declare this Credit" }}
</b-button>
</footer>
</div>
</b-modal>
<div style="display: flex;">
% if master.batch_handler.has_purchase_order(batch):
<nav class="panel" >
<p class="panel-heading">Purchase Order</p>
<div class="panel-block">
<div>
${form.render_field_readonly('po_line_number')}
${form.render_field_readonly('po_unit_cost')}
${form.render_field_readonly('po_case_size')}
${form.render_field_readonly('po_total')}
</div>
</div>
</nav>
% endif
% if master.batch_handler.has_invoice_file(batch):
<nav class="panel" >
<p class="panel-heading">Invoice</p>
<div class="panel-block">
<div>
${form.render_field_readonly('invoice_line_number')}
${form.render_field_readonly('invoice_unit_cost')}
% if master.has_perm('edit_row'):
<div class="is-pulled-right">
<once-button type="is-primary"
tag="a" href="${master.get_row_action_url('edit', row)}"
## @click="editUnitCost()"
## icon-pack="fas"
icon-left="edit"
text="Edit Unit Cost">
</once-button>
${form.render_field_readonly('invoice_case_size')}
${form.render_field_readonly('invoice_total', label="Invoice Total")}
</div>
</div>
</nav>
% endif
${form.render_field_readonly('invoice_cost_confirmed')}
<div class="is-pulled-right">
<b-button type="is-primary"
@click="confirmUnitCost()"
icon-pack="fas"
icon-left="check">
Confirm Unit Cost
</b-button>
</div>
${form.render_field_readonly('invoice_total')}
${form.render_field_readonly('invoice_total_calculated')}
</div>
</div>
</nav>
</div>
<nav class="panel" >
<p class="panel-heading">Credits</p>
<div class="panel-block">
<div>
${form.render_field_readonly('credits')}
</div>
</div>
</nav>
% else:
## legacy / not buefy
${parent.page_content()}
@ -164,6 +471,211 @@
alert("TODO: not yet implemented")
}
ThisPageData.rowData = ${json.dumps(row_context)|n}
ThisPageData.possibleReceivingModes = ${json.dumps(possible_receiving_modes)|n}
ThisPageData.possibleCreditTypes = ${json.dumps(possible_credit_types)|n}
ThisPageData.accountForProductShowDialog = false
ThisPageData.accountForProductMode = null
ThisPageData.accountForProductQuantity = null
ThisPageData.accountForProductUOM = 'units'
ThisPageData.accountForProductExpiration = null
ThisPageData.accountForProductSubmitting = false
ThisPage.computed.accountForProductTotalUnits = function() {
return this.renderQuantity(this.accountForProductQuantity,
this.accountForProductUOM)
}
ThisPage.computed.accountForProductSubmitDisabled = function() {
if (!this.accountForProductMode) {
return true
}
if (this.accountForProductMode == 'expired' && !this.accountForProductExpiration) {
return true
}
if (!this.accountForProductQuantity) {
return true
}
if (this.accountForProductSubmitting) {
return true
}
return false
}
ThisPage.methods.accountForProductInit = function() {
this.accountForProductMode = 'received'
this.accountForProductExpiration = null
this.accountForProductQuantity = null
this.accountForProductUOM = 'units'
this.accountForProductShowDialog = true
}
ThisPage.methods.accountForProductUOMClicked = function(uom) {
// TODO: this does not seem to work as expected..even though
// the code appears to be correct
this.$nextTick(() => {
this.$refs.accountForProductQuantityInput.focus()
})
}
ThisPage.methods.accountForProductSubmit = function() {
let qty = parseFloat(this.accountForProductQuantity)
if (qty == NaN || !qty) {
this.$buefy.toast.open({
message: "You must enter a quantity.",
type: 'is-warning',
duration: 4000, // 4 seconds
})
return
}
if (this.accountForProductMode != 'received' && qty < 0) {
this.$buefy.toast.open({
message: "Negative amounts are only allowed for the \"received\" state.",
type: 'is-warning',
duration: 4000, // 4 seconds
})
return
}
this.accountForProductSubmitting = true
let url = '${url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}'
let params = {
mode: this.accountForProductMode,
quantity: {cases: null, units: null},
expiration_date: this.accountForProductExpiration,
}
if (this.accountForProductUOM == 'cases') {
params.quantity.cases = this.accountForProductQuantity
} else {
params.quantity.units = this.accountForProductQuantity
}
this.submitForm(url, params, response => {
this.rowData = response.data.row
this.accountForProductSubmitting = false
this.accountForProductShowDialog = false
}, response => {
this.accountForProductSubmitting = false
})
}
ThisPageData.declareCreditShowDialog = false
ThisPageData.declareCreditType = null
ThisPageData.declareCreditExpiration = null
ThisPageData.declareCreditQuantity = null
ThisPageData.declareCreditUOM = 'units'
ThisPageData.declareCreditSubmitting = false
ThisPage.methods.renderQuantity = function(qty, uom) {
qty = parseFloat(qty)
if (qty == NaN) {
return "n/a"
}
if (uom == 'cases') {
qty *= this.rowData.case_quantity
}
if (qty == NaN) {
return "n/a"
}
if (qty == 1) {
return "1 unit"
}
if (qty == -1) {
return "-1 unit"
}
if (Math.round(qty) == qty) {
return qty.toString() + " units"
}
return qty.toFixed(4) + " units"
}
ThisPage.computed.declareCreditTotalUnits = function() {
return this.renderQuantity(this.declareCreditQuantity,
this.declareCreditUOM)
}
ThisPage.computed.declareCreditSubmitDisabled = function() {
if (!this.declareCreditType) {
return true
}
if (this.declareCreditType == 'expired' && !this.declareCreditExpiration) {
return true
}
if (!this.declareCreditQuantity) {
return true
}
if (this.declareCreditSubmitting) {
return true
}
return false
}
ThisPage.methods.declareCreditInit = function() {
this.declareCreditType = null
this.declareCreditExpiration = null
if (this.rowData.cases_received) {
this.declareCreditQuantity = this.rowData.cases_received
this.declareCreditUOM = 'cases'
} else {
this.declareCreditQuantity = this.rowData.units_received
this.declareCreditUOM = 'units'
}
this.declareCreditShowDialog = true
}
ThisPage.methods.declareCreditSubmit = function() {
this.declareCreditSubmitting = true
let url = '${url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}'
let params = {
credit_type: this.declareCreditType,
cases: null,
units: null,
expiration_date: this.declareCreditExpiration,
}
if (this.declareCreditUOM == 'cases') {
params.cases = this.declareCreditQuantity
} else {
params.units = this.declareCreditQuantity
}
this.submitForm(url, params, response => {
this.rowData = response.data.row
this.declareCreditSubmitting = false
this.declareCreditShowDialog = false
}, response => {
this.declareCreditSubmitting = false
})
}
ThisPageData.removeCreditShowDialog = false
ThisPageData.removeCreditRow = {}
ThisPageData.removeCreditSubmitting = false
ThisPage.methods.removeCreditInit = function(row) {
this.removeCreditRow = row
this.removeCreditShowDialog = true
}
ThisPage.methods.removeCreditSubmit = function() {
this.removeCreditSubmitting = true
let url = '${url('{}.undeclare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}'
let params = {
uuid: this.removeCreditRow.uuid,
}
this.submitForm(url, params, response => {
this.rowData = response.data.row
this.removeCreditSubmitting = false
this.removeCreditShowDialog = false
})
}
</script>
</%def>

View file

@ -31,6 +31,8 @@
</head>
<body>
${declare_formposter_mixin()}
${self.body()}
<div id="whole-page-app">
@ -517,7 +519,6 @@
</%def>
<%def name="declare_whole_page_vars()">
${declare_formposter_mixin()}
${h.javascript_link(request.static_url('tailbone:static/themes/falafel/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))}
<script type="text/javascript">

View file

@ -115,7 +115,9 @@ class BatchMasterView(MasterView):
def __init__(self, request):
super(BatchMasterView, self).__init__(request)
self.handler = self.get_handler()
self.batch_handler = self.get_handler()
# TODO: deprecate / remove this (?)
self.handler = self.batch_handler
@classmethod
def get_handler_factory(cls, rattail_config):
@ -1149,19 +1151,28 @@ class BatchMasterView(MasterView):
"""
Batch rows are editable only until batch is complete or executed.
"""
if not (self.rows_editable or self.rows_editable_but_not_directly):
return False
batch = self.get_parent(row)
return self.rows_editable and not batch.executed and not batch.complete
if batch.complete or batch.executed:
return False
return True
def row_deletable(self, row):
"""
Batch rows are deletable only until batch is complete or executed.
"""
if self.rows_deletable:
batch = self.get_parent(row)
if not batch.executed and not batch.complete:
return True
if not self.rows_deletable:
return False
batch = self.get_parent(row)
if batch.complete or batch.executed:
return False
return True
def template_kwargs_view_row(self, **kwargs):
kwargs['batch_model_title'] = kwargs['parent_model_title']
# TODO: should these be set somewhere else?

View file

@ -166,6 +166,7 @@ class MasterView(View):
rows_viewable = True
rows_creatable = False
rows_editable = False
rows_editable_but_not_directly = False
rows_deletable = False
rows_deletable_speedbump = True
rows_bulk_deletable = False
@ -3852,6 +3853,7 @@ class MasterView(View):
return self.render_to_response('edit_row', {
'instance': row,
'row_parent': parent,
'parent_model_title': self.get_model_title(),
'parent_title': self.get_instance_title(parent),
'parent_url': self.get_action_url('view', parent),
'parent_instance': parent,
@ -3884,6 +3886,8 @@ class MasterView(View):
considered "deletable". Returns ``True`` by default; override as
necessary.
"""
if not self.rows_deletable:
return False
return True
def delete_row_object(self, row):
@ -4099,6 +4103,7 @@ class MasterView(View):
config_title = cls.get_config_title()
if cls.has_rows:
row_model_title = cls.get_row_model_title()
row_model_title_plural = cls.get_row_model_title_plural()
config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False)
@ -4386,9 +4391,10 @@ class MasterView(View):
# edit row
if cls.has_rows:
if cls.rows_editable:
if cls.rows_editable or cls.rows_editable_but_not_directly:
config.add_tailbone_permission(permission_prefix, '{}.edit_row'.format(permission_prefix),
"Edit individual {} rows".format(model_title))
"Edit individual {}".format(row_model_title_plural))
if cls.rows_editable:
config.add_route('{}.edit_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/edit'.format(url_prefix))
config.add_view(cls, attr='edit_row', route_name='{}.edit_row'.format(route_prefix),
permission='{}.edit_row'.format(permission_prefix))
@ -4397,7 +4403,7 @@ class MasterView(View):
if cls.has_rows:
if cls.rows_deletable:
config.add_tailbone_permission(permission_prefix, '{}.delete_row'.format(permission_prefix),
"Delete individual {} rows".format(model_title))
"Delete individual {}".format(row_model_title_plural))
config.add_route('{}.delete_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/delete'.format(url_prefix))
config.add_view(cls, attr='delete_row', route_name='{}.delete_row'.format(route_prefix),
permission='{}.delete_row'.format(permission_prefix))

View file

@ -99,8 +99,10 @@ class PurchasingBatchView(BatchMasterView):
'upc': "UPC",
'item_id': "Item ID",
'brand_name': "Brand",
'case_quantity': "Case Size",
'po_line_number': "PO Line Number",
'po_unit_cost': "PO Unit Cost",
'po_case_size': "PO Case Size",
'po_total': "PO Total",
}
@ -144,6 +146,9 @@ class PurchasingBatchView(BatchMasterView):
'mispick',
'cases_mispick',
'units_mispick',
'missing',
'cases_missing',
'units_missing',
'po_line_number',
'po_unit_cost',
'po_total',
@ -710,8 +715,11 @@ class PurchasingBatchView(BatchMasterView):
f.set_renderer('damaged', self.render_row_quantity)
f.set_renderer('expired', self.render_row_quantity)
f.set_renderer('mispick', self.render_row_quantity)
f.set_renderer('missing', self.render_row_quantity)
f.set_type('case_quantity', 'quantity')
f.set_type('po_case_size', 'quantity')
f.set_type('invoice_case_size', 'quantity')
f.set_type('cases_ordered', 'quantity')
f.set_type('units_ordered', 'quantity')
f.set_type('cases_shipped', 'quantity')
@ -724,6 +732,8 @@ class PurchasingBatchView(BatchMasterView):
f.set_type('units_expired', 'quantity')
f.set_type('cases_mispick', 'quantity')
f.set_type('units_mispick', 'quantity')
f.set_type('cases_missing', 'quantity')
f.set_type('units_missing', 'quantity')
# currency fields
# nb. we only show "total" fields as currency, but not case or
@ -746,6 +756,7 @@ class PurchasingBatchView(BatchMasterView):
# credits
f.set_readonly('credits')
if self.viewing:
f.set_renderer('credits', self.render_row_credits)
if self.creating:
@ -786,35 +797,57 @@ class PurchasingBatchView(BatchMasterView):
app = self.get_rattail_app()
cases = getattr(row, 'cases_{}'.format(field))
units = getattr(row, 'units_{}'.format(field))
if cases and units:
return "{} cases + {} units".format(app.render_quantity(cases),
app.render_quantity(units))
if cases and not units:
return "{} cases".format(app.render_quantity(cases))
if units and not cases:
return "{} units".format(app.render_quantity(units))
def render_row_credits(self, row, field):
if not row.credits:
return ""
return app.render_cases_units(cases, units)
def make_row_credits_grid(self, row):
use_buefy = self.get_use_buefy()
route_prefix = self.get_route_prefix()
columns = [
'credit_type',
'cases_shorted',
'units_shorted',
'credit_total',
]
g = grids.Grid(
factory = self.get_grid_factory()
g = factory(
key='{}.row_credits'.format(route_prefix),
data=row.credits,
columns=columns,
labels={'credit_type': "Type",
data=[] if use_buefy else row.credits,
columns=[
'credit_type',
# 'cases_shorted',
# 'units_shorted',
'shorted',
'credit_total',
'expiration_date',
# 'mispick_upc',
# 'mispick_brand_name',
# 'mispick_description',
# 'mispick_size',
],
labels={
'credit_type': "Type",
'cases_shorted': "Cases",
'units_shorted': "Units"})
'units_shorted': "Units",
'shorted': "Quantity",
'credit_total': "Total",
'mispick_upc': "Mispick UPC",
'mispick_brand_name': "MP Brand",
'mispick_description': "MP Description",
'mispick_size': "MP Size",
})
g.set_type('cases_shorted', 'quantity')
g.set_type('units_shorted', 'quantity')
g.set_type('credit_total', 'currency')
return g
def render_row_credits(self, row, field):
use_buefy = self.get_use_buefy()
if not use_buefy and not row.credits:
return
g = self.make_row_credits_grid(row)
if use_buefy:
return HTML.literal(
g.render_buefy_table_element(data_prop='rowData.credits'))
else:
return HTML.literal(g.render_grid())
# def item_lookup(self, value, field=None):

View file

@ -51,6 +51,21 @@ from tailbone.views.purchasing import PurchasingBatchView
log = logging.getLogger(__name__)
POSSIBLE_RECEIVING_MODES = [
'received',
'damaged',
'expired',
# 'mispick',
'missing',
]
POSSIBLE_CREDIT_TYPES = [
'damaged',
'expired',
# 'mispick',
'missing',
]
class ReceivingBatchView(PurchasingBatchView):
"""
@ -63,7 +78,9 @@ class ReceivingBatchView(PurchasingBatchView):
index_title = "Receiving"
downloadable = True
bulk_deletable = True
rows_editable = True
rows_editable = False
rows_editable_but_not_directly = True
rows_deletable = True
default_uom_is_case = True
@ -181,13 +198,18 @@ class ReceivingBatchView(PurchasingBatchView):
'mispick',
'cases_mispick',
'units_mispick',
'missing',
'cases_missing',
'units_missing',
'catalog_unit_cost',
'po_line_number',
'po_unit_cost',
'po_case_size',
'po_total',
'invoice_line_number',
'invoice_unit_cost',
'invoice_cost_confirmed',
'invoice_case_size',
'invoice_total',
'invoice_total_calculated',
'status_code',
@ -322,17 +344,14 @@ class ReceivingBatchView(PurchasingBatchView):
return self.render_to_response('create', context)
def row_deletable(self, row):
# first run it through the normal logic, if that doesn't like
# it then we won't either
if not super(ReceivingBatchView, self).row_deletable(row):
return False
batch = row.batch
# don't allow if master view has disabled that entirely
if not self.rows_deletable:
return False
# can never delete rows for complete/executed batches
# TODO: not so sure about the 'complete' part though..?
if batch.executed or batch.complete:
return False
# can always delete rows from truck dump parent
if batch.is_truck_dump_parent():
return True
@ -362,7 +381,7 @@ class ReceivingBatchView(PurchasingBatchView):
super(ReceivingBatchView, self).configure_form(f)
model = self.model
batch = f.model_instance
allow_truck_dump = self.handler.allow_truck_dump_receiving()
allow_truck_dump = self.batch_handler.allow_truck_dump_receiving()
workflow = self.request.matchdict.get('workflow_key')
route_prefix = self.get_route_prefix()
use_buefy = self.get_use_buefy()
@ -472,9 +491,9 @@ class ReceivingBatchView(PurchasingBatchView):
and self.purchase_order_fieldname == 'purchase'):
if use_buefy:
f.replace('purchase', 'purchase_uuid')
purchases = self.handler.get_eligible_purchases(
purchases = self.batch_handler.get_eligible_purchases(
vendor, self.enum.PURCHASE_BATCH_MODE_RECEIVING)
values = [(p.uuid, self.handler.render_eligible_purchase(p))
values = [(p.uuid, self.batch_handler.render_eligible_purchase(p))
for p in purchases]
f.set_widget('purchase_uuid', dfwidget.SelectWidget(values=values))
f.set_label('purchase_uuid', "Purchase Order")
@ -497,12 +516,11 @@ class ReceivingBatchView(PurchasingBatchView):
f.remove('invoice_total_calculated')
# hide all invoice fields if batch does not have invoice file
if not self.creating and not self.handler.has_invoice_file(batch):
if not self.creating and not self.batch_handler.has_invoice_file(batch):
f.remove('invoice_file',
'invoice_date',
'invoice_number',
'invoice_total',
'invoice_total_calculated')
'invoice_total')
# receiving_complete
if self.creating:
@ -517,9 +535,12 @@ class ReceivingBatchView(PurchasingBatchView):
'invoice_parser_key')
elif workflow == 'from_invoice':
f.remove('truck_dump_batch_uuid')
f.set_required('invoice_file')
f.set_required('invoice_parser_key')
f.remove('truck_dump_batch_uuid',
'po_number',
'invoice_date',
'invoice_number')
elif workflow == 'from_po':
f.remove('truck_dump_batch_uuid',
@ -531,9 +552,13 @@ class ReceivingBatchView(PurchasingBatchView):
'invoice_number')
elif workflow == 'from_po_with_invoice':
f.remove('truck_dump_batch_uuid')
f.set_required('invoice_file')
f.set_required('invoice_parser_key')
f.remove('truck_dump_batch_uuid',
'date_ordered',
'po_number',
'invoice_date',
'invoice_number')
elif workflow == 'truck_dump_children_first':
f.remove('truck_dump_batch_uuid',
@ -614,16 +639,92 @@ class ReceivingBatchView(PurchasingBatchView):
raise NotImplementedError
return kwargs
def make_po_vs_invoice_breakdown(self, batch):
"""
Returns a simple breakdown as list of 2-tuples, each of which
has the display title as first member, and number of rows as
second member.
"""
grouped = {}
labels = OrderedDict([
('both', "Found in both PO and Invoice"),
('po_not_invoice', "Found in PO but not Invoice"),
('invoice_not_po', "Found in Invoice but not PO"),
('neither', "Not found in PO nor Invoice"),
])
for row in batch.active_rows():
if row.po_line_number and not row.invoice_line_number:
grouped.setdefault('po_not_invoice', []).append(row)
elif row.invoice_line_number and not row.po_line_number:
grouped.setdefault('invoice_not_po', []).append(row)
elif row.po_line_number and row.invoice_line_number:
grouped.setdefault('both', []).append(row)
else:
grouped.setdefault('neither', []).append(row)
breakdown = []
for key, label in labels.items():
if key in grouped:
breakdown.append({
'title': label,
'count': len(grouped[key]),
})
return breakdown
def template_kwargs_view(self, **kwargs):
kwargs = super(ReceivingBatchView, self).template_kwargs_view(**kwargs)
batch = kwargs['instance']
if self.handler.has_purchase_order(batch) and self.handler.has_invoice_file(batch):
breakdown = self.make_po_vs_invoice_breakdown(batch)
factory = self.get_grid_factory()
kwargs['po_vs_invoice_breakdown_grid'] = factory(
'batch_po_vs_invoice_breakdown',
data=breakdown,
columns=['title', 'count'])
return kwargs
def get_context_credits(self, row):
app = self.get_rattail_app()
credits_data = []
for credit in row.credits:
credits_data.append({
'uuid': credit.uuid,
'credit_type': credit.credit_type,
'expiration_date': six.text_type(credit.expiration_date) if credit.expiration_date else None,
'cases_shorted': app.render_quantity(credit.cases_shorted),
'units_shorted': app.render_quantity(credit.units_shorted),
'shorted': app.render_cases_units(credit.cases_shorted,
credit.units_shorted),
'credit_total': app.render_currency(credit.credit_total),
'mispick_upc': '-',
'mispick_brand_name': '-',
'mispick_description': '-',
'mispick_size': '-',
})
return credits_data
def template_kwargs_view_row(self, **kwargs):
kwargs = super(ReceivingBatchView, self).template_kwargs_view_row(**kwargs)
use_buefy = self.get_use_buefy()
app = self.get_rattail_app()
handler = app.get_products_handler()
products_handler = app.get_products_handler()
row = kwargs['instance']
if row.product:
kwargs['image_url'] = handler.get_image_url(row.product)
kwargs['image_url'] = products_handler.get_image_url(row.product)
elif row.upc:
kwargs['image_url'] = handler.get_image_url(upc=row.upc)
kwargs['image_url'] = products_handler.get_image_url(upc=row.upc)
if use_buefy:
kwargs['row_context'] = self.get_context_row(row)
kwargs['possible_receiving_modes'] = POSSIBLE_RECEIVING_MODES
kwargs['possible_credit_types'] = POSSIBLE_CREDIT_TYPES
return kwargs
@ -849,6 +950,24 @@ class ReceivingBatchView(PurchasingBatchView):
if row.product and row.product.is_pack_item():
return self.get_row_action_url('transform_unit', row)
def make_row_credits_grid(self, row):
# first make grid like normal
g = super(ReceivingBatchView, self).make_row_credits_grid(row)
if (self.get_use_buefy()
and self.has_perm('edit_row')
and self.row_editable(row)):
# add the Un-Declare action
g.main_actions.append(self.make_action(
'remove', label="Un-Declare",
url='#', icon='trash',
link_class='has-text-danger',
click_handler='removeCreditInit(props.row)'))
return g
def vuejs_convert_quantity(self, cstruct):
result = dict(cstruct)
if result['cases'] is colander.null:
@ -872,6 +991,55 @@ class ReceivingBatchView(PurchasingBatchView):
self.viewing = True
use_buefy = self.get_use_buefy()
row = self.get_row_instance()
# things are a bit different now w/ buefy support..
if use_buefy:
# don't even bother showing this page if that's all the
# request was about
if self.request.method == 'GET':
return self.redirect(self.get_row_action_url('view', row))
# make sure edit is allowed
if not (self.has_perm('edit_row') and self.row_editable(row)):
raise self.forbidden()
# check for JSON POST, which is submitted via AJAX from
# the "view row" page
if self.request.method == 'POST' and not self.request.POST:
data = self.request.json_body
kwargs = dict(data)
# TODO: for some reason quantities can come through as strings?
cases = kwargs['quantity']['cases']
if cases is not None:
if cases == '':
cases = None
else:
cases = decimal.Decimal(cases)
kwargs['cases'] = cases
units = kwargs['quantity']['units']
if units is not None:
if units == '':
units = None
else:
units = decimal.Decimal(units)
kwargs['units'] = units
del kwargs['quantity']
# handler takes care of the receiving logic for us
try:
self.batch_handler.receive_row(row, **kwargs)
except Exception as error:
return self.json_response({'error': six.text_type(error)})
self.Session.flush()
self.Session.refresh(row)
return self.json_response({
'ok': True,
'row': self.get_context_row(row)})
batch = row.batch
permission_prefix = self.get_permission_prefix()
possible_modes = [
@ -1024,11 +1192,59 @@ class ReceivingBatchView(PurchasingBatchView):
"""
use_buefy = self.get_use_buefy()
row = self.get_row_instance()
# things are a bit different now w/ buefy support..
if use_buefy:
# don't even bother showing this page if that's all the
# request was about
if self.request.method == 'GET':
return self.redirect(self.get_row_action_url('view', row))
# make sure edit is allowed
if not (self.has_perm('edit_row') and self.row_editable(row)):
raise self.forbidden()
# check for JSON POST, which is submitted via AJAX from
# the "view row" page
if self.request.method == 'POST' and not self.request.POST:
data = self.request.json_body
kwargs = dict(data)
# TODO: for some reason quantities can come through as strings?
if kwargs['cases'] is not None:
if kwargs['cases'] == '':
kwargs['cases'] = None
else:
kwargs['cases'] = decimal.Decimal(kwargs['cases'])
if kwargs['units'] is not None:
if kwargs['units'] == '':
kwargs['units'] = None
else:
kwargs['units'] = decimal.Decimal(kwargs['units'])
try:
result = self.handler.can_declare_credit(row, **kwargs)
except Exception as error:
return self.json_response({'error': six.text_type(error)})
else:
if result:
self.handler.declare_credit(row, **kwargs)
else:
return self.json_response({
'error': "Handler says you can't declare that credit; "
"not sure why"})
self.Session.flush()
self.Session.refresh(row)
return self.json_response({
'ok': True,
'row': self.get_context_row(row)})
batch = row.batch
possible_credit_types = [
'damaged',
'expired',
]
context = {
'row': row,
'batch': batch,
@ -1044,9 +1260,10 @@ class ReceivingBatchView(PurchasingBatchView):
schema = DeclareCreditForm()
form = forms.Form(schema=schema, request=self.request,
use_buefy=use_buefy)
form.cancel_url = self.get_row_action_url('view', row)
# credit_type
values = [(m, m) for m in possible_credit_types]
values = [(m, m) for m in POSSIBLE_CREDIT_TYPES]
if use_buefy:
widget = dfwidget.SelectWidget(values=values)
else:
@ -1085,6 +1302,54 @@ class ReceivingBatchView(PurchasingBatchView):
context['parent_title'] = self.get_instance_title(batch)
return self.render_to_response('declare_credit', context)
def undeclare_credit(self):
"""
View for un-declaring a credit, i.e. moving the credit amounts
back into the "received" tally.
"""
model = self.model
row = self.get_row_instance()
data = self.request.json_body
# make sure edit is allowed
if not (self.has_perm('edit_row') and self.row_editable(row)):
raise self.forbidden()
# figure out which credit to un-declare
credit = None
uuid = data.get('uuid')
if uuid:
credit = self.Session.query(model.PurchaseBatchCredit).get(uuid)
if not credit:
return {'error': "Credit not found"}
# un-declare it
self.batch_handler.undeclare_credit(row, credit)
self.Session.flush()
self.Session.refresh(row)
return {'ok': True,
'row': self.get_context_row(row)}
def get_context_row(self, row):
app = self.get_rattail_app()
return {
'sequence': row.sequence,
'case_quantity': float(row.case_quantity) if row.case_quantity is not None else None,
'ordered': self.render_row_quantity(row, 'ordered'),
'shipped': self.render_row_quantity(row, 'shipped'),
'received': self.render_row_quantity(row, 'received'),
'cases_received': float(row.cases_received) if row.cases_received is not None else None,
'units_received': float(row.units_received) if row.units_received is not None else None,
'damaged': self.render_row_quantity(row, 'damaged'),
'expired': self.render_row_quantity(row, 'expired'),
'mispick': self.render_row_quantity(row, 'mispick'),
'missing': self.render_row_quantity(row, 'missing'),
'credits': self.get_context_credits(row),
'invoice_total_calculated': app.render_currency(row.invoice_total_calculated),
'status': row.STATUS[row.status_code],
}
def transform_unit_row(self):
"""
View which transforms the given row, which is assumed to associate with
@ -1593,6 +1858,14 @@ class ReceivingBatchView(PurchasingBatchView):
config.add_view(cls, attr='declare_credit', route_name='{}.declare_credit'.format(route_prefix),
permission='{}.edit_row'.format(permission_prefix))
# un-declare credit
config.add_route('{}.undeclare_credit'.format(route_prefix),
'{}/rows/{{row_uuid}}/undeclare-credit'.format(instance_url_prefix))
config.add_view(cls, attr='undeclare_credit',
route_name='{}.undeclare_credit'.format(route_prefix),
permission='{}.edit_row'.format(permission_prefix),
renderer='json')
# update row cost
config.add_route('{}.update_row_cost'.format(route_prefix), '{}/update-row-cost'.format(instance_url_prefix))
config.add_view(cls, attr='update_row_cost', route_name='{}.update_row_cost'.format(route_prefix),
@ -1649,12 +1922,8 @@ class NewReceivingBatch(colander.Schema):
class ReceiveRowForm(colander.MappingSchema):
mode = colander.SchemaNode(colander.String(),
validator=colander.OneOf([
'received',
'damaged',
'expired',
# 'mispick',
]))
validator=colander.OneOf(
POSSIBLE_RECEIVING_MODES))
quantity = forms.types.ProductQuantity()
@ -1677,11 +1946,8 @@ class ReceiveRowForm(colander.MappingSchema):
class DeclareCreditForm(colander.MappingSchema):
credit_type = colander.SchemaNode(colander.String(),
validator=colander.OneOf([
'damaged',
'expired',
# 'mispick',
]))
validator=colander.OneOf(
POSSIBLE_CREDIT_TYPES))
quantity = forms.types.ProductQuantity()