selfservice quotations, #33852
authorMark Wells <mark@freeside.biz>
Sat, 25 Apr 2015 22:02:15 +0000 (15:02 -0700)
committerMark Wells <mark@freeside.biz>
Sat, 25 Apr 2015 22:02:23 +0000 (15:02 -0700)
12 files changed:
FS/FS/ClientAPI/MasonComponent.pm
FS/FS/ClientAPI/MyAccount.pm
FS/FS/ClientAPI/MyAccount/quotation.pm [new file with mode: 0644]
FS/FS/ClientAPI_XMLRPC.pm
FS/FS/quotation.pm
fs_selfservice/FS-SelfService/SelfService.pm
ng_selfservice/images/cross.png [new file with mode: 0644]
ng_selfservice/quotation.php [new file with mode: 0644]
ng_selfservice/quotation_add_pkg.php [new file with mode: 0644]
ng_selfservice/quotation_order.php [new file with mode: 0644]
ng_selfservice/quotation_print.php [new file with mode: 0644]
ng_selfservice/quotation_remove_pkg.php [new file with mode: 0644]

index 695b4ca..b6f8aa4 100644 (file)
@@ -27,6 +27,7 @@ my %allowed_comps = map { $_=>1 } qw(
 my %session_comps = map { $_=>1 } qw(
   /elements/location.html
   /elements/tr-amount_fee.html
 my %session_comps = map { $_=>1 } qw(
   /elements/location.html
   /elements/tr-amount_fee.html
+  /elements/select-part_pkg.html
   /edit/cust_main/first_pkg/select-part_pkg.html
 );
 
   /edit/cust_main/first_pkg/select-part_pkg.html
 );
 
@@ -106,6 +107,26 @@ my %session_callbacks = (
 
   },
 
 
   },
 
+  '/elements/select-part_pkg.html' => sub {
+    my( $custnum, $argsref ) = @_;
+    my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
+      or return "unknown custnum $custnum";
+
+    my $pkgpart = $cust_main->agent->pkgpart_hashref;
+
+    #false laziness w/ edit/cust_main/first_pkg.html
+    my @first_svc = ( 'svc_acct', 'svc_phone' );
+
+    my @part_pkg =
+      grep { $pkgpart->{ $_->pkgpart } 
+                  || ( $_->agentnum && $_->agentnum == $cust_main->agentnum )
+           }
+      qsearch( 'part_pkg', { 'disabled' => '' }, '', 'ORDER BY pkg' ); # case?
+
+    push @$argsref, 'part_pkg' =>  \@part_pkg;
+    '';
+  },
+
 );
 
 my $outbuf;
 );
 
 my $outbuf;
index 93f817d..e2f8595 100644 (file)
@@ -49,6 +49,8 @@ use FS::contact;
 use FS::cust_contact;
 use FS::cust_location;
 
 use FS::cust_contact;
 use FS::cust_location;
 
+use FS::ClientAPI::MyAccount::quotation; # just for code organization
+
 $DEBUG = 0;
 $me = '[FS::ClientAPI::MyAccount]';
 
 $DEBUG = 0;
 $me = '[FS::ClientAPI::MyAccount]';
 
diff --git a/FS/FS/ClientAPI/MyAccount/quotation.pm b/FS/FS/ClientAPI/MyAccount/quotation.pm
new file mode 100644 (file)
index 0000000..ce2debd
--- /dev/null
@@ -0,0 +1,218 @@
+package FS::ClientAPI::MyAccount::quotation;
+
+use strict;
+use FS::Record qw(qsearch qsearchs);
+use FS::quotation;
+use FS::quotation_pkg;
+
+our $DEBUG = 1;
+
+sub _custoragent_session_custnum {
+  FS::ClientAPI::MyAccount::_custoragent_session_custnum(@_);
+}
+
+sub _quotation {
+  # the currently active quotation
+  my $session = shift;
+  my $quotation;
+  if ( my $quotationnum = $session->{'quotationnum'} ) {
+    $quotation = FS::quotation->by_key($quotationnum);
+  } 
+  if ( !$quotation ) {
+    # find the last quotation created through selfservice
+    $quotation = qsearchs( 'quotation', {
+        'custnum'   => $session->{'custnum'},
+        'usernum'   => $FS::CurrentUser::CurrentUser->usernum,
+        'disabled'  => '',
+    }); 
+    warn "found selfservice quotation #". $quotation->quotationnum."\n"
+      if $quotation and $DEBUG;
+  } 
+  if ( !$quotation ) {
+    $quotation = FS::quotation->new({
+        'custnum'   => $session->{'custnum'},
+        'usernum'   => $FS::CurrentUser::CurrentUser->usernum,
+        '_date'     => time,
+    }); 
+    $quotation->insert; # what to do on error? call the police?
+    warn "started new selfservice quotation #". $quotation->quotationnum."\n"
+      if $quotation and $DEBUG;
+  } 
+  $session->{'quotationnum'} = $quotation->quotationnum;
+  return $quotation;
+}
+
+=item quotation_info { session }
+
+Returns a hashref describing the current quotation, containing:
+
+- "sections", an arrayref containing one section for each billing frequency.
+  Each one will have:
+  - "description"
+  - "subtotal"
+  - "detail_items", an arrayref of detail items, each with:
+    - "pkgnum", the reference number (actually the quotationpkgnum field)
+    - "description", the package name (or tax name)
+    - "quantity"
+    - "amount"
+
+=cut
+
+sub quotation_info {
+  my $p = shift;
+
+  my($context, $session, $custnum) = _custoragent_session_custnum($p);
+  return { 'error' => $session } if $context eq 'error';
+
+  my $quotation = _quotation($session);
+  return { 'error' => "No current quotation for this customer" } if !$quotation;
+  warn "quotation_info #".$quotation->quotationnum
+    if $DEBUG;
+
+  # code reuse ftw
+  my $null_escape = sub { @_ };
+  my ($sections) = $quotation->_items_sections(escape => $null_escape);
+  foreach my $section (@$sections) {
+    $section->{'detail_items'} =
+      [ $quotation->_items_pkg('section' => $section, escape_function => $null_escape) ]; 
+  }
+  return { 'error' => '', 'sections' => $sections }
+}
+
+=item quotation_print { session, 'format' }
+
+Renders the quotation. 'format' can be either 'html' or 'pdf'; the resulting
+hashref will contain 'document' => the HTML or PDF contents.
+
+=cut
+
+sub quotation_print {
+  my $p = shift;
+
+  my($context, $session, $custnum) = _custoragent_session_custnum($p);
+  return { 'error' => $session } if $context eq 'error';
+
+  my $quotation = _quotation($session);
+  return { 'error' => "No current quotation for this customer" } if !$quotation;
+  warn "quotation_print #".$quotation->quotationnum
+    if $DEBUG;
+
+  my $format = $p->{'format'}
+   or return { 'error' => "No rendering format specified" };
+
+  my $document;
+  if ($format eq 'html') {
+    $document = $quotation->print_html;
+  } elsif ($format eq 'pdf') {
+    $document = $quotation->print_pdf;
+  }
+  warn "$format, ".length($document)." bytes\n"
+    if $DEBUG;
+  return { 'error' => '', 'document' => $document };
+}
+
+=item quotation_add_pkg { session, 'pkgpart', 'quantity', [ location opts ] }
+
+Adds a package to the user's current quotation. Session info and 'pkgpart' are
+required. 'quantity' defaults to 1.
+
+Location can be specified as 'locationnum' to use an existing location, or
+'address1', 'address2', 'city', 'state', 'zip', 'country' to create a new one,
+or it will default to the customer's service location.
+
+=cut
+
+sub quotation_add_pkg {
+  my $p = shift;
+
+  my($context, $session, $custnum) = _custoragent_session_custnum($p);
+  return { 'error' => $session } if $context eq 'error';
+  
+  my $quotation = _quotation($session);
+  my $cust_main = $quotation->cust_main;
+
+  my $pkgpart = $p->{'pkgpart'};
+  my $allowed_pkgpart = $cust_main->agent->pkgpart_hashref;
+
+  my $part_pkg = FS::part_pkg->by_key($pkgpart);
+
+  if (!$part_pkg or !$allowed_pkgpart->{$pkgpart}) {
+    warn "disallowed quotation_pkg pkgpart $pkgpart\n"
+      if $DEBUG;
+    return { 'error' => "unknown package $pkgpart" };
+  }
+
+  warn "creating quotation_pkg with pkgpart $pkgpart\n"
+    if $DEBUG;
+  my $quotation_pkg = FS::quotation_pkg->new({
+    'quotationnum'  => $quotation->quotationnum,
+    'pkgpart'       => $p->{'pkgpart'},
+    'quantity'      => $p->{'quantity'} || 1,
+  });
+  if ( $p->{locationnum} > 0 ) {
+    $quotation_pkg->set('locationnum', $p->{locationnum});
+  } elsif ( $p->{address1} ) {
+    my $location = FS::cust_location->find_or_insert(
+      'custnum' => $cust_main->custnum,
+      map { $_ => $p->{$_} }
+        qw( address1 address2 city county state zip country )
+    );
+    $quotation_pkg->set('locationnum', $location->locationnum);
+  }
+
+  my $error = $quotation_pkg->insert
+           || $quotation->estimate;
+
+  { 'error'         => $error,
+    'quotationnum'  => $quotation->quotationnum };
+}
+=item quotation_remove_pkg { session, 'pkgnum' }
+
+Removes the package from the user's current quotation. 'pkgnum' is required.
+
+=cut
+
+sub quotation_remove_pkg {
+  my $p = shift;
+
+  my($context, $session, $custnum) = _custoragent_session_custnum($p);
+  return { 'error' => $session } if $context eq 'error';
+  
+  my $quotation = _quotation($session);
+  my $quotationpkgnum = $p->{pkgnum};
+  my $quotation_pkg = FS::quotation_pkg->by_key($quotationpkgnum);
+  if (!$quotation_pkg
+      or $quotation_pkg->quotationnum != $quotation->quotationnum) {
+    return { 'error' => "unknown quotation item $quotationpkgnum" };
+  }
+  warn "removing quotation_pkg with pkgpart ".$quotation_pkg->pkgpart."\n"
+    if $DEBUG;
+
+  my $error = $quotation_pkg->delete
+           || $quotation->estimate;
+
+  { 'error'         => $error,
+    'quotationnum'  => $quotation->quotationnum };
+}
+
+=item quotation_order
+
+Convert the current quotation to a package order.
+
+=cut
+
+sub quotation_order {
+  my $p = shift;
+
+  my($context, $session, $custnum) = _custoragent_session_custnum($p);
+  return { 'error' => $session } if $context eq 'error';
+  
+  my $quotation = _quotation($session);
+
+  my $error = $quotation->order;
+
+  return { 'error' => $error };
+}
+
+1;
index 952b199..5f1b38c 100644 (file)
@@ -52,6 +52,7 @@ our %typefix = (
   'login_info'         => \%typefix_skin_info,
   'invoice_logo'       => { 'logo' => 'base64', },
   'login_banner_image' => { 'image' => 'base64', },
   'login_info'         => \%typefix_skin_info,
   'invoice_logo'       => { 'logo' => 'base64', },
   'login_banner_image' => { 'image' => 'base64', },
+  'quotation_print'    => { 'document' => 'base64' },
 );
 
 sub AUTOLOAD {
 );
 
 sub AUTOLOAD {
@@ -186,6 +187,12 @@ sub ss2clientapi {
   'call_time'                 => 'PrepaidPhone/call_time',
   'call_time_nanpa'           => 'PrepaidPhone/call_time_nanpa',
   'phonenum_balance'          => 'PrepaidPhone/phonenum_balance',
   'call_time'                 => 'PrepaidPhone/call_time',
   'call_time_nanpa'           => 'PrepaidPhone/call_time_nanpa',
   'phonenum_balance'          => 'PrepaidPhone/phonenum_balance',
+
+  'quotation_info'            => 'MyAccount/quotation/quotation_info',
+  'quotation_print'           => 'MyAccount/quotation/quotation_print',
+  'quotation_add_pkg'         => 'MyAccount/quotation/quotation_add_pkg',
+  'quotation_remove_pkg'      => 'MyAccount/quotation/quotation_remove_pkg',
+  'quotation_order'           => 'MyAccount/quotation/quotation_order',
   };
 }
 
   };
 }
 
index f2a9620..45f3522 100644 (file)
@@ -695,22 +695,24 @@ sub estimate {
       # discounts
       if ( $cust_bill_pkg->get('discounts') ) {
         my $discount = $cust_bill_pkg->get('discounts')->[0];
       # discounts
       if ( $cust_bill_pkg->get('discounts') ) {
         my $discount = $cust_bill_pkg->get('discounts')->[0];
-        # discount records are generated as (setup, recur).
-        # well, not always, sometimes it's just (recur), but fixing this
-        # is horribly invasive.
-        my $qpd = $quotation_pkg_discount{$quotationpkgnum}
-              ||= qsearchs('quotation_pkg_discount', {
-                  'quotationpkgnum' => $quotationpkgnum
-                  });
-
-        if (!$qpd) { #can't happen
-          warn "$me simulated bill returned a discount but no discount is in effect.\n";
-        }
-        if ($discount and $qpd) {
-          if ( $i == 0 ) {
-            $qpd->set('setup_amount', $discount->amount);
-          } else {
-            $qpd->set('recur_amount', $discount->amount);
+        if ( $discount ) {
+          # discount records are generated as (setup, recur).
+          # well, not always, sometimes it's just (recur), but fixing this
+          # is horribly invasive.
+          my $qpd = $quotation_pkg_discount{$quotationpkgnum}
+                ||= qsearchs('quotation_pkg_discount', {
+                    'quotationpkgnum' => $quotationpkgnum
+                    });
+
+          if (!$qpd) { #can't happen
+            warn "$me simulated bill returned a discount but no discount is in effect.\n";
+          }
+          if ($discount and $qpd) {
+            if ( $i == 0 ) {
+              $qpd->set('setup_amount', $discount->amount);
+            } else {
+              $qpd->set('recur_amount', $discount->amount);
+            }
           }
         }
       } # end of discount stuff
           }
         }
       } # end of discount stuff
index 9d7e7ed..a9da564 100644 (file)
@@ -115,6 +115,13 @@ $socket .= '.'.$tag if defined $tag && length($tag);
 
   'start_thirdparty'          => 'MyAccount/start_thirdparty',
   'finish_thirdparty'         => 'MyAccount/finish_thirdparty',
 
   'start_thirdparty'          => 'MyAccount/start_thirdparty',
   'finish_thirdparty'         => 'MyAccount/finish_thirdparty',
+
+  'quotation_info'            => 'MyAccount/quotation/quotation_info',
+  'quotation_print'           => 'MyAccount/quotation/quotation_print',
+  'quotation_add_pkg'         => 'MyAccount/quotation/quotation_add_pkg',
+  'quotation_remove_pkg'      => 'MyAccount/quotation/quotation_remove_pkg',
+  'quotation_order'           => 'MyAccount/quotation/quotation_order',
+
 );
 @EXPORT_OK = (
   keys(%autoload),
 );
 @EXPORT_OK = (
   keys(%autoload),
diff --git a/ng_selfservice/images/cross.png b/ng_selfservice/images/cross.png
new file mode 100644 (file)
index 0000000..1514d51
Binary files /dev/null and b/ng_selfservice/images/cross.png differ
diff --git a/ng_selfservice/quotation.php b/ng_selfservice/quotation.php
new file mode 100644 (file)
index 0000000..cf45543
--- /dev/null
@@ -0,0 +1,130 @@
+<STYLE>
+td.amount {
+    text-align: right;
+}
+td.amount:before {
+    content: "$";
+}
+tr.total * {
+    background-color: #ddf;
+    font-weight: bold;
+}
+table.section {
+    width: 100%;
+    border-collapse: collapse;
+}
+table.section td {
+    font-size: small;
+    padding: 1ex 1ex;
+}
+table.section th {
+    text-align: left;
+    padding: 1ex;
+}
+.row0 td {
+    background-color: #eee;
+}
+.row1 td {
+    background-color: #fff;
+}
+</STYLE>
+
+<? $title ='Plan a new service order'; include('elements/header.php'); ?>
+<? $current_menu = 'services_new.php'; include('elements/menu.php'); ?>
+<?
+
+$quotation = $freeside->quotation_info(array(
+  'session_id'  => $_COOKIE['session_id'],
+));
+
+$can_order = 0;
+
+if ( isset($quotation['sections']) and count($quotation['sections']) > 0 ) {
+  $can_order = 1;
+  # there are other ways this could be formatted, yes.
+  # if you want the HTML-formatted quotation, use quotation_print().
+  print(
+    '<INPUT STYLE="float: right" TYPE="button" onclick="window.location.href=\'quotation_print.php\'" value="Download a quotation" />'.
+    '<H3>Order summary</H3>'.
+    "\n"
+  );
+  foreach ( $quotation['sections'] as $section ) {
+    print(
+      '<TABLE CLASS="section">'.
+      '<TR>'.
+      '<TH COLSPAN=4>'.  htmlspecialchars($section['description']).'</TH>'.
+      '</TR>'.
+      "\n"
+    );
+    $row = 0;
+    foreach ( $section['detail_items'] as $detail ) {
+      print(
+        '<TR CLASS="row' . $row . '">'.
+        '<TD>'
+      );
+      if ( $detail['pkgnum'] ) {
+        print(
+          '<A HREF="quotation_remove_pkg.php?pkgnum=' .
+          $detail['pkgnum'] . '">'.
+          '<IMG SRC="images/cross.png" /></A>'
+        );
+      }
+      print(
+        '</TD>'.
+        '<TD>'. htmlspecialchars($detail['description']). '</TD>'.
+        '<TD CLASS="amount">'. $detail['amount']. '</TD>'.
+        '</TR>'. "\n"
+      );
+      $row = 1 - $row;
+    }
+    print(
+      '<TR CLASS="total">'.
+      '<TD></TD>'.
+      '<TD>Total</TD>'.
+      '<TD CLASS="amount">'. $section['subtotal']. '</TD>'.
+      '</TR>'.
+      '</TABLE>'.
+      "\n"
+    );
+  } # foreach $section
+}
+
+$pkgselect = $freeside->mason_comp( array(
+    'session_id' => $_COOKIE['session_id'],
+    'comp'       => '/elements/select-part_pkg.html',
+    'args'       => array( 'onchange'       , 'enable_order_pkg()',
+                           'empty_label'    , 'Select package',
+                           'form_name'      , 'AddPkgForm',
+                         ),
+));
+if ( isset($pkgselect['error']) && $pkgselect['error'] ) {
+  $error = $pkgselect['error'];
+  header('Location:index.php?error='. urlencode($pkgselect));
+  die();
+}
+
+?>
+<SCRIPT TYPE="text/javascript">
+function enable_order_pkg () {
+    document.AddPkgForm.submit.disabled =
+        (document.AddPkgForm.pkgpart.value == '');
+}
+</SCRIPT>
+
+<DIV STYLE="border-top: 1px solid; padding: 1ex">
+<? $error = $_REQUEST['error']; include('elements/error.php'); ?>
+
+<FORM NAME="AddPkgForm" ACTION="quotation_add_pkg.php" METHOD=POST>
+<? echo $pkgselect['output']; ?>
+<INPUT NAME="submit" TYPE="submit" VALUE="Add package" <? if ( ! isset($_REQUEST['pkgpart']) ) { echo 'DISABLED'; } ?>>
+</FORM>
+
+<? if ( $can_order ) { ?>
+<FORM NAME="OrderQuoteForm" ACTION="quotation_order.php" METHOD=POST>
+<INPUT TYPE="submit" VALUE="Confirm this order" <? if ( !$can_order ) { echo 'DISABLED'; } ?>>
+<? } ?>
+
+</DIV>
+
+<? include('elements/menu_footer.php'); ?>
+<? include('elements/footer.php'); ?>
diff --git a/ng_selfservice/quotation_add_pkg.php b/ng_selfservice/quotation_add_pkg.php
new file mode 100644 (file)
index 0000000..1e7e71f
--- /dev/null
@@ -0,0 +1,31 @@
+<? require('elements/session.php');
+
+$dest = 'quotation.php';
+
+if ( isset($_REQUEST['pkgpart']) ) {
+
+  $results = array();
+
+  $params = array( 'custnum', 'pkgpart' );
+
+  $matches = array();
+  if ( preg_match( '/^(\d+)$/', $_REQUEST['pkgpart'] ) ) {
+
+    $args = array(
+        'session_id' => $_COOKIE['session_id'],
+        'pkgpart'    => $_REQUEST['pkgpart'],
+    );
+
+    $results = $freeside->quotation_add_pkg($args);
+
+  }
+
+  if ( isset($results['error']) && $results['error'] ) {
+    $dest .= '?error=' . $results['error'] . ';pkgpart=' . $_REQUEST['pkgpart'];
+  }
+}
+
+header("Location:$dest");
+
+?>
+
diff --git a/ng_selfservice/quotation_order.php b/ng_selfservice/quotation_order.php
new file mode 100644 (file)
index 0000000..d35eacb
--- /dev/null
@@ -0,0 +1,15 @@
+<? require('elements/session.php');
+
+$dest = 'services.php';
+
+$args = array( 'session_id' => $_COOKIE['session_id'] );
+
+$results = $freeside->quotation_order($args);
+
+if ( isset($results['error']) && $results['error'] ) {
+    $dest = 'quotation.php?error=' . $results['error'];
+}
+
+header("Location:$dest");
+
+?>
diff --git a/ng_selfservice/quotation_print.php b/ng_selfservice/quotation_print.php
new file mode 100644 (file)
index 0000000..9676405
--- /dev/null
@@ -0,0 +1,17 @@
+<? require('elements/session.php');
+
+$args = array(
+    'session_id' => $_COOKIE['session_id'],
+    'format'     => 'pdf'
+);
+
+$results = $freeside->quotation_print($args);
+if ( isset($results['document']) ) {
+    header('Content-Type: application/pdf');
+    header('Content-Disposition: filename=quotation.pdf');
+    print($results['document']->scalar);
+} else {
+    header("Location: quotation.php?error=" . $results['error']);
+}
+
+?>
diff --git a/ng_selfservice/quotation_remove_pkg.php b/ng_selfservice/quotation_remove_pkg.php
new file mode 100644 (file)
index 0000000..07548c7
--- /dev/null
@@ -0,0 +1,31 @@
+<? require('elements/session.php');
+
+$dest = 'quotation.php';
+
+if ( isset($_REQUEST['pkgnum']) ) {
+
+  $results = array();
+
+  $params = array( 'custnum', 'pkgnum' );
+
+  $matches = array();
+  if ( preg_match( '/^(\d+)$/', $_REQUEST['pkgnum'] ) ) {
+
+    $args = array(
+        'session_id' => $_COOKIE['session_id'],
+        'pkgnum'     => $_REQUEST['pkgnum'],
+    );
+
+    $results = $freeside->quotation_remove_pkg($args);
+
+  }
+
+  if ( isset($results['error']) && $results['error'] ) {
+    $dest .= '?error=' . $results['error'];
+  }
+
+}
+
+header("Location:$dest");
+
+?>