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"],
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"],
)

View File

@ -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):

View File

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

View File

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

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 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}`

View File

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

View File

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

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.idiq_clin_description" | 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>
</tr>
</thead>
@ -58,7 +58,7 @@
{{ clin.start_date | formattedDate }} - {{ clin.end_date | formattedDate }}
</td>
{# 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>
</tr>
{% endfor %}

View File

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

View File

@ -5,6 +5,7 @@
{% from 'components/icon.html' import Icon %}
{% from 'components/options_input.html' import OptionsInput %}
{% from 'components/text_input.html' import TextInput %}
{% from "components/clin_dollar_amount.html" import CLINDollarAmount %}
{% 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) %}
@ -16,6 +17,8 @@
<clin-fields
{% if fields %}
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-end-date="'{{ fields.end_date.data | string }}'"
v-bind:initial-clin-number="'{{ fields.number.data | string }}'"
@ -115,56 +118,18 @@
{{ 'task_orders.form.clin_funding' | translate }}
</div>
</div>
{% if fields %}
<div class="form-row">
<div class="form-col">
{{ TextInput(fields.obligated_amount, validation='dollars', watch=True, optional=False) }}
</div>
</div>
{{ CLINDollarAmount("total", field=fields.total_amount) }}
{{ CLINDollarAmount("obligated", field=fields.obligated_amount, funding_validation=True) }}
{% else %}
<div class="form-row">
<div class="form-col">
<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>
{{ CLINDollarAmount("total") }}
{{ CLINDollarAmount("obligated", funding_validation=True) }}
{% endif %}
<div class="h5 clin-card__title">Percent Obligated</div>
<p v-html='percentObligated'></p>
<hr>
<div class="form-row">
<div class="h4 clin-card__title">

View File

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

View File

@ -291,7 +291,8 @@ class CLINFactory(Base):
number = factory.LazyFunction(random_task_order_number)
start_date = datetime.date.today()
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(
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)
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 (
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):
to = TaskOrder()
clin4 = CLINFactory(task_order=to, jedi_clin_type=JEDICLINType.JEDI_CLIN_4)
assert to.total_obligated_funds == 0
clin1 = CLINFactory(task_order=to, jedi_clin_type=JEDICLINType.JEDI_CLIN_1)
clin2 = CLINFactory(task_order=to, jedi_clin_type=JEDICLINType.JEDI_CLIN_2)
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 (
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-end_date": "01/01/2021",
"clins-0-obligated_amount": "5000",
"clins-0-total_amount": "10000",
"clins-1-jedi_clin_type": "JEDI_CLIN_1",
"clins-1-number": "12312",
"clins-1-start_date": "01/01/2020",
"clins-1-end_date": "01/01/2021",
"clins-1-obligated_amount": "5000",
"clins-1-total_amount": "5000",
}
response = client.post(
url_for(
@ -221,6 +223,7 @@ def test_task_orders_submit_form_step_three_add_clins_existing_to(
"start_date": "01/01/2020",
"end_date": "01/01/2021",
"obligated_amount": "5000",
"total_amount": "10000",
},
{
"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",
"end_date": "01/01/2021",
"obligated_amount": "5000",
"total_amount": "10000",
},
]
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-end_date": "01/01/2021",
"clins-0-obligated_amount": "5000",
"clins-0-total_amount": "10000",
}
response = client.post(
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-end_date": "01/01/2021",
"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}.
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
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, youll 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