Merge pull request #1069 from dod-ccpo/clin-obligated-and-total-funding

CLIN obligated and total funding
This commit is contained in:
graham-dds 2019-09-13 11:10:46 -04:00 committed by GitHub
commit 1fe755bb69
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 499 additions and 235 deletions

View File

@ -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 ###

View File

@ -58,6 +58,7 @@ class TaskOrders(BaseDomainClass):
number=clin_data["number"], number=clin_data["number"],
start_date=clin_data["start_date"], start_date=clin_data["start_date"],
end_date=clin_data["end_date"], end_date=clin_data["end_date"],
total_amount=clin_data["total_amount"],
obligated_amount=clin_data["obligated_amount"], obligated_amount=clin_data["obligated_amount"],
jedi_clin_type=clin_data["jedi_clin_type"], jedi_clin_type=clin_data["jedi_clin_type"],
) )

View File

@ -7,9 +7,10 @@ from wtforms.fields import (
HiddenField, HiddenField,
) )
from wtforms.fields.html5 import DateField 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 flask_wtf import FlaskForm
from datetime import datetime from datetime import datetime
from numbers import Number
from .data import JEDI_CLIN_TYPES from .data import JEDI_CLIN_TYPES
from .fields import SelectField from .fields import SelectField
@ -17,6 +18,8 @@ from .forms import BaseForm
from atst.utils.localization import translate from atst.utils.localization import translate
from flask import current_app as app from flask import current_app as app
MAX_CLIN_AMOUNT = 1000000000
def coerce_enum(enum_inst): def coerce_enum(enum_inst):
if getattr(enum_inst, "value", None): if getattr(enum_inst, "value", None):
@ -25,6 +28,17 @@ def coerce_enum(enum_inst):
return 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): class CLINForm(FlaskForm):
jedi_clin_type = SelectField( jedi_clin_type = SelectField(
translate("task_orders.form.clin_type_label"), translate("task_orders.form.clin_type_label"),
@ -47,9 +61,26 @@ class CLINForm(FlaskForm):
format="%m/%d/%Y", format="%m/%d/%Y",
validators=[Optional()], 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( obligated_amount = DecimalField(
label=translate("task_orders.form.obligated_funds_label"), 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): def validate(self, *args, **kwargs):

View File

@ -23,6 +23,7 @@ class CLIN(Base, mixins.TimestampsMixin):
number = Column(String, nullable=True) number = Column(String, nullable=True)
start_date = Column(Date, nullable=True) start_date = Column(Date, nullable=True)
end_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) obligated_amount = Column(Numeric(scale=2), nullable=True)
jedi_clin_type = Column(SQLAEnum(JEDICLINType, native_enum=False), 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.number,
self.start_date, self.start_date,
self.end_date, self.end_date,
self.total_amount,
self.obligated_amount, self.obligated_amount,
self.jedi_clin_type, self.jedi_clin_type,
] ]

View File

@ -6,7 +6,6 @@ from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from atst.models import Attachment, Base, mixins, types from atst.models import Attachment, Base, mixins, types
from atst.models.clin import JEDICLINType
from atst.utils.clock import Clock from atst.utils.clock import Clock
@ -148,10 +147,7 @@ class TaskOrder(Base, mixins.TimestampsMixin):
def total_obligated_funds(self): def total_obligated_funds(self):
total = 0 total = 0
for clin in self.clins: for clin in self.clins:
if clin.obligated_amount is not None and clin.jedi_clin_type in [ if clin.obligated_amount is not None:
JEDICLINType.JEDI_CLIN_1,
JEDICLINType.JEDI_CLIN_3,
]:
total += clin.obligated_amount total += clin.obligated_amount
return total return total
@ -159,8 +155,8 @@ class TaskOrder(Base, mixins.TimestampsMixin):
def total_contract_amount(self): def total_contract_amount(self):
total = 0 total = 0
for clin in self.clins: for clin in self.clins:
if clin.obligated_amount is not None: if clin.total_amount is not None:
total += clin.obligated_amount total += clin.total_amount
return total return total
@property @property

View File

@ -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)
},
},
}

View File

@ -6,7 +6,10 @@ import { emitEvent } from '../lib/emitters'
import Modal from '../mixins/modal' import Modal from '../mixins/modal'
import optionsinput from './options_input' import optionsinput from './options_input'
import textinput from './text_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 START_DATE = 'start_date'
const END_DATE = 'end_date' const END_DATE = 'end_date'
const POP = 'period_of_performance' const POP = 'period_of_performance'
@ -19,12 +22,21 @@ export default {
DateSelector, DateSelector,
optionsinput, optionsinput,
textinput, textinput,
clindollaramount,
}, },
mixins: [Modal], mixins: [Modal],
props: { props: {
initialClinIndex: Number, initialClinIndex: Number,
initialTotal: {
type: Number,
default: 0,
},
initialObligated: {
type: Number,
default: 0,
},
initialStartDate: { initialStartDate: {
type: String, type: String,
default: null, default: null,
@ -54,6 +66,10 @@ export default {
const end = !!this.initialEndDate const end = !!this.initialEndDate
? new Date(this.initialEndDate) ? new Date(this.initialEndDate)
: undefined : undefined
const fundingValidation =
this.initialObligated && this.initialTotal
? this.initialObligated <= this.initialTotal
: true
const popValidation = !this.initialStartDate ? false : start < end const popValidation = !this.initialStartDate ? false : start < end
const clinNumber = !!this.initialClinNumber const clinNumber = !!this.initialClinNumber
? this.initialClinNumber ? this.initialClinNumber
@ -63,6 +79,7 @@ export default {
return { return {
clinIndex: this.initialClinIndex, clinIndex: this.initialClinIndex,
clinNumber: clinNumber,
startDate: start, startDate: start,
endDate: end, endDate: end,
popValid: popValidation, popValid: popValidation,
@ -93,14 +110,23 @@ export default {
)}.`, )}.`,
}, },
], ],
totalAmount: this.initialTotal || 0,
obligatedAmount: this.initialObligated || 0,
fundingValid: fundingValidation,
} }
}, },
mounted: function() { mounted: function() {
this.$root.$on('field-change', this.handleFieldChange) this.$root.$on('field-change', this.handleFieldChange)
this.validateFunding()
}, },
created: function() { created: function() {
emitEvent('clin-change', this, {
id: this._uid,
obligatedAmount: this.initialObligated,
totalAmount: this.initialTotal,
})
emitEvent('field-mount', this, { emitEvent('field-mount', this, {
optional: false, optional: false,
name: 'clins-' + this.clinIndex + '-' + POP, name: 'clins-' + this.clinIndex + '-' + POP,
@ -109,6 +135,14 @@ export default {
}, },
methods: { methods: {
clinChangeEvent: function() {
emitEvent('clin-change', this, {
id: this._uid,
obligatedAmount: this.initialObligated,
totalAmount: this.initialTotal,
})
},
checkPopValid: function() { checkPopValid: function() {
return ( return (
this.popDateOrder() && this.popDateOrder() &&
@ -153,9 +187,25 @@ export default {
return true return true
}, },
checkFundingValid: function() {
return this.obligatedAmount <= this.totalAmount
},
validateFunding: function() {
if (this.totalAmount && this.obligatedAmount) {
this.fundingValid = this.checkFundingValid()
}
},
handleFieldChange: function(event) { handleFieldChange: function(event) {
if (this._uid === event.parent_uid) { 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.value) this.startDate = new Date(event.value)
if (!!event.valid) this.startDateValid = event.valid if (!!event.valid) this.startDateValid = event.valid
this.validatePop() this.validatePop()
@ -186,6 +236,20 @@ export default {
return `CLIN` 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() { removeModalId: function() {
return `remove-clin-${this.clinIndex}` return `remove-clin-${this.clinIndex}`

View File

@ -1,174 +1,6 @@
import MaskedInput, { conformToMask } from 'vue-text-mask' import TextInputMixin from '../mixins/text_input_mixin'
import inputValidations from '../lib/input_validations'
import { formatDollars } from '../lib/dollars'
import { emitEvent } from '../lib/emitters'
export default { export default {
name: 'textinput', name: 'textinput',
mixins: [TextInputMixin],
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
},
},
} }

View File

@ -34,6 +34,13 @@ export default {
unmask: ['$', ','], unmask: ['$', ','],
validationError: 'Please enter a dollar amount', 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: { email: {
mask: emailMask, mask: emailMask,
match: /^.+@[^.].*\.[a-zA-Z]{2,10}$/, match: /^.+@[^.].*\.[a-zA-Z]{2,10}$/,

View File

@ -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
},
},
}

View File

@ -0,0 +1,70 @@
{% from 'components/icon.html' import Icon %}
{% macro CLINDollarAmount(type, field=None, funding_validation=False) -%}
<div class="form-row">
<div class="form-col">
<clindollaramount
v-cloak
inline-template
{% if funding_validation %}
:funding-valid='fundingValid'
{% else %}
:funding-valid='true'
{% endif %}
{% if field %}
name='{{ field.name }}'
{% if field.data is not none %}initial-value='{{ field.data }}'{% endif %}
{% if field.errors %}v-bind:initial-errors='{{ field.errors | list }}'{% endif %}
key='{{ field.name }}'
{% else %}
:name="'clins-' + clinIndex + '-' + '{{ type }}' + '_amount'"
:key="'clins-' + clinIndex + '-' + '{{ type }}' + '_amount'"
{% endif %}
validation="clinDollars"
:watch='true'>
<div v-bind:class="['usa-input usa-input--validation--dollars', { 'usa-input--error': showFundingError, 'usa-input--success': showFundingValid}]">
{% if field %}
<label for='{{ field.name }}'>
{% else %}
<label :for='name'>
{% endif %}
{% if type=="obligated" %}
<div class="usa-input__title">{{ 'task_orders.form.obligated_funds_label' | translate }}</div>
{% else %}
<div class="usa-input__title">{{ 'task_orders.form.total_funds_label' | translate }}</div>
{% endif %}
<span v-show='showFundingError'>{{ Icon('alert',classes="icon-validation") }}</span>
<span v-show='showFundingValid'>{{ Icon('ok',classes="icon-validation") }}</span>
</label>
<masked-input
v-on:input='onInput'
v-on:blur='onBlur'
v-on:change='onChange'
v-bind:value='value'
v-bind:mask='mask'
v-bind:pipe='pipe'
v-bind:keep-char-positions='keepCharPositions'
v-bind:aria-invalid='showError'
type='text'
:id='name'
ref='input'>
</masked-input>
<input type='hidden' v-bind:value='rawValue' :name='name' />
<template v-if='!fundingValid'>
<span class='usa-input__message'>{{ "forms.task_order.clin_funding_errors.obligated_amount_error" | translate }}</span>
</template>
<template v-else-if='showError'>
<span class='usa-input__message' v-html='validationError'></span>
</template>
<template v-else>
<span class='usa-input__message'></span>
</template>
</div>
</clindollaramount>
</div>
</div>
{%- endmacro %}

View File

@ -43,7 +43,7 @@
<th>{{ "task_orders.review.clins.type" | translate }}</th> <th>{{ "task_orders.review.clins.type" | translate }}</th>
<th>{{ "task_orders.review.clins.idiq_clin_description" | translate }}</th> <th>{{ "task_orders.review.clins.idiq_clin_description" | translate }}</th>
<th>{{ "task_orders.review.clins.pop" | translate }}</th> <th>{{ "task_orders.review.clins.pop" | translate }}</th>
<th class="task-order__amount">{{ "task_orders.review.clins.amount" | translate }}</th> <th class="task-order__amount">{{ "task_orders.review.clins.total_amount" | translate }}</th>
<th class="task-order__amount">{{ "task_orders.review.clins.obligated" | translate }}</th> <th class="task-order__amount">{{ "task_orders.review.clins.obligated" | translate }}</th>
</tr> </tr>
</thead> </thead>
@ -58,7 +58,7 @@
{{ clin.start_date | formattedDate }} - {{ clin.end_date | formattedDate }} {{ clin.start_date | formattedDate }} - {{ clin.end_date | formattedDate }}
</td> </td>
{# TODO: Swap in total CLIN amount #} {# TODO: Swap in total CLIN amount #}
<td class="task-order__amount">$123,456,789.00</td> <td class="task-order__amount">{{ clin.total_amount | dollars }}</td>
<td class="task-order__amount">{{ clin.obligated_amount | dollars }}</td> <td class="task-order__amount">{{ clin.obligated_amount | dollars }}</td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@ -81,6 +81,9 @@
<div class="card__header"> <div class="card__header">
<h3>Task Order #{{ task_order.number }}</h3> <h3>Task Order #{{ task_order.number }}</h3>
</div> </div>
<div class="card__body">
<b>Total amount: </b>{{ task_order.total_contract_amount | dollars }}
</div>
<div class="card__body"> <div class="card__body">
<b>Obligated amount: </b>{{ task_order.total_obligated_funds | dollars }} <b>Obligated amount: </b>{{ task_order.total_obligated_funds | dollars }}
</div> </div>

View File

@ -5,6 +5,7 @@
{% from 'components/icon.html' import Icon %} {% from 'components/icon.html' import Icon %}
{% from 'components/options_input.html' import OptionsInput %} {% from 'components/options_input.html' import OptionsInput %}
{% from 'components/text_input.html' import TextInput %} {% from 'components/text_input.html' import TextInput %}
{% from "components/clin_dollar_amount.html" import CLINDollarAmount %}
{% from 'task_orders/form_header.html' import TOFormStepHeader %} {% from 'task_orders/form_header.html' import TOFormStepHeader %}
{% set action = url_for("task_orders.submit_form_step_three_add_clins", task_order_id=task_order_id) %} {% set action = url_for("task_orders.submit_form_step_three_add_clins", task_order_id=task_order_id) %}
@ -16,6 +17,8 @@
<clin-fields <clin-fields
{% if fields %} {% if fields %}
v-bind:initial-clin-index='{{ index }}' v-bind:initial-clin-index='{{ index }}'
v-bind:initial-total='{{ fields.total_amount.data or 0 }}'
v-bind:initial-obligated='{{ fields.obligated_amount.data or 0 }}'
v-bind:initial-start-date="'{{ fields.start_date.data | string }}'" v-bind:initial-start-date="'{{ fields.start_date.data | string }}'"
v-bind:initial-end-date="'{{ fields.end_date.data | string }}'" v-bind:initial-end-date="'{{ fields.end_date.data | string }}'"
v-bind:initial-clin-number="'{{ fields.number.data | string }}'" v-bind:initial-clin-number="'{{ fields.number.data | string }}'"
@ -115,56 +118,18 @@
{{ 'task_orders.form.clin_funding' | translate }} {{ 'task_orders.form.clin_funding' | translate }}
</div> </div>
</div> </div>
{% if fields %} {% if fields %}
<div class="form-row"> {{ CLINDollarAmount("total", field=fields.total_amount) }}
<div class="form-col"> {{ CLINDollarAmount("obligated", field=fields.obligated_amount, funding_validation=True) }}
{{ TextInput(fields.obligated_amount, validation='dollars', watch=True, optional=False) }}
</div>
</div>
{% else %} {% else %}
<div class="form-row"> {{ CLINDollarAmount("total") }}
<div class="form-col"> {{ CLINDollarAmount("obligated", funding_validation=True) }}
<textinput
v-cloak
inline-template
:name="'clins-' + clinIndex + '-obligated_amount'"
validation="dollars"
:watch='true'>
<div v-bind:class="['usa-input usa-input--validation--' + validation, { 'usa-input--error': showError, 'usa-input--success': showValid, 'usa-input--validation--paragraph': paragraph, 'no-max-width': noMaxWidth }]">
<label :for="name">
<div class="usa-input__title">{{ 'task_orders.form.obligated_funds_label' | translate }}</div>
<span v-show='showError'>{{ Icon('alert',classes="icon-validation") }}</span>
<span v-show='showValid'>{{ Icon('ok',classes="icon-validation") }}</span>
</label>
<masked-input
v-on:input='onInput'
v-on:blur='onBlur'
v-on:change='onChange'
v-bind:value='value'
v-bind:mask='mask'
v-bind:pipe='pipe'
v-bind:keep-char-positions='keepCharPositions'
v-bind:aria-invalid='showError'
v-bind:show-mask='false'
type='text'
:id='name'
ref='input'>
</masked-input>
<input type='hidden' v-bind:value='rawValue' :name='name' />
<template v-if='showError'>
<span class='usa-input__message' v-html='validationError'></span>
</template>
<template v-else>
<span class='usa-input__message'></span>
</template>
</div>
</textinput>
</div>
</div>
{% endif %} {% endif %}
<div class="h5 clin-card__title">Percent Obligated</div>
<p v-html='percentObligated'></p>
<hr> <hr>
<div class="form-row"> <div class="form-row">
<div class="h4 clin-card__title"> <div class="h4 clin-card__title">

View File

@ -84,6 +84,7 @@ def test_create_adds_clins():
"start_date": date(2020, 1, 1), "start_date": date(2020, 1, 1),
"end_date": date(2021, 1, 1), "end_date": date(2021, 1, 1),
"obligated_amount": Decimal("5000"), "obligated_amount": Decimal("5000"),
"total_amount": Decimal("10000"),
}, },
{ {
"jedi_clin_type": "JEDI_CLIN_1", "jedi_clin_type": "JEDI_CLIN_1",
@ -91,6 +92,7 @@ def test_create_adds_clins():
"start_date": date(2020, 1, 1), "start_date": date(2020, 1, 1),
"end_date": date(2021, 1, 1), "end_date": date(2021, 1, 1),
"obligated_amount": Decimal("5000"), "obligated_amount": Decimal("5000"),
"total_amount": Decimal("10000"),
}, },
] ]
task_order = TaskOrders.create( task_order = TaskOrders.create(
@ -113,6 +115,7 @@ def test_update_adds_clins():
"start_date": date(2020, 1, 1), "start_date": date(2020, 1, 1),
"end_date": date(2021, 1, 1), "end_date": date(2021, 1, 1),
"obligated_amount": Decimal("5000"), "obligated_amount": Decimal("5000"),
"total_amount": Decimal("10000"),
}, },
{ {
"jedi_clin_type": "JEDI_CLIN_1", "jedi_clin_type": "JEDI_CLIN_1",
@ -120,6 +123,7 @@ def test_update_adds_clins():
"start_date": date(2020, 1, 1), "start_date": date(2020, 1, 1),
"end_date": date(2021, 1, 1), "end_date": date(2021, 1, 1),
"obligated_amount": Decimal("5000"), "obligated_amount": Decimal("5000"),
"total_amount": Decimal("10000"),
}, },
] ]
task_order = TaskOrders.create( task_order = TaskOrders.create(
@ -144,6 +148,7 @@ def test_update_does_not_duplicate_clins():
"start_date": date(2020, 1, 1), "start_date": date(2020, 1, 1),
"end_date": date(2021, 1, 1), "end_date": date(2021, 1, 1),
"obligated_amount": Decimal("5000"), "obligated_amount": Decimal("5000"),
"total_amount": Decimal("10000"),
}, },
{ {
"jedi_clin_type": "JEDI_CLIN_1", "jedi_clin_type": "JEDI_CLIN_1",
@ -151,6 +156,7 @@ def test_update_does_not_duplicate_clins():
"start_date": date(2020, 1, 1), "start_date": date(2020, 1, 1),
"end_date": date(2021, 1, 1), "end_date": date(2021, 1, 1),
"obligated_amount": Decimal("5000"), "obligated_amount": Decimal("5000"),
"total_amount": Decimal("10000"),
}, },
] ]
task_order = TaskOrders.update( task_order = TaskOrders.update(

View File

@ -291,7 +291,8 @@ class CLINFactory(Base):
number = factory.LazyFunction(random_task_order_number) number = factory.LazyFunction(random_task_order_number)
start_date = datetime.date.today() start_date = datetime.date.today()
end_date = factory.LazyFunction(random_future_date) end_date = factory.LazyFunction(random_future_date)
obligated_amount = factory.LazyFunction(lambda *args: random.randint(100, 999999)) total_amount = factory.LazyFunction(lambda *args: random.randint(50000, 999999))
obligated_amount = factory.LazyFunction(lambda *args: random.randint(100, 50000))
jedi_clin_type = factory.LazyFunction( jedi_clin_type = factory.LazyFunction(
lambda *args: random.choice(list(clin.JEDICLINType)) lambda *args: random.choice(list(clin.JEDICLINType))
) )

View File

@ -72,3 +72,34 @@ def test_clin_form_pop_dates_within_contract_dates():
) )
valid_clin_form = CLINForm(obj=valid_clin) valid_clin_form = CLINForm(obj=valid_clin)
assert valid_clin_form.validate() assert valid_clin_form.validate()
def test_clin_form_obligated_greater_than_total():
invalid_clin = factories.CLINFactory.create(
total_amount=0,
obligated_amount=1,
start_date=datetime.date(2019, 9, 15),
end_date=datetime.date(2020, 9, 14),
)
invalid_clin_form = CLINForm(obj=invalid_clin)
assert not invalid_clin_form.validate()
assert (
translate("forms.task_order.clin_funding_errors.obligated_amount_error")
) in invalid_clin_form.obligated_amount.errors
def test_clin_form_dollar_amounts_out_of_range():
invalid_clin = factories.CLINFactory.create(
total_amount=-1,
obligated_amount=1000000001,
start_date=datetime.date(2019, 9, 15),
end_date=datetime.date(2020, 9, 14),
)
invalid_clin_form = CLINForm(obj=invalid_clin)
assert not invalid_clin_form.validate()
assert (
translate("forms.task_order.clin_funding_errors.funding_range_error")
) in invalid_clin_form.total_amount.errors
assert (
translate("forms.task_order.clin_funding_errors.funding_range_error")
) in invalid_clin_form.obligated_amount.errors

View File

@ -152,19 +152,23 @@ class TestBudget:
assert ( assert (
to.total_contract_amount to.total_contract_amount
== clin1.obligated_amount + clin2.obligated_amount + clin3.obligated_amount == clin1.total_amount + clin2.total_amount + clin3.total_amount
) )
def test_total_obligated_funds(self): def test_total_obligated_funds(self):
to = TaskOrder() to = TaskOrder()
clin4 = CLINFactory(task_order=to, jedi_clin_type=JEDICLINType.JEDI_CLIN_4)
assert to.total_obligated_funds == 0 assert to.total_obligated_funds == 0
clin1 = CLINFactory(task_order=to, jedi_clin_type=JEDICLINType.JEDI_CLIN_1) clin1 = CLINFactory(task_order=to, jedi_clin_type=JEDICLINType.JEDI_CLIN_1)
clin2 = CLINFactory(task_order=to, jedi_clin_type=JEDICLINType.JEDI_CLIN_2) clin2 = CLINFactory(task_order=to, jedi_clin_type=JEDICLINType.JEDI_CLIN_2)
clin3 = CLINFactory(task_order=to, jedi_clin_type=JEDICLINType.JEDI_CLIN_3) clin3 = CLINFactory(task_order=to, jedi_clin_type=JEDICLINType.JEDI_CLIN_3)
clin4 = CLINFactory(task_order=to, jedi_clin_type=JEDICLINType.JEDI_CLIN_4)
assert ( assert (
to.total_obligated_funds == clin1.obligated_amount + clin3.obligated_amount to.total_obligated_funds
== clin1.obligated_amount
+ clin2.obligated_amount
+ clin3.obligated_amount
+ clin4.obligated_amount
) )

View File

@ -194,11 +194,13 @@ def test_task_orders_submit_form_step_three_add_clins(client, user_session, task
"clins-0-start_date": "01/01/2020", "clins-0-start_date": "01/01/2020",
"clins-0-end_date": "01/01/2021", "clins-0-end_date": "01/01/2021",
"clins-0-obligated_amount": "5000", "clins-0-obligated_amount": "5000",
"clins-0-total_amount": "10000",
"clins-1-jedi_clin_type": "JEDI_CLIN_1", "clins-1-jedi_clin_type": "JEDI_CLIN_1",
"clins-1-number": "12312", "clins-1-number": "12312",
"clins-1-start_date": "01/01/2020", "clins-1-start_date": "01/01/2020",
"clins-1-end_date": "01/01/2021", "clins-1-end_date": "01/01/2021",
"clins-1-obligated_amount": "5000", "clins-1-obligated_amount": "5000",
"clins-1-total_amount": "5000",
} }
response = client.post( response = client.post(
url_for( url_for(
@ -221,6 +223,7 @@ def test_task_orders_submit_form_step_three_add_clins_existing_to(
"start_date": "01/01/2020", "start_date": "01/01/2020",
"end_date": "01/01/2021", "end_date": "01/01/2021",
"obligated_amount": "5000", "obligated_amount": "5000",
"total_amount": "10000",
}, },
{ {
"jedi_clin_type": "JEDI_CLIN_1", "jedi_clin_type": "JEDI_CLIN_1",
@ -228,6 +231,7 @@ def test_task_orders_submit_form_step_three_add_clins_existing_to(
"start_date": "01/01/2020", "start_date": "01/01/2020",
"end_date": "01/01/2021", "end_date": "01/01/2021",
"obligated_amount": "5000", "obligated_amount": "5000",
"total_amount": "10000",
}, },
] ]
TaskOrders.create_clins(task_order.id, clin_list) TaskOrders.create_clins(task_order.id, clin_list)
@ -240,6 +244,7 @@ def test_task_orders_submit_form_step_three_add_clins_existing_to(
"clins-0-start_date": "01/01/2020", "clins-0-start_date": "01/01/2020",
"clins-0-end_date": "01/01/2021", "clins-0-end_date": "01/01/2021",
"clins-0-obligated_amount": "5000", "clins-0-obligated_amount": "5000",
"clins-0-total_amount": "10000",
} }
response = client.post( response = client.post(
url_for( url_for(

View File

@ -530,6 +530,7 @@ def test_task_orders_new_post_routes(post_url_assert_status):
"clins-0-start_date": "01/01/2020", "clins-0-start_date": "01/01/2020",
"clins-0-end_date": "01/01/2021", "clins-0-end_date": "01/01/2021",
"clins-0-obligated_amount": "5000", "clins-0-obligated_amount": "5000",
"clins-0-total_amount": "10000",
}, },
), ),
] ]

View File

@ -205,6 +205,9 @@ forms:
start: PoP start date must be on or after {date}. start: PoP start date must be on or after {date}.
scope_description: 'What do you plan to do on the cloud? Some examples might include migrating an existing application or creating a prototype. You dont need to include a detailed plan of execution, but should list key requirements. This section will be reviewed by your contracting officer, but wont be sent to the CCPO. <p>Not sure how to describe your scope? <a href="#">Read some examples</a> to get some inspiration.</p>' scope_description: 'What do you plan to do on the cloud? Some examples might include migrating an existing application or creating a prototype. You dont need to include a detailed plan of execution, but should list key requirements. This section will be reviewed by your contracting officer, but wont be sent to the CCPO. <p>Not sure how to describe your scope? <a href="#">Read some examples</a> to get some inspiration.</p>'
scope_label: Cloud project scope 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: team_experience:
built_1: Built, migrated, or consulted on 1-2 applications built_1: Built, migrated, or consulted on 1-2 applications
built_3: Built, migrated, or consulted on 3-5 applications built_3: Built, migrated, or consulted on 3-5 applications
@ -395,7 +398,7 @@ task_orders:
type: CLIN Type type: CLIN Type
idiq_clin_description: Description (IDIQ CLIN) idiq_clin_description: Description (IDIQ CLIN)
pop: PoP pop: PoP
amount: CLIN Value total_amount: CLIN Value
obligated: Amount Obligated obligated: Amount Obligated
form: form:
add_clin: Add another CLIN 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_description: "Refer to your task order to locate your Contract Line Item Numbers (CLINs)."
clin_details: CLIN Details clin_details: CLIN Details
clin_funding: CLIN Funding clin_funding: CLIN Funding
clin_number_label: CLIN Number clin_number_label: CLIN
clin_type_label: Corresponding IDIQ CLIN clin_type_label: Corresponding IDIQ CLIN
clin_remove_text: 'Do you want to remove ' clin_remove_text: 'Do you want to remove '
clin_remove_confirm: Yes, remove CLIN 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. cloud_funding_text: Data must match with what is in your uploaded document.
draft_alert_title: Your information has been saved 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, youll be ready to submit this request. draft_alert_message: You can return to the Task Order Builder to enter missing information. Once you are finished, youll be ready to submit this request.
total_funds_label: Total CLIN Value
obligated_funds_label: Obligated Funds obligated_funds_label: Obligated Funds
pop: Period of Performance pop: Period of Performance
pop_end: End Date pop_end: End Date