You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
485 lines
16 KiB
485 lines
16 KiB
#!/usr/bin/perl |
|
use strict; |
|
|
|
use Time::Local; |
|
use DBI; |
|
|
|
####################################################################### |
|
# Copyright (C) 2006 Rod Roark <rod@sunsetsystems.com> |
|
# |
|
# This program is free software; you can redistribute it and/or |
|
# modify it under the terms of the GNU General Public License |
|
# as published by the Free Software Foundation; either version 2 |
|
# of the License, or (at your option) any later version. |
|
####################################################################### |
|
# This program is to be run frequently via the system crontab. On |
|
# each run it will move scanned-in documents from a shared directory |
|
# into matching locations in the openemr/documents directory, and also |
|
# update the database accordingly. |
|
# Each scanned-in file must be placed into a directory corresponding |
|
# to its category, and its name must begin with the patient's pubpid |
|
# followed by any non-alphanumeric character. For example: |
|
# <shared-directory>/Categories/XRay/1234-knee-xray-20060131.jpg |
|
####################################################################### |
|
# NOTE: This is contributed as-is for the possible benefit of those who |
|
# may wish to build on it. As of this writing it is not known how well |
|
# it works, if at all, with current OpenEMR. |
|
####################################################################### |
|
|
|
####################################################################### |
|
# Parameters that you must customize # |
|
####################################################################### |
|
|
|
# Parameters for MySQL database connections: |
|
# |
|
my $DBNAME = "openemr"; # database name |
|
my $DBUSER = "openemr"; # database user name |
|
my $DBPASS = "secret"; # database user's password |
|
|
|
# Log file location: |
|
# |
|
my $INSLOG = "/mnt/drive2/emr_scan_load.log"; |
|
|
|
# Shared directory base where the scanners deposit files: |
|
# |
|
my $INPATH = "/mnt/drive2/scan_docs"; |
|
|
|
# Base directory for OpenEMR documents: |
|
# |
|
# my $OUTPATH = "/usr/local/apache2/htdocs/openemr/documents"; |
|
my $OUTPATH = "/mnt/drive2/documents"; |
|
|
|
# This should specify the user and group that the web server runs as: |
|
# |
|
my $CHOWN_COMMAND = "chown nobody:nogroup"; |
|
|
|
# This is the user from whom patient notes are addressed: |
|
# |
|
my $SCANNER_OPERATOR = 'ksears'; |
|
|
|
# This person gets patient notes if the doctor cannot be determined or is |
|
# out of the office: |
|
# |
|
my $DEFAULT_PRACTITIONER = 'candroney'; |
|
|
|
# We need a SQL condition to identify encounter forms that will only be |
|
# entered by practitioners. Yeah there's probably a better way. |
|
# |
|
my $PRACTITIONER_FORM = |
|
"formdir = 'soap' OR formdir = 'reviewofs' OR formdir = 'ros'"; |
|
|
|
# For each day of the week (Sun-Sat), if the office is open: |
|
# |
|
my @open_days = (0, 1, 1, 1, 1, 1, 0); |
|
|
|
# Office closing time: |
|
# |
|
my $closing_time = '17:00:00'; |
|
|
|
# Set this to 0 for production use: |
|
# |
|
my $DEBUG = 0; |
|
|
|
####################################################################### |
|
# Initialization # |
|
####################################################################### |
|
|
|
my $dbh = DBI->connect("dbi:mysql:dbname=$DBNAME", $DBUSER, $DBPASS) |
|
or die $DBI::errstr; |
|
|
|
$| = 1; # Turn on autoflushing of stdout. |
|
|
|
####################################################################### |
|
# Functions # |
|
####################################################################### |
|
|
|
# Write a log message. |
|
# |
|
sub tolog($$) { |
|
my ($msg, $error) = @_; |
|
my @tm = localtime; $tm[5] += 1900; $tm[4] += 1; |
|
my $ts = sprintf "%04u-%02u-%02u %02u:%02u:%02u", |
|
$tm[5], $tm[4], $tm[3], $tm[2], $tm[1], $tm[0]; |
|
if ($error) { |
|
$msg = '***ERROR: ' . $msg; |
|
} |
|
if ($DEBUG) { |
|
$msg = '*DEBUGGING* ' . $msg; |
|
} |
|
open LOG, ">> $INSLOG" or die "Cannot open $INSLOG: $!"; |
|
print LOG "$ts $msg\n"; |
|
close LOG; |
|
} |
|
|
|
# Determine if the designated doc is in the office at the specified time, |
|
# or will be later that day. |
|
# |
|
sub is_doc_available($$) { |
|
my ($practitioner, $now) = @_; |
|
|
|
$now = time() if (! $now); |
|
|
|
my @tm = localtime $now; $tm[5] += 1900; $tm[4] += 1; |
|
my $current_date = sprintf "%04u-%02u-%02u", $tm[5], $tm[4], $tm[3]; |
|
my $current_time = sprintf "%02u:%02u:%02u", $tm[2], $tm[1], $tm[0]; |
|
my $daynow = int($now / (24 * 60 * 60)); |
|
my $docid = $dbh->selectrow_array("SELECT id FROM users WHERE " . |
|
"username = '$practitioner'"); |
|
|
|
my $query = "SELECT " . |
|
"pc_catid, pc_eventDate, pc_endDate, pc_recurrtype, pc_recurrspec, " . |
|
"pc_startTime, pc_endTime, pc_alldayevent " . |
|
"FROM openemr_postcalendar_events " . |
|
"WHERE pc_aid = '$docid' AND " . |
|
"( pc_catid = 2 OR pc_catid = 3 OR pc_duration >= 21600 ) AND " . |
|
"pc_eventDate <= '$current_date' AND pc_endDate >= '$current_date' " . |
|
"ORDER BY pc_startTime"; |
|
my $esth = $dbh->prepare($query) |
|
or die $dbh->errstr; |
|
$esth->execute() or die $esth->errstr; |
|
|
|
# &tolog($query, 0); # debugging |
|
|
|
my $vacation = 0; |
|
my $in_active = 0; |
|
my $in_until = ''; |
|
|
|
# Look at each event selected. |
|
# |
|
while (my @erow = $esth->fetchrow_array()) { |
|
my ($pc_catid, $pc_eventDate, $pc_endDate, $pc_recurrtype, $pc_recurrspec, |
|
$pc_startTime, $pc_endTime, $pc_alldayevent) = @erow; |
|
my $repeattype = '0'; |
|
my $repeatfreq = '0'; |
|
if ($pc_recurrspec =~ /"event_repeat_freq_type";s:1:"(\d)"/) { |
|
# 0 = day, 1 = week, 2 = month, 3 = year, 4 = workday |
|
$repeattype = $1; |
|
} |
|
if ($pc_recurrspec =~ /"event_repeat_freq";s:1:"(\d)"/) { |
|
# 1 = every, 2 = every other, etc. |
|
$repeatfreq = $1; |
|
} |
|
|
|
# If this is a repeating event, determine if it applies to today. |
|
# |
|
if ($pc_recurrtype) { |
|
$pc_eventDate =~ /^(\d+)\D(\d+)\D(\d+)/; |
|
my $time0 = timelocal(1, 0, 0, $3, $2 - 1, $1 - 1900); |
|
my $day0 = int($time0 / (24 * 60 * 60)); |
|
my $elapsed_days = $daynow - $day0; |
|
my @tm0 = localtime $time0; $tm0[5] += 1900; $tm0[4] += 1; |
|
|
|
if ($repeattype == 0) { # day |
|
if ($repeatfreq > 1) { |
|
my $quotient = sprintf('%.4f', $elapsed_days / $repeatfreq); |
|
next if ($quotient != int($quotient)); |
|
} |
|
} |
|
elsif ($repeattype == 1) { # week |
|
my $repdays = $repeatfreq * 7; |
|
if ($repdays > 0) { |
|
my $quotient = sprintf('%.4f', $elapsed_days / $repdays); |
|
next if ($quotient != int($quotient)); |
|
} |
|
} |
|
elsif ($repeattype == 2) { # month |
|
next if ($tm[3] != $tm0[3]); # if not same day of month |
|
if ($repeatfreq > 1) { |
|
my $elapsed_months = ($tm[5] - $tm0[5]) * 12 + $tm[4] - $tm0[4]; |
|
my $quotient = sprintf('%.4f', $elapsed_months / $repeatfreq); |
|
next if ($quotient != int($quotient)); |
|
} |
|
} |
|
elsif ($repeattype == 3) { # year |
|
next if ($tm[3] != $tm0[3] || $tm[4] != $tm0[4]); |
|
if ($repeatfreq > 1) { |
|
my $elapsed_years = $tm[5] - $tm0[5]; |
|
my $quotient = sprintf('%.4f', $elapsed_years / $repeatfreq); |
|
next if ($quotient != int($quotient)); |
|
} |
|
} |
|
elsif ($repeattype == 4) { # work day (M-F) |
|
next if ($tm0[6] == 0 || $tm0[6] == 6); # if today is not a work day |
|
if ($repeatfreq > 1) { |
|
my $dowdiff = $tm[6] - $tm0[6]; |
|
my $elapsed_workdays = ($elapsed_days - $dowdiff) * 5 / 7 + $dowdiff; |
|
my $quotient = sprintf('%.4f', $elapsed_workdays / $repeatfreq); |
|
next if ($quotient != int($quotient)); |
|
} |
|
} |
|
} |
|
|
|
# Phew. Now we know that this event is applicable to this day. |
|
|
|
if ($pc_catid == 2) { # In Office |
|
$in_active = 1; |
|
$in_until = '23:59:59'; |
|
} |
|
elsif ($pc_catid == 3) { # Out of Office |
|
if ($in_active) { |
|
$in_until = $pc_startTime; |
|
$in_active = 0; |
|
} |
|
} |
|
else { # Vacation or equivalent |
|
$vacation = 1; |
|
} |
|
} |
|
|
|
return 1 if ($in_until && ! $vacation && $in_until gt $current_time); |
|
return 0; |
|
} |
|
|
|
# Generate a patient note if appropriate for this top-level category. |
|
# |
|
sub generate_note($$$) { |
|
# my ($pid, $path, $topcategory, $docid) = @_; |
|
my ($pid, $path, $docid) = @_; |
|
|
|
# if ($NO_NOTE_CATEGORIES{$topcategory}) { |
|
# return; |
|
# } |
|
|
|
# Get the login name of the user who entered the last clinical form for |
|
# this patient. That's who we'll send the note to. |
|
# |
|
my $fsth = $dbh->prepare("SELECT user, groupname FROM forms WHERE " . |
|
"pid = '$pid' AND ( $PRACTITIONER_FORM ) ORDER BY date DESC LIMIT 1") |
|
or die $dbh->errstr; |
|
$fsth->execute() or die $fsth->errstr; |
|
my @frow = $fsth->fetchrow_array(); |
|
# |
|
my $assigned_to = $DEFAULT_PRACTITIONER; |
|
my $groupname = ''; |
|
if (@frow) { |
|
$assigned_to = $frow[0]; |
|
$groupname = $frow[1]; |
|
|
|
# Check the schedule to see if this doc is in today (or will be in the |
|
# next working day if it's after hours now); if not, assign the default. |
|
# |
|
my $now = time(); |
|
my @tm = localtime $now; |
|
my $current_time = sprintf "%02u:%02u:%02u", $tm[2], $tm[1], $tm[0]; |
|
while ($open_days[$tm[6]] == 0 || $current_time gt $closing_time) { |
|
$current_time = '00:00:00'; |
|
$now = timelocal(0, 0, 0, $tm[3], $tm[4], $tm[5]) + (24 * 60 * 60); |
|
@tm = localtime $now; |
|
} |
|
if (! &is_doc_available($assigned_to, $now)) { |
|
&tolog("$assigned_to not available, using default practitioner", 0); |
|
$assigned_to = $DEFAULT_PRACTITIONER; |
|
} |
|
} else { |
|
&tolog("Patient $pid has no clinical forms, using default practitioner", 0); |
|
} |
|
|
|
# Build the text of the note including timestamp and addressing. |
|
# The document ID is also included, so that OpenEMR can easily |
|
# look up and display the document when the note is viewed. |
|
# |
|
my @tm = localtime; $tm[5] += 1900; $tm[4] += 1; |
|
my $body = sprintf "%04u-%02u-%02u %02u:%02u", |
|
$tm[5], $tm[4], $tm[3], $tm[2], $tm[1]; |
|
$body .= " ($SCANNER_OPERATOR to $assigned_to) "; |
|
$body .= "New scanned document $docid: $path"; |
|
|
|
# Write it to the database. |
|
# |
|
my $query = "INSERT INTO pnotes ( date, body, pid, user, groupname, " . |
|
"authorized, activity, title, assigned_to ) VALUES ( " . |
|
"NOW(), '$body', '$pid', '$SCANNER_OPERATOR', '$groupname', '1', '1', " . |
|
"'New Document', '$assigned_to')"; |
|
if (! $DEBUG) { |
|
$dbh->do($query) or die $query; |
|
} |
|
|
|
&tolog("Patient note assigned to $assigned_to", 0); |
|
} |
|
|
|
# Process a document file. |
|
# |
|
sub process_file($$) { |
|
my ($path, $notify) = @_; |
|
|
|
# Extract the ending filename from the path. Clean it up a bit for |
|
# use as an output filename. Return if it's a leftover problem. |
|
# |
|
my $dname = ''; |
|
my $fname = $path; |
|
if ($path =~ m'^(.*)/([^/]+)$') { |
|
$dname = $1; |
|
$fname = $2; |
|
} |
|
return if ($fname =~ /^ERR/); |
|
$fname =~ s/[^a-zA-Z0-9_.]/_/g; |
|
while ($fname =~ s/__/_/g) {} |
|
|
|
# Get out if the source file is open by any other process. This |
|
# normally means that it's still being written via smbd. |
|
# |
|
if (my $pr = `lsof -t '$INPATH/$path'`) { |
|
&tolog("Temporarily skipping '$path' which is open by process $pr", 0); |
|
return; |
|
} |
|
|
|
# Get the chart number and look up the patient's pid. |
|
# |
|
my $pubpid = ''; |
|
if ($fname =~ /^([A-Za-z0-9]+)/) { |
|
$pubpid = $1; |
|
} |
|
# |
|
my $psth = $dbh->prepare("SELECT pid FROM patient_data " . |
|
"WHERE pubpid = '$pubpid' LIMIT 1") |
|
or die $dbh->errstr; |
|
$psth->execute() or die $psth->errstr; |
|
my @prow = $psth->fetchrow_array(); |
|
# |
|
if (! @prow) { |
|
&tolog("$path: there is no patient with chart id '$pubpid'", 1); |
|
rename "$INPATH/$path", "$INPATH/$dname/ERR-$fname" if (! $DEBUG); |
|
return; |
|
} |
|
if ($psth->fetchrow_array()) { |
|
&tolog("$path: there are multiple patients with chart id '$pubpid'", 1); |
|
rename "$INPATH/$path", "$INPATH/$dname/ERR-$fname" if (! $DEBUG); |
|
return; |
|
} |
|
my $pid = $prow[0]; |
|
|
|
# Look up the document category and get its ID. |
|
# |
|
my @catpath = split /\//, $dname; |
|
my $catid = 0; |
|
my $catname = ''; |
|
for (my $i = 0; $i < scalar @catpath; ++$i) { |
|
$catname = $catpath[$i]; |
|
$catid = $dbh->selectrow_array("SELECT id FROM categories WHERE " . |
|
"name = '$catname' AND parent = $catid"); |
|
} |
|
if (! $catid) { |
|
&tolog("Category '$dname' does not exist", 1); |
|
rename "$INPATH/$path", "$INPATH/$dname/ERR-$fname"; |
|
return; |
|
} |
|
|
|
# Get the source file size; if zero, skip it. It appears this |
|
# can be a case where the scanner software has created the |
|
# directory entry but has not yet written the data, so we do |
|
# not want to delete the file. |
|
# |
|
my $fsize = (stat("$INPATH/$path"))[7]; |
|
if (! $fsize) { |
|
# &tolog("Deleting and skipping empty file '$path'", 1); |
|
# unlink "$INPATH/$path"; |
|
&tolog("Skipping empty file '$path'", 1); |
|
return; |
|
} |
|
|
|
# Make sure the target directory exists. |
|
# |
|
system "mkdir -p '$OUTPATH/$pid'; $CHOWN_COMMAND '$OUTPATH/$pid'" if (! $DEBUG); |
|
|
|
# If the target filename exists, modify it until it doesn't. |
|
# |
|
my $count = 0; |
|
while (-e "$OUTPATH/$pid/$fname") { |
|
my $oldfname = $fname; |
|
my $fsuff = ''; |
|
if ($fname =~ /^(.*)(\..+)$/) { |
|
$fname = $1; |
|
$fsuff = $2; |
|
} |
|
if ($count++) { |
|
$fname =~ s/_\d+$//; |
|
} |
|
$fname .= '_' . $count . $fsuff; |
|
&tolog("File '$pid/$oldfname' already exists; trying '$pid/$fname' ...", 0); |
|
} |
|
my $target = "$OUTPATH/$pid/$fname"; |
|
|
|
# Move the file to its destination and set its owner and group. |
|
# |
|
my $movecmd = "mv '$INPATH/$path' '$target'"; |
|
if (! $DEBUG) { |
|
my $rc = system $movecmd; |
|
if ($rc != 0) { |
|
&tolog("Command '$movecmd' failed with return code $rc", 1); |
|
return; |
|
} |
|
system "$CHOWN_COMMAND '$target'"; |
|
} |
|
|
|
# Compute assorted values for the documents table. |
|
# |
|
$dbh->do("update sequences set id = id + 1") if (! $DEBUG); |
|
my $newid = $dbh->selectrow_array("SELECT id FROM sequences"); |
|
my @tm = localtime; $tm[5] += 1900; $tm[4] += 1; |
|
my $ts1 = sprintf "%04u-%02u-%02u %02u:%02u:%02u", $tm[5], $tm[4], $tm[3], $tm[2], $tm[1], $tm[0]; |
|
my $ts2 = $ts1; |
|
$ts2 =~ s/\D//g; |
|
my $mimetype = $DEBUG ? '' : `file -i $target`; |
|
$mimetype =~ s/;.*$//; # remove trailing "; charset=..." if present |
|
$mimetype =~ s/^.*:\s*//; # remove everything preceding the mime type |
|
$mimetype =~ s/\s*$//; # remove any trailing line feed or other whitespace |
|
if (! $mimetype) { |
|
&tolog("Unable to determine MIME type using 'file -i $target'; proceeding with empty type", 1); |
|
} |
|
|
|
if (! $DEBUG) { |
|
# Update the database. |
|
# |
|
my $query = "INSERT INTO documents ( " . |
|
"id, type, size, date, url, mimetype, revision, foreign_id" . |
|
" ) VALUES ( " . |
|
"'$newid', 'file_url', '$fsize', '$ts1', 'file://$target', '$mimetype', '$ts2', $pid " . |
|
")"; |
|
$dbh->do($query) or die $query; |
|
# |
|
my $query = "INSERT INTO categories_to_documents ( " . |
|
"category_id, document_id" . |
|
" ) VALUES ( " . |
|
"'$catid', '$newid' " . |
|
")"; |
|
$dbh->do($query) or die $query; |
|
} |
|
|
|
&tolog("Loaded '$path' as $mimetype", 0); |
|
|
|
# Generate the patient note if appropriate for this category. |
|
# |
|
# &generate_note($pid, "$dname/$fname", $catpath[1], $newid); |
|
&generate_note($pid, "$dname/$fname", $newid) if $notify; |
|
} |
|
|
|
# Scan the source directory recursively to invoke processing of each |
|
# document file. |
|
# |
|
sub scan_dir($) { |
|
my $path = shift; |
|
my $notify = -f "$INPATH/$path/.notify"; |
|
opendir my $dh, "$INPATH/$path"; |
|
while (my $dirent = readdir $dh) { |
|
next if ($dirent =~ /^\./); |
|
my $thispath = $path ? "$path/$dirent" : $dirent; |
|
if (-d "$INPATH/$thispath") { |
|
&scan_dir($thispath); |
|
} else { |
|
&process_file($thispath, $notify); |
|
} |
|
} |
|
closedir $dh; |
|
} |
|
|
|
####################################################################### |
|
# Processing # |
|
####################################################################### |
|
|
|
&scan_dir('Categories'); # This makes everything happen. |
|
|
|
####################################################################### |
|
# Shutdown # |
|
####################################################################### |
|
|
|
$dbh->disconnect;
|
|
|