diff --git a/alembic/versions/f03333c42bdb_add_total_amount_to_clins.py b/alembic/versions/f03333c42bdb_add_total_amount_to_clins.py new file mode 100644 index 00000000..8201bb61 --- /dev/null +++ b/alembic/versions/f03333c42bdb_add_total_amount_to_clins.py @@ -0,0 +1,29 @@ +"""Add total amount to CLINs + +Revision ID: f03333c42bdb +Revises: 4a3122ffe898 +Create Date: 2019-09-04 15:35:39.446486 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f03333c42bdb' # pragma: allowlist secret +down_revision = '30ea1cb20807' # pragma: allowlist secret +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('clins', sa.Column('total_amount', sa.Numeric(scale=2), nullable=True)) + op.execute("UPDATE clins SET total_amount = 0") + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('clins', 'total_amount') + # ### end Alembic commands ### diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index 2c27977c..93521843 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -58,6 +58,7 @@ class TaskOrders(BaseDomainClass): number=clin_data["number"], start_date=clin_data["start_date"], end_date=clin_data["end_date"], + total_amount=clin_data["total_amount"], obligated_amount=clin_data["obligated_amount"], jedi_clin_type=clin_data["jedi_clin_type"], ) diff --git a/atst/forms/task_order.py b/atst/forms/task_order.py index 09a8fe8e..ddf029f8 100644 --- a/atst/forms/task_order.py +++ b/atst/forms/task_order.py @@ -7,9 +7,10 @@ from wtforms.fields import ( HiddenField, ) from wtforms.fields.html5 import DateField -from wtforms.validators import Required, Optional, Length +from wtforms.validators import Required, Optional, Length, NumberRange, ValidationError from flask_wtf import FlaskForm from datetime import datetime +from numbers import Number from .data import JEDI_CLIN_TYPES from .fields import SelectField @@ -17,6 +18,8 @@ from .forms import BaseForm from atst.utils.localization import translate from flask import current_app as app +MAX_CLIN_AMOUNT = 1000000000 + def coerce_enum(enum_inst): if getattr(enum_inst, "value", None): @@ -25,6 +28,17 @@ def coerce_enum(enum_inst): return enum_inst +def validate_funding(form, field): + if ( + isinstance(form.total_amount.data, Number) + and isinstance(field.data, Number) + and form.total_amount.data < field.data + ): + raise ValidationError( + translate("forms.task_order.clin_funding_errors.obligated_amount_error") + ) + + class CLINForm(FlaskForm): jedi_clin_type = SelectField( translate("task_orders.form.clin_type_label"), @@ -47,9 +61,26 @@ class CLINForm(FlaskForm): format="%m/%d/%Y", validators=[Optional()], ) + total_amount = DecimalField( + label=translate("task_orders.form.total_funds_label"), + validators=[ + NumberRange( + 0, + MAX_CLIN_AMOUNT, + translate("forms.task_order.clin_funding_errors.funding_range_error"), + ) + ], + ) obligated_amount = DecimalField( label=translate("task_orders.form.obligated_funds_label"), - validators=[Optional()], + validators=[ + validate_funding, + NumberRange( + 0, + MAX_CLIN_AMOUNT, + translate("forms.task_order.clin_funding_errors.funding_range_error"), + ), + ], ) def validate(self, *args, **kwargs): diff --git a/atst/models/clin.py b/atst/models/clin.py index da2d1d02..6107a1f3 100644 --- a/atst/models/clin.py +++ b/atst/models/clin.py @@ -23,6 +23,7 @@ class CLIN(Base, mixins.TimestampsMixin): number = Column(String, nullable=True) start_date = Column(Date, nullable=True) end_date = Column(Date, nullable=True) + total_amount = Column(Numeric(scale=2), nullable=True) obligated_amount = Column(Numeric(scale=2), nullable=True) jedi_clin_type = Column(SQLAEnum(JEDICLINType, native_enum=False), nullable=True) @@ -46,6 +47,7 @@ class CLIN(Base, mixins.TimestampsMixin): self.number, self.start_date, self.end_date, + self.total_amount, self.obligated_amount, self.jedi_clin_type, ] diff --git a/atst/models/task_order.py b/atst/models/task_order.py index bd592bea..3fe3d3e3 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -6,7 +6,6 @@ from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import relationship from atst.models import Attachment, Base, mixins, types -from atst.models.clin import JEDICLINType from atst.utils.clock import Clock @@ -148,10 +147,7 @@ class TaskOrder(Base, mixins.TimestampsMixin): def total_obligated_funds(self): total = 0 for clin in self.clins: - if clin.obligated_amount is not None and clin.jedi_clin_type in [ - JEDICLINType.JEDI_CLIN_1, - JEDICLINType.JEDI_CLIN_3, - ]: + if clin.obligated_amount is not None: total += clin.obligated_amount return total @@ -159,8 +155,8 @@ class TaskOrder(Base, mixins.TimestampsMixin): def total_contract_amount(self): total = 0 for clin in self.clins: - if clin.obligated_amount is not None: - total += clin.obligated_amount + if clin.total_amount is not None: + total += clin.total_amount return total @property diff --git a/js/components/clin_dollar_amount.js b/js/components/clin_dollar_amount.js new file mode 100644 index 00000000..ccebed68 --- /dev/null +++ b/js/components/clin_dollar_amount.js @@ -0,0 +1,39 @@ +import inputValidations from '../lib/input_validations' +import TextInputMixin from '../mixins/text_input_mixin' + +export default { + name: 'clindollaramount', + + mixins: [TextInputMixin], + + props: { + fundingValid: Boolean, + }, + + computed: { + rawValue: function() { + return this._rawValue(this.value) + }, + showFundingError: function() { + return this.showError || !this.fundingValid + }, + showFundingValid: function() { + return this.showValid && this.fundingValid + }, + }, + watch: { + fundingValid: function(oldVal, newVal) { + this._checkIfValid({ value: this.value, invalidate: true }) + }, + }, + + methods: { + _validate: function(value) { + const rawValue = this._rawValue(value) + if (rawValue < 0 || rawValue > 1000000000 || !this.fundingValid) { + return false + } + return inputValidations[this.validation].match.test(rawValue) + }, + }, +} diff --git a/js/components/clin_fields.js b/js/components/clin_fields.js index eb958780..fb42b2a7 100644 --- a/js/components/clin_fields.js +++ b/js/components/clin_fields.js @@ -6,7 +6,10 @@ import { emitEvent } from '../lib/emitters' import Modal from '../mixins/modal' import optionsinput from './options_input' import textinput from './text_input' +import clindollaramount from './clin_dollar_amount' +const TOTAL_AMOUNT = 'total_amount' +const OBLIGATED_AMOUNT = 'obligated_amount' const START_DATE = 'start_date' const END_DATE = 'end_date' const POP = 'period_of_performance' @@ -19,12 +22,21 @@ export default { DateSelector, optionsinput, textinput, + clindollaramount, }, mixins: [Modal], props: { initialClinIndex: Number, + initialTotal: { + type: Number, + default: 0, + }, + initialObligated: { + type: Number, + default: 0, + }, initialStartDate: { type: String, default: null, @@ -54,6 +66,10 @@ export default { const end = !!this.initialEndDate ? new Date(this.initialEndDate) : undefined + const fundingValidation = + this.initialObligated && this.initialTotal + ? this.initialObligated <= this.initialTotal + : true const popValidation = !this.initialStartDate ? false : start < end const clinNumber = !!this.initialClinNumber ? this.initialClinNumber @@ -63,6 +79,7 @@ export default { return { clinIndex: this.initialClinIndex, + clinNumber: clinNumber, startDate: start, endDate: end, popValid: popValidation, @@ -93,14 +110,23 @@ export default { )}.`, }, ], + totalAmount: this.initialTotal || 0, + obligatedAmount: this.initialObligated || 0, + fundingValid: fundingValidation, } }, mounted: function() { this.$root.$on('field-change', this.handleFieldChange) + this.validateFunding() }, created: function() { + emitEvent('clin-change', this, { + id: this._uid, + obligatedAmount: this.initialObligated, + totalAmount: this.initialTotal, + }) emitEvent('field-mount', this, { optional: false, name: 'clins-' + this.clinIndex + '-' + POP, @@ -109,6 +135,14 @@ export default { }, methods: { + clinChangeEvent: function() { + emitEvent('clin-change', this, { + id: this._uid, + obligatedAmount: this.initialObligated, + totalAmount: this.initialTotal, + }) + }, + checkPopValid: function() { return ( this.popDateOrder() && @@ -153,9 +187,25 @@ export default { return true }, + checkFundingValid: function() { + return this.obligatedAmount <= this.totalAmount + }, + + validateFunding: function() { + if (this.totalAmount && this.obligatedAmount) { + this.fundingValid = this.checkFundingValid() + } + }, + handleFieldChange: function(event) { if (this._uid === event.parent_uid) { - if (event.name.includes(START_DATE)) { + if (event.name.includes(TOTAL_AMOUNT)) { + this.totalAmount = parseFloat(event.value) + this.validateFunding() + } else if (event.name.includes(OBLIGATED_AMOUNT)) { + this.obligatedAmount = parseFloat(event.value) + this.validateFunding() + } else if (event.name.includes(START_DATE)) { if (!!event.value) this.startDate = new Date(event.value) if (!!event.valid) this.startDateValid = event.valid this.validatePop() @@ -186,6 +236,20 @@ export default { return `CLIN` } }, + percentObligated: function() { + const percentage = (this.obligatedAmount / this.totalAmount) * 100 + if (!!percentage) { + if (percentage > 0 && percentage < 1) { + return '<1%' + } else if (percentage > 99 && percentage < 100) { + return '>99%' + } else { + return `${percentage.toFixed(0)}%` + } + } else { + return '0%' + } + }, removeModalId: function() { return `remove-clin-${this.clinIndex}` diff --git a/js/components/text_input.js b/js/components/text_input.js index 92c8c7e4..9f91c832 100644 --- a/js/components/text_input.js +++ b/js/components/text_input.js @@ -1,174 +1,6 @@ -import MaskedInput, { conformToMask } from 'vue-text-mask' -import inputValidations from '../lib/input_validations' -import { formatDollars } from '../lib/dollars' -import { emitEvent } from '../lib/emitters' +import TextInputMixin from '../mixins/text_input_mixin' export default { name: 'textinput', - - components: { - MaskedInput, - }, - - props: { - name: String, - validation: { - type: String, - default: () => 'anything', - }, - initialValue: { - type: String, - default: () => '', - }, - initialErrors: { - type: Array, - default: () => [], - }, - paragraph: String, - noMaxWidth: String, - optional: Boolean, - watch: { - type: Boolean, - default: false, - }, - }, - - data: function() { - return { - showError: (this.initialErrors && this.initialErrors.length) || false, - showValid: false, - mask: inputValidations[this.validation].mask, - pipe: inputValidations[this.validation].pipe || undefined, - keepCharPositions: - inputValidations[this.validation].keepCharPositions || false, - validationError: - this.initialErrors.join(' ') || - inputValidations[this.validation].validationError, - value: this.initialValue, - modified: false, - } - }, - - computed: { - rawValue: function() { - return this._rawValue(this.value) - }, - }, - - mounted: function() { - if (this.value) { - this._checkIfValid({ - value: this.value, - invalidate: true, - showValidationIcon: false, - }) - - if (this.mask && this.validation !== 'email') { - const mask = - typeof this.mask.mask !== 'function' - ? this.mask - : mask.mask(this.value).filter(val => val !== '[]') - - this.value = conformToMask(this.value, mask).conformedValue - } - } - }, - - created: function() { - emitEvent('field-mount', this, { - optional: this.optional, - name: this.name, - valid: this._isValid(this.value), - }) - }, - - methods: { - // When user types a character - onInput: function(e) { - // When we use the native textarea element, we receive an event object - // When we use the masked-input component, we receive the value directly - const value = typeof e === 'object' ? e.target.value : e - this.value = value - this.modified = true - this._checkIfValid({ value }) - }, - - // When field is blurred (un-focused) - onChange: function(e) { - // Only invalidate the field when it blurs - this._checkIfValid({ value: e.target.value, invalidate: true }) - }, - - onBlur: function(e) { - if (!(this.optional && e.target.value === '')) { - this._checkIfValid({ value: e.target.value.trim(), invalidate: true }) - } else if (this.modified && !this.optional) { - this._checkIfValid({ value: e.target.value.trim(), invalidate: true }) - } - this.value = e.target.value.trim() - - if (this.validation === 'dollars') { - let value = Number.isNaN(e.target.value) ? '0' : e.target.value - this.value = formatDollars(this._rawValue(value)) - } - }, - - // - _checkIfValid: function({ - value, - invalidate = false, - showValidationIcon = true, - }) { - const valid = this._isValid(value) - if (this.modified) { - this.validationError = inputValidations[this.validation].validationError - } - - // Show error messages or not - if (valid) { - this.showError = false - } else if (invalidate) { - this.showError = true - } - - if (showValidationIcon) { - this.showValid = this.value != '' && valid - } - - // Emit a change event - emitEvent('field-change', this, { - value: this._rawValue(value), - valid: this._isValid(value), - name: this.name, - watch: this.watch, - }) - }, - - _rawValue: function(value) { - return inputValidations[this.validation].unmask.reduce( - (currentValue, character) => { - return currentValue.split(character).join('') - }, - value - ) - }, - - _validate: function(value) { - return inputValidations[this.validation].match.test(this._rawValue(value)) - }, - - _isValid: function(value) { - let valid = this._validate(value) - - if (!this.modified && this.initialErrors && this.initialErrors.length) { - valid = false - } else if (this.optional && value === '') { - valid = true - } else if (!this.optional && value === '') { - valid = false - } - - return valid - }, - }, + mixins: [TextInputMixin], } diff --git a/js/lib/input_validations.js b/js/lib/input_validations.js index a873b3c0..af74f211 100644 --- a/js/lib/input_validations.js +++ b/js/lib/input_validations.js @@ -34,6 +34,13 @@ export default { unmask: ['$', ','], validationError: 'Please enter a dollar amount', }, + clinDollars: { + mask: createNumberMask({ prefix: '$', allowDecimal: true }), + match: /^-?\d+\.?\d*$/, + unmask: ['$', ','], + validationError: + 'Please enter a dollar amount between $0.00 and $1,000,000,000.00', + }, email: { mask: emailMask, match: /^.+@[^.].*\.[a-zA-Z]{2,10}$/, diff --git a/js/mixins/text_input_mixin.js b/js/mixins/text_input_mixin.js new file mode 100644 index 00000000..1e728ee7 --- /dev/null +++ b/js/mixins/text_input_mixin.js @@ -0,0 +1,173 @@ +import MaskedInput, { conformToMask } from 'vue-text-mask' +import inputValidations from '../lib/input_validations' +import { formatDollars } from '../lib/dollars' +import { emitEvent } from '../lib/emitters' + +export default { + name: 'textinput', + + components: { + MaskedInput, + }, + + props: { + name: String, + validation: { + type: String, + default: () => 'anything', + }, + initialValue: { + type: String, + default: () => '', + }, + initialErrors: { + type: Array, + default: () => [], + }, + paragraph: String, + noMaxWidth: String, + optional: Boolean, + watch: { + type: Boolean, + default: false, + }, + }, + + data: function() { + return { + showError: (this.initialErrors && this.initialErrors.length) || false, + showValid: false, + mask: inputValidations[this.validation].mask, + pipe: inputValidations[this.validation].pipe || undefined, + keepCharPositions: + inputValidations[this.validation].keepCharPositions || false, + validationError: + this.initialErrors.join(' ') || + inputValidations[this.validation].validationError, + value: this.initialValue, + modified: false, + } + }, + + computed: { + rawValue: function() { + return this._rawValue(this.value) + }, + }, + + mounted: function() { + if (this.value) { + this._checkIfValid({ + value: this.value, + invalidate: true, + showValidationIcon: false, + }) + + if (this.mask && this.validation !== 'email') { + const mask = + typeof this.mask.mask !== 'function' + ? this.mask + : mask.mask(this.value).filter(val => val !== '[]') + + this.value = conformToMask(this.value, mask).conformedValue + } + } + }, + + created: function() { + emitEvent('field-mount', this, { + optional: this.optional, + name: this.name, + valid: this._isValid(this.value), + }) + }, + + methods: { + // When user types a character + onInput: function(e) { + // When we use the native textarea element, we receive an event object + // When we use the masked-input component, we receive the value directly + const value = typeof e === 'object' ? e.target.value : e + this.value = value + this.modified = true + this._checkIfValid({ value }) + }, + + // When field is blurred (un-focused) + onChange: function(e) { + // Only invalidate the field when it blurs + this._checkIfValid({ value: e.target.value, invalidate: true }) + }, + + onBlur: function(e) { + if (!(this.optional && e.target.value === '')) { + this._checkIfValid({ value: e.target.value.trim(), invalidate: true }) + } else if (this.modified && !this.optional) { + this._checkIfValid({ value: e.target.value.trim(), invalidate: true }) + } + this.value = e.target.value.trim() + + if (this.validation === 'dollars') { + let value = Number.isNaN(e.target.value) ? '0' : e.target.value + this.value = formatDollars(this._rawValue(value)) + } + }, + + // + _checkIfValid: function({ + value, + invalidate = false, + showValidationIcon = true, + }) { + const valid = this._isValid(value) + if (this.modified) { + this.validationError = inputValidations[this.validation].validationError + } + + // Show error messages or not + if (valid) { + this.showError = false + } else if (invalidate) { + this.showError = true + } + + if (showValidationIcon) { + this.showValid = this.value != '' && valid + } + + // Emit a change event + emitEvent('field-change', this, { + value: this._rawValue(value), + valid: this._isValid(value), + name: this.name, + watch: this.watch, + }) + }, + + _rawValue: function(value) { + return inputValidations[this.validation].unmask.reduce( + (currentValue, character) => { + return currentValue.split(character).join('') + }, + value + ) + }, + + _validate: function(value) { + return inputValidations[this.validation].match.test(this._rawValue(value)) + }, + + _isValid: function(value) { + let valid = this._validate(value) + if (!this.modified && this.initialErrors && this.initialErrors.length) { + valid = false + } else if (this.optional && value === '') { + valid = true + } else if (!this.optional && value === '') { + valid = false + } + + return valid + }, + }, +} diff --git a/templates/components/clin_dollar_amount.html b/templates/components/clin_dollar_amount.html new file mode 100644 index 00000000..dd8468dc --- /dev/null +++ b/templates/components/clin_dollar_amount.html @@ -0,0 +1,70 @@ +{% from 'components/icon.html' import Icon %} + +{% macro CLINDollarAmount(type, field=None, funding_validation=False) -%} +
Not sure how to describe your scope? Read some examples to get some inspiration.
' scope_label: Cloud project scope + clin_funding_errors: + obligated_amount_error: Obligated amount must be less than or equal to total amount + funding_range_error: Dollar amount must be from $0.00 to $1,000,000,000.00 team_experience: built_1: Built, migrated, or consulted on 1-2 applications built_3: Built, migrated, or consulted on 3-5 applications @@ -395,7 +398,7 @@ task_orders: type: CLIN Type idiq_clin_description: Description (IDIQ CLIN) pop: PoP - amount: CLIN Value + total_amount: CLIN Value obligated: Amount Obligated form: add_clin: Add another CLIN @@ -406,7 +409,7 @@ task_orders: clin_description: "Refer to your task order to locate your Contract Line Item Numbers (CLINs)." clin_details: CLIN Details clin_funding: CLIN Funding - clin_number_label: CLIN Number + clin_number_label: CLIN clin_type_label: Corresponding IDIQ CLIN clin_remove_text: 'Do you want to remove ' clin_remove_confirm: Yes, remove CLIN @@ -415,6 +418,7 @@ task_orders: cloud_funding_text: Data must match with what is in your uploaded document. draft_alert_title: Your information has been saved draft_alert_message: You can return to the Task Order Builder to enter missing information. Once you are finished, you’ll be ready to submit this request. + total_funds_label: Total CLIN Value obligated_funds_label: Obligated Funds pop: Period of Performance pop_end: End Date